Extract prime.go into focused files: - prime_session.go: session ID handling, hooks, persistence - prime_output.go: all output/rendering functions - prime_molecule.go: molecule workflow context - prime_state.go: handoff markers, session state detection Main prime.go now ~730 lines with core flow visible as "table of contents". No behavior changes - pure file organization following Go idioms. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
198 lines
5.2 KiB
Go
198 lines
5.2 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/steveyegge/gastown/internal/events"
|
|
"github.com/steveyegge/gastown/internal/runtime"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// hookInput represents the JSON input from LLM runtime hooks.
|
|
// Claude Code sends this on stdin for SessionStart hooks.
|
|
type hookInput struct {
|
|
SessionID string `json:"session_id"`
|
|
TranscriptPath string `json:"transcript_path"`
|
|
Source string `json:"source"` // startup, resume, clear, compact
|
|
}
|
|
|
|
// readHookSessionID reads session ID from available sources in hook mode.
|
|
// Priority: stdin JSON, GT_SESSION_ID env, CLAUDE_SESSION_ID env, auto-generate.
|
|
func readHookSessionID() (sessionID, source string) {
|
|
// 1. Try reading stdin JSON (Claude Code format)
|
|
if input := readStdinJSON(); input != nil {
|
|
if input.SessionID != "" {
|
|
return input.SessionID, input.Source
|
|
}
|
|
}
|
|
|
|
// 2. Environment variables
|
|
if id := os.Getenv("GT_SESSION_ID"); id != "" {
|
|
return id, ""
|
|
}
|
|
if id := os.Getenv("CLAUDE_SESSION_ID"); id != "" {
|
|
return id, ""
|
|
}
|
|
|
|
// 3. Auto-generate
|
|
return uuid.New().String(), ""
|
|
}
|
|
|
|
// readStdinJSON attempts to read and parse JSON from stdin.
|
|
// Returns nil if stdin is empty, not a pipe, or invalid JSON.
|
|
func readStdinJSON() *hookInput {
|
|
// Check if stdin has data (non-blocking)
|
|
stat, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Only read if stdin is a pipe or has data
|
|
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
|
// stdin is a terminal, not a pipe - no data to read
|
|
return nil
|
|
}
|
|
|
|
// Read first line (JSON should be on one line)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil && line == "" {
|
|
return nil
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return nil
|
|
}
|
|
|
|
var input hookInput
|
|
if err := json.Unmarshal([]byte(line), &input); err != nil {
|
|
return nil
|
|
}
|
|
|
|
return &input
|
|
}
|
|
|
|
// persistSessionID writes the session ID to .runtime/session_id
|
|
// This allows subsequent gt prime calls to find the session ID.
|
|
func persistSessionID(dir, sessionID string) {
|
|
runtimeDir := filepath.Join(dir, ".runtime")
|
|
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
|
|
return // Non-fatal
|
|
}
|
|
|
|
sessionFile := filepath.Join(runtimeDir, "session_id")
|
|
content := fmt.Sprintf("%s\n%s\n", sessionID, time.Now().Format(time.RFC3339))
|
|
_ = os.WriteFile(sessionFile, []byte(content), 0644) // Non-fatal
|
|
}
|
|
|
|
// ReadPersistedSessionID reads a previously persisted session ID.
|
|
// Checks cwd first, then town root.
|
|
// Returns empty string if not found.
|
|
func ReadPersistedSessionID() string {
|
|
// Try cwd first
|
|
cwd, err := os.Getwd()
|
|
if err == nil {
|
|
if id := readSessionFile(cwd); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
|
|
// Try town root
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err == nil && townRoot != "" {
|
|
if id := readSessionFile(townRoot); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func readSessionFile(dir string) string {
|
|
sessionFile := filepath.Join(dir, ".runtime", "session_id")
|
|
data, err := os.ReadFile(sessionFile)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
if len(lines) > 0 {
|
|
return strings.TrimSpace(lines[0])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveSessionIDForPrime finds the session ID from available sources.
|
|
// Priority: GT_SESSION_ID env, CLAUDE_SESSION_ID env, persisted file, fallback.
|
|
func resolveSessionIDForPrime(actor string) string {
|
|
// 1. Try runtime's session ID lookup (checks GT_SESSION_ID_ENV, then CLAUDE_SESSION_ID)
|
|
if id := runtime.SessionIDFromEnv(); id != "" {
|
|
return id
|
|
}
|
|
|
|
// 2. Persisted session file (from gt prime --hook)
|
|
if id := ReadPersistedSessionID(); id != "" {
|
|
return id
|
|
}
|
|
|
|
// 3. Fallback to generated identifier
|
|
return fmt.Sprintf("%s-%d", actor, os.Getpid())
|
|
}
|
|
|
|
// emitSessionEvent emits a session_start event for seance discovery.
|
|
// The event is written to ~/gt/.events.jsonl and can be queried via gt seance.
|
|
// Session ID resolution order: GT_SESSION_ID, CLAUDE_SESSION_ID, persisted file, fallback.
|
|
func emitSessionEvent(ctx RoleContext) {
|
|
if ctx.Role == RoleUnknown {
|
|
return
|
|
}
|
|
|
|
// Get agent identity for the actor field
|
|
actor := getAgentIdentity(ctx)
|
|
if actor == "" {
|
|
return
|
|
}
|
|
|
|
// Get session ID from multiple sources
|
|
sessionID := resolveSessionIDForPrime(actor)
|
|
|
|
// Determine topic from hook state or default
|
|
topic := ""
|
|
if ctx.Role == RoleWitness || ctx.Role == RoleRefinery || ctx.Role == RoleDeacon {
|
|
topic = "patrol"
|
|
}
|
|
|
|
// Emit the event
|
|
payload := events.SessionPayload(sessionID, actor, topic, ctx.WorkDir)
|
|
_ = events.LogFeed(events.TypeSessionStart, actor, payload)
|
|
}
|
|
|
|
// outputSessionMetadata prints a structured metadata line for seance discovery.
|
|
// Format: [GAS TOWN] role:<role> pid:<pid> session:<session_id>
|
|
// This enables gt seance to discover sessions from gt prime output.
|
|
func outputSessionMetadata(ctx RoleContext) {
|
|
if ctx.Role == RoleUnknown {
|
|
return
|
|
}
|
|
|
|
// Get agent identity for the role field
|
|
actor := getAgentIdentity(ctx)
|
|
if actor == "" {
|
|
return
|
|
}
|
|
|
|
// Get session ID from multiple sources
|
|
sessionID := resolveSessionIDForPrime(actor)
|
|
|
|
// Output structured metadata line
|
|
fmt.Printf("[GAS TOWN] role:%s pid:%d session:%s\n", actor, os.Getpid(), sessionID)
|
|
}
|