feat: Implement no-tmux mode with beads as data plane (gt-vc3l4)
Enable Gas Town to operate without tmux by using beads for args transport: - Add `attached_args` field to beads AttachmentFields - gt sling: Store args in bead description, graceful fallback if no tmux - gt prime: Display attached args prominently on startup - gt mol status: Include attached_args in status output - gt spawn --naked: Assign work via mail only, skip tmux session Agents discover args via gt prime / bd show when starting manually. Docs added explaining what works vs degraded behavior in no-tmux mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
122
docs/no-tmux-mode.md
Normal file
122
docs/no-tmux-mode.md
Normal file
@@ -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 <bead> --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 <bead>` | 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()`.
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <bead> <rig> # 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)
|
||||
|
||||
Reference in New Issue
Block a user