diff --git a/docs/no-tmux-mode.md b/docs/no-tmux-mode.md new file mode 100644 index 00000000..da3171e6 --- /dev/null +++ b/docs/no-tmux-mode.md @@ -0,0 +1,122 @@ +# No-Tmux Mode + +Gas Town can operate without tmux or the daemon, using beads as the universal data plane for passing args and context. + +## Background + +Tmux instability can crash workers. The daemon relies on tmux for: +- Session management (creating/killing panes) +- Nudging agents via SendKeys +- Crash detection via pane-died hooks + +When tmux is unstable, the entire operation fails. No-tmux mode enables continued operation in degraded mode. + +## Key Insight: Beads Replace SendKeys + +In normal mode, `--args` are injected via tmux SendKeys. In no-tmux mode: +- Args are stored in the pinned bead description (`attached_args` field) +- `gt prime` reads and displays args from the pinned bead +- No prompt injection needed - agents discover everything via `bd show` + +## Usage + +### Slinging Work with Args + +```bash +# Normal mode: args injected via tmux + stored in bead +gt sling gt-abc --args "patch release" + +# In no-tmux mode: nudge fails gracefully, but args are in the bead +# Agent discovers args via gt prime when it starts +``` + +### Spawning without Tmux + +```bash +# Use --naked to skip tmux session creation +gt spawn gastown/Toast --issue gt-abc --naked + +# Output tells you how to start the agent manually: +# cd ~/gt/gastown/polecats/Toast +# claude +``` + +### Agent Discovery + +When an agent starts (manually or via IDE), the SessionStart hook runs `gt prime`, which: +1. Detects the agent's role from cwd +2. Finds pinned work +3. Displays attached args prominently +4. Shows current molecule step + +The agent sees: + +``` +## ATTACHED WORK DETECTED + +Pinned bead: gt-abc +Attached molecule: gt-xyz +Attached at: 2025-12-26T12:00:00Z + +ARGS (use these to guide execution): + patch release + +**Progress:** 0/5 steps complete +``` + +## What Works vs What's Degraded + +### What Still Works + +| Feature | How It Works | +|---------|--------------| +| Propulsion via pinned beads | Agents pick up work on startup | +| Self-handoff | Agents can cycle themselves | +| Patrol loops | Deacon, Witness, Refinery keep running | +| Mail system | Beads-based, no tmux needed | +| Args passing | Stored in bead description | +| Work discovery | `gt prime` reads from bead | + +### What Is Degraded + +| Limitation | Impact | +|------------|--------| +| No interrupts | Cannot nudge busy agents mid-task | +| Polling only | Agents must actively check inbox (no push) | +| Await steps block | "Wait for human" steps require manual agent restart | +| No crash detection | pane-died hooks unavailable | +| Manual startup | Human must start each agent in separate terminal | + +### Workflow Implications + +- **Patrol agents** work fine (they poll as part of their loop) +- **Task workers** need restart to pick up new work +- Cannot redirect a busy worker to urgent task +- Human must monitor and restart crashed agents + +## Commands Summary + +| Command | Purpose | +|---------|---------| +| `gt sling --args "..."` | Store args in bead, nudge gracefully | +| `gt spawn --naked` | Assign work without tmux session | +| `gt prime` | Display attached work + args on startup | +| `gt mol status` | Show current work status including args | +| `bd show ` | View raw bead with attached_args field | + +## Implementation Details + +Args are stored in the bead description as a `key: value` field: + +``` +attached_molecule: gt-xyz +attached_at: 2025-12-26T12:00:00Z +attached_args: patch release +``` + +The `beads.AttachmentFields` struct includes: +- `AttachedMolecule` - the work molecule ID +- `AttachedAt` - timestamp when attached +- `AttachedArgs` - natural language instructions + +These are parsed by `beads.ParseAttachmentFields()` and formatted by `beads.FormatAttachmentFields()`. diff --git a/internal/beads/beads.go b/internal/beads/beads.go index edd429c4..736b9c08 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -632,6 +632,7 @@ func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) { type AttachmentFields struct { AttachedMolecule string // Root issue ID of the attached molecule AttachedAt string // ISO 8601 timestamp when attached + AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode) } // ParseAttachmentFields extracts attachment fields from an issue's description. @@ -670,6 +671,9 @@ func ParseAttachmentFields(issue *Issue) *AttachmentFields { case "attached_at", "attached-at", "attachedat": fields.AttachedAt = value hasFields = true + case "attached_args", "attached-args", "attachedargs": + fields.AttachedArgs = value + hasFields = true } } @@ -694,6 +698,9 @@ func FormatAttachmentFields(fields *AttachmentFields) string { if fields.AttachedAt != "" { lines = append(lines, "attached_at: "+fields.AttachedAt) } + if fields.AttachedArgs != "" { + lines = append(lines, "attached_args: "+fields.AttachedArgs) + } return strings.Join(lines, "\n") } @@ -704,12 +711,15 @@ func FormatAttachmentFields(fields *AttachmentFields) string { func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string { // Known attachment field keys (lowercase) attachmentKeys := map[string]bool{ - "attached_molecule": true, - "attached-molecule": true, - "attachedmolecule": true, - "attached_at": true, - "attached-at": true, - "attachedat": true, + "attached_molecule": true, + "attached-molecule": true, + "attachedmolecule": true, + "attached_at": true, + "attached-at": true, + "attachedat": true, + "attached_args": true, + "attached-args": true, + "attachedargs": true, } // Collect non-attachment lines from existing description diff --git a/internal/cmd/molecule_status.go b/internal/cmd/molecule_status.go index be00f2d3..9c52d320 100644 --- a/internal/cmd/molecule_status.go +++ b/internal/cmd/molecule_status.go @@ -35,6 +35,7 @@ type MoleculeStatusInfo struct { PinnedBead *beads.Issue `json:"pinned_bead,omitempty"` AttachedMolecule string `json:"attached_molecule,omitempty"` AttachedAt string `json:"attached_at,omitempty"` + AttachedArgs string `json:"attached_args,omitempty"` IsWisp bool `json:"is_wisp"` Progress *MoleculeProgressInfo `json:"progress,omitempty"` NextAction string `json:"next_action,omitempty"` @@ -264,6 +265,7 @@ func runMoleculeStatus(cmd *cobra.Command, args []string) error { if attachment != nil { status.AttachedMolecule = attachment.AttachedMolecule status.AttachedAt = attachment.AttachedAt + status.AttachedArgs = attachment.AttachedArgs // Check if it's a wisp (look for wisp indicator in description) status.IsWisp = strings.Contains(pinnedBeads[0].Description, "wisp: true") || @@ -456,6 +458,9 @@ func outputMoleculeStatus(status MoleculeStatusInfo) error { if status.AttachedAt != "" { fmt.Printf(" Attached: %s\n", status.AttachedAt) } + if status.AttachedArgs != "" { + fmt.Printf(" %s %s\n", style.Bold.Render("Args:"), status.AttachedArgs) + } } else { fmt.Printf("%s\n", style.Dim.Render("No molecule attached")) } diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 35b08ee1..c869b07a 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -572,6 +572,11 @@ func outputAttachmentStatus(ctx RoleContext) { if attachment.AttachedAt != "" { fmt.Printf("Attached at: %s\n", attachment.AttachedAt) } + if attachment.AttachedArgs != "" { + fmt.Println() + fmt.Printf("%s\n", style.Bold.Render("📋 ARGS (use these to guide execution):")) + fmt.Printf(" %s\n", attachment.AttachedArgs) + } fmt.Println() // Show current step from molecule diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 844a7a49..53dd004f 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" ) @@ -214,12 +215,69 @@ func runSling(cmd *cobra.Command, args []string) error { fmt.Printf("%s Work attached to hook (pinned bead)\n", style.Bold.Render("✓")) - // Inject the "start now" prompt - if err := injectStartPrompt(targetPane, beadID, slingSubject, slingArgs); err != nil { - return fmt.Errorf("injecting start prompt: %w", err) + // Store args in bead description (no-tmux mode: beads as data plane) + if slingArgs != "" { + if err := storeArgsInBead(beadID, slingArgs); err != nil { + // Warn but don't fail - args will still be in the nudge prompt + fmt.Printf("%s Could not store args in bead: %v\n", style.Dim.Render("Warning:"), err) + } else { + fmt.Printf("%s Args stored in bead (durable)\n", style.Bold.Render("✓")) + } + } + + // Try to inject the "start now" prompt (graceful if no tmux) + if targetPane == "" { + fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○")) + } else if err := injectStartPrompt(targetPane, beadID, slingSubject, slingArgs); err != nil { + // Graceful fallback for no-tmux mode + fmt.Printf("%s Could not nudge (no tmux?): %v\n", style.Dim.Render("○"), err) + fmt.Printf(" Agent will discover work via gt prime / bd show\n") + } else { + fmt.Printf("%s Start prompt sent\n", style.Bold.Render("▶")) + } + + return nil +} + +// storeArgsInBead stores args in the bead's description using attached_args field. +// This enables no-tmux mode where agents discover args via gt prime / bd show. +func storeArgsInBead(beadID, args string) error { + // Get the bead to preserve existing description content + showCmd := exec.Command("bd", "show", beadID, "--json") + out, err := showCmd.Output() + if err != nil { + return fmt.Errorf("fetching bead: %w", err) + } + + // Parse the bead + var issues []beads.Issue + if err := json.Unmarshal(out, &issues); err != nil { + return fmt.Errorf("parsing bead: %w", err) + } + if len(issues) == 0 { + return fmt.Errorf("bead not found") + } + issue := &issues[0] + + // Get or create attachment fields + fields := beads.ParseAttachmentFields(issue) + if fields == nil { + fields = &beads.AttachmentFields{} + } + + // Set the args + fields.AttachedArgs = args + + // Update the description + newDesc := beads.SetAttachmentFields(issue, fields) + + // Update the bead + updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc) + updateCmd.Stderr = os.Stderr + if err := updateCmd.Run(); err != nil { + return fmt.Errorf("updating bead description: %w", err) } - fmt.Printf("%s Start prompt sent\n", style.Bold.Render("▶")) return nil } @@ -485,9 +543,18 @@ func runSlingFormula(args []string) error { } fmt.Printf("%s Attached to hook (pinned bead)\n", style.Bold.Render("✓")) - // Step 4: Nudge to start + // Store args in wisp bead if provided (no-tmux mode: beads as data plane) + if slingArgs != "" { + if err := storeArgsInBead(wispResult.RootID, slingArgs); err != nil { + fmt.Printf("%s Could not store args in bead: %v\n", style.Dim.Render("Warning:"), err) + } else { + fmt.Printf("%s Args stored in bead (durable)\n", style.Bold.Render("✓")) + } + } + + // Step 4: Nudge to start (graceful if no tmux) if targetPane == "" { - fmt.Printf("%s No pane to nudge (target may need manual start)\n", style.Dim.Render("○")) + fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○")) return nil } @@ -499,9 +566,12 @@ func runSlingFormula(args []string) error { } t := tmux.NewTmux() if err := t.NudgePane(targetPane, prompt); err != nil { - return fmt.Errorf("nudging: %w", err) + // Graceful fallback for no-tmux mode + fmt.Printf("%s Could not nudge (no tmux?): %v\n", style.Dim.Render("○"), err) + fmt.Printf(" Agent will discover work via gt prime / bd show\n") + } else { + fmt.Printf("%s Nudged to start\n", style.Bold.Render("▶")) } - fmt.Printf("%s Nudged to start\n", style.Bold.Render("▶")) return nil } diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 6d5efd9e..8eadbab5 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -31,6 +31,7 @@ var ( spawnMessage string spawnCreate bool spawnNoStart bool + spawnNaked bool spawnPolecat string spawnRig string spawnMolecule string @@ -60,11 +61,15 @@ molecule, or -m/--message for free-form tasks without a molecule. SIMPLER ALTERNATIVE: For quick polecat spawns, use 'gt sling': gt sling # Auto-spawns polecat, hooks work, starts immediately +NO-TMUX MODE: Use --naked to assign work without creating a tmux session. +Agent must be started manually, but discovers work via gt prime on startup. + Examples: gt spawn gastown/Toast --issue gt-abc # uses mol-polecat-work gt spawn gastown --issue gt-def # auto-select polecat gt spawn gastown/Nux -m "Fix the tests" # free-form task (no molecule) gt spawn gastown/Capable --issue gt-xyz --create # create if missing + gt spawn gastown/Toast --issue gt-abc --naked # no-tmux mode # Flag-based selection (rig inferred from current directory): gt spawn --issue gt-xyz --polecat Angharad @@ -102,6 +107,7 @@ func init() { spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description") spawnCmd.Flags().BoolVar(&spawnCreate, "create", false, "Create polecat if it doesn't exist") spawnCmd.Flags().BoolVar(&spawnNoStart, "no-start", false, "Assign work but don't start session") + spawnCmd.Flags().BoolVar(&spawnNaked, "naked", false, "No-tmux mode: assign work via mail only, skip tmux session (agent starts manually)") spawnCmd.Flags().StringVar(&spawnPolecat, "polecat", "", "Polecat name (alternative to positional arg)") spawnCmd.Flags().StringVar(&spawnRig, "rig", "", "Rig name (defaults to current directory's rig)") spawnCmd.Flags().StringVar(&spawnMolecule, "molecule", "", "Molecule ID to instantiate on the issue") @@ -426,6 +432,18 @@ func runSpawn(cmd *cobra.Command, args []string) error { } fmt.Printf("%s Work assignment sent\n", style.Bold.Render("✓")) + // Stop here if --naked (no-tmux mode) + if spawnNaked { + fmt.Println() + fmt.Printf("%s\n", style.Bold.Render("🔧 NO-TMUX MODE (--naked)")) + fmt.Printf("Work assigned via mail. Agent must be started manually.\n\n") + fmt.Printf("To start the agent:\n") + fmt.Printf(" cd %s/%s/%s\n", townRoot, rigName, polecatName) + fmt.Printf(" claude # Or: claude-code\n\n") + fmt.Printf("Agent will discover work via gt prime / bd show on startup.\n") + return nil + } + // Resolve account for Claude config accountsPath := constants.MayorAccountsPath(townRoot) claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, spawnAccount)