refactor(prime): split 1833-line file into logical modules
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>
This commit is contained in:
197
internal/cmd/prime_session.go
Normal file
197
internal/cmd/prime_session.go
Normal file
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user