feat(prime): Add --hook flag for LLM runtime session handling

Enables gt prime to receive session metadata from LLM runtime hooks.
When called with --hook, reads JSON from stdin containing session_id
and persists it to .runtime/session_id for use by PropulsionNudge.

- Add --hook flag for hook mode
- Parse Claude Code session JSON from stdin
- Support GT_SESSION_ID environment variable fallback
- Persist session ID to .runtime/session_id

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-03 11:29:27 -08:00
committed by beads/crew/dave
parent 386dbf85fb
commit e7994d98f7
3 changed files with 181 additions and 14 deletions

1
go.mod
View File

@@ -19,6 +19,7 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect

2
go.sum
View File

@@ -23,6 +23,8 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

View File

@@ -1,6 +1,7 @@
package cmd
import (
"bufio"
"bytes"
"encoding/json"
"errors"
@@ -11,6 +12,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/checkpoint"
@@ -22,6 +24,8 @@ import (
"github.com/steveyegge/gastown/internal/workspace"
)
var primeHookMode bool
// Role represents a detected agent role.
type Role string
@@ -47,11 +51,25 @@ Role detection:
- <rig>/refinery/rig/ → Refinery context
- <rig>/polecats/<name>/ → Polecat context
This command is typically used in shell prompts or agent initialization.`,
This command is typically used in shell prompts or agent initialization.
HOOK MODE (--hook):
When called as an LLM runtime hook, use --hook to enable session ID handling.
This reads session metadata from stdin and persists it for the session.
Claude Code integration (in .claude/settings.json):
"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --hook"}]}]
Claude Code sends JSON on stdin:
{"session_id": "uuid", "transcript_path": "/path", "source": "startup|resume"}
Other agents can set GT_SESSION_ID environment variable instead.`,
RunE: runPrime,
}
func init() {
primeCmd.Flags().BoolVar(&primeHookMode, "hook", false,
"Hook mode: read session ID from stdin JSON (for LLM runtime hooks)")
rootCmd.AddCommand(primeCmd)
}
@@ -74,6 +92,23 @@ func runPrime(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace")
}
// Handle hook mode: read session ID from stdin and persist it
if primeHookMode {
sessionID, source := readHookSessionID()
persistSessionID(townRoot, sessionID)
if cwd != townRoot {
persistSessionID(cwd, sessionID)
}
// Set environment for this process (affects event emission below)
os.Setenv("GT_SESSION_ID", sessionID)
os.Setenv("CLAUDE_SESSION_ID", sessionID) // Legacy compatibility
// Output session beacon
fmt.Printf("[session:%s]\n", sessionID)
if source != "" {
fmt.Printf("[source:%s]\n", source)
}
}
// Get role using env-aware detection
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
@@ -1499,7 +1534,7 @@ func outputCheckpointContext(ctx RoleContext) {
// 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 comes from CLAUDE_SESSION_ID env var if available.
// Session ID resolution order: GT_SESSION_ID, CLAUDE_SESSION_ID, persisted file, fallback.
func emitSessionEvent(ctx RoleContext) {
if ctx.Role == RoleUnknown {
return
@@ -1511,12 +1546,8 @@ func emitSessionEvent(ctx RoleContext) {
return
}
// Get session ID from environment (set by Claude Code hooks)
sessionID := os.Getenv("CLAUDE_SESSION_ID")
if sessionID == "" {
// Fall back to a generated identifier
sessionID = fmt.Sprintf("%s-%d", actor, os.Getpid())
}
// Get session ID from multiple sources
sessionID := resolveSessionIDForPrime(actor)
// Determine topic from hook state or default
topic := ""
@@ -1543,13 +1574,146 @@ func outputSessionMetadata(ctx RoleContext) {
return
}
// Get session ID from environment (set by Claude Code hooks)
sessionID := os.Getenv("CLAUDE_SESSION_ID")
if sessionID == "" {
// Fall back to a generated identifier
sessionID = fmt.Sprintf("%s-%d", actor, os.Getpid())
}
// 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)
}
// 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. GT_SESSION_ID (new canonical)
if id := os.Getenv("GT_SESSION_ID"); id != "" {
return id
}
// 2. CLAUDE_SESSION_ID (legacy/Claude Code)
if id := os.Getenv("CLAUDE_SESSION_ID"); id != "" {
return id
}
// 3. Persisted session file (from gt prime --hook)
if id := ReadPersistedSessionID(); id != "" {
return id
}
// 4. Fallback to generated identifier
return fmt.Sprintf("%s-%d", actor, os.Getpid())
}
// 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 ""
}