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:
Steve Yegge
2025-12-26 18:04:57 -08:00
parent 5198e566b8
commit 9462208f79
6 changed files with 244 additions and 14 deletions

View File

@@ -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"))
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)