spawn: Add work instruction + export SessionName

- Export session.Manager.SessionName for spawn.go access
- Add --address alias for --identity in mail inbox/check
- Send explicit work instruction to polecat after spawn
- Add CapturePaneLines and WaitForClaudeReady helpers (unused for now)
- Proper solution filed as gt-hb0 (needs Witness/Deacon AI monitoring)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-20 08:15:23 -08:00
parent cf756f06d3
commit d0259af61e
6 changed files with 94 additions and 16 deletions

View File

@@ -177,6 +177,7 @@ func init() {
mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON")
mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages")
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "identity", "", "Explicit identity for inbox (e.g., gastown/Toast)")
mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "address", "", "Alias for --identity")
// Read flags
mailReadCmd.Flags().BoolVar(&mailReadJSON, "json", false, "Output as JSON")
@@ -185,6 +186,7 @@ func init() {
mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks")
mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON")
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "identity", "", "Explicit identity for inbox (e.g., gastown/Toast)")
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "address", "", "Alias for --identity")
// Thread flags
mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON")

View File

@@ -336,7 +336,21 @@ func runSpawn(cmd *cobra.Command, args []string) error {
fmt.Printf("%s Session started. Attach with: %s\n",
style.Bold.Render("✓"),
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
fmt.Printf(" %s\n", style.Dim.Render("Polecat will read work assignment from inbox on startup"))
// TODO: Proper solution requires Witness/Deacon to monitor polecat startup
// and detect when Claude is ready using AI intelligence. See gt-polecat-ready issue.
// For now, use a fixed delay - SessionStart hook runs gt prime which tells polecat
// to check mail, but we also send an explicit instruction as backup.
sessionName := sessMgr.SessionName(polecatName)
time.Sleep(5 * time.Second)
// Send work instruction - backup in case SessionStart hook doesn't trigger action
workInstruction := "Check your inbox with `gt mail inbox` and begin working on your assigned issue."
if err := t.SendKeys(sessionName, workInstruction); err != nil {
fmt.Printf(" %s\n", style.Dim.Render(fmt.Sprintf("Warning: could not send work instruction: %v", err)))
}
fmt.Printf(" %s\n", style.Dim.Render("Work instruction sent to polecat"))
return nil
}

View File

@@ -71,8 +71,8 @@ type Info struct {
Windows int `json:"windows,omitempty"`
}
// sessionName generates the tmux session name for a polecat.
func (m *Manager) sessionName(polecat string) string {
// SessionName generates the tmux session name for a polecat.
func (m *Manager) SessionName(polecat string) string {
return fmt.Sprintf("gt-%s-%s", m.rig.Name, polecat)
}
@@ -98,7 +98,7 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
return fmt.Errorf("%w: %s", ErrPolecatNotFound, polecat)
}
sessionID := m.sessionName(polecat)
sessionID := m.SessionName(polecat)
// Check if session already exists
running, err := m.tmux.HasSession(sessionID)
@@ -159,7 +159,7 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
// Stop terminates a polecat session.
// If force is true, skips graceful shutdown and kills immediately.
func (m *Manager) Stop(polecat string, force bool) error {
sessionID := m.sessionName(polecat)
sessionID := m.SessionName(polecat)
// Check if session exists
running, err := m.tmux.HasSession(sessionID)
@@ -203,13 +203,13 @@ func (m *Manager) syncBeads(workDir string) error {
// IsRunning checks if a polecat session is active.
func (m *Manager) IsRunning(polecat string) (bool, error) {
sessionID := m.sessionName(polecat)
sessionID := m.SessionName(polecat)
return m.tmux.HasSession(sessionID)
}
// Status returns detailed status for a polecat session.
func (m *Manager) Status(polecat string) (*Info, error) {
sessionID := m.sessionName(polecat)
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
if err != nil {
@@ -286,7 +286,7 @@ func (m *Manager) List() ([]Info, error) {
// Attach attaches to a polecat session.
func (m *Manager) Attach(polecat string) error {
sessionID := m.sessionName(polecat)
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
if err != nil {
@@ -301,7 +301,7 @@ func (m *Manager) Attach(polecat string) error {
// Capture returns the recent output from a polecat session.
func (m *Manager) Capture(polecat string, lines int) (string, error) {
sessionID := m.sessionName(polecat)
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
if err != nil {
@@ -317,7 +317,7 @@ func (m *Manager) Capture(polecat string, lines int) (string, error) {
// Inject sends a message to a polecat session.
// Uses a longer debounce delay for large messages to ensure paste completes.
func (m *Manager) Inject(polecat, message string) error {
sessionID := m.sessionName(polecat)
sessionID := m.SessionName(polecat)
running, err := m.tmux.HasSession(sessionID)
if err != nil {

View File

@@ -16,7 +16,7 @@ func TestSessionName(t *testing.T) {
}
m := NewManager(tmux.NewTmux(), r)
name := m.sessionName("Toast")
name := m.SessionName("Toast")
if name != "gt-gastown-Toast" {
t.Errorf("sessionName = %q, want gt-gastown-Toast", name)
}

View File

@@ -177,6 +177,18 @@ func (t *Tmux) CapturePaneAll(session string) (string, error) {
return t.run("capture-pane", "-p", "-t", session, "-S", "-")
}
// CapturePaneLines captures the last N lines of a pane as a slice.
func (t *Tmux) CapturePaneLines(session string, lines int) ([]string, error) {
out, err := t.CapturePane(session, lines)
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
}
// AttachSession attaches to an existing session.
// Note: This replaces the current process with tmux attach.
func (t *Tmux) AttachSession(session string) error {
@@ -314,6 +326,30 @@ func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
return fmt.Errorf("timeout waiting for shell")
}
// WaitForClaudeReady polls until Claude's prompt indicator appears in the pane.
// Claude is ready when we see "> " at the start of a line (the input prompt).
// This is more reliable than just checking if node is running.
func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
// Capture last few lines of the pane
lines, err := t.CapturePaneLines(session, 10)
if err != nil {
time.Sleep(200 * time.Millisecond)
continue
}
// Look for Claude's prompt indicator "> " at start of line
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "> ") || trimmed == ">" {
return nil
}
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for Claude prompt")
}
// GetSessionInfo returns detailed information about a session.
func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) {
format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}"