diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 72358620c..0a0821605 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "3.260302.2", + "version": "3.260309.1", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/TEAM_LEAD_PROMPT.md b/TEAM_LEAD_PROMPT.md index 9552aeb1a..40fe097c1 100644 --- a/TEAM_LEAD_PROMPT.md +++ b/TEAM_LEAD_PROMPT.md @@ -1,43 +1,43 @@ -# Genie CLI — MANDATORY Worker Orchestration +# Genie CLI — MANDATORY Agent Orchestration -You are a team-lead in a **genie-managed environment**. ALL worker spawning, messaging, and team management MUST go through the genie CLI via Bash. +You are a team-lead in a **genie-managed environment**. ALL agent spawning, messaging, and team management MUST go through the genie CLI via Bash. ## CRITICAL: NEVER Use These Native Tools -NEVER use the `Agent` tool to spawn agents or subagents. Use `genie worker spawn` instead. -NEVER use `SendMessage` to communicate with workers. Use `genie msg send` instead. +NEVER use the `Agent` tool to spawn agents or subagents. Use `genie agent spawn` instead. +NEVER use `SendMessage` to communicate with agents. Use `genie send` instead. NEVER use `TeamCreate` or `TeamDelete`. Use `genie team ensure` / `genie team delete` instead. If you catch yourself about to use Agent, SendMessage, TeamCreate, or TeamDelete — STOP and use the genie CLI equivalent below. -## Workers +## Agents ```bash -# Spawn a worker (ALWAYS use this instead of Agent tool) -genie worker spawn --role # implementor, tests, review, fix, refactor -genie worker spawn --role --skill # With specific skill +# Spawn an agent (ALWAYS use this instead of Agent tool) +genie agent spawn --role # implementor, tests, review, fix, refactor +genie agent spawn --role --skill # With specific skill # Monitor -genie worker list # List all workers -genie worker dashboard # Live dashboard -genie worker history # Session history -genie worker read --follow # Tail terminal output +genie agent list # List all agents +genie agent dashboard # Live dashboard +genie agent history # Session history +genie agent read --follow # Tail terminal output # Control -genie worker kill # Force kill -genie worker suspend # Suspend (preserves session) -genie worker exec "" # Run command in worker pane -genie worker answer # Answer prompt (1-9 or text:...) +genie agent kill # Force kill +genie agent suspend # Suspend (preserves session) +genie agent exec "" # Run command in agent pane +genie agent answer # Answer prompt (1-9 or text:...) ``` ## Messaging ```bash -# Send message to a worker (ALWAYS use this instead of SendMessage) -genie msg send "" --to # Send to specific worker -genie msg inbox # View worker inbox -genie msg inbox --unread # Unread only +# Send message to an agent (ALWAYS use this instead of SendMessage) +genie send "" --to # Send to specific agent +genie inbox # View agent inbox +genie inbox --unread # Unread only ``` ## Teams @@ -51,19 +51,19 @@ genie team delete # Delete team ## Typical Flow ```bash -# 1. Spawn a worker -genie worker spawn --role implementor +# 1. Spawn an agent +genie agent spawn --role implementor # 2. Monitor -genie worker list +genie agent list # 3. Send instructions -genie msg send "Implement endpoint X" --to +genie send "Implement endpoint X" --to # 4. Check progress -genie worker history +genie agent history # 5. Shut down -genie worker kill +genie agent kill ``` diff --git a/docs/CO-ORCHESTRATION-GUIDE.md b/docs/CO-ORCHESTRATION-GUIDE.md index 0e8781ffb..b0f7d387b 100644 --- a/docs/CO-ORCHESTRATION-GUIDE.md +++ b/docs/CO-ORCHESTRATION-GUIDE.md @@ -15,7 +15,7 @@ Human (Orchestrator) ├── genie work bd-2 ──▶ Worker 2 (Claude in pane %2) │ └── worktree: .worktrees/bd-2/ │ - └── genie worker list ──▶ Status dashboard + └── genie agent list ──▶ Status dashboard ``` ## Prerequisites @@ -39,13 +39,13 @@ bd create "Update API documentation" genie work bd-1 # 4. Check worker status -genie worker list +genie agent list # 5. When worker needs approval -genie worker approve +genie agent approve # 6. When done, close the issue -genie worker close bd-1 +genie agent close bd-1 ``` ## Detailed Workflow @@ -99,10 +99,10 @@ genie work next ```bash # Check all workers -genie worker list +genie agent list # JSON output for scripting -genie worker list --json +genie agent list --json ``` **Worker States:** @@ -119,14 +119,14 @@ genie worker list --json **Approve permissions:** ```bash -genie worker approve # Approve pending permission -genie worker approve --start # Start auto-approve engine +genie agent approve # Approve pending permission +genie agent approve --start # Start auto-approve engine ``` **Answer questions:** ```bash -genie worker answer bd-1 1 # Select option 1 -genie worker answer bd-1 "text:custom" # Provide custom text answer +genie agent answer bd-1 1 # Select option 1 +genie agent answer bd-1 "text:custom" # Provide custom text answer ``` ### Phase 5: Closing Issues @@ -135,7 +135,7 @@ When a worker completes its task: ```bash # Close issue and cleanup worker -genie worker close bd-1 +genie agent close bd-1 # Options: # --merge Merge worktree branch to main before cleanup @@ -156,7 +156,7 @@ genie worker close bd-1 If a worker is stuck or needs to be terminated: ```bash -genie worker kill bd-1 +genie agent kill bd-1 ``` Note: This does NOT close the issue. The task remains `in_progress` in beads. @@ -190,7 +190,7 @@ bd update bd-2 --blocked-by bd-1 genie work bd-1 # When bd-1 completes, bd-2 becomes ready -genie worker close bd-1 +genie agent close bd-1 genie work next # Picks bd-2 ``` @@ -208,7 +208,7 @@ genie work bd-2 genie work bd-3 # Monitor all -genie worker list +genie agent list ``` ### Pattern 3: Review and Iterate @@ -231,7 +231,7 @@ genie work bd-1 ### Worker shows as dead but pane exists ```bash # The registry may be out of sync -genie worker kill # Clean up registry entry +genie agent kill # Clean up registry entry genie work # Start fresh ``` @@ -256,7 +256,7 @@ bd daemon start --auto-commit 1. **Clear issue titles**: Workers use titles as context 2. **One task per worker**: Keep issues focused 3. **Use dependencies**: `--blocked-by` prevents premature work -4. **Review before closing**: Check worker output before `genie worker close` +4. **Review before closing**: Check worker output before `genie agent close` 5. **Use worktrees**: They provide isolation and can be reviewed independently 6. **Keep daemon running**: Ensures beads state is synced to git @@ -266,16 +266,16 @@ bd daemon start --auto-commit |---------|-------------| | `genie work ` | Spawn worker for issue | | `genie work next` | Work on next ready issue | -| `genie worker list` | List all workers | -| `genie worker approve` | Approve permission / manage auto-approve | -| `genie worker answer ` | Answer question | -| `genie worker history ` | Compressed session catch-up | -| `genie worker events [pane-id]` | Stream Claude Code events | -| `genie worker close ` | Close issue and cleanup | -| `genie worker ship ` | Mark done, merge, cleanup | -| `genie worker kill ` | Force kill worker | -| `genie worker read ` | Read worker pane output | -| `genie worker exec ` | Execute command in worker pane | +| `genie agent list` | List all workers | +| `genie agent approve` | Approve permission / manage auto-approve | +| `genie agent answer ` | Answer question | +| `genie agent history ` | Compressed session catch-up | +| `genie agent events [pane-id]` | Stream Claude Code events | +| `genie agent close ` | Close issue and cleanup | +| `genie agent ship ` | Mark done, merge, cleanup | +| `genie agent kill ` | Force kill worker | +| `genie agent read ` | Read worker pane output | +| `genie agent exec ` | Execute command in worker pane | | `genie daemon start` | Start beads daemon | | `genie daemon stop` | Stop beads daemon | | `genie daemon status` | Show daemon status | diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index af25e4726..4165d33a7 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -1,6 +1,6 @@ -# Worker Profiles +# Agent Profiles -Worker profiles configure how genie-cli spawns Claude Code workers. Each profile bundles: +Agent profiles configure how genie-cli spawns Claude Code agents. Each profile bundles: - **Launcher**: `claude` (direct Claude Code invocation) - **Claude Args**: CLI arguments passed to Claude Code @@ -47,10 +47,10 @@ genie profiles default coding-fast ## Using Profiles -### With genie worker spawn +### With genie agent spawn ```bash -genie worker spawn --role implementor --profile coding-fast -genie worker spawn --role implementor --profile safe # For interactive work +genie agent spawn --role implementor --profile coding-fast +genie agent spawn --role implementor --profile safe # For interactive work ``` ### With genie work diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 174f96810..51d7e3db4 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "genie", "name": "Genie", "description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit", - "version": "3.260302.2", + "version": "3.260309.1", "configSchema": { "type": "object", "additionalProperties": false, diff --git a/package.json b/package.json index 50dfd3670..57354a3da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "3.260302.2", + "version": "3.260309.1", "description": "Collaborative terminal toolkit for human + AI workflows", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 0f09b2a22..bbe7edb39 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,11 +1,15 @@ { - "name": "genie", - "version": "3.260302.2", + "name": "automagik-genie", + "version": "3.260309.1", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" }, "repository": "https://github.com/automagik-dev/genie", "license": "MIT", - "keywords": ["workflow", "orchestration", "collaboration", "claude-code", "tmux", "agents"] + "keywords": ["workflow", "orchestration", "collaboration", "claude-code", "tmux", "agents"], + "skills": "./skills", + "hooks": "./hooks/hooks.json", + "agents": "./agents", + "settings": "./settings.json" } diff --git a/plugins/genie/hooks/hooks.json b/plugins/genie/hooks/hooks.json index 166e8801c..c84162e4b 100644 --- a/plugins/genie/hooks/hooks.json +++ b/plugins/genie/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Genie CLI hooks - setup, wish validation, session context, and completion awareness", + "description": "Genie plugin hooks — setup, onboarding, dispatch, wish validation, and completion awareness", "hooks": { "SessionStart": [ { @@ -10,6 +10,11 @@ "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/smart-install.js", "timeout": 60 }, + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/first-run-check.cjs", + "timeout": 5 + }, { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/session-context.cjs", @@ -19,6 +24,16 @@ } ], "PreToolUse": [ + { + "matcher": "SendMessage", + "hooks": [ + { + "type": "command", + "command": "genie hook dispatch", + "timeout": 15 + } + ] + }, { "matcher": "Write", "hooks": [ diff --git a/plugins/genie/package.json b/plugins/genie/package.json index a6a78f19a..787446b54 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "3.260302.2", + "version": "3.260309.1", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", diff --git a/plugins/genie/references/dispatch-contract.md b/plugins/genie/references/dispatch-contract.md index d69d9840c..9098aeecc 100644 --- a/plugins/genie/references/dispatch-contract.md +++ b/plugins/genie/references/dispatch-contract.md @@ -64,20 +64,20 @@ Documented for future validation. Three-layer chain: OpenClaw → genie → Clau ```bash # Spawn a Claude Code worker via genie -genie worker spawn --role implementor +genie agent spawn --role implementor # Or for heavy multi-file work with bead tracking bd create "" --type task genie work # Monitor -genie worker list -genie worker read +genie agent list +genie agent read ``` - **Isolation:** Claude Code worktrees (same as CC path, initiated via genie) - **Model:** sonnet (passed to CC via genie) -- **Coordination:** genie worker status + bead updates +- **Coordination:** genie agent status + bead updates - **Risk:** 3 layers of indirection — use timeouts, fall back to sequential on failure ## Rules diff --git a/plugins/genie/scripts/first-run-check.cjs b/plugins/genie/scripts/first-run-check.cjs new file mode 100755 index 000000000..34954dbb2 --- /dev/null +++ b/plugins/genie/scripts/first-run-check.cjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +"use strict"; + +/** + * First-run detection for genie plugin. + * + * Runs on SessionStart. If no AGENTS.md exists in the working directory, + * outputs a message to stderr prompting /onboarding. + * + * This is non-blocking (always exits 0) — it only suggests, never forces. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +// Use CLAUDE_CWD if available (set by Claude Code), otherwise process.cwd() +const cwd = process.env.CLAUDE_CWD || process.cwd(); +const agentsMd = path.join(cwd, "AGENTS.md"); +const claudeMd = path.join(cwd, "CLAUDE.md"); + +// Only suggest onboarding if neither AGENTS.md nor CLAUDE.md exists. +// Having either means the workspace is already configured. +if (!fs.existsSync(agentsMd) && !fs.existsSync(claudeMd)) { + console.error(""); + console.error("=".repeat(50)); + console.error(" \u{1F9DE} Genie — First Run Detected"); + console.error(" No AGENTS.md found in this workspace."); + console.error(""); + console.error(" Run /onboarding to set up your environment."); + console.error("=".repeat(50)); + console.error(""); +} + +process.exit(0); diff --git a/plugins/genie/scripts/genie.cjs b/plugins/genie/scripts/genie.cjs index d7daa1448..a2d8be827 100755 --- a/plugins/genie/scripts/genie.cjs +++ b/plugins/genie/scripts/genie.cjs @@ -155,7 +155,7 @@ Next steps:`),console.log(" 1. Reload tmux: tmux source ~/.tmux.conf"),console. `:"";return(0,pe.writeFileSync)(e,a),!0}async function Mp(){let e=(0,Zl.homedir)(),t="generated by genie-cli";console.log(`Uninstalling Warp-like shortcuts... `);let n=(0,st.join)(e,".tmux.conf");tr(n,t)?await Gr(`Remove shortcuts from ${n}? [Y/n] `)!=="n"?Jl(n,t)&&console.log(`\u2705 Removed from ${n}`):console.log("\u23ED\uFE0F Skipped tmux.conf"):console.log("\u2713 tmux.conf has no genie shortcuts");let r=(0,st.join)(e,".zshrc"),s=(0,st.join)(e,".bashrc");for(let c of[r,s])(0,pe.existsSync)(c)&&(tr(c,t)?await Gr(`Remove shell functions from ${c}? [Y/n] `)!=="n"?Jl(c,t)&&console.log(`\u2705 Removed from ${c}`):console.log(`\u23ED\uFE0F Skipped ${c}`):console.log(`\u2713 ${c} has no genie shortcuts`));let i=(0,st.join)(e,".termux"),o=(0,st.join)(i,"termux.properties"),a=(0,pe.existsSync)(i)||process.env.TERMUX_VERSION;a&&(tr(o,t)?await Gr(`Remove extra keys from ${o}? [Y/n] `)!=="n"?Jl(o,t)&&(console.log(`\u2705 Removed from ${o}`),console.log(" Run: termux-reload-settings")):console.log("\u23ED\uFE0F Skipped termux.properties"):console.log("\u2713 termux.properties has no genie shortcuts")),console.log(` \u2705 Uninstallation complete!`),console.log(` -Next steps:`),console.log(" 1. Reload tmux: tmux source ~/.tmux.conf"),console.log(" 2. Restart your shell or run: source ~/.bashrc"),a&&console.log(" 3. Reload Termux: termux-reload-settings")}function Hr(){console.log(),console.log(`\x1B[1m\x1B[36m${"=".repeat(64)}\x1B[0m`),console.log("\x1B[1m\x1B[36m Genie Setup Wizard\x1B[0m"),console.log(`\x1B[1m\x1B[36m${"=".repeat(64)}\x1B[0m`),console.log()}function on(e,t){console.log(),console.log(`\x1B[1m${e}\x1B[0m`),t&&console.log(`\x1B[2m${t}\x1B[0m`),console.log()}async function ew(){on("1. Prerequisites Check","Checking required tools...");let e=[{name:"tmux",required:!0},{name:"bun",required:!0},{name:"claude",required:!1,displayName:"Claude Code CLI"},{name:"codex",required:!1,displayName:"OpenAI Codex CLI"},{name:"jq",required:!1}];for(let t of e){let n=await Je(t.name),r=t.displayName||t.name;n.exists?console.log(` \x1B[32m\u2713\x1B[0m ${r} ${n.version?`(${n.version})`:""}`):t.required?console.log(` \x1B[31m\u2717\x1B[0m ${r} \x1B[2m(required)\x1B[0m`):console.log(` \x1B[33m!\x1B[0m ${r} \x1B[2m(optional)\x1B[0m`)}}async function Np(e,t){if(on("2. Session Configuration","Configure tmux session settings"),t)return console.log(` Using defaults: session="${e.session.name}", window="${e.session.defaultWindow}"`),e;let n=await vt({message:"Session name:",default:e.session.name}),r=await vt({message:"Default window name:",default:e.session.defaultWindow}),s=await le({message:"Auto-create session on connect?",default:e.session.autoCreate});return e.session={name:n,defaultWindow:r,autoCreate:s},e}async function Lp(e,t){if(on("3. Terminal Defaults","Configure default values for term commands"),t)return console.log(` Using defaults: timeout=${e.terminal.execTimeout}ms, lines=${e.terminal.readLines}`),e;let n=await vt({message:"Exec timeout (milliseconds):",default:String(e.terminal.execTimeout),validate:i=>{let o=Number.parseInt(i,10);return!Number.isNaN(o)&&o>0?!0:"Must be a positive number"}}),r=await vt({message:"Read lines (default for genie worker read):",default:String(e.terminal.readLines),validate:i=>{let o=Number.parseInt(i,10);return!Number.isNaN(o)&&o>0?!0:"Must be a positive number"}}),s=await vt({message:"Worktree base directory:",default:e.terminal.worktreeBase});return e.terminal={execTimeout:Number.parseInt(n,10),readLines:Number.parseInt(r,10),worktreeBase:s},e}async function jp(e,t){on("4. Keyboard Shortcuts","Warp-like tmux shortcuts for quick navigation");let n=(0,Up.homedir)(),r=(0,Vp.join)(n,".tmux.conf");return Vs(r)?(console.log(" \x1B[32m\u2713\x1B[0m Tmux shortcuts already installed"),e.shortcuts.tmuxInstalled=!0,e):(console.log(" Available shortcuts:"),console.log(" \x1B[36mCtrl+T\x1B[0m \u2192 New tab (window)"),console.log(" \x1B[36mCtrl+S\x1B[0m \u2192 Vertical split"),console.log(" \x1B[36mCtrl+H\x1B[0m \u2192 Horizontal split"),console.log(),t?(console.log(" Skipped in quick mode. Run \x1B[36mgenie setup --shortcuts\x1B[0m to install."),e):(await le({message:"Install tmux keyboard shortcuts?",default:!1})?(console.log(),await wo(),e.shortcuts.tmuxInstalled=!0,await xd({tmuxInstalled:!0})):console.log(" Skipped. Run \x1B[36mgenie shortcuts install\x1B[0m later."),e))}async function Wp(e,t){return on("5. Claudio Integration","LLM router profile management"),(await Je("claude")).exists?t?(console.log(" Skipped in quick mode. Run \x1B[36mclaudio setup\x1B[0m to configure."),e):(await le({message:"Configure Claudio API profiles?",default:!1})?(e.claudio={enabled:!0},console.log(),console.log(" Run \x1B[36mclaudio setup\x1B[0m to configure API profiles.")):console.log(" Skipped. Run \x1B[36mclaudio setup\x1B[0m later."),e):(console.log(" \x1B[33m!\x1B[0m Claude Code CLI not found. Skipping claudio integration."),e)}async function qp(e,t){on("6. Codex Integration","Configure OpenAI Codex for genie workers");let n=await Je("codex");if(!n.exists)return console.log(" \x1B[33m!\x1B[0m Codex CLI not found. Skipping codex integration."),e;if(console.log(` \x1B[32m\u2713\x1B[0m Codex CLI found (${n.version??"unknown version"})`),Fp())return console.log(" \x1B[32m\u2713\x1B[0m Codex config already configured"),e.codex={configured:!0},e;if(console.log(),console.log(" Genie needs to configure codex for worker communication:"),console.log(" \x1B[36mdisable_paste_burst\x1B[0m \u2192 Reliable tmux command injection"),console.log(" \x1B[36mOTel exporter\x1B[0m \u2192 Telemetry relay for state detection"),console.log(` Config: \x1B[2m${$r(Op())}\x1B[0m`),console.log(),t){let s=Us();return console.log(s==="changed"?" \x1B[32m\u2713\x1B[0m Codex config updated":s==="unchanged"?" \x1B[32m\u2713\x1B[0m Codex config already up to date":" \x1B[31m\u2717\x1B[0m Failed to update codex config"),e.codex={configured:s!=="error"},e}if(await le({message:"Configure Codex for genie worker integration?",default:!0})){let s=Us();console.log(s==="changed"?" \x1B[32m\u2713\x1B[0m Codex config updated":s==="unchanged"?" \x1B[32m\u2713\x1B[0m Codex config already up to date":" \x1B[31m\u2717\x1B[0m Failed to update codex config"),e.codex={configured:s!=="error"}}else console.log(" Skipped. Run \x1B[36mgenie setup --codex\x1B[0m later.");return e}async function tw(e,t){if(on("7. Debug Options","Logging and debugging settings"),t)return console.log(" Using defaults: tmuxDebug=false, verbose=false"),e;let n=await le({message:"Enable tmux debug logging?",default:e.logging.tmuxDebug}),r=await le({message:"Enable verbose mode?",default:e.logging.verbose});return e.logging={tmuxDebug:n,verbose:r},e}async function nw(e){on("Summary",`Configuration will be saved to ${$r(Es())}`),console.log(` Session: \x1B[36m${e.session.name}\x1B[0m (window: ${e.session.defaultWindow})`),console.log(` Terminal: timeout=${e.terminal.execTimeout}ms, lines=${e.terminal.readLines}`),console.log(` Shortcuts: ${e.shortcuts.tmuxInstalled?"\x1B[32minstalled\x1B[0m":"\x1B[2mnot installed\x1B[0m"}`),console.log(` Claudio: ${e.claudio?.enabled?"\x1B[32menabled\x1B[0m":"\x1B[2mnot configured\x1B[0m"}`),console.log(` Codex: ${e.codex?.configured?"\x1B[32mconfigured\x1B[0m":"\x1B[2mnot configured\x1B[0m"}`),console.log(` Debug: tmux=${e.logging.tmuxDebug}, verbose=${e.logging.verbose}`),console.log(),e.setupComplete=!0,e.lastSetupAt=new Date().toISOString(),await je(e),console.log("\x1B[32m\u2713 Configuration saved!\x1B[0m")}async function rw(){let e=await ce();console.log(),console.log("\x1B[1mCurrent Genie Configuration\x1B[0m"),console.log(`\x1B[2m${$r(Es())}\x1B[0m`),console.log(),console.log(JSON.stringify(e,null,2)),console.log()}function sw(){console.log(),console.log("\x1B[1mNext Steps:\x1B[0m"),console.log(),console.log(" Start a session: \x1B[36mclaudio launch\x1B[0m"),console.log(" Watch AI work: \x1B[36mtmux attach -t genie\x1B[0m"),console.log(" Check health: \x1B[36mgenie doctor\x1B[0m"),console.log()}async function Do(e={}){if(e.show){await rw();return}if(e.reset){await md(),console.log("\x1B[32m\u2713 Configuration reset to defaults.\x1B[0m"),console.log();return}let t=await ce();if(e.shortcuts){Hr(),await jp(t,!1),await nc();return}if(e.terminal){Hr(),t=await Lp(t,!1),await je(t),console.log("\x1B[32m\u2713 Terminal configuration saved.\x1B[0m");return}if(e.session){Hr(),t=await Np(t,!1),await je(t),console.log("\x1B[32m\u2713 Session configuration saved.\x1B[0m");return}if(e.claudio){Hr(),await Wp(t,!1),await nc();return}if(e.codex){Hr(),t=await qp(t,!1),await je(t),t.codex?.configured&&console.log("\x1B[32m\u2713 Codex configuration saved.\x1B[0m");return}let n=e.quick??!1;Hr(),n&&console.log("\x1B[2mQuick mode: accepting all defaults\x1B[0m"),await ew(),t=await Np(t,n),t=await Lp(t,n),t=await jp(t,n),t=await Wp(t,n),t=await qp(t,n),t=await tw(t,n),await nw(t),sw()}var iw={tmux:{brew:"brew install tmux",apt:"sudo apt update && sudo apt install -y tmux",dnf:"sudo dnf install -y tmux",yum:"sudo yum install -y tmux",pacman:"sudo pacman -S --noconfirm tmux",none:null},bun:{all:"curl -fsSL https://bun.sh/install | bash"},claude:{all:"npm install -g @anthropic-ai/claude-code"}};function ow(e,t){let n=iw[e];return n?n.all?n.all:t!=="none"&&n[t]?n[t]:n.none!==void 0?n.none:null:null}function Gp(e){switch(e){case"tmux":return"Visit https://github.com/tmux/tmux/wiki/Installing for installation instructions";case"bun":return"Visit https://bun.sh for installation instructions";case"claude":return"Run: npm install -g @anthropic-ai/claude-code (requires Node.js/npm)";default:return`Search for "${e} install" for installation instructions`}}async function aw(e){return new Promise(t=>{console.log(`\x1B[2m$ ${e}\x1B[0m +Next steps:`),console.log(" 1. Reload tmux: tmux source ~/.tmux.conf"),console.log(" 2. Restart your shell or run: source ~/.bashrc"),a&&console.log(" 3. Reload Termux: termux-reload-settings")}function Hr(){console.log(),console.log(`\x1B[1m\x1B[36m${"=".repeat(64)}\x1B[0m`),console.log("\x1B[1m\x1B[36m Genie Setup Wizard\x1B[0m"),console.log(`\x1B[1m\x1B[36m${"=".repeat(64)}\x1B[0m`),console.log()}function on(e,t){console.log(),console.log(`\x1B[1m${e}\x1B[0m`),t&&console.log(`\x1B[2m${t}\x1B[0m`),console.log()}async function ew(){on("1. Prerequisites Check","Checking required tools...");let e=[{name:"tmux",required:!0},{name:"bun",required:!0},{name:"claude",required:!1,displayName:"Claude Code CLI"},{name:"codex",required:!1,displayName:"OpenAI Codex CLI"},{name:"jq",required:!1}];for(let t of e){let n=await Je(t.name),r=t.displayName||t.name;n.exists?console.log(` \x1B[32m\u2713\x1B[0m ${r} ${n.version?`(${n.version})`:""}`):t.required?console.log(` \x1B[31m\u2717\x1B[0m ${r} \x1B[2m(required)\x1B[0m`):console.log(` \x1B[33m!\x1B[0m ${r} \x1B[2m(optional)\x1B[0m`)}}async function Np(e,t){if(on("2. Session Configuration","Configure tmux session settings"),t)return console.log(` Using defaults: session="${e.session.name}", window="${e.session.defaultWindow}"`),e;let n=await vt({message:"Session name:",default:e.session.name}),r=await vt({message:"Default window name:",default:e.session.defaultWindow}),s=await le({message:"Auto-create session on connect?",default:e.session.autoCreate});return e.session={name:n,defaultWindow:r,autoCreate:s},e}async function Lp(e,t){if(on("3. Terminal Defaults","Configure default values for term commands"),t)return console.log(` Using defaults: timeout=${e.terminal.execTimeout}ms, lines=${e.terminal.readLines}`),e;let n=await vt({message:"Exec timeout (milliseconds):",default:String(e.terminal.execTimeout),validate:i=>{let o=Number.parseInt(i,10);return!Number.isNaN(o)&&o>0?!0:"Must be a positive number"}}),r=await vt({message:"Read lines (default for genie agent read):",default:String(e.terminal.readLines),validate:i=>{let o=Number.parseInt(i,10);return!Number.isNaN(o)&&o>0?!0:"Must be a positive number"}}),s=await vt({message:"Worktree base directory:",default:e.terminal.worktreeBase});return e.terminal={execTimeout:Number.parseInt(n,10),readLines:Number.parseInt(r,10),worktreeBase:s},e}async function jp(e,t){on("4. Keyboard Shortcuts","Warp-like tmux shortcuts for quick navigation");let n=(0,Up.homedir)(),r=(0,Vp.join)(n,".tmux.conf");return Vs(r)?(console.log(" \x1B[32m\u2713\x1B[0m Tmux shortcuts already installed"),e.shortcuts.tmuxInstalled=!0,e):(console.log(" Available shortcuts:"),console.log(" \x1B[36mCtrl+T\x1B[0m \u2192 New tab (window)"),console.log(" \x1B[36mCtrl+S\x1B[0m \u2192 Vertical split"),console.log(" \x1B[36mCtrl+H\x1B[0m \u2192 Horizontal split"),console.log(),t?(console.log(" Skipped in quick mode. Run \x1B[36mgenie setup --shortcuts\x1B[0m to install."),e):(await le({message:"Install tmux keyboard shortcuts?",default:!1})?(console.log(),await wo(),e.shortcuts.tmuxInstalled=!0,await xd({tmuxInstalled:!0})):console.log(" Skipped. Run \x1B[36mgenie shortcuts install\x1B[0m later."),e))}async function Wp(e,t){return on("5. Claudio Integration","LLM router profile management"),(await Je("claude")).exists?t?(console.log(" Skipped in quick mode. Run \x1B[36mclaudio setup\x1B[0m to configure."),e):(await le({message:"Configure Claudio API profiles?",default:!1})?(e.claudio={enabled:!0},console.log(),console.log(" Run \x1B[36mclaudio setup\x1B[0m to configure API profiles.")):console.log(" Skipped. Run \x1B[36mclaudio setup\x1B[0m later."),e):(console.log(" \x1B[33m!\x1B[0m Claude Code CLI not found. Skipping claudio integration."),e)}async function qp(e,t){on("6. Codex Integration","Configure OpenAI Codex for genie agents");let n=await Je("codex");if(!n.exists)return console.log(" \x1B[33m!\x1B[0m Codex CLI not found. Skipping codex integration."),e;if(console.log(` \x1B[32m\u2713\x1B[0m Codex CLI found (${n.version??"unknown version"})`),Fp())return console.log(" \x1B[32m\u2713\x1B[0m Codex config already configured"),e.codex={configured:!0},e;if(console.log(),console.log(" Genie needs to configure codex for worker communication:"),console.log(" \x1B[36mdisable_paste_burst\x1B[0m \u2192 Reliable tmux command injection"),console.log(" \x1B[36mOTel exporter\x1B[0m \u2192 Telemetry relay for state detection"),console.log(` Config: \x1B[2m${$r(Op())}\x1B[0m`),console.log(),t){let s=Us();return console.log(s==="changed"?" \x1B[32m\u2713\x1B[0m Codex config updated":s==="unchanged"?" \x1B[32m\u2713\x1B[0m Codex config already up to date":" \x1B[31m\u2717\x1B[0m Failed to update codex config"),e.codex={configured:s!=="error"},e}if(await le({message:"Configure Codex for genie agent integration?",default:!0})){let s=Us();console.log(s==="changed"?" \x1B[32m\u2713\x1B[0m Codex config updated":s==="unchanged"?" \x1B[32m\u2713\x1B[0m Codex config already up to date":" \x1B[31m\u2717\x1B[0m Failed to update codex config"),e.codex={configured:s!=="error"}}else console.log(" Skipped. Run \x1B[36mgenie setup --codex\x1B[0m later.");return e}async function tw(e,t){if(on("7. Debug Options","Logging and debugging settings"),t)return console.log(" Using defaults: tmuxDebug=false, verbose=false"),e;let n=await le({message:"Enable tmux debug logging?",default:e.logging.tmuxDebug}),r=await le({message:"Enable verbose mode?",default:e.logging.verbose});return e.logging={tmuxDebug:n,verbose:r},e}async function nw(e){on("Summary",`Configuration will be saved to ${$r(Es())}`),console.log(` Session: \x1B[36m${e.session.name}\x1B[0m (window: ${e.session.defaultWindow})`),console.log(` Terminal: timeout=${e.terminal.execTimeout}ms, lines=${e.terminal.readLines}`),console.log(` Shortcuts: ${e.shortcuts.tmuxInstalled?"\x1B[32minstalled\x1B[0m":"\x1B[2mnot installed\x1B[0m"}`),console.log(` Claudio: ${e.claudio?.enabled?"\x1B[32menabled\x1B[0m":"\x1B[2mnot configured\x1B[0m"}`),console.log(` Codex: ${e.codex?.configured?"\x1B[32mconfigured\x1B[0m":"\x1B[2mnot configured\x1B[0m"}`),console.log(` Debug: tmux=${e.logging.tmuxDebug}, verbose=${e.logging.verbose}`),console.log(),e.setupComplete=!0,e.lastSetupAt=new Date().toISOString(),await je(e),console.log("\x1B[32m\u2713 Configuration saved!\x1B[0m")}async function rw(){let e=await ce();console.log(),console.log("\x1B[1mCurrent Genie Configuration\x1B[0m"),console.log(`\x1B[2m${$r(Es())}\x1B[0m`),console.log(),console.log(JSON.stringify(e,null,2)),console.log()}function sw(){console.log(),console.log("\x1B[1mNext Steps:\x1B[0m"),console.log(),console.log(" Start a session: \x1B[36mclaudio launch\x1B[0m"),console.log(" Watch AI work: \x1B[36mtmux attach -t genie\x1B[0m"),console.log(" Check health: \x1B[36mgenie doctor\x1B[0m"),console.log()}async function Do(e={}){if(e.show){await rw();return}if(e.reset){await md(),console.log("\x1B[32m\u2713 Configuration reset to defaults.\x1B[0m"),console.log();return}let t=await ce();if(e.shortcuts){Hr(),await jp(t,!1),await nc();return}if(e.terminal){Hr(),t=await Lp(t,!1),await je(t),console.log("\x1B[32m\u2713 Terminal configuration saved.\x1B[0m");return}if(e.session){Hr(),t=await Np(t,!1),await je(t),console.log("\x1B[32m\u2713 Session configuration saved.\x1B[0m");return}if(e.claudio){Hr(),await Wp(t,!1),await nc();return}if(e.codex){Hr(),t=await qp(t,!1),await je(t),t.codex?.configured&&console.log("\x1B[32m\u2713 Codex configuration saved.\x1B[0m");return}let n=e.quick??!1;Hr(),n&&console.log("\x1B[2mQuick mode: accepting all defaults\x1B[0m"),await ew(),t=await Np(t,n),t=await Lp(t,n),t=await jp(t,n),t=await Wp(t,n),t=await qp(t,n),t=await tw(t,n),await nw(t),sw()}var iw={tmux:{brew:"brew install tmux",apt:"sudo apt update && sudo apt install -y tmux",dnf:"sudo dnf install -y tmux",yum:"sudo yum install -y tmux",pacman:"sudo pacman -S --noconfirm tmux",none:null},bun:{all:"curl -fsSL https://bun.sh/install | bash"},claude:{all:"npm install -g @anthropic-ai/claude-code"}};function ow(e,t){let n=iw[e];return n?n.all?n.all:t!=="none"&&n[t]?n[t]:n.none!==void 0?n.none:null:null}function Gp(e){switch(e){case"tmux":return"Visit https://github.com/tmux/tmux/wiki/Installing for installation instructions";case"bun":return"Visit https://bun.sh for installation instructions";case"claude":return"Run: npm install -g @anthropic-ai/claude-code (requires Node.js/npm)";default:return`Search for "${e} install" for installation instructions`}}async function aw(e){return new Promise(t=>{console.log(`\x1B[2m$ ${e}\x1B[0m `);let n=(0,zp.spawn)("bash",["-c",e],{stdio:"inherit",env:{...process.env,FORCE_COLOR:"1"}});n.on("close",r=>{t(r===0)}),n.on("error",r=>{console.error(`\x1B[31mError: ${r.message}\x1B[0m`),t(!1)})})}function cw(){console.log(),console.log("\x1B[1m\u{1F527} Genie Prerequisites Check\x1B[0m"),console.log()}function lw(e){let t=e.os==="macos"?"macOS":"Linux";e.linuxDistro&&e.linuxDistro!=="unknown"&&(t=`Linux (${vd(e.linuxDistro)})`),console.log(`System: ${t} (${e.arch})`),console.log(`Package Manager: ${e.preferredPM==="none"?"none detected":e.preferredPM}`),console.log()}function uw(e){console.log("Checking prerequisites..."),console.log();for(let t of e)if(t.installed){let n=t.version?` ${t.version}`:"",r=t.path?`\x1B[2m (${t.path})\x1B[0m`:"";console.log(` \x1B[32m\u2705\x1B[0m ${t.name}${n}${r}`)}else t.required?console.log(` \x1B[31m\u274C\x1B[0m ${t.name} \x1B[31mnot found\x1B[0m`):console.log(` \x1B[33m\u26A0\uFE0F\x1B[0m ${t.name} \x1B[33mnot found\x1B[0m \x1B[2m(optional)\x1B[0m`);console.log()}function vo(){console.log(`\x1B[2m${"\u2500".repeat(40)}\x1B[0m`),console.log()}async function dw(e,t,n){let r=e.required?"\x1B[31mrequired\x1B[0m":"\x1B[2moptional, recommended\x1B[0m";if(!t)return console.log(`\x1B[33mManual installation required for\x1B[0m ${e.name} (${r})`),console.log(` ${Gp(e.name)}`),console.log(),"skipped";console.log(`Install \x1B[1m${e.name}\x1B[0m? (${r})`),console.log(` Command: \x1B[36m${t}\x1B[0m`);let s;if(n.yes?(s=!0,console.log("\x1B[2mAuto-approved with --yes\x1B[0m")):s=await le({message:"Proceed",default:e.required}),!s)return console.log(`\x1B[33mSkipped: ${e.name}\x1B[0m`),console.log(),"skipped";if(console.log(),console.log(`Installing ${e.name}...`),console.log(),await aw(t)){let o=await Je(e.name);if(o.exists){let a=o.version?` ${o.version}`:"";return console.log(),console.log(`\x1B[32m\u2705 ${e.name}${a} installed\x1B[0m`),console.log(),"installed"}return console.log(),console.log(`\x1B[33m\u26A0\uFE0F ${e.name} installed but not found in PATH\x1B[0m`),console.log("\x1B[2m You may need to restart your shell or source your profile\x1B[0m"),console.log(),"installed"}return console.log(),console.log(`\x1B[31m\u274C Failed to install ${e.name}\x1B[0m`),console.log(`\x1B[2m ${Gp(e.name)}\x1B[0m`),console.log(),"failed"}async function Hp(e){if(tn()){console.log("\x1B[2m\u2713 Genie hooks already configured (~/.genie/config.json)\x1B[0m"),console.log(" Run \x1B[36mgenie setup\x1B[0m to reconfigure."),console.log();return}console.log(),vo(),console.log("\x1B[1m\u{1F9DE} Configure Genie Hooks?\x1B[0m"),console.log(),console.log("\x1B[2mHooks let you control how AI tools execute - without wasting tokens!\x1B[0m"),console.log('\x1B[2mFor example, the "collaborative" hook routes all bash commands through\x1B[0m'),console.log("\x1B[2mtmux so you can watch the AI work in real-time.\x1B[0m"),console.log();let t;e.yes?(t=!0,console.log("\x1B[2mAuto-approved with --yes\x1B[0m")):t=await le({message:"Would you like to configure genie hooks now?",default:!0}),t?(console.log(),await Do()):(console.log(),console.log("\x1B[2mSkipped. Run \x1B[0m\x1B[36mgenie setup\x1B[0m\x1B[2m anytime to configure hooks.\x1B[0m"),console.log())}async function Kp(e){cw();let t=await wd();lw(t);let n=await Dd();uw(n);let r=n.filter(u=>!u.installed),s=r.filter(u=>u.required),i=r.filter(u=>!u.required);if(r.length===0){console.log("\x1B[32m\u2705 All prerequisites are installed!\x1B[0m"),console.log(),await Hp(e),console.log("Run \x1B[36mterm --help\x1B[0m or \x1B[36mclaudio --help\x1B[0m to get started."),console.log();return}if(console.log(`Missing: ${s.length} required, ${i.length} optional`),console.log(),e.check){s.length>0&&(console.log("\x1B[31m\u274C Missing required prerequisites\x1B[0m"),console.log("Run \x1B[36mgenie install\x1B[0m to install them."),process.exit(1));return}vo();let o={installed:[],skipped:[],failed:[]};for(let u of r){let d=ow(u.name,t.preferredPM),f=await dw(u,d,e);f==="installed"?o.installed.push(u.name):f==="skipped"?o.skipped.push(u.name):o.failed.push(u.name),u!==r[r.length-1]&&vo()}vo(),console.log("\x1B[1mSummary:\x1B[0m");let a=o.installed.filter(u=>s.some(d=>d.name===u)),c=o.failed.filter(u=>s.some(d=>d.name===u)),l=o.skipped.filter(u=>i.some(d=>d.name===u));c.length>0?console.log(`\x1B[31m \u274C ${c.length} required failed: ${c.join(", ")}\x1B[0m`):(a.length>0||s.length===0)&&console.log("\x1B[32m \u2705 All required prerequisites installed\x1B[0m"),o.installed.length>0&&console.log(`\x1B[32m \u2705 Installed: ${o.installed.join(", ")}\x1B[0m`),l.length>0&&console.log(`\x1B[33m \u26A0\uFE0F ${l.length} optional skipped: ${l.join(", ")}\x1B[0m`),console.log(),c.length===0?(await Hp(e),console.log("Run \x1B[36mterm --help\x1B[0m or \x1B[36mclaudio --help\x1B[0m to get started.")):(console.log("\x1B[31mSome required prerequisites could not be installed.\x1B[0m"),console.log("Please install them manually and run this command again."),process.exit(1)),console.log()}var Yp=require("node:fs/promises"),ko=require("node:path");async function Jp(e={}){let t=(0,ko.resolve)(e.repo||process.cwd()),n=(0,ko.join)(t,".beads","issues.jsonl"),r;try{r=await(0,Yp.readFile)(n,"utf-8")}catch{let l=`\u274C Missing: ${n}`;e.json&&(console.log(JSON.stringify({ok:!1,error:l,issuesPath:n},null,2)),process.exit(1)),console.error(l),process.exit(1)}let s=[],i=0,o=new Set,a=r.split(` `);for(let l=0;l20&&console.error(` ...and ${s.length-20} more`)}c||process.exit(1)}async function Zp(e){await Jp(e)}var Qp=require("node:child_process"),Gs=require("node:path"),Xp=require("node:url"),pw={};function fw(){let e=typeof __filename<"u"?(0,Gs.dirname)(__filename):(0,Gs.dirname)((0,Xp.fileURLToPath)(pw.url));return(0,Gs.resolve)(e,"../../packages/genie-pdf/src/index.ts")}function Co(e){let t;try{t=fw()}catch{return console.error("\u274C genie-pdf is not available in this context (bundled plugin mode)."),console.error(" Install genie-cli from source to use PDF commands."),Promise.resolve(1)}return new Promise(n=>{let r=(0,Qp.spawn)("bun",["run",t,...e],{stdio:"inherit",cwd:process.cwd()});r.on("close",s=>{n(s??0)}),r.on("error",s=>{console.error(`\u274C Failed to execute genie-pdf: ${s.message}`),n(1)})})}async function em(e,t){let n=["render",e];t.output&&n.push("-o",t.output),t.theme&&n.push("-t",t.theme),t.pageNumbers===!1&&n.push("--no-page-numbers"),t.watch&&n.push("-w");let r=await Co(n);r!==0&&process.exit(r)}async function tm(e,t){let n=["template",e];t.data&&n.push("-d",t.data),t.output&&n.push("-o",t.output),t.theme&&n.push("-t",t.theme);let r=await Co(n);r!==0&&process.exit(r)}async function nm(){let e=await Co(["themes"]);e!==0&&process.exit(e)}async function rm(){let e=await Co(["templates"]);e!==0&&process.exit(e)}async function Ql(){let e=await ce(),t=e.workerProfiles||{},n=Object.keys(t);if(n.length===0){console.log(` No worker profiles configured. @@ -240,7 +240,7 @@ Done:`);for(let d of u)console.log(` ${d.id}: ${d.title}`)}console.log("")}catc `:""}var Mk=Rk,Nk={dump:Mk};function s0(e,t){return function(){throw new Error("Function yaml."+e+" is removed in js-yaml 4. Use yaml."+t+" instead, which is now safe by default.")}}var sh=Hg.load,q3=Hg.loadAll,U3=Nk.dump;var V3=s0("safeLoad","load"),G3=s0("safeLoadAll","loadAll"),H3=s0("safeDump","dump");Er();var ih=0;function jk(){ih++;let e=Date.now().toString(36),t=ih.toString(36).padStart(4,"0");return`req-${e}-${t}`}function oh(e){return e.type!=="tool_call"&&e.type!=="permission_request"||!e.toolName?null:{id:jk(),toolName:e.toolName,toolInput:e.toolInput,paneId:e.paneId,wishId:e.wishId,sessionId:e.sessionId,cwd:e.cwd,timestamp:e.timestamp,toolCallId:e.toolCallId}}function ah(){let e=[];return{add(t){e.push(t)},next(){return e.length===0?null:e.shift()||null},get(t){return e.find(n=>n.id===t)||null},remove(t){let n=e.findIndex(r=>r.id===t);n!==-1&&e.splice(n,1)},getAll(){return[...e]},getByPane(t){return e.filter(n=>n.paneId===t)},size(){return e.length},clear(){e.length=0}}}function ch(e){if(e.toolName!=="Bash"||!e.toolInput)return null;let t=e.toolInput.command;return typeof t=="string"?t:null}var ma=b.string(),dh=b.object({inherit:b.enum(["global","none"]).optional(),allow:b.array(ma).default([]),deny:b.array(ma).default([]),bash_allow_patterns:b.array(b.string()).optional(),bash_deny_patterns:b.array(b.string()).optional()}),Wk=b.object({allow:b.array(ma).default([]),deny:b.array(ma).default([]),bash_allow_patterns:b.array(b.string()).optional(),bash_deny_patterns:b.array(b.string()).optional()}),fh=b.object({defaults:Wk.default({}),repos:b.record(b.string(),dh).optional()});function qk(){return(0,ga.join)((0,uh.homedir)(),".config","genie")}function ph(e,t){if(!(0,xa.existsSync)(e))return null;try{let n=(0,xa.readFileSync)(e,"utf-8"),r=sh(n),s=t.safeParse(r);return s.success?s.data:(console.warn(`Warning: Invalid config at ${e}:`,s.error.message),null)}catch(n){return console.warn(`Warning: Failed to load config at ${e}:`,n.message),null}}function Uk(e={}){let t=e.globalConfigDir??qk(),n=(0,ga.join)(t,"auto-approve.yaml");return ph(n,fh)}function Vk(e){let t=(0,ga.join)(e,".genie","auto-approve.yaml");return ph(t,dh)}function Gk(e,t){if(!e.repos)return null;if(e.repos[t])return e.repos[t];for(let[n,r]of Object.entries(e.repos))if(t.startsWith(`${n}/`)||t===n)return r;return null}function pa(e,t){let n=e??[],r=t??[];return Array.from(new Set([...n,...r]))}function o0(e,t){return!("inherit"in t?t.inherit!=="none":!0)&&"allow"in t?{defaults:{allow:t.allow??[],deny:t.deny??[],bash_allow_patterns:t.bash_allow_patterns,bash_deny_patterns:t.bash_deny_patterns},repos:e.repos}:{defaults:{allow:pa(e.defaults.allow,t.allow),deny:pa(e.defaults.deny,t.deny),bash_allow_patterns:pa(e.defaults.bash_allow_patterns,t.bash_allow_patterns),bash_deny_patterns:pa(e.defaults.bash_deny_patterns,t.bash_deny_patterns)},repos:e.repos}}async function a0(e,t={}){let n=fh.parse({}),r=Uk(t);if(r&&(n=r),r){let i=Gk(r,e);i&&(n=o0(n,i))}let s=Vk(e);return s&&(s.inherit==="global"?n=o0(n,s):n={defaults:{allow:s.allow,deny:s.deny,bash_allow_patterns:s.bash_allow_patterns,bash_deny_patterns:s.bash_deny_patterns}}),n}function Hk(e){let t={allow:[],deny:[],bash_allow_patterns:[],bash_deny_patterns:[]},r=/^## Auto-Approve\s*$/m.exec(e);if(!r)return t;let s=r.index+r[0].length,i=/^##?\s+/m.exec(e.slice(s)),o=i?s+i.index:e.length,c=e.slice(s,o).split(` `);for(let l of c){let u=l.trim();if(!u.startsWith("-"))continue;let d=u.slice(1).trim(),f=/^bash:\s*["'](.+?)["']$/.exec(d);if(f){t.bash_allow_patterns.push(f[1]);continue}let p=/^allow:\s*(\w+)$/.exec(d);if(p){t.allow.push(p[1]);continue}let m=/^deny:\s*(\w+)$/.exec(d);m&&t.deny.push(m[1])}return t}async function mh(e,t,n={}){let r=await a0(e,n);if(t){let s=Hk(t);r=o0(r,s)}return r}var lh=100;function zk(e){let t=e.trim().replace(/\s+/g," ");return t===""?"":(t=t.replace(/^\/[\w./-]*\/(\w)/,"$1"),t)}function Kk(e){return/(&&|\||;|`|\$\()/.test(e)}function Yk(e,t){let n=Date.now(),r=t.length>8192?t.slice(0,8192):t;try{let s=e.test(r),i=Date.now()-n;return i>lh?(console.warn(`Warning: Regex pattern "${e.source}" took ${i}ms to evaluate (limit: ${lh}ms). Treating as non-match for safety.`),!1):s}catch{return!1}}function i0(e,t){for(let n of t)try{let r=new RegExp(n);if(Yk(r,e))return n}catch{if(console.warn(`Warning: Invalid regex pattern "${n}" in bash patterns; falling back to substring match`),e.includes(n))return n}return null}function xh(e,t){let{toolName:n}=e,{allow:r,deny:s,bash_allow_patterns:i,bash_deny_patterns:o}=t.defaults;if(s.includes(n))return{action:"deny",reason:`Tool "${n}" is in the deny list`};if(!r.includes(n))return{action:"escalate",reason:`Tool "${n}" is not in the allow list; requires human review`};if(n!=="Bash")return{action:"approve",reason:`Tool "${n}" is in the allow list`};let a=ch(e);if(a===null)return{action:"escalate",reason:"Bash tool request has no command to evaluate; requires human review"};let c=zk(a),l=o??[];if(l.length>0){let d=i0(c,l);if(d)return{action:"deny",reason:`Bash command matches deny pattern "${d}": ${c}`}}let u=i??[];if(u.length===0&&l.length===0)return{action:"approve",reason:"Bash tool is allowed and no command patterns are configured"};if(Kk(c)){if(u.length>0){let d=i0(c,u);if(d)try{let f=new RegExp(d),p=c.match(f);if(p&&p[0]===c)return{action:"approve",reason:`Bash compound command fully matches allow pattern "${d}": ${c}`}}catch{}}return{action:"escalate",reason:`Bash command contains shell metacharacters and does not fully match any allow pattern; requires human review: ${c}`}}if(u.length>0){let d=i0(c,u);if(d)return{action:"approve",reason:`Bash command matches allow pattern "${d}": ${c}`}}return{action:"escalate",reason:`Bash command does not match any allow pattern; requires human review: ${c}`}}ue();var Jk="auto-approve-audit.jsonl";function Zk(e){return(0,l0.join)(e,".genie",Jk)}function Qk(e){let t=(0,l0.join)(e,".genie");(0,ms.existsSync)(t)||(0,ms.mkdirSync)(t,{recursive:!0})}function c0(e,t){Qk(e);let n=Zk(e),r=`${JSON.stringify(t)} `;(0,ms.appendFileSync)(n,r,"utf-8")}function Xk(e){return/^%\d+$/.test(e)}async function ha(e){await P(`send-keys -t '${e}' Enter`)}function ya(e){let{config:t,auditDir:n,sendApproval:r}=e,s=!1,i={approved:0,denied:0,escalated:0,total:0};function o(){i={approved:0,denied:0,escalated:0,total:0}}async function a(l){if(!s)return{action:"escalate",reason:"Auto-approve engine is not running; request requires human review"};if(l.paneId&&!Xk(l.paneId)){let f={action:"escalate",reason:`Security: invalid pane ID "${l.paneId}" \u2014 possible command injection; escalating to human review`},p={timestamp:new Date().toISOString(),paneId:l.paneId,toolName:l.toolName,action:f.action,reason:f.reason,wishId:l.wishId,requestId:l.id};try{c0(n,p)}catch{}return i.total++,i.escalated++,f}let u=xh(l,t),d={timestamp:new Date().toISOString(),paneId:l.paneId,toolName:l.toolName,action:u.action,reason:u.reason,wishId:l.wishId,requestId:l.id};try{c0(n,d)}catch(f){let p={action:"escalate",reason:`Audit log write failed (${f instanceof Error?f.message:String(f)}); escalating to human review to preserve audit trail`};return i.total++,i.escalated++,p}if(i.total++,u.action==="approve"?i.approved++:u.action==="deny"?i.denied++:i.escalated++,u.action==="approve"&&l.paneId)try{await r(l.paneId)}catch(f){let p={timestamp:new Date().toISOString(),paneId:l.paneId,toolName:l.toolName,action:"escalate",reason:`Approval delivery failed via sendApproval (${f instanceof Error?f.message:String(f)}); send-keys did not reach pane`,wishId:l.wishId,requestId:l.id};try{c0(n,p)}catch{}}return u}async function c(l){if(!s||l.type!=="permission_request")return;let u=oh(l);u&&await a(u)}return{start(){s||(o(),s=!0)},stop(){s&&(s=!1)},isRunning(){return s},processRequest:a,processEvent:c,getStats(){return{...i}}}}ue();ot();var Tt=require("node:fs/promises"),Ht=require("node:path"),Ve=require("bun"),e8=".genie/worktrees";async function t8(){try{return typeof Bun.which=="function"?!!Bun.which("bd"):(await Ve.$`bd --version`.quiet(),!0)}catch{return!1}}async function u0(e){try{let t=await Ve.$`bd ${e}`.quiet();return{stdout:t.stdout.toString().trim(),stderr:t.stderr.toString().trim(),exitCode:0}}catch(t){return{stdout:t.stdout?.toString().trim()||"",stderr:t.stderr?.toString().trim()||"",exitCode:t.exitCode||1}}}function gh(e){if(!e)return null;try{return JSON.parse(e)}catch{return null}}function f0(e){return(0,Ht.join)(e,e8)}function d0(e,t){return(0,Ht.join)(f0(e),t)}function ba(e){return`work/${e}`}var p0=class{repoPath;constructor(t){this.repoPath=t}async create(t,n){let r=d0(n,t),s=ba(t);await(0,Tt.mkdir)(f0(n),{recursive:!0});try{return await(0,Tt.access)(r),await this.getWorktreeInfo(t,n)}catch{}let i=!1;try{await Ve.$`git -C ${n} rev-parse --verify ${s}`.quiet(),i=!0}catch{}i?await Ve.$`git -C ${n} worktree add ${r} ${s}`.quiet():await Ve.$`git -C ${n} worktree add -b ${s} ${r}`.quiet();let o=(0,Ht.join)(r,".genie");await(0,Tt.mkdir)(o,{recursive:!0}),await(0,Tt.writeFile)((0,Ht.join)(o,"redirect"),(0,Ht.join)(n,".genie"));let a;try{a=(await Ve.$`git -C ${r} rev-parse HEAD`.quiet()).stdout.toString().trim()}catch{}return{path:r,branch:s,wishId:t,commitHash:a,createdAt:new Date}}async remove(t){let n=d0(this.repoPath,t);try{await Ve.$`git -C ${this.repoPath} worktree remove ${n} --force`.quiet()}catch{try{await(0,Tt.rm)(n,{recursive:!0,force:!0})}catch{}}try{await Ve.$`git -C ${this.repoPath} worktree prune`.quiet()}catch{}}async list(){let n=(await Ve.$`git -C ${this.repoPath} worktree list --porcelain`.quiet()).stdout.toString(),r=f0(this.repoPath),s=[],i={};for(let o of n.split(` -`))o.startsWith("worktree ")?(i.path?.startsWith(r)&&(i.wishId=(0,Ht.basename)(i.path),s.push(i)),i={path:o.slice(9)}):o.startsWith("HEAD ")?i.commitHash=o.slice(5):o.startsWith("branch ")&&(i.branch=o.slice(7).replace("refs/heads/",""));return i.path?.startsWith(r)&&(i.wishId=(0,Ht.basename)(i.path),s.push(i)),s}async get(t){return this.getWorktreeInfo(t,this.repoPath)}async getWorktreeInfo(t,n){let r=d0(n,t);try{await(0,Tt.access)(r)}catch{return null}let s=ba(t);try{s=(await Ve.$`git -C ${r} branch --show-current`.quiet()).stdout.toString().trim()||s}catch{}let i;try{i=(await Ve.$`git -C ${r} rev-parse HEAD`.quiet()).stdout.toString().trim()}catch{}return{path:r,branch:s,wishId:t,commitHash:i}}},m0=class{async create(t,n){let{stdout:r,exitCode:s,stderr:i}=await u0(["worktree","create",t,"--json"]);if(s!==0)throw new Error(`bd worktree create failed: ${i||r}`);let o=gh(r);if(!o)throw new Error("Failed to parse bd worktree create output");let a=ba(t);return o.branch!==a&&(console.log(`\u{1F527} Fixing branch name: ${o.branch} \u2192 ${a}`),await Ve.$`git -C ${o.path} branch -m ${a}`.quiet(),o.branch=a),{path:o.path,branch:o.branch,wishId:t}}async remove(t){let{exitCode:n,stderr:r}=await u0(["worktree","remove",t]);if(n!==0)throw new Error(`bd worktree remove failed: ${r}`)}async list(){let{stdout:t,exitCode:n}=await u0(["worktree","list","--json"]);if(n!==0||!t)return[];let r=gh(t);return r?r.map(s=>({path:s.path,branch:s.branch,wishId:s.name})):[]}async get(t){return(await this.list()).find(r=>r.wishId===t||r.branch===t||r.branch===ba(t))||null}};async function hh(e){return await t8()?new m0:new p0(e)}var kn=fn()&&(typeof Bun.which=="function"?!!Bun.which("bd"):!0);var n8={"genie-cli":"code/genie-cli","genie work":"code/genie-cli","genie worker":"code/genie-cli"};function yh(e,t){if(e===t)return"";let n=t.replace(/'/g,"'\\''");return`[ -f '${n}/.env' ] && set -a && source '${n}/.env' && set +a; `}async function x0(e){try{return{stdout:(await _h.$`bd ${e}`.quiet()).stdout.toString().trim(),exitCode:0}}catch(t){return{stdout:t.stdout?.toString().trim()||"",exitCode:t.exitCode||1}}}function r8(e){if(!e||e.length>128)return null;let t=e.trim();return!t||!/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/.test(t)||t==="__proto__"||t==="constructor"||t==="prototype"||/\.\./.test(t)||/[./]$/.test(t)||/\.lock$/.test(t)?null:t}function s8(e){return e?{id:e.id,title:e.title||e.description?.substring(0,50)||"Untitled",status:e.status,description:e.description,blockedBy:e.blockedBy||[]}:null}async function Eh(e){let{stdout:t,exitCode:n}=await x0(["show",e,"--json"]);if(n===0&&t)try{let r=JSON.parse(t),s=Array.isArray(r)?r[0]:r;return s8(s)}catch{}return null}async function i8(e){let t=Se(e);if(t.kind==="local"){let s=await t.queue();if(s.ready.length===0)return null;let i=s.ready[0],o=await t.get(i);return o?{id:o.id,title:o.title,status:o.status,description:o.description,blockedBy:o.blockedBy||[]}:null}let{stdout:n,exitCode:r}=await x0(["ready","--json"]);if(r!==0||!n)return null;try{let s=JSON.parse(n);if(Array.isArray(s)&&s.length>0){let i=s[0];return{id:i.id,title:i.title||i.description?.substring(0,50)||"Untitled",status:i.status,description:i.description,blockedBy:i.blockedBy||[]}}return null}catch{let s=n.split(` +`))o.startsWith("worktree ")?(i.path?.startsWith(r)&&(i.wishId=(0,Ht.basename)(i.path),s.push(i)),i={path:o.slice(9)}):o.startsWith("HEAD ")?i.commitHash=o.slice(5):o.startsWith("branch ")&&(i.branch=o.slice(7).replace("refs/heads/",""));return i.path?.startsWith(r)&&(i.wishId=(0,Ht.basename)(i.path),s.push(i)),s}async get(t){return this.getWorktreeInfo(t,this.repoPath)}async getWorktreeInfo(t,n){let r=d0(n,t);try{await(0,Tt.access)(r)}catch{return null}let s=ba(t);try{s=(await Ve.$`git -C ${r} branch --show-current`.quiet()).stdout.toString().trim()||s}catch{}let i;try{i=(await Ve.$`git -C ${r} rev-parse HEAD`.quiet()).stdout.toString().trim()}catch{}return{path:r,branch:s,wishId:t,commitHash:i}}},m0=class{async create(t,n){let{stdout:r,exitCode:s,stderr:i}=await u0(["worktree","create",t,"--json"]);if(s!==0)throw new Error(`bd worktree create failed: ${i||r}`);let o=gh(r);if(!o)throw new Error("Failed to parse bd worktree create output");let a=ba(t);return o.branch!==a&&(console.log(`\u{1F527} Fixing branch name: ${o.branch} \u2192 ${a}`),await Ve.$`git -C ${o.path} branch -m ${a}`.quiet(),o.branch=a),{path:o.path,branch:o.branch,wishId:t}}async remove(t){let{exitCode:n,stderr:r}=await u0(["worktree","remove",t]);if(n!==0)throw new Error(`bd worktree remove failed: ${r}`)}async list(){let{stdout:t,exitCode:n}=await u0(["worktree","list","--json"]);if(n!==0||!t)return[];let r=gh(t);return r?r.map(s=>({path:s.path,branch:s.branch,wishId:s.name})):[]}async get(t){return(await this.list()).find(r=>r.wishId===t||r.branch===t||r.branch===ba(t))||null}};async function hh(e){return await t8()?new m0:new p0(e)}var kn=fn()&&(typeof Bun.which=="function"?!!Bun.which("bd"):!0);var n8={"genie-cli":"code/genie-cli","genie work":"code/genie-cli","genie agent":"code/genie-cli"};function yh(e,t){if(e===t)return"";let n=t.replace(/'/g,"'\\''");return`[ -f '${n}/.env' ] && set -a && source '${n}/.env' && set +a; `}async function x0(e){try{return{stdout:(await _h.$`bd ${e}`.quiet()).stdout.toString().trim(),exitCode:0}}catch(t){return{stdout:t.stdout?.toString().trim()||"",exitCode:t.exitCode||1}}}function r8(e){if(!e||e.length>128)return null;let t=e.trim();return!t||!/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/.test(t)||t==="__proto__"||t==="constructor"||t==="prototype"||/\.\./.test(t)||/[./]$/.test(t)||/\.lock$/.test(t)?null:t}function s8(e){return e?{id:e.id,title:e.title||e.description?.substring(0,50)||"Untitled",status:e.status,description:e.description,blockedBy:e.blockedBy||[]}:null}async function Eh(e){let{stdout:t,exitCode:n}=await x0(["show",e,"--json"]);if(n===0&&t)try{let r=JSON.parse(t),s=Array.isArray(r)?r[0]:r;return s8(s)}catch{}return null}async function i8(e){let t=Se(e);if(t.kind==="local"){let s=await t.queue();if(s.ready.length===0)return null;let i=s.ready[0],o=await t.get(i);return o?{id:o.id,title:o.title,status:o.status,description:o.description,blockedBy:o.blockedBy||[]}:null}let{stdout:n,exitCode:r}=await x0(["ready","--json"]);if(r!==0||!n)return null;try{let s=JSON.parse(n);if(Array.isArray(s)&&s.length>0){let i=s[0];return{id:i.id,title:i.title||i.description?.substring(0,50)||"Untitled",status:i.status,description:i.description,blockedBy:i.blockedBy||[]}}return null}catch{let s=n.split(` `).filter(i=>i.trim());if(s.length>0){let i=s[0].match(/^(bd-\d+)/);if(i)return Eh(i[1])}return null}}async function o8(e){let{exitCode:t}=await x0(["update",e,"--status","in_progress"]);return t===0}async function a8(e){let t=await import("node:fs/promises"),n={};try{let r=await t.readFile(e,"utf-8"),s=r.split(` `),i=s[0]?.match(/^#\s+(?:Wish\s+\d+:\s+)?(.+)$/i);i&&(n.title=i[1].trim());for(let a of s.slice(0,20)){let c=a.match(/^\*\*Status:\*\*\s*(.+)$/i);if(c){n.status=c[1].trim();continue}let l=a.match(/^\*\*Slug:\*\*\s*`?([^`]+)`?$/i);if(l){n.slug=l[1].trim();continue}let u=a.match(/^\*\*repo:\*\*\s*`?([^`]+)`?$/i);u&&(n.repo=u[1].trim())}let o=r.indexOf("## Summary");if(o!==-1){let a=r.slice(o+10),c=a.indexOf(` ## `),l=c!==-1?a.slice(0,c):a.slice(0,500);n.description=l.trim()}return n}catch{return n}}async function c8(e,t,n){let r=await import("node:fs/promises"),s=`${e} ${t||""}`.toLowerCase();for(let[i,o]of Object.entries(n8))if(s.includes(i.toLowerCase())){let a=(0,ge.join)(n,o);try{let c=(0,ge.join)(a,".git");return await r.access(c),o}catch{}}return null}async function l8(e,t,n,r,s){if(n)return{targetRepo:(0,ge.isAbsolute)(n)?n:(0,ge.resolve)(t,n),detectionMethod:"--repo flag"};let i=(0,ge.join)(t,".genie","wishes",e,"wish.md"),o=await a8(i);if(o.repo)return{targetRepo:(0,ge.isAbsolute)(o.repo)?o.repo:(0,ge.resolve)(t,o.repo),detectionMethod:"wish.md repo: field"};let a=o.title||r||"",c=o.description||s||"",l=await c8(a,c,t);return l?{targetRepo:(0,ge.resolve)(t,l),detectionMethod:`heuristic (matched "${l}")`}:{targetRepo:t,detectionMethod:"default (current repo)"}}async function u8(){try{return(await P("display-message -p '#{session_name}'")).trim()||null}catch{return null}}async function bh(e){if(e)return e;let t=await u8();if(t)return t;let r=Bi()||"genie-workers";return await ke(r)?(console.log(`\u{1F4FA} Found existing tmux session '${r}'. Attach with: tmux attach -t ${r}`),r):(await Jr(r)||(console.error("\u274C Failed to create tmux session. Is tmux installed?"),process.exit(1)),console.log(`\u{1F4FA} Created tmux session '${r}'. Attach with: tmux attach -t ${r}`),r)}async function d8(e,t){try{return(await(await hh(t)).create(e,t)).path}catch(n){return console.error(`\u26A0\uFE0F Failed to create worktree: ${n.message}`),null}}async function $h(e,t){let n=await import("node:fs/promises"),r=(0,ge.join)(t,".wishes");try{await n.access(r)}catch{return}async function s(a){let c=[],l;try{l=await n.readdir(a,{withFileTypes:!0})}catch{return c}for(let u of l){let d=(0,ge.join)(a,u.name);u.isDirectory()?c.push(...await s(d)):u.isFile()&&u.name.endsWith("-wish.md")&&c.push(d)}return c}let i=await s(r),o=[new RegExp(`\\*\\*Beads:\\*\\*\\s*${wh(e)}`),new RegExp(`^Beads:\\s*${wh(e)}`,"m")];for(let a of i)try{let c=await n.readFile(a,"utf-8");for(let l of o)if(l.test(c))return a}catch{}}function wh(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}async function f8(e,t){let n=await import("node:fs/promises"),r=(0,ge.join)(t,".genie","wishes",e,"wish.md");try{return await n.access(r),!0}catch{}return await $h(e,t)!==void 0}async function p8(e,t){let n=await import("node:fs/promises"),r=(0,ge.join)(t,".genie","wishes",e,"wish.md");try{return await n.readFile(r,"utf-8")}catch{}let s=await $h(e,t);if(s)try{return await n.readFile(s,"utf-8")}catch{return}}async function Dh(e,t,n){try{let r=await p8(e,t),s=await mh(n,r),i=ya({config:s,auditDir:t,sendApproval:ha});return i.start(),i}catch(r){console.log(`\u26A0\uFE0F Auto-approve setup failed: ${r.message} (non-fatal)`);return}}async function vh(e){return console.log(` @@ -249,23 +249,23 @@ Done:`);for(let d of u)console.log(` ${d.id}: ${d.title}`)}console.log("")}catc `).filter(o=>o.trim());if(i.length>0){let o=i.slice(-5).join(` `);if(o.includes("\u276F")||o.includes("? for shortcuts")||o.includes("What would you like")||o.includes("How can I help"))return!0}}catch{}await new Promise(s=>setTimeout(s,n))}return!1}async function kh(e,t,n){try{let r=await ke(e);if(!r)return console.error(`\u274C Session "${e}" not found`),null;let s=await au(r.id,t);if(s){let a=await St(s.id);return!a||a.length===0?(console.error(`\u274C No panes in existing window "${t}"`),null):{paneId:a[0].id,windowId:s.id,windowCreated:!1}}let i=await ou(r.id,t,n);if(!i)return console.error(`\u274C Failed to create window "${t}"`),null;let o=await St(i.id);return!o||o.length===0?(console.error(`\u274C No panes in new window "${t}"`),null):{paneId:o[0].id,windowId:i.id,windowCreated:!0}}catch(r){return console.error(`\u274C Error ensuring worker window: ${r.message}`),null}}function Ch(e,t,n,r){let s=new li(t,{pollIntervalMs:1e3,paneId:n});if(r){let i=0;s.on("permission",async o=>{let a=Date.now();if(a-i<2e3)return;let c=o.state?.rawOutput||"",l=wu(c),u="unknown",d;if(l)switch(l.type){case"bash":u="Bash",l.command&&(d={command:l.command});break;case"file":/Allow.*Edit/i.test(c)?u="Edit":/Allow.*Write/i.test(c)?u="Write":/Allow.*Read/i.test(c)?u="Read":u="Write",l.file&&(d={file_path:l.file});break;default:u=l.type}let f={id:`auto-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,6)}`,toolName:u,toolInput:d,paneId:n,wishId:e,sessionId:"",cwd:"",timestamp:new Date().toISOString()},p=await r.processRequest(f);p.action==="approve"?(i=a,console.log(` \u2705 Auto-approved: ${u}${l?.command?` (${l.command.substring(0,50)})`:""}`)):p.action==="deny"&&console.log(` \u274C Auto-denied: ${u} - ${p.reason}`)})}s.on("state_change",async i=>{if(!i.state)return;let o;switch(i.state.type){case"working":case"tool_use":o="working";break;case"idle":o="idle";break;case"permission":o="permission";break;case"question":o="question";break;case"error":o="error";break;case"complete":o="done";break;default:return}try{kn&&await hu(e,o),await dr(e,o)}catch{}}),s.on("poll_error",()=>{kn&&sx(e).catch(()=>{}),Me(e).catch(()=>{}),as(n).catch(()=>{}),s.stop()}),s.start().catch(()=>{})}async function Ah(e,t={}){try{let n=process.cwd();if(kn&&!(await pn()).running){console.log("\u{1F504} Starting beads daemon for auto-sync...");let Kt=await si({autoCommit:!0});console.log(Kt?" \u2705 Daemon started":" \u26A0\uFE0F Daemon failed to start (non-fatal)")}let r=await ce(),s;if(t.profile){if(s=Ar(r,t.profile),!s){let ne=Object.keys(r.workerProfiles||{});console.error(`Profile '${t.profile}' not found.`),ne.length>0?console.log(`Available profiles: ${ne.join(", ")}`):console.log("No profiles configured in ~/.genie/config.json"),process.exit(1)}}else s=gd(r);let i=null;if(e==="next"){if(console.log("\u{1F50D} Finding next ready issue..."),i=await i8(n),!i){console.log("\u2139\uFE0F No ready issues. Run `bd ready` to see the queue.");return}console.log(`\u{1F4CB} Found: ${i.id} - "${i.title}"`)}else if(e==="wish")console.error("\u274C `genie work wish` is not yet implemented."),process.exit(1);else{let ne=r8(e);ne||(console.error(`\u274C Invalid task ID: "${e}"`),console.error(' Task IDs must be alphanumeric with hyphens/underscores (e.g., "bd-123", "wish-1").'),console.error(" Got characters that are unsafe for git branches or shell commands."),process.exit(1)),e=ne;let Kt=Se(n);if(Kt.kind==="local"){let pt=await Kt.get(e);pt&&(i={id:pt.id,title:pt.title,status:pt.status,description:pt.description,blockedBy:pt.blockedBy||[]})}i||(i=await Eh(e)),i||(Se(n).kind==="local"?(console.error(`\u274C Issue "${e}" not found in local task registry.`),console.error(` File: ${(0,ge.join)(n,".genie","tasks.json")}`),(await import("node:fs")).existsSync((0,ge.join)(n,".genie","tasks.json"))?console.error(` Task "${e}" is not in tasks.json. Run \`bd list\` to see available tasks.`):(console.error(" \u26A0\uFE0F tasks.json does not exist. This is likely a fresh repo."),console.error(' Fix: Run `genie task create "Your task title"` to create the first task,'),console.error(" or `bd sync` if using beads."))):console.error(`\u274C Issue "${e}" not found. Run \`bd list\` to see issues.`),process.exit(1))}let o=i.id,a=kn?await Qr(o):null;if(a||(a=await hn(o)),a){if(a.claudeSessionId&&t.resume!==!1){console.log(`\u{1F4CB} Found existing worker for ${o} with resumable session`),console.log(` Session ID: ${a.claudeSessionId}`),console.log(" Resuming previous Claude session...");let ne=await bh(t.session),Kt=a.worktree||a.repoPath;console.log("\u{1F680} Ensuring worker window...");let pt=await kh(ne,o,Kt);pt||process.exit(1);let{paneId:ys,windowId:zh}=pt;await ts(a.id,{paneId:ys,session:ne,windowName:o,windowId:zh,state:"spawning",lastStateChange:new Date().toISOString()}),kn&&await Et(a.id,"spawning").catch(()=>{});let Kh=(0,ge.join)(a.repoPath,".genie"),Yh=Kt.replace(/'/g,"'\\''"),Jh=Ir(s,{resume:a.claudeSessionId,beadsDir:Kh}),Zh=yh(Kt,a.repoPath);await an(ys,`cd '${Yh}' && ${Zh}${Jh}`,!0,!1),kn&&await Et(a.id,"working").catch(()=>{}),await dr(a.id,"working");let bs;t.noAutoApprove||(bs=await Dh(o,a.repoPath,a.repoPath),bs&&console.log("\u{1F512} Auto-approve engine started")),Ch(a.id,ne,ys,bs),t.focus===!0&&await P(`select-window -t '${ne}:${o}'`),console.log(` \u2705 Resumed worker for ${o}`),console.log(` Window: ${o}`),console.log(` Pane: ${ys}`),console.log(` Session: ${ne}`),console.log(` Claude Session: ${a.claudeSessionId}`),console.log(` -Commands:`),console.log(" genie worker list - Check worker status"),console.log(" genie worker approve - Approve permissions"),console.log(` genie worker close ${o} - Close issue when done`),console.log(` genie worker kill ${o} - Force kill worker`),bs&&!t._skipAutoApproveBlock&&await vh(bs);return}console.error(`\u274C ${o} already has a worker (pane ${a.paneId})`),console.log(` Run \`genie worker kill ${a.id}\` first, or work on a different issue.`),process.exit(1)}let c=await bh(t.session);console.log(`\u{1F4DD} Claiming ${o}...`);let l=Se(n),u=!1,d;try{u=await(l.kind==="local"?l.claim(o):o8(o))}catch(ne){d=ne.message||String(ne)}if(!u)if(l.kind==="beads")console.error(`\u274C Failed to claim ${o}.${d?` Reason: ${d}`:""}`),console.error(" The issue may not exist or is already claimed."),console.error(` Run \`bd show ${o}\` to check status.`),process.exit(1);else{let ne=await l.get(o);ne?(console.error(`\u274C Failed to claim ${o} (status: ${ne.status}).`),console.error(" Task may already be in_progress or done.")):(console.error(`\u274C Task "${o}" not found in .genie/tasks.json.`),console.error(" Available tasks: run `cat .genie/tasks.json | jq '.order'`"),console.error(` Or create one: \`term create "${o} title"\``)),process.exit(1)}let{targetRepo:f,detectionMethod:p}=await l8(o,n,t.repo,i.title,i.description);f!==n&&(console.log(`\u{1F3AF} Detected nested repo: ${f}`),console.log(` Detection: ${p}`));let m=f,x=null;t.noWorktree||(console.log(`\u{1F333} Creating worktree for ${o} in ${f}...`),x=await d8(o,f),x?(m=x,console.log(` Created: ${x}`),console.log(` Branch: work/${o}`)):console.log("\u26A0\uFE0F Worktree creation failed. Using shared repo.")),console.log(`\u{1F680} Creating worker window "${o}"...`);let g=await kh(c,o,m);g||process.exit(1);let{paneId:h,windowId:y}=g,S=(0,Sh.randomUUID)(),E=await ku(o,t.name),C=await Jo(o);C>0&&console.log(` \u{1F4CC} Additional worker on task (${C+1} total)`);let B={id:E,paneId:h,session:c,worktree:x,taskId:o,taskTitle:i.title,startedAt:new Date().toISOString(),state:"spawning",lastStateChange:new Date().toISOString(),repoPath:f,claudeSessionId:S,windowName:o,windowId:y,role:t.role,customName:t.name};if(kn)try{let ne=await ex(o,{paneId:h,session:c,worktree:x,repoPath:f,taskId:o,taskTitle:i.title,claudeSessionId:S});await nx(o,o),await Et(o,"spawning")}catch(ne){console.log(`\u26A0\uFE0F Beads registration failed: ${ne.message} (non-fatal)`)}await ur(B);let O=t.skill;!O&&!t.prompt&&await f8(o,n)&&(O="forge",console.log("\u{1F4CB} Found wish.md - using /forge skill"));let K;O?K=`/${O}`:K=t.prompt||`Work on beads issue ${o}: "${i.title}" +Commands:`),console.log(" genie agent list - Check worker status"),console.log(" genie agent approve - Approve permissions"),console.log(` genie agent close ${o} - Close issue when done`),console.log(` genie agent kill ${o} - Force kill worker`),bs&&!t._skipAutoApproveBlock&&await vh(bs);return}console.error(`\u274C ${o} already has a worker (pane ${a.paneId})`),console.log(` Run \`genie agent kill ${a.id}\` first, or work on a different issue.`),process.exit(1)}let c=await bh(t.session);console.log(`\u{1F4DD} Claiming ${o}...`);let l=Se(n),u=!1,d;try{u=await(l.kind==="local"?l.claim(o):o8(o))}catch(ne){d=ne.message||String(ne)}if(!u)if(l.kind==="beads")console.error(`\u274C Failed to claim ${o}.${d?` Reason: ${d}`:""}`),console.error(" The issue may not exist or is already claimed."),console.error(` Run \`bd show ${o}\` to check status.`),process.exit(1);else{let ne=await l.get(o);ne?(console.error(`\u274C Failed to claim ${o} (status: ${ne.status}).`),console.error(" Task may already be in_progress or done.")):(console.error(`\u274C Task "${o}" not found in .genie/tasks.json.`),console.error(" Available tasks: run `cat .genie/tasks.json | jq '.order'`"),console.error(` Or create one: \`term create "${o} title"\``)),process.exit(1)}let{targetRepo:f,detectionMethod:p}=await l8(o,n,t.repo,i.title,i.description);f!==n&&(console.log(`\u{1F3AF} Detected nested repo: ${f}`),console.log(` Detection: ${p}`));let m=f,x=null;t.noWorktree||(console.log(`\u{1F333} Creating worktree for ${o} in ${f}...`),x=await d8(o,f),x?(m=x,console.log(` Created: ${x}`),console.log(` Branch: work/${o}`)):console.log("\u26A0\uFE0F Worktree creation failed. Using shared repo.")),console.log(`\u{1F680} Creating worker window "${o}"...`);let g=await kh(c,o,m);g||process.exit(1);let{paneId:h,windowId:y}=g,S=(0,Sh.randomUUID)(),E=await ku(o,t.name),C=await Jo(o);C>0&&console.log(` \u{1F4CC} Additional worker on task (${C+1} total)`);let B={id:E,paneId:h,session:c,worktree:x,taskId:o,taskTitle:i.title,startedAt:new Date().toISOString(),state:"spawning",lastStateChange:new Date().toISOString(),repoPath:f,claudeSessionId:S,windowName:o,windowId:y,role:t.role,customName:t.name};if(kn)try{let ne=await ex(o,{paneId:h,session:c,worktree:x,repoPath:f,taskId:o,taskTitle:i.title,claudeSessionId:S});await nx(o,o),await Et(o,"spawning")}catch(ne){console.log(`\u26A0\uFE0F Beads registration failed: ${ne.message} (non-fatal)`)}await ur(B);let O=t.skill;!O&&!t.prompt&&await f8(o,n)&&(O="forge",console.log("\u{1F4CB} Found wish.md - using /forge skill"));let K;O?K=`/${O}`:K=t.prompt||`Work on beads issue ${o}: "${i.title}" ## Description ${i.description||"No description provided."} When you're done, commit your changes and let me know.`;let _=K.replace(/'/g,"'\\''"),D=(0,ge.join)(n,".genie"),T=m.replace(/'/g,"'\\''"),H=Ir(s,{sessionId:S,beadsDir:D}),ee=yh(m,n);await an(h,`cd '${T}' && ${ee}${H}`,!0,!1),console.log(` Session ID: ${S}`),await m8(h)||console.log(" (Claude startup timed out, sending prompt anyway)"),await P(`send-keys -t '${h}' '${_}' Enter`),kn&&await Et(o,"working").catch(()=>{}),await dr(o,"working");let ct;t.noAutoApprove||(ct=await Dh(o,n,f),ct&&console.log("\u{1F512} Auto-approve engine started")),Ch(o,c,h,ct),t.focus===!0&&await P(`select-window -t '${c}:${o}'`),console.log(` \u2705 Worker started for ${o}`),console.log(` Window: ${o}`),console.log(` Pane: ${h}`),console.log(` Session: ${c}`),x&&(console.log(` Worktree: ${x}`),console.log(` Branch: work/${o}`)),f!==n&&console.log(` Target repo: ${f}`),console.log(` -Commands:`),console.log(" genie worker list - Check worker status"),console.log(" genie worker approve - Approve permissions"),console.log(` genie worker close ${o} - Close issue when done`),console.log(` genie worker kill ${o} - Force kill worker`),ct&&!t._skipAutoApproveBlock&&await vh(ct)}catch(n){console.error(`\u274C Error: ${n.message}`),process.exit(1)}}var wa=require("node:fs"),Ih=require("node:path");var Cn=null,g0=ah();var g8="auto-approve-audit.jsonl";function h8(e){let t=(0,Ih.join)(e,".genie",g8);if(!(0,wa.existsSync)(t))return[];try{let r=(0,wa.readFileSync)(t,"utf-8").trim().split(` -`).filter(i=>i.trim()),s=[];for(let i of r)try{s.push(JSON.parse(i))}catch{}return s}catch{return[]}}function y8(e){switch(e){case"approve":return"approved";case"deny":return"denied";case"escalate":return"escalated"}}function b8(e){let t=[],n=h8(e.auditDir);for(let s of n)t.push({requestId:s.requestId,toolName:s.toolName,paneId:s.paneId,status:y8(s.action),reason:s.reason,timestamp:s.timestamp});let r=e.queue.getAll();for(let s of r)t.push({requestId:s.id,toolName:s.toolName,paneId:s.paneId,status:"pending",reason:"",timestamp:s.timestamp});return t}function w8(e,t){return t.queue.get(e)?(t.queue.remove(e),!0):!1}function D8(e,t){return t.queue.get(e)?(t.queue.remove(e),!0):!1}function Ph(){return Cn!==null&&Cn.isRunning()}async function v8(e){if(Cn?.isRunning())return;let t=await a0(e.repoPath);Cn=ya({config:t,auditDir:e.auditDir,sendApproval:ha}),Cn.start()}function k8(){Cn&&(Cn.stop(),Cn=null)}async function Th(e,t){let n=process.cwd(),r=n;if(t.status){let s=b8({auditDir:r,queue:g0});if(s.length===0){console.log("No auto-approve requests found.");return}console.log("Auto-Approve Requests:"),console.log("");for(let i of s){let o=i.status.toUpperCase().padEnd(10),a=i.paneId??"N/A";console.log(` [${o}] ${i.requestId} ${i.toolName} pane:${a} ${i.timestamp}`),i.reason&&console.log(` Reason: ${i.reason}`)}return}if(t.start){if(Ph()){console.log("Auto-approve engine is already running.");return}await v8({auditDir:r,repoPath:n}),console.log("Auto-approve engine started.");return}if(t.stop){if(!Ph()){console.log("Auto-approve engine is not running.");return}k8(),console.log("Auto-approve engine stopped.");return}if(t.deny){D8(t.deny,{queue:g0})?console.log(`Denied request: ${t.deny}`):(console.error(`Request "${t.deny}" not found in pending queue.`),process.exit(1));return}if(e){w8(e,{queue:g0})?console.log(`Approved request: ${e}`):(console.error(`Request "${e}" not found in pending queue.`),process.exit(1));return}console.log("Usage:"),console.log(" genie worker approve --status Show pending/approved/denied requests"),console.log(" genie worker approve Manually approve a pending request"),console.log(" genie worker approve --deny Manually deny a pending request"),console.log(" genie worker approve --start Start the auto-approve engine"),console.log(" genie worker approve --stop Stop the auto-approve engine")}function zt(e){process.env.DEBUG&&console.error(`[target-resolver] ${e}`)}async function S8(e,t){try{let n=await Promise.resolve().then(()=>(ue(),Ce)),r=await n.findSessionByName(e);if(!r)return null;let s=await n.listWindows(r.id);if(!s||s.length===0)return null;let i;if(t){if(i=s.find(c=>c.name===t),!i)return null}else i=s.find(c=>c.active)||s[0];let o=await n.listPanes(i.id);return!o||o.length===0?null:{paneId:(o.find(c=>c.active)||o[0]).id,session:e}}catch{return null}}async function _8(e){try{return(await(await Promise.resolve().then(()=>(ue(),Ce))).executeTmux(`display-message -p -t '${e}' '#{pane_id}'`)).trim()===e}catch{return!1}}async function E8(e,t){try{await(await Promise.resolve().then(()=>(ot(),it))).removeSubPane(e,t)}catch{}}async function $8(e){try{return(await(await Promise.resolve().then(()=>(ue(),Ce))).executeTmux(`display-message -p -t '${e}' '#{session_name}'`)).trim()||null}catch{return null}}async function xs(e,t={}){let{checkLiveness:n=!1,workers:r,tmuxLookup:s=S8,isPaneLive:i=_8,cleanupDeadPane:o=E8,deriveSession:a=$8}=t;if(zt(`resolving "${e}"`),e.startsWith("%")){if(zt(`"${e}" -> raw pane ID passthrough`),n&&!await i(e))throw new Error(`Pane ${e} is dead or does not exist. Check with: tmux list-panes -a`);let d=await a(e);return zt(`"${e}" -> derived session: ${d||"(none)"}`),{paneId:e,session:d??void 0,resolvedVia:"raw"}}if(e.startsWith("@")){zt(`"${e}" -> window ID lookup`);let d=await Fh(r,t.registryPath),f=e,p=Object.values(d).find(m=>m.windowId===f);if(p){if(zt(`"${e}" -> found worker "${p.id}" with pane ${p.paneId}`),n&&!await i(p.paneId))throw new Error(`Window ${e}: worker ${p.id} pane ${p.paneId} is dead. Run 'genie worker kill ${p.id}' to clean up.`);return{paneId:p.paneId,session:p.session,workerId:p.id,resolvedVia:"worker"}}throw new Error(`Window "${e}" not found in worker registry. -Run 'genie worker list' to list workers.`)}let c=await Fh(r,t.registryPath),l=e.indexOf(":");if(l!==-1){let d=e.substring(0,l),f=e.substring(l+1),p=c[d];if(p){let x=Number.parseInt(f,10);if(Number.isNaN(x)||x<0)throw new Error(`Invalid sub-pane index "${f}" for worker "${d}". Use a non-negative integer (0 = primary, 1+ = sub-panes).`);let g=A8(p,x);if(!g){let h=p.subPanes?p.subPanes.length:0;throw new Error(`Worker "${d}" has no sub-pane index ${x}. Available: 0 (primary)${h>0?`, 1-${h} (sub-panes)`:""}. Sub-pane index ${x} does not exist.`)}if(zt(`"${e}" -> worker "${d}" pane index ${x} -> ${g}`),n&&!await i(g))throw await o(d,g),new Error(`Worker ${d}: pane ${g} is dead. Run 'genie worker kill ${d}' to clean up.`);return{paneId:g,session:p.session,workerId:d,paneIndex:x,resolvedVia:"worker"}}zt(`"${d}" is not a registered worker, trying session:window`);let m=await s(d,f);if(m){if(zt(`"${e}" -> session:window -> pane ${m.paneId}`),n&&!await i(m.paneId))throw new Error(`Session "${d}" window "${f}": pane ${m.paneId} is dead.`);return{paneId:m.paneId,session:m.session,resolvedVia:"session:window"}}throw new Error(`Target "${e}" not found. No worker "${d}" in registry and no tmux session:window "${d}:${f}" found. -Run 'genie worker list' to list workers.`)}let u=c[e];if(u){if(zt(`"${e}" -> worker lookup -> pane ${u.paneId}, session ${u.session}`),n&&!await i(u.paneId))throw await o(e,u.paneId),new Error(`Worker ${e}: pane ${u.paneId} is dead. Run 'genie worker kill ${e}' to clean up.`);return{paneId:u.paneId,session:u.session,workerId:e,resolvedVia:"worker"}}throw new Error(`Target "${e}" not found. Not a worker or pane ID. -Run 'genie worker list' to list workers.`)}function Da(e,t){let n=[];e.workerId?(n.push(e.workerId),e.paneIndex!==void 0&&e.paneIndex>0&&(n[n.length-1]+=`:${e.paneIndex}`)):n.push(t);let r=[`pane ${e.paneId}`];return e.session&&r.push(`session ${e.session}`),`${n[0]} (${r.join(", ")})`}async function Fh(e,t){if(e!==void 0)return e;try{let r=await(await Promise.resolve().then(()=>(ot(),it))).list(),s={};for(let i of r)s[i.id]=i;return s}catch{return{}}}function A8(e,t){if(t===0)return e.paneId;let n=t-1;return!e.subPanes||n>=e.subPanes.length||n<0?null:e.subPanes[n]}ue();async function Oh(e,t,n={}){try{let r=await xs(e),s=r.paneId,i=Da(r,e),o=Oi(),a=n.timeout??o.execTimeout,{output:c,exitCode:l}=await lu(s,t,a);c&&!n.quiet&&console.log(c),n.quiet||console.error(`Executed in ${i}`),process.exit(l)}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}ot();function va(e){if(typeof e=="string")return e;if(Array.isArray(e)){let t=[];for(let n of e)typeof n=="string"?t.push(n):n&&typeof n=="object"&&"text"in n&&t.push(String(n.text));return t.join(" ")}return""}function Bh(e){try{return new Date(e).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",hour12:!1})}catch{return"??:??"}}function Di(e,t){return e.length<=t?e:`${e.slice(0,t-3)}...`}function wi(e){let t=e.replace(/^\/home\/\w+\/workspace\//,"~/").replace(/^\/home\/\w+\//,"~/");return Di(t,50)}function I8(e){let t=[],n=[],r=[],s="",i="",o=()=>{n.length>0&&(t.push({timestamp:s,type:"read",summary:n.length===1?`Read: ${wi(n[0])}`:`Read ${n.length} files`,details:n.length>1?n.map(wi):void 0}),n.length=0)},a=()=>{r.length>0&&(t.push({timestamp:i,type:"edit",summary:r.length===1?`Edit: ${wi(r[0])}`:`Edit ${r.length} files`,details:r.length>1?r.map(wi):void 0}),r.length=0)};for(let c of e){if(c.type==="user"&&c.message){o(),a();let l=va(c.message.content);l&&t.push({timestamp:c.timestamp,type:"prompt",summary:Di(l.replace(/\n/g," "),80)})}if(c.type==="assistant"&&c.toolCalls)for(let l of c.toolCalls){let u=l.input;switch(l.name){case"Read":s=c.timestamp,u.file_path&&n.push(String(u.file_path));break;case"Edit":o(),i=c.timestamp,u.file_path&&r.push(String(u.file_path));break;case"Write":o(),a(),t.push({timestamp:c.timestamp,type:"write",summary:`Write: ${wi(String(u.file_path||"unknown"))}`});break;case"Bash":{o(),a();let d=String(u.command||"").replace(/\n/g," ");t.push({timestamp:c.timestamp,type:"bash",summary:`Bash: ${Di(d,60)}`});break}case"AskUserQuestion":{o(),a();let f=u.questions?.[0]?.question||"question";t.push({timestamp:c.timestamp,type:"question",summary:`Question: ${Di(f,60)}`});break}}}if(c.type==="assistant"&&c.message&&!c.toolCalls){o(),a();let l=va(c.message.content);l&&l.length>100&&t.push({timestamp:c.timestamp,type:"response",summary:Di(l.replace(/\n/g," "),80)})}}return o(),a(),t}function T8(e){if(e.length===0)return"unknown";let t=e.slice(-10);for(let r of t.reverse()){if(r.type==="assistant"&&r.toolCalls&&r.toolCalls[r.toolCalls.length-1].name==="AskUserQuestion")return"question";if(r.type==="progress"&&r.data&&r.data.type==="permission_request")return"permission"}let n=e[e.length-1];return n.type==="user"?"working":n.type==="assistant"?"idle":"unknown"}function F8(e,t){let n=[];n.push(""),n.push(`Session: ${t.workerId}${t.branch?` (${t.branch})`:""} | ${t.duration} | ${t.totalLines} lines \u2192 ${t.compressedLines} lines`),n.push("");for(let r of e){let s=Bh(r.timestamp),i=O8(r.type);if(n.push(`[${s}] ${i} ${r.summary}`),r.details&&r.details.length>0){for(let o of r.details.slice(0,5))n.push(` ${o}`);r.details.length>5&&n.push(` ... and ${r.details.length-5} more`)}r.result&&n.push(` \u2192 ${r.result}`)}return n.push(""),n.push(`Status: ${t.status.toUpperCase()} | ${t.exchanges} exchanges | ${t.toolCalls} tool calls | ${t.compressionRatio.toFixed(0)}x compression`),n.push(""),n.join(` +Commands:`),console.log(" genie agent list - Check worker status"),console.log(" genie agent approve - Approve permissions"),console.log(` genie agent close ${o} - Close issue when done`),console.log(` genie agent kill ${o} - Force kill worker`),ct&&!t._skipAutoApproveBlock&&await vh(ct)}catch(n){console.error(`\u274C Error: ${n.message}`),process.exit(1)}}var wa=require("node:fs"),Ih=require("node:path");var Cn=null,g0=ah();var g8="auto-approve-audit.jsonl";function h8(e){let t=(0,Ih.join)(e,".genie",g8);if(!(0,wa.existsSync)(t))return[];try{let r=(0,wa.readFileSync)(t,"utf-8").trim().split(` +`).filter(i=>i.trim()),s=[];for(let i of r)try{s.push(JSON.parse(i))}catch{}return s}catch{return[]}}function y8(e){switch(e){case"approve":return"approved";case"deny":return"denied";case"escalate":return"escalated"}}function b8(e){let t=[],n=h8(e.auditDir);for(let s of n)t.push({requestId:s.requestId,toolName:s.toolName,paneId:s.paneId,status:y8(s.action),reason:s.reason,timestamp:s.timestamp});let r=e.queue.getAll();for(let s of r)t.push({requestId:s.id,toolName:s.toolName,paneId:s.paneId,status:"pending",reason:"",timestamp:s.timestamp});return t}function w8(e,t){return t.queue.get(e)?(t.queue.remove(e),!0):!1}function D8(e,t){return t.queue.get(e)?(t.queue.remove(e),!0):!1}function Ph(){return Cn!==null&&Cn.isRunning()}async function v8(e){if(Cn?.isRunning())return;let t=await a0(e.repoPath);Cn=ya({config:t,auditDir:e.auditDir,sendApproval:ha}),Cn.start()}function k8(){Cn&&(Cn.stop(),Cn=null)}async function Th(e,t){let n=process.cwd(),r=n;if(t.status){let s=b8({auditDir:r,queue:g0});if(s.length===0){console.log("No auto-approve requests found.");return}console.log("Auto-Approve Requests:"),console.log("");for(let i of s){let o=i.status.toUpperCase().padEnd(10),a=i.paneId??"N/A";console.log(` [${o}] ${i.requestId} ${i.toolName} pane:${a} ${i.timestamp}`),i.reason&&console.log(` Reason: ${i.reason}`)}return}if(t.start){if(Ph()){console.log("Auto-approve engine is already running.");return}await v8({auditDir:r,repoPath:n}),console.log("Auto-approve engine started.");return}if(t.stop){if(!Ph()){console.log("Auto-approve engine is not running.");return}k8(),console.log("Auto-approve engine stopped.");return}if(t.deny){D8(t.deny,{queue:g0})?console.log(`Denied request: ${t.deny}`):(console.error(`Request "${t.deny}" not found in pending queue.`),process.exit(1));return}if(e){w8(e,{queue:g0})?console.log(`Approved request: ${e}`):(console.error(`Request "${e}" not found in pending queue.`),process.exit(1));return}console.log("Usage:"),console.log(" genie agent approve --status Show pending/approved/denied requests"),console.log(" genie agent approve Manually approve a pending request"),console.log(" genie agent approve --deny Manually deny a pending request"),console.log(" genie agent approve --start Start the auto-approve engine"),console.log(" genie agent approve --stop Stop the auto-approve engine")}function zt(e){process.env.DEBUG&&console.error(`[target-resolver] ${e}`)}async function S8(e,t){try{let n=await Promise.resolve().then(()=>(ue(),Ce)),r=await n.findSessionByName(e);if(!r)return null;let s=await n.listWindows(r.id);if(!s||s.length===0)return null;let i;if(t){if(i=s.find(c=>c.name===t),!i)return null}else i=s.find(c=>c.active)||s[0];let o=await n.listPanes(i.id);return!o||o.length===0?null:{paneId:(o.find(c=>c.active)||o[0]).id,session:e}}catch{return null}}async function _8(e){try{return(await(await Promise.resolve().then(()=>(ue(),Ce))).executeTmux(`display-message -p -t '${e}' '#{pane_id}'`)).trim()===e}catch{return!1}}async function E8(e,t){try{await(await Promise.resolve().then(()=>(ot(),it))).removeSubPane(e,t)}catch{}}async function $8(e){try{return(await(await Promise.resolve().then(()=>(ue(),Ce))).executeTmux(`display-message -p -t '${e}' '#{session_name}'`)).trim()||null}catch{return null}}async function xs(e,t={}){let{checkLiveness:n=!1,workers:r,tmuxLookup:s=S8,isPaneLive:i=_8,cleanupDeadPane:o=E8,deriveSession:a=$8}=t;if(zt(`resolving "${e}"`),e.startsWith("%")){if(zt(`"${e}" -> raw pane ID passthrough`),n&&!await i(e))throw new Error(`Pane ${e} is dead or does not exist. Check with: tmux list-panes -a`);let d=await a(e);return zt(`"${e}" -> derived session: ${d||"(none)"}`),{paneId:e,session:d??void 0,resolvedVia:"raw"}}if(e.startsWith("@")){zt(`"${e}" -> window ID lookup`);let d=await Fh(r,t.registryPath),f=e,p=Object.values(d).find(m=>m.windowId===f);if(p){if(zt(`"${e}" -> found worker "${p.id}" with pane ${p.paneId}`),n&&!await i(p.paneId))throw new Error(`Window ${e}: worker ${p.id} pane ${p.paneId} is dead. Run 'genie agent kill ${p.id}' to clean up.`);return{paneId:p.paneId,session:p.session,workerId:p.id,resolvedVia:"worker"}}throw new Error(`Window "${e}" not found in worker registry. +Run 'genie agent list' to list workers.`)}let c=await Fh(r,t.registryPath),l=e.indexOf(":");if(l!==-1){let d=e.substring(0,l),f=e.substring(l+1),p=c[d];if(p){let x=Number.parseInt(f,10);if(Number.isNaN(x)||x<0)throw new Error(`Invalid sub-pane index "${f}" for worker "${d}". Use a non-negative integer (0 = primary, 1+ = sub-panes).`);let g=A8(p,x);if(!g){let h=p.subPanes?p.subPanes.length:0;throw new Error(`Worker "${d}" has no sub-pane index ${x}. Available: 0 (primary)${h>0?`, 1-${h} (sub-panes)`:""}. Sub-pane index ${x} does not exist.`)}if(zt(`"${e}" -> worker "${d}" pane index ${x} -> ${g}`),n&&!await i(g))throw await o(d,g),new Error(`Worker ${d}: pane ${g} is dead. Run 'genie agent kill ${d}' to clean up.`);return{paneId:g,session:p.session,workerId:d,paneIndex:x,resolvedVia:"worker"}}zt(`"${d}" is not a registered worker, trying session:window`);let m=await s(d,f);if(m){if(zt(`"${e}" -> session:window -> pane ${m.paneId}`),n&&!await i(m.paneId))throw new Error(`Session "${d}" window "${f}": pane ${m.paneId} is dead.`);return{paneId:m.paneId,session:m.session,resolvedVia:"session:window"}}throw new Error(`Target "${e}" not found. No worker "${d}" in registry and no tmux session:window "${d}:${f}" found. +Run 'genie agent list' to list workers.`)}let u=c[e];if(u){if(zt(`"${e}" -> worker lookup -> pane ${u.paneId}, session ${u.session}`),n&&!await i(u.paneId))throw await o(e,u.paneId),new Error(`Worker ${e}: pane ${u.paneId} is dead. Run 'genie agent kill ${e}' to clean up.`);return{paneId:u.paneId,session:u.session,workerId:e,resolvedVia:"worker"}}throw new Error(`Target "${e}" not found. Not a worker or pane ID. +Run 'genie agent list' to list workers.`)}function Da(e,t){let n=[];e.workerId?(n.push(e.workerId),e.paneIndex!==void 0&&e.paneIndex>0&&(n[n.length-1]+=`:${e.paneIndex}`)):n.push(t);let r=[`pane ${e.paneId}`];return e.session&&r.push(`session ${e.session}`),`${n[0]} (${r.join(", ")})`}async function Fh(e,t){if(e!==void 0)return e;try{let r=await(await Promise.resolve().then(()=>(ot(),it))).list(),s={};for(let i of r)s[i.id]=i;return s}catch{return{}}}function A8(e,t){if(t===0)return e.paneId;let n=t-1;return!e.subPanes||n>=e.subPanes.length||n<0?null:e.subPanes[n]}ue();async function Oh(e,t,n={}){try{let r=await xs(e),s=r.paneId,i=Da(r,e),o=Oi(),a=n.timeout??o.execTimeout,{output:c,exitCode:l}=await lu(s,t,a);c&&!n.quiet&&console.log(c),n.quiet||console.error(`Executed in ${i}`),process.exit(l)}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}ot();function va(e){if(typeof e=="string")return e;if(Array.isArray(e)){let t=[];for(let n of e)typeof n=="string"?t.push(n):n&&typeof n=="object"&&"text"in n&&t.push(String(n.text));return t.join(" ")}return""}function Bh(e){try{return new Date(e).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",hour12:!1})}catch{return"??:??"}}function Di(e,t){return e.length<=t?e:`${e.slice(0,t-3)}...`}function wi(e){let t=e.replace(/^\/home\/\w+\/workspace\//,"~/").replace(/^\/home\/\w+\//,"~/");return Di(t,50)}function I8(e){let t=[],n=[],r=[],s="",i="",o=()=>{n.length>0&&(t.push({timestamp:s,type:"read",summary:n.length===1?`Read: ${wi(n[0])}`:`Read ${n.length} files`,details:n.length>1?n.map(wi):void 0}),n.length=0)},a=()=>{r.length>0&&(t.push({timestamp:i,type:"edit",summary:r.length===1?`Edit: ${wi(r[0])}`:`Edit ${r.length} files`,details:r.length>1?r.map(wi):void 0}),r.length=0)};for(let c of e){if(c.type==="user"&&c.message){o(),a();let l=va(c.message.content);l&&t.push({timestamp:c.timestamp,type:"prompt",summary:Di(l.replace(/\n/g," "),80)})}if(c.type==="assistant"&&c.toolCalls)for(let l of c.toolCalls){let u=l.input;switch(l.name){case"Read":s=c.timestamp,u.file_path&&n.push(String(u.file_path));break;case"Edit":o(),i=c.timestamp,u.file_path&&r.push(String(u.file_path));break;case"Write":o(),a(),t.push({timestamp:c.timestamp,type:"write",summary:`Write: ${wi(String(u.file_path||"unknown"))}`});break;case"Bash":{o(),a();let d=String(u.command||"").replace(/\n/g," ");t.push({timestamp:c.timestamp,type:"bash",summary:`Bash: ${Di(d,60)}`});break}case"AskUserQuestion":{o(),a();let f=u.questions?.[0]?.question||"question";t.push({timestamp:c.timestamp,type:"question",summary:`Question: ${Di(f,60)}`});break}}}if(c.type==="assistant"&&c.message&&!c.toolCalls){o(),a();let l=va(c.message.content);l&&l.length>100&&t.push({timestamp:c.timestamp,type:"response",summary:Di(l.replace(/\n/g," "),80)})}}return o(),a(),t}function T8(e){if(e.length===0)return"unknown";let t=e.slice(-10);for(let r of t.reverse()){if(r.type==="assistant"&&r.toolCalls&&r.toolCalls[r.toolCalls.length-1].name==="AskUserQuestion")return"question";if(r.type==="progress"&&r.data&&r.data.type==="permission_request")return"permission"}let n=e[e.length-1];return n.type==="user"?"working":n.type==="assistant"?"idle":"unknown"}function F8(e,t){let n=[];n.push(""),n.push(`Session: ${t.workerId}${t.branch?` (${t.branch})`:""} | ${t.duration} | ${t.totalLines} lines \u2192 ${t.compressedLines} lines`),n.push("");for(let r of e){let s=Bh(r.timestamp),i=O8(r.type);if(n.push(`[${s}] ${i} ${r.summary}`),r.details&&r.details.length>0){for(let o of r.details.slice(0,5))n.push(` ${o}`);r.details.length>5&&n.push(` ... and ${r.details.length-5} more`)}r.result&&n.push(` \u2192 ${r.result}`)}return n.push(""),n.push(`Status: ${t.status.toUpperCase()} | ${t.exchanges} exchanges | ${t.toolCalls} tool calls | ${t.compressionRatio.toFixed(0)}x compression`),n.push(""),n.join(` `)}function O8(e){switch(e){case"prompt":return"\u{1F4AC}";case"read":return"\u{1F4D6}";case"edit":return"\u270F\uFE0F";case"write":return"\u{1F4DD}";case"bash":return"\u26A1";case"question":return"\u2753";case"answer":return"\u2705";case"permission":return"\u{1F510}";case"thinking":return"\u{1F914}";case"response":return"\u{1F4AD}";default:return"\u2022"}}function B8(e){let t=[];for(let n of e){let r=Bh(n.timestamp);if(n.type==="user"&&n.message){let s=va(n.message.content);t.push(` [${r}] USER:`),t.push(s)}if(n.type==="assistant"){if(n.toolCalls&&n.toolCalls.length>0){t.push(` [${r}] ASSISTANT (tools):`);for(let s of n.toolCalls){let i=s.input;s.name==="Bash"?t.push(` ${s.name}: ${i.command}`):s.name==="Read"||s.name==="Edit"||s.name==="Write"?t.push(` ${s.name}: ${i.file_path}`):t.push(` ${s.name}`)}}else if(n.message){let s=va(n.message.content);s&&(t.push(` [${r}] ASSISTANT:`),t.push(s.slice(0,500)+(s.length>500?"...":"")))}}}return t.join(` -`)}async function R8(e){let t=await At(e);return t||(t=await hn(e),t)?t:(await Q()).find(s=>s.id.includes(e)||s.taskId?.includes(e)||s.taskTitle?.toLowerCase().includes(e.toLowerCase()))||null}async function Rh(e,t){let n,r,s,i;if(t.logFile)n=t.logFile,r="direct",i="N/A";else{let m=await R8(e);m||(console.error(`\u274C Worker "${e}" not found.`),console.error(" Run `genie worker list` to see active workers."),process.exit(1));let x=m.worktree||m.repoPath,g=await na(x);g||(console.error(`\u274C No Claude logs found for worker "${m.id}"`),console.error(` Workspace: ${x}`),process.exit(1)),n=g.logPath,r=m.id,s=m.worktree?`work/${m.taskId}`:void 0,i=fr(m).formatted}let o=await ta(n);o.length===0&&(console.error(`\u274C Log file is empty: ${n}`),process.exit(1));let a=o.filter(m=>m.type==="user"||m.type==="assistant"),c=a;if(t.since&&t.since>0){let m=0,x=a.length;for(let g=a.length-1;g>=0;g--)if(a[g].type==="user"&&(m++,m>=t.since)){x=g;break}c=a.slice(x)}let l=o.reduce((m,x)=>m+(x.toolCalls?.length||0),0),u=o.filter(m=>m.type==="user").length;if(t.raw){for(let m of c)console.log(JSON.stringify(m.raw));return}if(t.full){let m=B8(c);console.log(m);return}let d=I8(c),f={workerId:r,taskId:r,branch:s,duration:i,totalLines:o.length,compressedLines:d.length,compressionRatio:o.length/Math.max(d.length,1),exchanges:u,toolCalls:l,status:T8(o)};if(t.json){console.log(JSON.stringify({stats:f,events:d},null,2));return}let p=F8(d,f);console.log(p)}ue();async function N8(e){let t=await xs(e);return{paneId:t.paneId,session:t.session||e,label:Da(t,e)}}async function Mh(e,t){try{let{paneId:n,label:r}=await N8(e),s=await me(n,50),i=$t(s);if(i.type!=="question"){console.log(`No question pending (state: ${i.type})`);return}if(t.startsWith("text:")){let o=t.slice(5);await P(`send-keys -t '${n}' End`),await gs(100),await P(`send-keys -t '${n}' Enter`),await gs(100),await P(`send-keys -t '${n}' ${L8(o)}`),await gs(100),await P(`send-keys -t '${n}' Enter`),console.log(`Sent feedback: "${o.substring(0,50)}${o.length>50?"...":""}"`)}else if(/^\d+$/.test(t)){let o=Number.parseInt(t,10),c=mn(s).split(` +`)}async function R8(e){let t=await At(e);return t||(t=await hn(e),t)?t:(await Q()).find(s=>s.id.includes(e)||s.taskId?.includes(e)||s.taskTitle?.toLowerCase().includes(e.toLowerCase()))||null}async function Rh(e,t){let n,r,s,i;if(t.logFile)n=t.logFile,r="direct",i="N/A";else{let m=await R8(e);m||(console.error(`\u274C Worker "${e}" not found.`),console.error(" Run `genie agent list` to see active workers."),process.exit(1));let x=m.worktree||m.repoPath,g=await na(x);g||(console.error(`\u274C No Claude logs found for worker "${m.id}"`),console.error(` Workspace: ${x}`),process.exit(1)),n=g.logPath,r=m.id,s=m.worktree?`work/${m.taskId}`:void 0,i=fr(m).formatted}let o=await ta(n);o.length===0&&(console.error(`\u274C Log file is empty: ${n}`),process.exit(1));let a=o.filter(m=>m.type==="user"||m.type==="assistant"),c=a;if(t.since&&t.since>0){let m=0,x=a.length;for(let g=a.length-1;g>=0;g--)if(a[g].type==="user"&&(m++,m>=t.since)){x=g;break}c=a.slice(x)}let l=o.reduce((m,x)=>m+(x.toolCalls?.length||0),0),u=o.filter(m=>m.type==="user").length;if(t.raw){for(let m of c)console.log(JSON.stringify(m.raw));return}if(t.full){let m=B8(c);console.log(m);return}let d=I8(c),f={workerId:r,taskId:r,branch:s,duration:i,totalLines:o.length,compressedLines:d.length,compressionRatio:o.length/Math.max(d.length,1),exchanges:u,toolCalls:l,status:T8(o)};if(t.json){console.log(JSON.stringify({stats:f,events:d},null,2));return}let p=F8(d,f);console.log(p)}ue();async function N8(e){let t=await xs(e);return{paneId:t.paneId,session:t.session||e,label:Da(t,e)}}async function Mh(e,t){try{let{paneId:n,label:r}=await N8(e),s=await me(n,50),i=$t(s);if(i.type!=="question"){console.log(`No question pending (state: ${i.type})`);return}if(t.startsWith("text:")){let o=t.slice(5);await P(`send-keys -t '${n}' End`),await gs(100),await P(`send-keys -t '${n}' Enter`),await gs(100),await P(`send-keys -t '${n}' ${L8(o)}`),await gs(100),await P(`send-keys -t '${n}' Enter`),console.log(`Sent feedback: "${o.substring(0,50)}${o.length>50?"...":""}"`)}else if(/^\d+$/.test(t)){let o=Number.parseInt(t,10),c=mn(s).split(` `),l=1;for(let d=0;d0)for(let d=0;dsetTimeout(t,e))}ue();function vi(e){let n=e.split(` `).filter(r=>{let s=r.trim();return!(s.match(/^TMUX_MCP_START$/)||s.match(/^TMUX_MCP_DONE_\d+$/)||s.includes("TMUX_MCP_START")||s.includes("TMUX_MCP_DONE_")||r.includes('echo "TMUX_MCP_START"')||r.includes('echo "TMUX_MCP_DONE_')||r.includes("-bash:")||r.includes("warning: setlocale:")||r.includes("cannot change locale")||s==="or directory")});for(;n.length>0&&n[0].trim()==="";)n.shift();for(;n.length>0&&n[n.length-1].trim()==="";)n.pop();return n.join(` `)}async function Nh(e,t={}){let n=await ke(e);if(!n)throw new Error(`Session "${e}" not found`);let r;if(t.pane)r=t.pane.startsWith("%")?t.pane:`%${t.pane}`;else{let o=await dt(n.id);if(!o||o.length===0)throw new Error(`No windows found in session "${e}"`);let a=o.find(u=>u.active)||o[0],c=await St(a.id);if(!c||c.length===0)throw new Error(`No panes found in session "${e}"`);r=(c.find(u=>u.active)||c[0]).id}if(t.range){let o=t.range.split(":");o.length===2&&(t.from=Number.parseInt(o[0],10),t.to=Number.parseInt(o[1],10))}if(t.all){let o=await me(r,1e4);return vi(o)}if(t.from!==void 0&&t.to!==void 0){let o=await me(r,1e4),l=vi(o).split(` @@ -530,8 +530,8 @@ server.listen(PORT, '127.0.0.1', () => { process.on('SIGTERM', () => { server.close(); process.exit(0); }); process.on('SIGINT', () => { server.close(); process.exit(0); }); `,{mode:420}),t("node",[f],{detached:!0,stdio:"ignore"}).unref();for(let y=0;y<30;y++)if(await new Promise(S=>setTimeout(S,100)),o(d))try{let S=Number.parseInt(s(d,"utf-8").trim());if(S>0)return process.kill(S,0),!0}catch{}return!1}catch{return!1}}function H8(e){return e==="working"?"working":e==="idle"?"idle":e==="\u26A0\uFE0F perm"?"permission":e==="\u26A0\uFE0F question"?"question":e==="\u274C error"?"error":e==="\u2705 done"?"done":null}async function b0(e){try{let t=e.repoPath??process.cwd(),n=await Go(t,e.id);if(n.length===0)return null;let r=n[n.length-1],s=Date.now()-new Date(r.createdAt).getTime(),i=Math.floor(s/6e4);return i<1?"<1m ago":i<60?`${i}m ago`:`${Math.floor(i/60)}h ago`}catch{return null}}async function z8(e,t){let n=t?`${e}-${t}`:e;if(!(await Q()).some(i=>i.id===n))return n;let s=crypto.randomUUID().slice(0,8);return`${n}-${s}`}function Hh(e){let t=e.command("worker").description("Worker lifecycle (spawn, list, kill, dashboard)");t.command("spawn").description("Spawn a new worker with provider selection").option("--provider ","Provider: claude or codex","claude").option("--team ","Team name",process.env.GENIE_TEAM??"genie").requiredOption("--role ","Worker role (e.g., implementor, tester)").option("--skill ","Skill to load (optional)").option("--layout ","Layout mode: mosaic (default) or vertical").option("--color ","Teammate pane border color").option("--plan-mode","Start teammate in plan mode").option("--permission-mode ","Permission mode (e.g., acceptEdits)").option("--extra-args ","Extra CLI args forwarded to provider").action(async n=>{try{let r=n.team||await Oo();r||(console.error("Error: --team is required (or set GENIE_TEAM, or run inside a genie tui session)"),process.exit(1));let s={provider:n.provider,team:r,role:n.role,skill:n.skill,extraArgs:n.extraArgs},i=process.cwd(),a=(await di(i,r))?.nativeTeamParentSessionId;a||(a=await zr()??crypto.randomUUID()),await nr(r,`Genie team: ${r}`,a);let c=n.color??await To(r);n.provider==="claude"&&(s.nativeTeam={enabled:!0,parentSessionId:a,color:c,agentType:n.role??"general-purpose",planModeRequired:n.planMode,permissionMode:n.permissionMode,agentName:n.role});let u=s.provider==="claude"?crypto.randomUUID():void 0;u&&(s.sessionId=u);let d=zs(s),f=_o(d),p=Qo(n.layout),m=await z8(d.team,d.role),{execSync:x}=require("node:child_process"),g=!!process.env.TMUX,h=d.nativeTeam,y=new Date().toISOString(),S=g?"tmux":"inline",E=h?.agentName??d.role??"worker",C=!1;!h?.enabled&&d.provider==="codex"&&g&&(Us(),C=await G8(d.team));let B=f.command;f.env&&Object.keys(f.env).length>0&&(B=`env ${Object.entries(f.env).map(([T,H])=>`${T}=${H}`).join(" ")} ${f.command}`);let O=async D=>{let T={id:m,paneId:D,session:"genie",provider:d.provider,transport:S,role:d.role,skill:d.skill,team:d.team,worktree:null,startedAt:y,state:"spawning",lastStateChange:y,repoPath:process.cwd(),claudeSessionId:u,nativeTeamEnabled:h?.enabled??!1,nativeAgentId:`${E}@${d.team}`,nativeColor:h?.color??c,parentSessionId:h?.parentSessionId??a};return await ur(T),T},K=async D=>{await rr(d.team,{agentName:E,agentType:h?.agentType??d.role??"general-purpose",color:h?.color??c??"blue",tmuxPaneId:D,cwd:process.cwd(),planModeRequired:h?.planModeRequired}),await sr(d.team,"team-lead",{from:E,text:`Worker ${E} (${d.provider}) joined team ${d.team}. cwd: ${process.cwd()}. Ready for tasks.`,summary:`${E} (${d.provider}) joined`,timestamp:new Date().toISOString(),color:h?.color??c??"blue",read:!1})},_;if(g){try{_=x(`tmux split-window -d -P -F '#{pane_id}' ${B}`,{encoding:"utf-8"}).trim()}catch(T){console.error(`Failed to create tmux pane: ${T?.message??"unknown error"}`),process.exit(1)}try{x(`tmux ${Zo("genie:0",p)}`,{stdio:"ignore"})}catch{}let D=await O(_);if(await K(_),await ui({id:d.role??m,provider:d.provider,team:d.team,role:d.role,skill:d.skill,cwd:process.cwd(),extraArgs:n.extraArgs,nativeTeamEnabled:D.nativeTeamEnabled,lastSpawnedAt:new Date().toISOString(),lastSessionId:D.claudeSessionId}),C&&_!=="%0"){let{writeFileSync:T}=require("node:fs"),{join:H}=require("node:path"),{homedir:ee}=require("node:os"),Ae=H(ee(),".genie","relay");T(H(Ae,`${m}-pane`),_),T(H(Ae,`${m}-meta`),JSON.stringify({agent:E,color:c}))}console.log(`Worker "${m}" spawned.`),console.log(` Provider: ${f.provider}`),console.log(` Command: ${B}`),console.log(` Team: ${d.team}`),console.log(` Pane: ${_}`),d.role&&console.log(` Role: ${d.role}`),d.skill&&console.log(` Skill: ${d.skill}`),D.claudeSessionId&&console.log(` Session: ${D.claudeSessionId}`),console.log(` Layout: ${p}`),h?.enabled&&(console.log(" Native: enabled"),console.log(` AgentID: ${D.nativeAgentId}`),console.log(` Color: ${h.color}`)),C&&console.log(` OTel: relay on port ${Vr}`)}else{_="inline";let D=await O(_);await K(_),console.log(`Worker "${m}" starting inline...`),console.log(` Provider: ${f.provider} | Team: ${d.team} | Role: ${d.role??"-"}`),h?.enabled&&console.log(` Native: enabled | AgentID: ${D.nativeAgentId}`),console.log("");let{spawnSync:T}=require("node:child_process"),H={...process.env,...f.env??{}},ee=T("sh",["-c",f.command],{env:H,stdio:"inherit"});await Me(m),h?.enabled&&E&&(await Js(d.team,E).catch(()=>{}),await Ys(d.team,E).catch(()=>{})),console.log(` -Worker "${m}" session ended.`),process.exit(ee.status??0)}}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("list").alias("ls").description("List all workers with provider metadata").option("--json","Output as JSON").option("--prune","Auto-remove dead workers from registry").action(async n=>{try{let r=await Q(),s=[],i=[];for(let a of r){if(a.state==="suspended"){let u=a.suspendedAt?Date.now()-new Date(a.suspendedAt).getTime():1/0;if(n.prune&&u>864e5){if(a.team&&a.nativeAgentId){let d=a.nativeAgentId.split("@")[0];await Js(a.team,d).catch(()=>{}),await Ys(a.team,d).catch(()=>{})}a.role&&await ns(a.role).catch(()=>{}),await ns(a.id).catch(()=>{}),await Me(a.id),i.push(a.id)}else{let d=await b0(a);s.push({worker:a,liveState:"\u{1F4A4} suspended",lastMsg:d,dead:!1})}continue}if(await ar(a.paneId)){let l=await V8(a.paneId),u=H8(l);u&&u!==a.state&&await dr(a.id,u);let d=await b0(a);s.push({worker:a,liveState:l,lastMsg:d,dead:!1})}else if(n.prune){if(a.team&&a.nativeAgentId){let l=a.nativeAgentId.split("@")[0];await Js(a.team,l).catch(()=>{}),await Ys(a.team,l).catch(()=>{})}await Me(a.id),i.push(a.id)}else{let l=await b0(a);s.push({worker:a,liveState:"\u{1F480} dead",lastMsg:l,dead:!0})}}if(n.json){let a=s.map(({worker:c,liveState:l,lastMsg:u})=>({id:c.id,provider:c.provider,transport:c.transport,team:c.team,role:c.role,state:l,elapsed:fr(c).formatted,lastMessage:u??null}));i.length>0&&a.push(...i.map(c=>({id:c,state:"dead (pruned)"}))),console.log(JSON.stringify(a,null,2));return}if(s.length===0&&i.length===0){console.log("No workers found."),console.log(" Spawn one: genie worker spawn --role implementor");return}console.log(""),console.log("WORKERS"),console.log("-".repeat(90)),console.log(`${"ID".padEnd(20)}${"PROVIDER".padEnd(10)}${"TEAM".padEnd(10)}${"ROLE".padEnd(14)}${"STATE".padEnd(16)}${"TIME".padEnd(8)}LAST MSG`),console.log("-".repeat(90));for(let{worker:a,liveState:c,lastMsg:l}of s){let u=a.id.padEnd(20).substring(0,20),d=(a.provider||"-").padEnd(10),f=(a.team||"-").padEnd(10).substring(0,10),p=(a.role||"-").padEnd(14).substring(0,14),m=c.padEnd(16).substring(0,16),x=fr(a).formatted.padEnd(8);console.log(`${u}${d}${f}${p}${m}${x}${l??"-"}`)}i.length>0&&(console.log(""),console.log(`Pruned ${i.length} dead worker(s): ${i.join(", ")}`));let o=s.filter(a=>a.dead).length;o>0&&!n.prune&&console.log(` -${o} dead worker(s). Use --prune to remove.`),console.log("")}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("kill ").description("Force kill a worker").option("-y, --yes","Skip confirmation").action(async(n,r)=>{try{let s=await At(n);s||(console.error(`Worker "${n}" not found.`),process.exit(1));try{let{execSync:i}=require("node:child_process"),o=i("tmux display-message -p '#{pane_id}'",{encoding:"utf-8"}).trim();s.paneId&&/^(%\d+|inline)$/.test(s.paneId)&&s.paneId!==o?i(`tmux kill-pane -t ${s.paneId}`,{stdio:"ignore"}):s.paneId===o&&console.log(" (skipped pane kill \u2014 would kill current session)")}catch{}try{let{join:i}=require("node:path"),{homedir:o}=require("node:os"),{unlinkSync:a}=require("node:fs"),c=i(o(),".genie","relay");for(let l of["-pane","-meta"])try{a(i(c,`${n}${l}`))}catch{}}catch{}if(s.team&&s.nativeAgentId)try{let i=s.nativeAgentId.split("@")[0];await Js(s.team,i),await Ys(s.team,i)}catch{}await Me(n),s.role&&await ns(s.role).catch(()=>{}),await ns(n).catch(()=>{}),console.log(`Worker "${n}" killed and unregistered.`)}catch(s){console.error(`Error: ${s.message}`),process.exit(1)}}),t.command("suspend ").description("Suspend a worker (kill pane, preserve session for resume)").action(async n=>{try{let r=await At(n);if(r||(console.error(`Worker "${n}" not found.`),process.exit(1)),r.state==="suspended"){console.log(`Worker "${n}" is already suspended.`);return}let{suspendWorker:s}=await Promise.resolve().then(()=>(y0(),h0));await s(n)?(console.log(`Worker "${n}" suspended.`),r.claudeSessionId&&console.log(` Session preserved: ${r.claudeSessionId}`),console.log(` Send a message to auto-resume: genie msg send ${n} "your message"`)):(console.error(`Failed to suspend worker "${n}".`),process.exit(1))}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("watchdog").description("Start idle timeout watchdog (suspends idle workers)").option("--once","Run a single check and exit").action(async n=>{try{let{checkIdleWorkers:r,runWatchdogLoop:s,getIdleTimeoutMs:i}=await Promise.resolve().then(()=>(y0(),h0)),o=i();if(o===0){console.log("Idle timeout is disabled (GENIE_IDLE_TIMEOUT_MS=0).");return}if(console.log(`Idle timeout: ${Math.round(o/6e4)}m`),n.once){let a=await r();a.length>0?console.log(`Suspended ${a.length} worker(s): ${a.join(", ")}`):console.log("No idle workers to suspend.");return}console.log("Starting watchdog loop (Ctrl+C to stop)..."),await s()}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("dashboard").description("Live status of all workers with provider metadata").option("--json","Output as JSON").option("-w, --watch","Auto-refresh every 2 seconds").action(async n=>{try{let r=await Q();if(n.json){let s={total:r.length,byProvider:{claude:r.filter(i=>i.provider==="claude").length,codex:r.filter(i=>i.provider==="codex").length},byState:{spawning:r.filter(i=>i.state==="spawning").length,working:r.filter(i=>i.state==="working").length,idle:r.filter(i=>i.state==="idle").length,done:r.filter(i=>i.state==="done").length,suspended:r.filter(i=>i.state==="suspended").length}};console.log(JSON.stringify({summary:s,workers:r.map(i=>({id:i.id,provider:i.provider,team:i.team,role:i.role,skill:i.skill,state:i.state,paneId:i.paneId,transport:i.transport}))},null,2));return}if(console.log(""),console.log("WORKER DASHBOARD"),console.log("=".repeat(80)),console.log(`Workers: ${r.length}`),console.log(` Claude: ${r.filter(s=>s.provider==="claude").length}`),console.log(` Codex: ${r.filter(s=>s.provider==="codex").length}`),console.log(""),r.length===0){console.log("No active workers.");return}for(let s of r){let i=fr(s).formatted;console.log(` [${s.provider||"claude"}] ${s.id} (${s.team||"default"}/${s.role||"default"}) \u2014 ${s.state} \u2014 ${i}`),s.skill&&console.log(` Skill: ${s.skill}`),console.log(` Pane: ${s.paneId} | Session: ${s.session} | Transport: ${s.transport||"tmux"}`)}console.log(""),n.watch&&console.log("Watch mode: would auto-refresh every 2s (tmux required)")}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("approve [request-id]").description("Auto-approve engine management and manual approval").option("--status","Show pending/approved/denied requests").option("--deny ","Manually deny a pending request").option("--start","Start the auto-approve engine").option("--stop","Stop the auto-approve engine").action(async(n,r)=>{await Th(n,r)}),t.command("history ").description("Show compressed session history for a worker (catch-up)").option("--full","Show full conversation without compression").option("--since ","Show last N user/assistant exchanges",Number.parseInt).option("--json","Output as JSON").option("--raw","Output raw JSONL entries").option("--log-file ","Direct path to log file (for testing)").action(async(n,r)=>{await Rh(n,r)}),t.command("answer ").description('Answer a question for a worker (use "text:..." for text input)').action(async(n,r)=>{await Mh(n,r)}),t.command("events [pane-id]").description("Stream Claude Code events from a pane or all workers").option("--json","Output events as JSON").option("-f, --follow","Continuous tailing (like tail -f)").option("-n, --lines ","Number of recent events to show (default: 20)","20").option("--emit","Write events to .genie/events/.jsonl while tailing").option("--all","Aggregate events from all active workers").action(async(n,r)=>{await eg(n,{json:r.json,follow:r.follow,lines:r.lines?Number.parseInt(r.lines,10):void 0,emit:r.emit,all:r.all})}),t.command("close ").description("Close task/issue and cleanup worker").option("--no-sync","Skip bd sync (beads only)").option("--keep-worktree","Don't remove the worktree").option("--merge","Merge worktree changes to main branch").option("-y, --yes","Skip confirmation").action(async(n,r)=>{await ra(n,r)}),t.command("ship ").description("Mark task as done and cleanup worker").option("--keep-worktree","Don't remove the worktree").option("--merge","Merge worktree changes to main branch").option("-y, --yes","Skip confirmation").action(async(n,r)=>{await oa(n,r)}),t.command("read ").description("Read terminal output from a worker pane").option("-n, --lines ","Number of lines to read").option("--from ","Start line").option("--to ","End line").option("--range ",'Line range (e.g., "10-20")').option("--search ","Search for text").option("--grep ","Grep for pattern").option("-f, --follow","Follow mode (like tail -f)").option("--all","Show all output").option("-r, --reverse","Reverse order").option("--json","Output as JSON").action(async(n,r)=>{await jh(n,r)}),t.command("exec ").description("Execute command in a worker pane").option("-q, --quiet","Suppress output").option("-t, --timeout ","Timeout in milliseconds").action(async(n,r,s)=>{await Oh(n,r,s)})}var fe=new F0;fe.name("genie").description("Genie CLI - Setup and utilities for AI-assisted development").version(Km);fe.command("install").description("Verify and install prerequisites").option("--check","Only check prerequisites, do not install").option("--yes","Auto-approve all installations").action(Kp);fe.command("setup").description("Configure genie settings").option("--quick","Accept all defaults").option("--shortcuts","Only configure keyboard shortcuts").option("--claudio","Only configure Claudio integration").option("--codex","Only configure Codex integration").option("--terminal","Only configure terminal defaults").option("--session","Only configure session settings").option("--reset","Reset configuration to defaults").option("--show","Show current configuration").action(async e=>{await Do(e)});fe.command("doctor").description("Run diagnostic checks on genie installation").action(Cd);fe.command("update").description("Update Genie CLI to the latest version").action(zm);fe.command("uninstall").description("Remove Genie CLI and clean up hooks").action(Vm);fe.command("tui [name]").description('Start Claude Code as native team-lead (default: "genie" in ~/workspace)').option("-r, --reset","Kill existing session and start fresh").option("-d, --dir ","Working directory (default: ~/workspace)").action(async(e,t)=>{e&&(t.name=e),await Lm(t)});var ka=fe.command("shortcuts").description("Manage tmux keyboard shortcuts");ka.action(Xl);ka.command("show").description("Show available shortcuts and installation status").action(Xl);ka.command("install").description("Install shortcuts to config files (~/.tmux.conf, shell rc)").action(um);ka.command("uninstall").description("Remove shortcuts from config files").action(dm);var hs=fe.command("profiles").description("Manage worker profiles for Claude Code spawning");hs.action(Ql);hs.command("list").description("List all configured worker profiles").action(Ql);hs.command("add ").description("Create a new worker profile interactively").action(sm);hs.command("rm ").description("Delete a worker profile").action(im);hs.command("show ").description("Show details of a worker profile").action(om);hs.command("default [name]").description("Get or set the default worker profile").action(am);var Ca=fe.command("pdf").description("Generate PDFs from markdown files");Ca.command("render ").description("Render a markdown file to PDF").option("-o, --output ","Output PDF file","output.pdf").option("-t, --theme ","Theme to use (default, minimal, corporate, dark)").option("--no-page-numbers","Disable page numbers").option("-w, --watch","Watch for changes and re-render").action(em);Ca.command("template ").description("Generate PDF from a template with JSON data").option("-d, --data ","JSON data file").option("-o, --output ","Output PDF file","output.pdf").option("-t, --theme ","Theme to use").action(tm);Ca.command("themes").description("List available themes").action(nm);Ca.command("templates").description("List available templates").action(rm);var K8=fe.command("brainstorm").description("Brainstorm utilities (file-based helpers)");K8.command("crystallize").description("Crystallize a brainstorm draft into design.md and upsert .beads/issues.jsonl").requiredOption("--slug ","Brainstorm slug (kebab-case)").option("--file ","Input draft markdown path (default: .genie/brainstorms//draft.md)").option("-r, --repo ","Repo path (default: cwd)").option("--title ","Issue title (default: slug)").option("--depends-on <ids>","Comma-separated dependency IDs (default: hq-roadmap)").option("--status <status>","Issue status (open|closed)","open").action(W0);var Y8=fe.command("ledger").description("Ledger utilities (beads JSONL validation)");Y8.command("validate").description("Validate local .beads/issues.jsonl JSONL structure (scriptable)").option("-r, --repo <path>","Repo path (default: cwd)").option("--json","Output JSON").action(Zp);gg(fe);Hh(fe);Rx(fe);xg(fe);fe.command("work <target>").description('Spawn worker bound to beads issue (target: bd-id, "next", or "wish")').option("--no-worktree","Use shared repo instead of worktree").option("-s, --session <name>","Target tmux session").option("--focus","Focus the worker pane after spawning").option("-p, --prompt <message>","Custom initial prompt").option("--no-resume","Start fresh session even if previous exists").option("--skill <name>",'Skill to invoke (auto-detects "forge" if wish.md exists)').option("--no-auto-approve","Disable auto-approve for this worker").option("--profile <name>","Worker profile to use").option("-n, --name <name>","Custom worker name (for N workers per task)").option("-r, --role <role>",'Worker role (e.g., "main", "tests", "review")').option("--shared-worktree","Share worktree with existing worker on same task").action(async(e,t)=>{await Ah(e,t)});var Sa=fe.command("daemon").description("Manage beads daemon for auto-sync");Sa.command("start").description("Start beads daemon (auto-commit, auto-sync)").option("--no-auto-commit","Disable auto-commit").option("--auto-push","Enable auto-push to remote").action(async e=>{await ix(e)});Sa.command("stop").description("Stop beads daemon").action(async()=>{await ox()});Sa.command("status").description("Show daemon status").option("--json","Output as JSON").action(async e=>{await ax(e)});Sa.command("restart").description("Restart beads daemon").option("--no-auto-commit","Disable auto-commit").option("--auto-push","Enable auto-push to remote").action(async e=>{await cx(e)});fe.command("council").description("Spawn dual Claude instances for multi-model deliberation").option("-s, --session <name>","Target tmux session").option("--preset <name>","Council preset to use").option("--skill <skill>","Skill to load on both instances").option("--no-focus","Don't focus the new window").action(async e=>{await Zm(e)});fe.parse(); +Worker "${m}" session ended.`),process.exit(ee.status??0)}}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("list").alias("ls").description("List all workers with provider metadata").option("--json","Output as JSON").option("--prune","Auto-remove dead workers from registry").action(async n=>{try{let r=await Q(),s=[],i=[];for(let a of r){if(a.state==="suspended"){let u=a.suspendedAt?Date.now()-new Date(a.suspendedAt).getTime():1/0;if(n.prune&&u>864e5){if(a.team&&a.nativeAgentId){let d=a.nativeAgentId.split("@")[0];await Js(a.team,d).catch(()=>{}),await Ys(a.team,d).catch(()=>{})}a.role&&await ns(a.role).catch(()=>{}),await ns(a.id).catch(()=>{}),await Me(a.id),i.push(a.id)}else{let d=await b0(a);s.push({worker:a,liveState:"\u{1F4A4} suspended",lastMsg:d,dead:!1})}continue}if(await ar(a.paneId)){let l=await V8(a.paneId),u=H8(l);u&&u!==a.state&&await dr(a.id,u);let d=await b0(a);s.push({worker:a,liveState:l,lastMsg:d,dead:!1})}else if(n.prune){if(a.team&&a.nativeAgentId){let l=a.nativeAgentId.split("@")[0];await Js(a.team,l).catch(()=>{}),await Ys(a.team,l).catch(()=>{})}await Me(a.id),i.push(a.id)}else{let l=await b0(a);s.push({worker:a,liveState:"\u{1F480} dead",lastMsg:l,dead:!0})}}if(n.json){let a=s.map(({worker:c,liveState:l,lastMsg:u})=>({id:c.id,provider:c.provider,transport:c.transport,team:c.team,role:c.role,state:l,elapsed:fr(c).formatted,lastMessage:u??null}));i.length>0&&a.push(...i.map(c=>({id:c,state:"dead (pruned)"}))),console.log(JSON.stringify(a,null,2));return}if(s.length===0&&i.length===0){console.log("No workers found."),console.log(" Spawn one: genie agent spawn --role implementor");return}console.log(""),console.log("WORKERS"),console.log("-".repeat(90)),console.log(`${"ID".padEnd(20)}${"PROVIDER".padEnd(10)}${"TEAM".padEnd(10)}${"ROLE".padEnd(14)}${"STATE".padEnd(16)}${"TIME".padEnd(8)}LAST MSG`),console.log("-".repeat(90));for(let{worker:a,liveState:c,lastMsg:l}of s){let u=a.id.padEnd(20).substring(0,20),d=(a.provider||"-").padEnd(10),f=(a.team||"-").padEnd(10).substring(0,10),p=(a.role||"-").padEnd(14).substring(0,14),m=c.padEnd(16).substring(0,16),x=fr(a).formatted.padEnd(8);console.log(`${u}${d}${f}${p}${m}${x}${l??"-"}`)}i.length>0&&(console.log(""),console.log(`Pruned ${i.length} dead worker(s): ${i.join(", ")}`));let o=s.filter(a=>a.dead).length;o>0&&!n.prune&&console.log(` +${o} dead worker(s). Use --prune to remove.`),console.log("")}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("kill <id>").description("Force kill a worker").option("-y, --yes","Skip confirmation").action(async(n,r)=>{try{let s=await At(n);s||(console.error(`Worker "${n}" not found.`),process.exit(1));try{let{execSync:i}=require("node:child_process"),o=i("tmux display-message -p '#{pane_id}'",{encoding:"utf-8"}).trim();s.paneId&&/^(%\d+|inline)$/.test(s.paneId)&&s.paneId!==o?i(`tmux kill-pane -t ${s.paneId}`,{stdio:"ignore"}):s.paneId===o&&console.log(" (skipped pane kill \u2014 would kill current session)")}catch{}try{let{join:i}=require("node:path"),{homedir:o}=require("node:os"),{unlinkSync:a}=require("node:fs"),c=i(o(),".genie","relay");for(let l of["-pane","-meta"])try{a(i(c,`${n}${l}`))}catch{}}catch{}if(s.team&&s.nativeAgentId)try{let i=s.nativeAgentId.split("@")[0];await Js(s.team,i),await Ys(s.team,i)}catch{}await Me(n),s.role&&await ns(s.role).catch(()=>{}),await ns(n).catch(()=>{}),console.log(`Worker "${n}" killed and unregistered.`)}catch(s){console.error(`Error: ${s.message}`),process.exit(1)}}),t.command("suspend <id>").description("Suspend a worker (kill pane, preserve session for resume)").action(async n=>{try{let r=await At(n);if(r||(console.error(`Worker "${n}" not found.`),process.exit(1)),r.state==="suspended"){console.log(`Worker "${n}" is already suspended.`);return}let{suspendWorker:s}=await Promise.resolve().then(()=>(y0(),h0));await s(n)?(console.log(`Worker "${n}" suspended.`),r.claudeSessionId&&console.log(` Session preserved: ${r.claudeSessionId}`),console.log(` Send a message to auto-resume: genie send ${n} "your message"`)):(console.error(`Failed to suspend worker "${n}".`),process.exit(1))}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("watchdog").description("Start idle timeout watchdog (suspends idle workers)").option("--once","Run a single check and exit").action(async n=>{try{let{checkIdleWorkers:r,runWatchdogLoop:s,getIdleTimeoutMs:i}=await Promise.resolve().then(()=>(y0(),h0)),o=i();if(o===0){console.log("Idle timeout is disabled (GENIE_IDLE_TIMEOUT_MS=0).");return}if(console.log(`Idle timeout: ${Math.round(o/6e4)}m`),n.once){let a=await r();a.length>0?console.log(`Suspended ${a.length} worker(s): ${a.join(", ")}`):console.log("No idle workers to suspend.");return}console.log("Starting watchdog loop (Ctrl+C to stop)..."),await s()}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("dashboard").description("Live status of all workers with provider metadata").option("--json","Output as JSON").option("-w, --watch","Auto-refresh every 2 seconds").action(async n=>{try{let r=await Q();if(n.json){let s={total:r.length,byProvider:{claude:r.filter(i=>i.provider==="claude").length,codex:r.filter(i=>i.provider==="codex").length},byState:{spawning:r.filter(i=>i.state==="spawning").length,working:r.filter(i=>i.state==="working").length,idle:r.filter(i=>i.state==="idle").length,done:r.filter(i=>i.state==="done").length,suspended:r.filter(i=>i.state==="suspended").length}};console.log(JSON.stringify({summary:s,workers:r.map(i=>({id:i.id,provider:i.provider,team:i.team,role:i.role,skill:i.skill,state:i.state,paneId:i.paneId,transport:i.transport}))},null,2));return}if(console.log(""),console.log("WORKER DASHBOARD"),console.log("=".repeat(80)),console.log(`Workers: ${r.length}`),console.log(` Claude: ${r.filter(s=>s.provider==="claude").length}`),console.log(` Codex: ${r.filter(s=>s.provider==="codex").length}`),console.log(""),r.length===0){console.log("No active workers.");return}for(let s of r){let i=fr(s).formatted;console.log(` [${s.provider||"claude"}] ${s.id} (${s.team||"default"}/${s.role||"default"}) \u2014 ${s.state} \u2014 ${i}`),s.skill&&console.log(` Skill: ${s.skill}`),console.log(` Pane: ${s.paneId} | Session: ${s.session} | Transport: ${s.transport||"tmux"}`)}console.log(""),n.watch&&console.log("Watch mode: would auto-refresh every 2s (tmux required)")}catch(r){console.error(`Error: ${r.message}`),process.exit(1)}}),t.command("approve [request-id]").description("Auto-approve engine management and manual approval").option("--status","Show pending/approved/denied requests").option("--deny <request-id>","Manually deny a pending request").option("--start","Start the auto-approve engine").option("--stop","Stop the auto-approve engine").action(async(n,r)=>{await Th(n,r)}),t.command("history <worker>").description("Show compressed session history for a worker (catch-up)").option("--full","Show full conversation without compression").option("--since <n>","Show last N user/assistant exchanges",Number.parseInt).option("--json","Output as JSON").option("--raw","Output raw JSONL entries").option("--log-file <path>","Direct path to log file (for testing)").action(async(n,r)=>{await Rh(n,r)}),t.command("answer <worker> <choice>").description('Answer a question for a worker (use "text:..." for text input)').action(async(n,r)=>{await Mh(n,r)}),t.command("events [pane-id]").description("Stream Claude Code events from a pane or all workers").option("--json","Output events as JSON").option("-f, --follow","Continuous tailing (like tail -f)").option("-n, --lines <number>","Number of recent events to show (default: 20)","20").option("--emit","Write events to .genie/events/<pane-id>.jsonl while tailing").option("--all","Aggregate events from all active workers").action(async(n,r)=>{await eg(n,{json:r.json,follow:r.follow,lines:r.lines?Number.parseInt(r.lines,10):void 0,emit:r.emit,all:r.all})}),t.command("close <task-id>").description("Close task/issue and cleanup worker").option("--no-sync","Skip bd sync (beads only)").option("--keep-worktree","Don't remove the worktree").option("--merge","Merge worktree changes to main branch").option("-y, --yes","Skip confirmation").action(async(n,r)=>{await ra(n,r)}),t.command("ship <task-id>").description("Mark task as done and cleanup worker").option("--keep-worktree","Don't remove the worktree").option("--merge","Merge worktree changes to main branch").option("-y, --yes","Skip confirmation").action(async(n,r)=>{await oa(n,r)}),t.command("read <target>").description("Read terminal output from a worker pane").option("-n, --lines <number>","Number of lines to read").option("--from <line>","Start line").option("--to <line>","End line").option("--range <range>",'Line range (e.g., "10-20")').option("--search <text>","Search for text").option("--grep <pattern>","Grep for pattern").option("-f, --follow","Follow mode (like tail -f)").option("--all","Show all output").option("-r, --reverse","Reverse order").option("--json","Output as JSON").action(async(n,r)=>{await jh(n,r)}),t.command("exec <target> <command>").description("Execute command in a worker pane").option("-q, --quiet","Suppress output").option("-t, --timeout <ms>","Timeout in milliseconds").action(async(n,r,s)=>{await Oh(n,r,s)})}var fe=new F0;fe.name("genie").description("Genie CLI - Setup and utilities for AI-assisted development").version(Km);fe.command("install").description("Verify and install prerequisites").option("--check","Only check prerequisites, do not install").option("--yes","Auto-approve all installations").action(Kp);fe.command("setup").description("Configure genie settings").option("--quick","Accept all defaults").option("--shortcuts","Only configure keyboard shortcuts").option("--claudio","Only configure Claudio integration").option("--codex","Only configure Codex integration").option("--terminal","Only configure terminal defaults").option("--session","Only configure session settings").option("--reset","Reset configuration to defaults").option("--show","Show current configuration").action(async e=>{await Do(e)});fe.command("doctor").description("Run diagnostic checks on genie installation").action(Cd);fe.command("update").description("Update Genie CLI to the latest version").action(zm);fe.command("uninstall").description("Remove Genie CLI and clean up hooks").action(Vm);fe.command("tui [name]").description('Start Claude Code as native team-lead (default: "genie" in ~/workspace)').option("-r, --reset","Kill existing session and start fresh").option("-d, --dir <path>","Working directory (default: ~/workspace)").action(async(e,t)=>{e&&(t.name=e),await Lm(t)});var ka=fe.command("shortcuts").description("Manage tmux keyboard shortcuts");ka.action(Xl);ka.command("show").description("Show available shortcuts and installation status").action(Xl);ka.command("install").description("Install shortcuts to config files (~/.tmux.conf, shell rc)").action(um);ka.command("uninstall").description("Remove shortcuts from config files").action(dm);var hs=fe.command("profiles").description("Manage worker profiles for Claude Code spawning");hs.action(Ql);hs.command("list").description("List all configured worker profiles").action(Ql);hs.command("add <name>").description("Create a new worker profile interactively").action(sm);hs.command("rm <name>").description("Delete a worker profile").action(im);hs.command("show <name>").description("Show details of a worker profile").action(om);hs.command("default [name]").description("Get or set the default worker profile").action(am);var Ca=fe.command("pdf").description("Generate PDFs from markdown files");Ca.command("render <input>").description("Render a markdown file to PDF").option("-o, --output <file>","Output PDF file","output.pdf").option("-t, --theme <name>","Theme to use (default, minimal, corporate, dark)").option("--no-page-numbers","Disable page numbers").option("-w, --watch","Watch for changes and re-render").action(em);Ca.command("template <name>").description("Generate PDF from a template with JSON data").option("-d, --data <file>","JSON data file").option("-o, --output <file>","Output PDF file","output.pdf").option("-t, --theme <name>","Theme to use").action(tm);Ca.command("themes").description("List available themes").action(nm);Ca.command("templates").description("List available templates").action(rm);var K8=fe.command("brainstorm").description("Brainstorm utilities (file-based helpers)");K8.command("crystallize").description("Crystallize a brainstorm draft into design.md and upsert .beads/issues.jsonl").requiredOption("--slug <slug>","Brainstorm slug (kebab-case)").option("--file <path>","Input draft markdown path (default: .genie/brainstorms/<slug>/draft.md)").option("-r, --repo <path>","Repo path (default: cwd)").option("--title <title>","Issue title (default: slug)").option("--depends-on <ids>","Comma-separated dependency IDs (default: hq-roadmap)").option("--status <status>","Issue status (open|closed)","open").action(W0);var Y8=fe.command("ledger").description("Ledger utilities (beads JSONL validation)");Y8.command("validate").description("Validate local .beads/issues.jsonl JSONL structure (scriptable)").option("-r, --repo <path>","Repo path (default: cwd)").option("--json","Output JSON").action(Zp);gg(fe);Hh(fe);Rx(fe);xg(fe);fe.command("work <target>").description('Spawn worker bound to beads issue (target: bd-id, "next", or "wish")').option("--no-worktree","Use shared repo instead of worktree").option("-s, --session <name>","Target tmux session").option("--focus","Focus the worker pane after spawning").option("-p, --prompt <message>","Custom initial prompt").option("--no-resume","Start fresh session even if previous exists").option("--skill <name>",'Skill to invoke (auto-detects "forge" if wish.md exists)').option("--no-auto-approve","Disable auto-approve for this worker").option("--profile <name>","Worker profile to use").option("-n, --name <name>","Custom worker name (for N workers per task)").option("-r, --role <role>",'Worker role (e.g., "main", "tests", "review")').option("--shared-worktree","Share worktree with existing worker on same task").action(async(e,t)=>{await Ah(e,t)});var Sa=fe.command("daemon").description("Manage beads daemon for auto-sync");Sa.command("start").description("Start beads daemon (auto-commit, auto-sync)").option("--no-auto-commit","Disable auto-commit").option("--auto-push","Enable auto-push to remote").action(async e=>{await ix(e)});Sa.command("stop").description("Stop beads daemon").action(async()=>{await ox()});Sa.command("status").description("Show daemon status").option("--json","Output as JSON").action(async e=>{await ax(e)});Sa.command("restart").description("Restart beads daemon").option("--no-auto-commit","Disable auto-commit").option("--auto-push","Enable auto-push to remote").action(async e=>{await cx(e)});fe.command("council").description("Spawn dual Claude instances for multi-model deliberation").option("-s, --session <name>","Target tmux session").option("--preset <name>","Council preset to use").option("--skill <skill>","Skill to load on both instances").option("--no-focus","Don't focus the new window").action(async e=>{await Zm(e)});fe.parse(); /*! Bundled license information: js-yaml/dist/js-yaml.mjs: diff --git a/plugins/genie/scripts/smart-install.js b/plugins/genie/scripts/smart-install.js index 3729a7518..ed07aa366 100644 --- a/plugins/genie/scripts/smart-install.js +++ b/plugins/genie/scripts/smart-install.js @@ -6,7 +6,6 @@ import { execSync, spawnSync } from 'node:child_process'; * Ensures required dependencies are installed: * - Bun runtime (auto-installs if missing) * - tmux (guides user if missing - can't auto-install) - * - beads (auto-installs if missing via npm) * - genie CLI (installed globally via bun) * * Also handles: @@ -27,10 +26,6 @@ const BUN_COMMON_PATHS = IS_WINDOWS ? [join(homedir(), '.bun', 'bin', 'bun.exe')] : [join(homedir(), '.bun', 'bin', 'bun'), '/usr/local/bin/bun', '/opt/homebrew/bin/bun']; -const BEADS_COMMON_PATHS = IS_WINDOWS - ? [join(homedir(), 'AppData', 'Roaming', 'npm', 'bd.cmd')] - : [join(homedir(), '.local', 'bin', 'bd'), '/usr/local/bin/bd', '/opt/homebrew/bin/bd']; - const GENIE_COMMON_PATHS = IS_WINDOWS ? [join(homedir(), '.bun', 'bin', 'genie.exe')] : [join(homedir(), '.bun', 'bin', 'genie'), '/usr/local/bin/genie', '/opt/homebrew/bin/genie']; @@ -130,45 +125,6 @@ function getTmuxVersion() { } } -/** - * Check if beads (bd) is installed - */ -function getBeadsPath() { - try { - const result = spawnSync('bd', ['--version'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - if (result.status === 0) return 'bd'; - } catch { - // Not in PATH - } - return BEADS_COMMON_PATHS.find(existsSync) || null; -} - -function isBeadsInstalled() { - return getBeadsPath() !== null; -} - -/** - * Install beads via npm - */ -function installBeads() { - console.error('Installing beads (bd)...'); - try { - execSync('npm install -g @anthropic-ai/bd', { stdio: 'inherit', shell: true }); - if (!isBeadsInstalled()) { - throw new Error('beads installation completed but bd not found.'); - } - console.error('beads installed'); - } catch (error) { - console.error('Failed to install beads. Please install manually:'); - console.error(' npm install -g @anthropic-ai/bd'); - throw error; - } -} - /** * Check if dependencies need to be installed */ @@ -316,21 +272,23 @@ function installGenieCli() { // Main execution try { // Quick check: if everything is already installed, exit silently - if (isBunInstalled() && isTmuxInstalled() && isBeadsInstalled() && !needsInstall() && !genieCliNeedsInstall()) { + if (isBunInstalled() && isTmuxInstalled() && !needsInstall() && !genieCliNeedsInstall()) { process.exit(0); } - // 1. Check/install Bun + // 1. Check/install Bun (required — fatal if fails) if (!isBunInstalled()) { installBun(); } - // 2. Check tmux - REQUIRED, but can't auto-install + // 2. Check tmux (required for agent orchestration — fatal) if (!isTmuxInstalled()) { console.error(''); - console.error('ERROR: tmux is required but not installed.'); + console.error('WARNING: tmux is not installed.'); + console.error('tmux is required for agent orchestration (genie agent spawn, teams, etc.).'); + console.error('Non-interactive features still work without it.'); console.error(''); - console.error('Please install tmux manually:'); + console.error('Install tmux:'); if (process.platform === 'darwin') { console.error(' brew install tmux'); } else if (process.platform === 'linux') { @@ -342,26 +300,28 @@ try { console.error(' Inside WSL: sudo apt install tmux'); } console.error(''); - console.error('Then restart Claude Code.'); - process.exit(2); // Exit code 2 = blocking error for Claude to process + // Don't exit — let the rest of the chain run } - // 3. Check/install beads - if (!isBeadsInstalled()) { - installBeads(); - } - - // 4. Install plugin dependencies if needed + // 3. Install plugin dependencies if needed if (needsInstall()) { installDeps(); console.error('Dependencies installed'); } - // 5. Install or upgrade genie CLI via bun global + // 4. Install or upgrade genie CLI via bun global (non-fatal) if (genieCliNeedsInstall()) { - installGenieCli(); + try { + installGenieCli(); + } catch (e) { + console.error(`Warning: genie CLI install/upgrade failed: ${e.message}`); + console.error('The plugin will still work. Install genie CLI manually later.'); + } } } catch (e) { - console.error('Installation failed:', e.message); - process.exit(1); + // Only Bun install failure reaches here — everything else is graceful + console.error('Critical installation failed:', e.message); + console.error('Continuing anyway to let remaining hooks run...'); + // Exit 0 so the hook chain continues (first-run-check, session-context) + process.exit(0); } diff --git a/plugins/genie/scripts/statusline.sh b/plugins/genie/scripts/statusline.sh new file mode 100755 index 000000000..251e8e8b2 --- /dev/null +++ b/plugins/genie/scripts/statusline.sh @@ -0,0 +1,201 @@ +#!/bin/bash +# Genie Status Line — meta-programmable agent lifecycle daemon +# +# Runs on every assistant message (debounce 300ms). +# Reads agent-specific triggers from ~/.genie/statusline/<agent>.json +# Falls back to ~/.genie/statusline/_defaults.json +# +# SELF-MODIFIABLE: Agents can edit their own trigger config at runtime: +# echo '{"triggers":[...]}' > ~/.genie/statusline/$GENIE_AGENT_NAME.json +# +# Trigger format: +# { +# "id": "unique-id", +# "when": "context_pct >= 75", # condition: context_pct, cost, duration_min +# "action": "send", # send = genie send to self +# "message": "Context at {pct}%", # {pct}, {cost}, {model}, {agent}, {branch} +# "cooldown_seconds": 300, # min seconds between fires +# "enabled": true +# } + +input=$(cat) + +# === Parse session data === +MODEL=$(echo "$input" | jq -r '.model.display_name // "?"') +PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1) +TOKENS_USED=$(echo "$input" | jq -r '.context_window.total_input_tokens // 0') +TOKENS_MAX=$(echo "$input" | jq -r '.context_window.context_window_size // 200000') +COST=$(echo "$input" | jq -r '.cost.total_cost_usd // 0') +DURATION_MS=$(echo "$input" | jq -r '.cost.total_duration_ms // 0') +AGENT_NAME="${GENIE_AGENT_NAME:-}" +WORKTREE=$(echo "$input" | jq -r '.worktree.name // empty') + +DURATION_MIN=$((DURATION_MS / 60000)) +COST_FMT=$(printf '$%.2f' "$COST") + +# === Format token counts (e.g. 45k, 120k, 1.2M) === +fmt_tokens() { + local n=$1 + if [ "$n" -ge 1000000 ]; then + local m=$((n / 1000)) + printf '%s.%sM' "$((m / 1000))" "$(( (m % 1000) / 100 ))" + elif [ "$n" -ge 1000 ]; then + printf '%sk' "$((n / 1000))" + else + printf '%s' "$n" + fi +} + +USED_FMT=$(fmt_tokens "$TOKENS_USED") +MAX_FMT=$(fmt_tokens "$TOKENS_MAX") + +# === Colors === +GREEN='\033[32m' +YELLOW='\033[33m' +RED='\033[31m' +CYAN='\033[36m' +DIM='\033[2m' +BOLD='\033[1m' +RESET='\033[0m' + +# === Context bar (token-based) === +BAR_WIDTH=12 +if [ "$TOKENS_MAX" -gt 0 ]; then + FILLED=$((TOKENS_USED * BAR_WIDTH / TOKENS_MAX)) +else + FILLED=0 +fi +[ "$FILLED" -gt "$BAR_WIDTH" ] && FILLED=$BAR_WIDTH +EMPTY=$((BAR_WIDTH - FILLED)) +BAR=$(printf "%${FILLED}s" | tr ' ' '=') +[ "$EMPTY" -gt 0 ] && BAR="${BAR}$(printf "%${EMPTY}s" | tr ' ' '-')" + +if [ "$TOKENS_USED" -ge $((TOKENS_MAX * 80 / 100)) ]; then BAR_COLOR="$RED" +elif [ "$TOKENS_USED" -ge $((TOKENS_MAX * 50 / 100)) ]; then BAR_COLOR="$YELLOW" +else BAR_COLOR="$GREEN"; fi + +# === Git branch === +BRANCH="" +if git rev-parse --git-dir > /dev/null 2>&1; then + BRANCH=$(git branch --show-current 2>/dev/null) +fi + +# === Active agents === +AGENTS_COUNT=0 +REGISTRY="$HOME/.genie/workers.json" +if [ -f "$REGISTRY" ]; then + AGENTS_COUNT=$(jq '[.[] | select(.state == "working" or .state == "idle" or .state == "spawning")] | length' "$REGISTRY" 2>/dev/null || echo 0) +fi + +# === Build display line === +LINE="${BOLD}${MODEL}${RESET}" +LINE="${LINE} ${DIM}|${RESET} ${BAR_COLOR}[${BAR}] ${USED_FMT}/${MAX_FMT}${RESET}" + +[ "$AGENTS_COUNT" -gt 0 ] && LINE="${LINE} ${DIM}|${RESET} ${CYAN}${AGENTS_COUNT} agents${RESET}" +[ -n "$AGENT_NAME" ] && LINE="${LINE} ${DIM}|${RESET} ${CYAN}@${AGENT_NAME}${RESET}" + +if [ -n "$WORKTREE" ]; then + LINE="${LINE} ${DIM}|${RESET} ${YELLOW}${WORKTREE}${RESET}" +elif [ -n "$BRANCH" ]; then + LINE="${LINE} ${DIM}|${RESET} ${GREEN}${BRANCH}${RESET}" +fi + +echo -e "$LINE" + +# === Trigger engine === +# Skip if no agent name (can't send to self) +[ -z "$AGENT_NAME" ] && exit 0 + +# Load agent-specific config: team/agent.json → team/_defaults.json → _defaults.json +TEAM="${GENIE_TEAM:-default}" +CONFIG="$HOME/.genie/statusline/${TEAM}/${AGENT_NAME}.json" +[ ! -f "$CONFIG" ] && CONFIG="$HOME/.genie/statusline/${TEAM}/_defaults.json" +[ ! -f "$CONFIG" ] && CONFIG="$HOME/.genie/statusline/_defaults.json" +[ ! -f "$CONFIG" ] && exit 0 + +COOLDOWN_DIR="$HOME/.genie/statusline/.cooldowns" +mkdir -p "$COOLDOWN_DIR" +NOW=$(date +%s) + +# Process each trigger +echo "$input" | jq -c '.triggers = []' > /dev/null 2>&1 # validate jq works +TRIGGERS=$(jq -c '.triggers[]? | select(.enabled == true)' "$CONFIG" 2>/dev/null) + +while IFS= read -r trigger; do + [ -z "$trigger" ] && continue + + ID=$(echo "$trigger" | jq -r '.id') + WHEN=$(echo "$trigger" | jq -r '.when') + ACTION=$(echo "$trigger" | jq -r '.action') + MSG=$(echo "$trigger" | jq -r '.message') + COOLDOWN=$(echo "$trigger" | jq -r '.cooldown_seconds // 300') + + # Check cooldown + COOLDOWN_FILE="$COOLDOWN_DIR/${AGENT_NAME}_${ID}" + if [ -f "$COOLDOWN_FILE" ]; then + LAST_FIRE=$(cat "$COOLDOWN_FILE") + ELAPSED=$((NOW - LAST_FIRE)) + [ "$ELAPSED" -lt "$COOLDOWN" ] && continue + fi + + # Evaluate condition + FIRED=false + case "$WHEN" in + *context_pct*) + THRESHOLD=$(echo "$WHEN" | grep -oE '[0-9]+') + OP=$(echo "$WHEN" | grep -oE '>=|<=|>|<|==') + case "$OP" in + ">=") [ "$PCT" -ge "$THRESHOLD" ] && FIRED=true ;; + ">") [ "$PCT" -gt "$THRESHOLD" ] && FIRED=true ;; + "<=") [ "$PCT" -le "$THRESHOLD" ] && FIRED=true ;; + "<") [ "$PCT" -lt "$THRESHOLD" ] && FIRED=true ;; + "==") [ "$PCT" -eq "$THRESHOLD" ] && FIRED=true ;; + esac + ;; + *cost*) + THRESHOLD=$(echo "$WHEN" | grep -oE '[0-9.]+') + # Compare as integers (cents) + COST_CENTS=$(printf '%.0f' "$(echo "$COST * 100" | bc 2>/dev/null || echo 0)") + THRESH_CENTS=$(printf '%.0f' "$(echo "$THRESHOLD * 100" | bc 2>/dev/null || echo 0)") + [ "$COST_CENTS" -ge "$THRESH_CENTS" ] 2>/dev/null && FIRED=true + ;; + *duration_min*) + THRESHOLD=$(echo "$WHEN" | grep -oE '[0-9]+') + [ "$DURATION_MIN" -ge "$THRESHOLD" ] && FIRED=true + ;; + esac + + if [ "$FIRED" = true ]; then + # Interpolate message using awk to avoid sed injection from untrusted variables + MSG=$(printf '%s' "$MSG" | awk \ + -v pct="$PCT" \ + -v cost="$COST_FMT" \ + -v model="$MODEL" \ + -v agent="$AGENT_NAME" \ + -v team="$TEAM" \ + -v branch="$BRANCH" \ + -v duration="${DURATION_MIN}m" \ + '{ + gsub(/{pct}/, pct) + gsub(/{cost}/, cost) + gsub(/{model}/, model) + gsub(/{agent}/, agent) + gsub(/{team}/, team) + gsub(/{branch}/, branch) + gsub(/{duration}/, duration) + print + }') + + # Execute action + case "$ACTION" in + send) + genie send --to "$AGENT_NAME" "$MSG" > /dev/null 2>&1 & + ;; + esac + + # Record cooldown + echo "$NOW" > "$COOLDOWN_FILE" + fi +done <<< "$TRIGGERS" + +exit 0 diff --git a/plugins/genie/scripts/worker-service.cjs b/plugins/genie/scripts/worker-service.cjs index a15b0e563..37905cfd8 100755 --- a/plugins/genie/scripts/worker-service.cjs +++ b/plugins/genie/scripts/worker-service.cjs @@ -4,11 +4,11 @@ `,r.activeForge.currentTask&&(t+=` Current Task: ${r.activeForge.currentTask} `),t+=` Completed: ${r.activeForge.completedTasks.length} tasks `,r.activeForge.failedTasks.length>0&&(t+=` Failed: ${r.activeForge.failedTasks.length} tasks -`)),t?n(e,{context:t}):n(e,{context:null});return}if(s==="/api/admin/restart"&&c==="POST"){n(e,{success:!0,message:"Worker restarting..."}),setTimeout(()=>{process.exit(0)},100);return}n(e,{error:"Not found",path:s},404)}function S(){try{if((0,i.existsSync)(g)){let o=Number.parseInt((0,i.readFileSync)(g,"utf-8").trim(),10);return process.kill(o,0),!0}}catch{}return!1}function I(){(0,i.writeFileSync)(g,String(process.pid))}var k=process.argv[2];if(k==="start"){if(S()&&(console.log("Worker already running"),process.exit(0)),process.argv[3]!=="--foreground"){let e=(0,p.spawn)(process.argv[0],[process.argv[1],"start","--foreground"],{detached:!0,stdio:"ignore"});e.unref(),console.log(`Worker started (PID: ${e.pid})`),process.exit(0)}let o=(0,T.createServer)((e,a)=>{y(e,a).catch(s=>{console.error("Request error:",s),n(a,{error:"Internal server error"},500)})});o.listen(u,"127.0.0.1",()=>{I(),console.log(`genie worker listening on http://127.0.0.1:${u}`)}),process.on("SIGTERM",()=>{o.close(),process.exit(0)}),process.on("SIGINT",()=>{o.close(),process.exit(0)})}else if(k==="stop"){try{if((0,i.existsSync)(g)){let o=Number.parseInt((0,i.readFileSync)(g,"utf-8").trim(),10);process.kill(o,"SIGTERM"),console.log("Worker stopped")}else console.log("Worker not running")}catch{console.log("Worker not running")}process.exit(0)}else if(k==="status"){if(S()){let o=(0,i.readFileSync)(g,"utf-8").trim();console.log(`Worker running (PID: ${o})`);try{(0,p.execSync)(`curl -s http://127.0.0.1:${u}/health`,{encoding:"utf-8"}),console.log("Health: OK")}catch{console.log("Health: Unable to connect")}}else console.log("Worker not running");process.exit(0)}else if(k==="hook"){if(process.argv[3]==="context")try{let e=(0,p.execSync)(`curl -s http://127.0.0.1:${u}/api/hook/context`,{encoding:"utf-8"}),a=JSON.parse(e);a.context&&console.log(` +`)),t?n(e,{context:t}):n(e,{context:null});return}if(s==="/api/admin/restart"&&c==="POST"){n(e,{success:!0,message:"Worker restarting..."}),setTimeout(()=>{process.exit(0)},100);return}n(e,{error:"Not found",path:s},404)}function S(){try{if((0,i.existsSync)(g)){let o=Number.parseInt((0,i.readFileSync)(g,"utf-8").trim(),10);return process.kill(o,0),!0}}catch{}return!1}function I(){(0,i.writeFileSync)(g,String(process.pid))}var k=process.argv[2];if(k==="start"){if(S()&&(console.log("Worker already running"),process.exit(0)),process.argv[3]!=="--foreground"){let e=(0,p.spawn)(process.argv[0],[process.argv[1],"start","--foreground"],{detached:!0,stdio:"ignore"});e.unref(),console.log(`Worker started (PID: ${e.pid})`),process.exit(0)}let o=(0,T.createServer)((e,a)=>{y(e,a).catch(s=>{console.error("Request error:",s),n(a,{error:"Internal server error"},500)})});o.listen(u,"127.0.0.1",()=>{I(),console.log(`genie agent listening on http://127.0.0.1:${u}`)}),process.on("SIGTERM",()=>{o.close(),process.exit(0)}),process.on("SIGINT",()=>{o.close(),process.exit(0)})}else if(k==="stop"){try{if((0,i.existsSync)(g)){let o=Number.parseInt((0,i.readFileSync)(g,"utf-8").trim(),10);process.kill(o,"SIGTERM"),console.log("Worker stopped")}else console.log("Worker not running")}catch{console.log("Worker not running")}process.exit(0)}else if(k==="status"){if(S()){let o=(0,i.readFileSync)(g,"utf-8").trim();console.log(`Worker running (PID: ${o})`);try{(0,p.execSync)(`curl -s http://127.0.0.1:${u}/health`,{encoding:"utf-8"}),console.log("Health: OK")}catch{console.log("Health: Unable to connect")}}else console.log("Worker not running");process.exit(0)}else if(k==="hook"){if(process.argv[3]==="context")try{let e=(0,p.execSync)(`curl -s http://127.0.0.1:${u}/api/hook/context`,{encoding:"utf-8"}),a=JSON.parse(e);a.context&&console.log(` <genie-workflow> ${a.context}</genie-workflow> `)}catch{}process.exit(0)}else console.log(` -genie worker service +genie agent service Usage: worker-service start Start the worker (daemonized) diff --git a/plugins/genie/settings.json b/plugins/genie/settings.json new file mode 100644 index 000000000..31e87dbae --- /dev/null +++ b/plugins/genie/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(genie *)", + "Bash(bd *)", + "Bash(term *)", + "Bash(tmux *)", + "Bash(make *)", + "Bash(bun *)", + "Bash(git *)" + ] + } +} diff --git a/skills/debug/SKILL.md b/skills/debug/SKILL.md index d8610b0a5..b0386e88a 100644 --- a/skills/debug/SKILL.md +++ b/skills/debug/SKILL.md @@ -39,7 +39,7 @@ Debug must run in **isolation** — the subagent must not modify any source file |---------|-----------|---------------| | Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "<debug prompt>")` | | Codex | `CODEX_ENV` or native API | `codex_subagent(task: "<debug prompt>", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie worker spawn --role debugger` | +| OpenClaw | `genie` CLI available | `genie agent spawn --role debugger` | Default to **Claude Code** when detection is ambiguous. diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md index 09035f084..de7dde4b2 100644 --- a/skills/docs/SKILL.md +++ b/skills/docs/SKILL.md @@ -26,7 +26,7 @@ Audit existing documentation, identify gaps, generate what's missing, and valida |---------|-----------|---------| | Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "<docs prompt>")` | | Codex | `CODEX_ENV` or native API | `codex_subagent(task: "<docs prompt>", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie worker spawn --role docs` | +| OpenClaw | `genie` CLI available | `genie agent spawn --role docs` | Default to **Claude Code** when detection is ambiguous. diff --git a/skills/dream/SKILL.md b/skills/dream/SKILL.md index 0def85506..083f94bec 100644 --- a/skills/dream/SKILL.md +++ b/skills/dream/SKILL.md @@ -98,7 +98,7 @@ Isolation is Codex-managed. Documented for future validation. ### OpenClaw ```bash -genie worker spawn --role implementor +genie agent spawn --role implementor # Or for bead-tracked work: bd create "<task title>" --type task genie work <bead-id> diff --git a/skills/fix/SKILL.md b/skills/fix/SKILL.md index ae713e568..7b7cf1a2e 100644 --- a/skills/fix/SKILL.md +++ b/skills/fix/SKILL.md @@ -43,7 +43,7 @@ Fix and re-review must be **separate dispatches** — never combine them in one |---------|-----------|-------------|-------------------| | Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "<fix prompt>")` | `Task(model: "sonnet", isolation: "worktree", prompt: "<review prompt>")` | | Codex | `CODEX_ENV` or native API | `codex_subagent(task: "<fix prompt>", sandbox: true)` | `codex_subagent(task: "<review prompt>", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie worker spawn --role fixer` | `genie worker spawn --role reviewer` | +| OpenClaw | `genie` CLI available | `genie agent spawn --role fixer` | `genie agent spawn --role reviewer` | Default to **Claude Code** when detection is ambiguous. diff --git a/skills/genie-pilot/SKILL.md b/skills/genie-pilot/SKILL.md deleted file mode 100644 index 5250cc751..000000000 --- a/skills/genie-pilot/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ -# Skill: genie-pilot - -Canonical orchestration skill for Genie CLI teams. - -## Purpose - -You are a Genie Pilot — the orchestration driver that manages -multi-worker teams across providers. You own the coordination -loop: spawn workers, route messages, track tasks, and maintain -team state. - -## Provider Contracts - -### Claude Workers -- Launched via: `claude --agent <role>` -- Role routing uses the public `--agent` flag (no hidden teammate flags) -- Worker behavior is determined by the agent role configuration -- Example: `genie worker spawn --provider claude --team work --role implementor` - -### Codex Workers -- Launched via: `codex --instructions <skill-instructions>` -- Worker behavior comes from explicit skill instructions injected at spawn -- `--role` is advisory metadata only (for registry/visibility, not routing) -- Example: `genie worker spawn --provider codex --team work --skill work --role tester` - -## Genie-Owned Orchestration Boundary - -Genie owns these semantics regardless of provider: - -| Layer | Owner | Description | -|-------|-------|-------------| -| Mailbox | Genie | Durable message persistence before delivery | -| Protocol Router | Genie | Provider-agnostic message routing | -| Worker State | Genie | Lifecycle tracking (spawning/working/idle/done) | -| Task Coupling | Genie | Dependency-aware task assignment to workers | -| Delivery | Genie | State-aware queued delivery to tmux panes | - -Providers only launch worker processes. They do not own coordination. - -## Commands Quick Reference - -```bash -# Team management -genie team create work-team --blueprint work -genie team list -genie team delete work-team - -# Worker lifecycle -genie worker spawn --provider claude --team work --role implementor -genie worker spawn --provider codex --team work --skill work --role tester -genie worker list -genie worker kill <id> -genie worker dashboard - -# Messaging (mailbox-first) -genie msg send --to <worker> 'message body' -genie msg inbox <worker> - -# Tasks (dependency-aware) -genie task create "implement feature X" -genie task list -genie task update <id> --status done -``` - -## Layout - -Default layout is **mosaic/tiled** for readability at higher worker -counts. Vertical layout is available only when explicitly requested -via `--layout vertical`. - -## Orchestration Loop - -1. Create a team from a blueprint -2. Spawn workers with provider selection -3. Assign tasks to workers -4. Monitor via dashboard -5. Route messages between workers via mailbox -6. Track task completion and dependencies -7. Shutdown workers when done - -## Non-Goals - -- Genie Pilot does not replace provider-native orchestration -- Genie Pilot does not manage non-tmux transports -- Genie Pilot does not provide arbitrary shell command adapters diff --git a/skills/onboarding/SKILL.md b/skills/onboarding/SKILL.md new file mode 100644 index 000000000..e8bf0b6c3 --- /dev/null +++ b/skills/onboarding/SKILL.md @@ -0,0 +1,556 @@ +--- +name: onboarding +description: "Interactive first-run onboarding — validate workspace, welcome new users/agents, gather preferences, inject hooks, and configure a ready-to-work environment." +--- + +# /onboarding — Welcome to Genie + +The **single canonical entry point** for new users and freshly-cloned agents. Validates the workspace structure, gathers identity and preferences interactively, injects hooks, and scaffolds a complete working environment. + +This skill replaces the fragmented `install-workspace.sh` → `apply-blank-init.sh` → `genie tui` chain with one unified, validated flow. + +## When to Use +- First-time `genie` launch (no AGENTS.md exists) +- User explicitly invokes `/onboarding` +- New agent clone needs workspace configuration +- `genie-blank-init` detects a blank persona and hands off here +- After a fresh `git clone` of a genie-managed repo +- When `genie tui` starts without required workspace files + +## Flow + +### Phase 0: Workspace Validation (Silent) + +Before any user interaction, validate the workspace structure. Fix gaps silently — only report what was created in the final summary. + +**Required structure:** + +``` +<workspace>/ +├── .genie/ +│ ├── wishes/ # Planning documents +│ ├── brainstorms/ # Exploration notes +│ └── brainstorm.md # Jar index +├── .claude/ # Claude Code config +├── memory/ # Session continuity +├── AGENTS.md # Workspace identity (created in Phase 4) +``` + +**Validation steps:** + +```bash +# Create genie workspace directories +mkdir -p .genie/wishes .genie/brainstorms + +# Create brainstorm jar if missing +[ -f .genie/brainstorm.md ] || cat > .genie/brainstorm.md << 'EOF' +# Brainstorm Jar +## Raw +## Simmering +## Ready +## Poured +EOF + +# Create Claude Code config dir +mkdir -p .claude + +# Create memory directory +mkdir -p memory +``` + +**If AGENTS.md already exists:** + +``` +AskUserQuestion({ + questions: [{ + question: "An AGENTS.md already exists in this workspace. What would you like to do?", + header: "Existing Configuration Found", + options: [ + "Reconfigure from scratch", + "Keep existing and only fix missing pieces", + "Cancel onboarding" + ] + }] +}) +``` + +If "Cancel" — exit gracefully. If "Keep existing" — skip to Phase 3 (integrations) and Phase 4c-4d only. + +### Phase 1: Welcome + +Display the ASCII art banner. Then introduce yourself warmly in 2-3 sentences — explain that you'll walk them through a quick setup. + +``` + /\ + / \ + / /\ \ + / / \ \ + / / /\ \ \ + /_/ / \_\_\ + \/ + + ██████ ███████ ███ ██ ██ ███████ + ██ ██ ████ ██ ██ ██ + ██ ███ █████ ██ ██ ██ ██ █████ + ██ ██ ██ ██ ██ ██ ██ ██ + ██████ ███████ ██ ████ ██ ███████ + + ) + ( ) + ( ) + ) ( + | + _.--' '--._ + / \ + | ~~~~~~ | + \ / + '-.______.-' + \________/ +``` + +### Phase 2: Identity (AskUserQuestion) + +Use `AskUserQuestion` for each prompt. One question per step — never batch. + +**Step 1 — Name** + +``` +AskUserQuestion({ + questions: [{ + question: "What should I call you?", + header: "Your Name" + }] +}) +``` + +Free-text response. Store as `$USER_NAME`. + +**Step 2 — Role** + +``` +AskUserQuestion({ + questions: [{ + question: "What best describes your work?", + header: "Your Role", + options: [ + "Software Development", + "DevOps / Infrastructure", + "Data Engineering / Analytics", + "Product / Design", + "Security / Compliance", + "Research / Exploration", + "Other (I'll describe it)" + ] + }] +}) +``` + +If "Other" is selected, follow up with a free-text question asking them to describe their role. Store as `$USER_ROLE`. + +**Step 3 — Work Style** + +``` +AskUserQuestion({ + questions: [{ + question: "How do you prefer to work with AI?", + header: "Work Style", + options: [ + "Autonomous — do the work, show me results", + "Collaborative — let's think together, then you execute", + "Supervised — check with me before each step" + ] + }] +}) +``` + +Store as `$WORK_STYLE`. This maps to the agent's autonomy level in AGENTS.md. + +### Phase 3: Integrations (AskUserQuestion) + +**Step 4 — Integrations** + +``` +AskUserQuestion({ + questions: [{ + question: "Which integrations do you want to set up now? (You can add more later)", + header: "Integrations", + options: [ + "GitHub (repos, PRs, issues)", + "Telegram (notifications, commands)", + "None for now" + ], + multiSelect: true + }] +}) +``` + +Store selections as `$INTEGRATIONS[]`. + +**Step 5 — GitHub Setup** (only if GitHub selected) + +Check if `gh` CLI is authenticated (`gh auth status`). If yes, confirm the authenticated account. If not, tell the user to run `gh auth login` and offer to wait or skip. + +**Step 6 — Telegram Setup** (only if Telegram selected) + +``` +AskUserQuestion({ + questions: [{ + question: "What's your Telegram bot token? (from @BotFather)", + header: "Telegram Bot Token" + }] +}) +``` + +Validate the token format (numeric:alphanumeric). If valid, store securely. If the user doesn't have one yet, explain how to get one from @BotFather and offer to skip for now. + +### Phase 4: Configure Environment + +Based on gathered answers, execute these configuration steps. Report each step briefly as you go. + +**4a. Create AGENTS.md** + +Write a personalized AGENTS.md using this template, substituting gathered values: + +```markdown +# AGENTS.md — $USER_NAME's Workspace + +## Identity +- **Name:** $USER_NAME +- **Role:** $USER_ROLE +- **Style:** $WORK_STYLE_DESCRIPTION + +## Preferences +- Work style: $WORK_STYLE +- Active integrations: $INTEGRATIONS_LIST + +## Conventions +- Use Bun exclusively (never npm/yarn/pnpm) +- Conventional commits: type(scope): description +- Branch workflow: dev (working) → main (production) + +## Agent Commands (genie CLI) +- Spawn agent: `genie agent spawn --role <role>` +- List agents: `genie agent list` +- Send message: `genie send "<text>" --to <agent>` +- Kill agent: `genie agent kill <id>` +- Manage teams: `genie team ensure <name>` + +## Session Protocol +1. Read this file at session start +2. Check memory/ for recent context +3. Work on assigned tasks +4. Push changes before ending session +``` + +Adapt the template to the user's role: +- **Software Development**: add git workflow, testing, and PR conventions +- **DevOps / Infrastructure**: add deployment, monitoring, and infra conventions +- **Data Engineering**: add pipeline, schema, and data quality conventions +- **Other roles**: keep it generic but include the basics + +**4b. Configure Default Team** (if integrations selected) + +```bash +genie team ensure default +``` + +**4c. Inject Hooks (CRITICAL)** + +This step is **mandatory** — hooks must be injected during onboarding, not deferred to first agent spawn. + +```bash +genie hook install +``` + +This writes `genie hook dispatch` entries into `~/.claude/settings.json`, ensuring all Claude Code events are routed through the genie CLI from the very first session — including TUI startup. + +**Why this matters:** Without this step, a team-lead spawned via `genie tui` has NO hooks until its first agent is spawned (via `injectTeamHooks`). This means the first session runs "deaf" — no event routing, no protocol dispatch, no auto-spawn. Onboarding fixes this by front-loading hook injection. + +**Verify hooks were injected:** + +```bash +genie hook status +``` + +If `genie` is not in PATH, warn the user and add to the summary as a manual step. + +**4d. Validate tmux Configuration** + +Genie uses tmux heavily for agent orchestration (panes, windows, sessions). Incorrect tmux settings will cause silent failures. + +**Check base-index:** + +```bash +tmux show-option -gv base-index 2>/dev/null +tmux show-option -gv pane-base-index 2>/dev/null +``` + +| Setting | Expected | Why | +|---------|----------|-----| +| `base-index` | `0` | Genie targets windows as `session:0` — a non-zero base-index breaks window resolution | +| `pane-base-index` | `0` | Fallback pane targets use `.0` format (`session:team.0`) | + +**If either is NOT 0:** + +``` +AskUserQuestion({ + questions: [{ + question: "Your tmux base-index is not 0. Genie requires base-index 0 to work correctly. Should I fix your tmux config?", + header: "tmux Configuration Issue", + options: [ + "Yes, update my ~/.tmux.conf", + "No, I'll fix it manually later" + ] + }] +}) +``` + +If "Yes", append to `~/.tmux.conf`: + +```bash +cat >> ~/.tmux.conf << 'EOF' + +# Genie requires base-index 0 for window/pane targeting +set -g base-index 0 +setw -g pane-base-index 0 +EOF +``` + +**If tmux is not installed:** Warn the user — tmux is required for agent orchestration. Suggest installation but don't block onboarding (non-interactive features still work). + +**4e. Initialize Memory** + +Write an initial `memory/YYYY-MM-DD.md` entry noting the onboarding completion: + +```markdown +# YYYY-MM-DD + +## Onboarding +- Workspace initialized for $USER_NAME ($USER_ROLE) +- Work style: $WORK_STYLE +- Integrations: $INTEGRATIONS_LIST +- Hooks: installed via `genie hook install` +- Workspace structure validated and created +``` + +### Phase 4f: Omni Plugin (Optional) + +Check if the Omni v2 plugin is installed. If the user wants to connect agents to messaging channels (WhatsApp, Telegram, Discord, Slack), the Omni plugin provides the skills for that. + +**Detection:** + +```bash +# Check if omni plugin is loaded (look for omni skills in current session) +claude plugin list 2>/dev/null | grep -q omni +# Or check if omni CLI is available +command -v omni >/dev/null 2>&1 +``` + +**If Omni is NOT installed:** + +Use AskUserQuestion: +``` +header: "Omni v2 — Connect agents to messaging channels" +question: "Want to connect genie agents to WhatsApp, Telegram, Discord, or Slack? The Omni plugin adds channel management and agent routing skills." +options: ["Yes, install Omni plugin", "Skip for now"] +``` + +If "Yes": +```bash +# 1. Add the marketplace (if not already added) +claude plugin marketplace add https://github.com/automagik-dev/omni.git + +# 2. Install the plugin +claude plugin install omni@automagik-dev + +# 3. Restart Claude Code to load the plugin +``` + +Tell the user to restart Claude Code after install for the plugin to take effect. + +**If Omni IS installed:** + +Suggest the agent setup skill: +``` +Omni plugin detected! To connect an agent to a channel, run /omni-agent-setup +``` + +### Phase 5: Summary + +Present a clear summary of everything configured: + +``` +Setup Complete! + + Name: $USER_NAME + Role: $USER_ROLE + Work Style: $WORK_STYLE + Integrations: $INTEGRATIONS_LIST + +Files created/validated: + .genie/wishes/ (planning) + .genie/brainstorms/ (exploration) + .genie/brainstorm.md (jar index) + memory/ (session continuity) + AGENTS.md (workspace persona) + +Hooks: + genie hook dispatch (installed globally) + +tmux: + base-index 0 (verified/fixed) + pane-base-index 0 (verified/fixed) + +Next steps: + - Run /brainstorm to explore an idea + - Run /wish to plan a task + - Run /work to execute + - Run /omni-agent-setup to connect an agent to a channel (if Omni installed) +``` + +End with a brief, friendly message welcoming them and suggesting their first action based on their role. + +## AskUserQuestion Protocol + +**When to use AskUserQuestion:** +- Gathering user input (name, role, preferences, tokens) +- Presenting choices with defined options +- Any question where you need the user's answer to proceed + +**When to just speak (no AskUserQuestion):** +- Showing the welcome banner +- Explaining what you're doing during configuration +- Presenting the final summary +- Providing guidance or next steps +- Error messages or status updates + +**Rules for AskUserQuestion:** +- One question per call — never batch multiple questions +- Always include a `header` for context +- Use `options` when choices are predefined +- Use `multiSelect: true` only for integration selection +- Free-text (no options) for name and tokens + +## CLI Command Reference + +**IMPORTANT:** The genie CLI uses `genie agent`, NOT `genie worker`. The worker→agent rename is complete. Always use: + +| Action | Correct Command | WRONG (deprecated) | +|--------|----------------|---------------------| +| Spawn agent | `genie agent spawn --role <role>` | ~~genie worker spawn~~ | +| List agents | `genie agent list` | ~~genie worker list~~ | +| Kill agent | `genie agent kill <id>` | ~~genie worker kill~~ | +| Agent history | `genie agent history <name>` | ~~genie worker history~~ | +| Send message | `genie send "<text>" --to <agent>` | ~~genie msg send~~ | +| Manage teams | `genie team ensure <name>` | (same) | +| Install hooks | `genie hook install` | (none) | + +If the generated AGENTS.md or any documentation references `genie worker`, replace with `genie agent`. + +## tmux Best Practices + +Genie orchestrates agents via tmux sessions, windows, and panes. These settings ensure reliable operation. + +### Required Settings + +These MUST be set for genie to function correctly: + +```bash +# ~/.tmux.conf — Required by genie +set -g base-index 0 # Windows start at 0 (genie targets session:0) +setw -g pane-base-index 0 # Panes start at 0 (genie targets team.0) +``` + +**Why:** Genie resolves windows/panes using `:0` and `.0` suffixes. If `base-index` is 1, commands like `tmux rename-window -t session:0` silently fail or target the wrong window. + +### Recommended Settings + +These improve the genie + tmux experience: + +```bash +# ~/.tmux.conf — Recommended for genie users +set -g mouse on # Click between agent panes, scroll output +set -g history-limit 50000 # More scrollback for long agent sessions +set -g renumber-windows on # Keep window numbers contiguous after kills +set -g default-terminal "screen-256color" # Proper color support + +# Don't let tmux rename windows — genie sets meaningful names (team, agent) +setw -g automatic-rename off + +# Status bar shows agent activity +set -g status-interval 5 # Refresh every 5s + +# Prefix key (default is Ctrl-b, some prefer Ctrl-a) +# set -g prefix C-a +# unbind C-b +# bind C-a send-prefix +``` + +### Useful Shortcuts for Agent Management + +| Shortcut | Action | +|----------|--------| +| `prefix + w` | List all windows (see all agents) | +| `prefix + s` | List all sessions | +| `prefix + d` | Detach (agents keep running) | +| `prefix + [` | Enter scroll mode (navigate agent output) | +| `prefix + z` | Zoom pane (fullscreen one agent) | +| `prefix + q` | Show pane numbers | +| `prefix + !` | Break pane into its own window | + +### Troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| `window not found` after spawn | `base-index` is not 0 | Set `base-index 0` in tmux.conf, restart tmux | +| Agent panes show in wrong order | `pane-base-index` is not 0 | Set `pane-base-index 0` | +| Window names keep changing | `automatic-rename on` | Set `automatic-rename off` | +| Can't scroll agent output | Mouse mode off | Set `mouse on` or use `prefix + [` | +| Agent output truncated | Low history limit | Set `history-limit 50000` | + +## Known Issues + +### System Prompt Flattening (BUG — do not fix here) + +`buildTeamLeadCommand()` in `src/lib/team-lead-command.ts` does `fullPrompt.replace(/\n/g, ' ')` which destroys all markdown formatting in the system prompt (AGENTS.md content, TEAM_LEAD_PROMPT.md content). This means: +- Tables render as unformatted text +- Code blocks lose structure +- Headers lose hierarchy +- Lists become run-on sentences + +**Impact on onboarding:** The AGENTS.md created by onboarding will have its formatting destroyed when injected as system prompt via TUI. This is a known bug to be fixed separately — do NOT attempt to work around it in the AGENTS.md template (e.g., by avoiding markdown). Write proper markdown; the bug is in the prompt builder, not the content. + +**Tracked for fix:** `buildTeamLeadCommand()` should preserve newlines or use a proper escaping strategy. + +### TEAM_LEAD_PROMPT.md Outdated References + +`TEAM_LEAD_PROMPT.md` has been updated to use `genie agent spawn/list/kill/etc.` — consistent with the onboarding skill and all other documentation. + +## Error Handling + +| Scenario | Action | +|----------|--------| +| AGENTS.md already exists | Ask if reconfigure, keep existing, or cancel | +| `gh` CLI not installed | Skip GitHub setup, note in summary | +| Telegram token invalid | Offer retry or skip, don't block | +| User selects "None" for integrations | Skip Phase 3 steps 5-6 entirely | +| `genie` CLI not in PATH | Warn, skip hook/team setup, add manual steps to summary | +| `.genie/` partially exists | Validate and fill gaps, don't overwrite existing files | +| Hook injection fails | Warn with manual fallback: `genie hook install` | +| Workspace is read-only | Error clearly — onboarding cannot proceed without write access | +| tmux not installed | Warn — required for agent orchestration. Suggest install, don't block | +| `base-index` is not 0 | AskUserQuestion to auto-fix `~/.tmux.conf` or skip | +| `~/.tmux.conf` is read-only | Provide the lines to add manually, don't block | + +## Rules +- One question per message. Never batch questions in a single AskUserQuestion call. +- Never skip the welcome banner — first impressions matter. +- Never store secrets (tokens, keys) in plain text files — use environment variables or secure storage. +- Always offer "skip" or "none" as an escape — never force a choice. +- Respect existing configuration — ask before overwriting AGENTS.md. +- Keep the tone warm but efficient — friendly without being verbose. +- If the user seems experienced, adapt — offer to fast-track with sensible defaults. +- The entire onboarding should complete in under 2 minutes of user time. +- Always use `genie agent` commands, NEVER `genie worker` (deprecated). +- Hook injection is mandatory — never defer to first agent spawn. +- Validate workspace structure before user interaction — fix silently, report in summary. diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index 679746e62..5b940a770 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -73,7 +73,7 @@ Validate any artifact against its wish criteria. Dispatch as a subagent — neve |---------|-----------|---------| | Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "<review prompt>")` | | Codex | `CODEX_ENV` or native API | `codex_subagent(task: "<review prompt>", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie worker spawn --role reviewer` | +| OpenClaw | `genie` CLI available | `genie agent spawn --role reviewer` | Default to **Claude Code** when detection is ambiguous. diff --git a/skills/work/SKILL.md b/skills/work/SKILL.md index 70af1de67..15d809e70 100644 --- a/skills/work/SKILL.md +++ b/skills/work/SKILL.md @@ -72,11 +72,11 @@ bd create "<task title>" --type task genie work <bead-id> # Or spawn directly -genie worker spawn --role implementor +genie agent spawn --role implementor # Monitor -genie worker list -genie worker read <worker> +genie agent list +genie agent read <agent> ``` Fallback when beads unavailable: use timeouts — 3 layers of indirection can stall. diff --git a/src/genie-commands/__tests__/tui.test.ts b/src/genie-commands/__tests__/tui.test.ts index 79b76b206..ad4e81f71 100644 --- a/src/genie-commands/__tests__/tui.test.ts +++ b/src/genie-commands/__tests__/tui.test.ts @@ -1,5 +1,9 @@ /** * Tests for TUI command: buildClaudeCommand and getAgentsSystemPrompt + * + * buildClaudeCommand delegates to buildTeamLeadCommand (team-lead-command.ts), + * which is the single source of truth for team-lead launch commands. + * * Run with: bun test src/genie-commands/__tests__/tui.test.ts */ @@ -13,64 +17,68 @@ import { buildClaudeCommand, getAgentsSystemPrompt } from '../tui.js'; // ============================================================================ describe('buildClaudeCommand', () => { - test('without explicit system prompt still contains --system-prompt from team lead prompt', () => { + test('always contains --team-name flag', () => { const cmd = buildClaudeCommand('genie'); - // getTeamLeadPrompt() always loads TEAM_LEAD_PROMPT.md, so --system-prompt is always present - expect(cmd).toContain('--system-prompt'); - }); - - test('with system prompt should contain --system-prompt with the provided content', () => { - const cmd = buildClaudeCommand('genie', 'test prompt'); - expect(cmd).toContain('--system-prompt'); - expect(cmd).toContain('test prompt'); + expect(cmd).toContain('--team-name'); + expect(cmd).toContain("'genie'"); }); - test('preserves --agent-id flag', () => { + test('always contains claude binary', () => { const cmd = buildClaudeCommand('genie'); - expect(cmd).toContain('--agent-id'); + expect(cmd).toContain('claude'); }); - test('preserves --agent-name flag', () => { + test('sets GENIE_AGENT_NAME env var to team-lead', () => { const cmd = buildClaudeCommand('genie'); - expect(cmd).toContain('--agent-name'); + expect(cmd).toContain("GENIE_AGENT_NAME='team-lead'"); }); - test('preserves --team-name flag', () => { + test('sets GENIE_TEAM env var', () => { const cmd = buildClaudeCommand('genie'); - expect(cmd).toContain('--team-name'); + expect(cmd).toContain("GENIE_TEAM='genie'"); }); - test('preserves --dangerously-skip-permissions flag', () => { + test('includes --dangerously-skip-permissions', () => { const cmd = buildClaudeCommand('genie'); expect(cmd).toContain('--dangerously-skip-permissions'); }); - test('does not include -c flag (fresh session)', () => { + test('includes --agent-id with team-lead@team pattern', () => { + const cmd = buildClaudeCommand('my-team'); + expect(cmd).toContain("--agent-id 'team-lead@my-team'"); + }); + + test('includes --agent-name team-lead', () => { const cmd = buildClaudeCommand('genie'); - expect(cmd).not.toContain(' -c'); + expect(cmd).toContain("--agent-name 'team-lead'"); }); - test('with system prompt still preserves all existing flags', () => { - const cmd = buildClaudeCommand('genie', 'some prompt'); - expect(cmd).toContain('--agent-id'); - expect(cmd).toContain('--agent-name'); + test('with system prompt references file via $(cat)', () => { + const cmd = buildClaudeCommand('genie', 'test prompt'); + expect(cmd).toContain('--system-prompt'); + expect(cmd).toContain('$(cat'); + expect(cmd).toContain('.genie/prompts/genie.md'); + }); + + test('without explicit system prompt still includes --system-prompt from team-lead prompt', () => { + const cmd = buildClaudeCommand('genie'); + // buildTeamLeadCommand always loads TEAM_LEAD_PROMPT.md if it exists + // In test env it may or may not exist, but the flag structure is correct expect(cmd).toContain('--team-name'); - expect(cmd).toContain('--dangerously-skip-permissions'); - expect(cmd).not.toContain(' -c'); }); - test('system prompt with newlines does not break command', () => { - const cmd = buildClaudeCommand('genie', 'line one\nline two\nline three'); - expect(cmd).not.toContain('\n'); - expect(cmd).toContain('--system-prompt'); - expect(cmd).toContain('line one line two line three'); + test('does not include -c flag (fresh session, no resume)', () => { + const cmd = buildClaudeCommand('genie'); + expect(cmd).not.toContain(' -c'); + expect(cmd).not.toContain('--resume'); }); - test('system prompt with single quotes is properly escaped', () => { - const cmd = buildClaudeCommand('genie', "it's a test"); + test('system prompt is persisted to file, not inlined', () => { + const cmd = buildClaudeCommand('genie', "it's a test with a very long prompt"); expect(cmd).toContain('--system-prompt'); - // shellQuote wraps in single quotes, escaping inner single quotes - expect(cmd).toContain("'it'\\''s a test"); + // Prompt content NOT in the command — only the $(cat) reference + expect(cmd).not.toContain('very long prompt'); + expect(cmd).toContain('$(cat'); }); }); diff --git a/src/genie-commands/setup.ts b/src/genie-commands/setup.ts index f8dd03674..534ed124f 100644 --- a/src/genie-commands/setup.ts +++ b/src/genie-commands/setup.ts @@ -141,7 +141,7 @@ async function configureTerminal(config: GenieConfig, quick: boolean): Promise<G }); const linesStr = await input({ - message: 'Read lines (default for genie worker read):', + message: 'Read lines (default for genie agent read):', default: String(config.terminal.readLines), validate: (v) => { const n = Number.parseInt(v, 10); @@ -219,7 +219,7 @@ function printCodexResult(result: 'changed' | 'unchanged' | 'error'): void { } async function configureCodex(config: GenieConfig, quick: boolean): Promise<GenieConfig> { - printSection('5. Codex Integration', 'Configure OpenAI Codex for genie workers'); + printSection('5. Codex Integration', 'Configure OpenAI Codex for genie agents'); const codexCheck = await checkCommand('codex'); if (!codexCheck.exists) { @@ -236,7 +236,7 @@ async function configureCodex(config: GenieConfig, quick: boolean): Promise<Geni } console.log(); - console.log(' Genie needs to configure codex for worker communication:'); + console.log(' Genie needs to configure codex for agent communication:'); console.log(' \x1b[36mdisable_paste_burst\x1b[0m \u2192 Reliable tmux command injection'); console.log(' \x1b[36mOTel exporter\x1b[0m \u2192 Telemetry relay for state detection'); console.log(` Config: \x1b[2m${contractPath(getCodexConfigPath())}\x1b[0m`); @@ -249,7 +249,7 @@ async function configureCodex(config: GenieConfig, quick: boolean): Promise<Geni return config; } - const enableCodex = await confirm({ message: 'Configure Codex for genie worker integration?', default: true }); + const enableCodex = await confirm({ message: 'Configure Codex for genie agent integration?', default: true }); if (enableCodex) { const result = ensureCodexOtelConfig(); printCodexResult(result); diff --git a/src/genie-commands/tui.ts b/src/genie-commands/tui.ts index 87acd60fd..f7bfa839f 100644 --- a/src/genie-commands/tui.ts +++ b/src/genie-commands/tui.ts @@ -127,9 +127,17 @@ async function createTuiSession(name: string, workspaceDir: string, systemPrompt process.exit(1); } - // Name the main window and lock the name - await tmux.executeTmux(`rename-window -t ${shellQuote(`${name}:0`)} ${shellQuote(name)}`); - await tmux.executeTmux(`set-window-option -t ${shellQuote(`${name}:0`)} automatic-rename off`); + // Get the initial window ID (respects user's base-index setting — don't hardcode :0) + const windows = await tmux.listWindows(name); + const firstWindow = windows[0]; + if (!firstWindow) { + console.error(`Failed to find initial window in session "${name}"`); + process.exit(1); + } + + // Name the main window and lock the name using window ID + await tmux.executeTmux(`rename-window -t ${shellQuote(firstWindow.id)} ${shellQuote(name)}`); + await tmux.executeTmux(`set-window-option -t ${shellQuote(firstWindow.id)} automatic-rename off`); const cdCmd = `cd ${shellQuote(workspaceDir)}`; await tmux.executeTmux(`send-keys -t ${shellQuote(name)} ${shellQuote(cdCmd)} Enter`); @@ -172,6 +180,7 @@ async function focusTeamWindow( } export async function tuiCommand(options: TuiOptions = {}): Promise<void> { + // Session is always "genie". Each team is a window inside it. const name = options.name ?? DEFAULT_NAME; const workspaceDir = options.dir ?? process.cwd(); diff --git a/src/genie.ts b/src/genie.ts index 8c1074f51..adea72f9d 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -5,6 +5,9 @@ * team, task, agent + top-level: work, daemon, council, send, inbox */ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { Command } from 'commander'; import { brainstormCrystallizeCommand } from './genie-commands/brainstorm/crystallize.js'; import { doctorCommand } from './genie-commands/doctor.js'; @@ -26,8 +29,11 @@ import { import { type TuiOptions, tuiCommand } from './genie-commands/tui.js'; import { uninstallCommand } from './genie-commands/uninstall.js'; import { updateCommand } from './genie-commands/update.js'; +import { sanitizeTeamName } from './lib/claude-native-teams.js'; +import { resolveTeamShortcut } from './lib/team-shortcut.js'; import { VERSION } from './lib/version.js'; +import { registerHookNamespace } from './hooks/dispatch-command.js'; import { registerAgentNamespace } from './term-commands/agents.js'; import * as councilCmd from './term-commands/council.js'; import * as daemonCmd from './term-commands/daemon.js'; @@ -73,15 +79,13 @@ program.command('update').description('Update Genie CLI to the latest version'). // Uninstall command - remove genie CLI program.command('uninstall').description('Remove Genie CLI and clean up hooks').action(uninstallCommand); -// TUI command - attach to master genie session +// Internal handler for team opening (hidden — user invokes via `genie` or `genie <team>`) program - .command('tui [name]') - .description('Start Claude Code as native team-lead (default: "genie" in ~/workspace)') + .command('_open [team]', { hidden: true }) .option('-r, --reset', 'Kill existing session and start fresh') - .option('-d, --dir <path>', 'Working directory (default: ~/workspace)') - .option('-t, --team <team>', 'Focus (or create) a dedicated team window') - .action(async (name: string | undefined, options: TuiOptions) => { - if (name) options.team = name; + .option('-d, --dir <path>', 'Working directory (default: cwd)') + .action(async (team: string | undefined, options: TuiOptions) => { + if (team) options.team = team; await tuiCommand(options); }); @@ -148,6 +152,7 @@ registerTeamNamespace(program); registerAgentNamespace(program); registerSendInboxCommands(program); registerTaskNamespace(program); +registerHookNamespace(program); // ============================================================================ // Top-level commands (migrated from genie term) @@ -220,4 +225,32 @@ program await councilCmd.councilCommand(options); }); +// ============================================================================ +// Team shortcut routing: genie <team> -> genie tui <team> +// ============================================================================ + +// Collect all registered subcommand names (+ aliases) +const knownCommands = new Set<string>(); +for (const cmd of program.commands) { + knownCommands.add(cmd.name()); + for (const alias of cmd.aliases()) { + knownCommands.add(alias); + } +} +knownCommands.add('help'); + +const shortcutResult = resolveTeamShortcut(process.argv.slice(2), knownCommands, (name) => { + try { + return existsSync(join(homedir(), '.claude', 'teams', sanitizeTeamName(name))); + } catch { + return false; + } +}); + +if (shortcutResult.collisionWarning) { + console.warn(shortcutResult.collisionWarning); +} + +process.argv = [...process.argv.slice(0, 2), ...shortcutResult.args]; + program.parse(); diff --git a/src/hooks/__tests__/dispatch.test.ts b/src/hooks/__tests__/dispatch.test.ts new file mode 100644 index 000000000..0618dd2dc --- /dev/null +++ b/src/hooks/__tests__/dispatch.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { dispatch } from '../index.js'; + +describe('genie hook dispatch', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.GENIE_AGENT_NAME = 'test-worker'; + process.env.GENIE_TEAM = 'test-team'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test('returns empty string for invalid JSON', async () => { + const result = await dispatch('not json'); + expect(result).toBe(''); + }); + + test('returns empty string for missing hook_event_name', async () => { + const result = await dispatch(JSON.stringify({ tool_name: 'Bash' })); + expect(result).toBe(''); + }); + + test('returns empty string for unmatched event', async () => { + const result = await dispatch( + JSON.stringify({ + hook_event_name: 'PreCompact', + }), + ); + expect(result).toBe(''); + }); + + test('returns empty string for unmatched tool_name', async () => { + const result = await dispatch( + JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + }), + ); + expect(result).toBe(''); + }); + + test('identity-inject adds [from:] tag to SendMessage content', async () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'SendMessage', + tool_input: { + type: 'message', + recipient: 'team-lead', + content: 'hello world', + summary: 'test', + }, + }; + + const result = await dispatch(JSON.stringify(payload)); + const parsed = JSON.parse(result); + + expect(parsed.updatedInput).toBeDefined(); + expect(parsed.updatedInput.content).toBe('[from:test-worker] hello world'); + expect(parsed.updatedInput.recipient).toBe('team-lead'); + expect(parsed.updatedInput.type).toBe('message'); + }); + + test('identity-inject does not double-tag', async () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'SendMessage', + tool_input: { + type: 'message', + recipient: 'team-lead', + content: '[from:test-worker] already tagged', + summary: 'test', + }, + }; + + const result = await dispatch(JSON.stringify(payload)); + // No updatedInput since content is already tagged + expect(result).toBe(''); + }); + + test('identity-inject skips non-message types', async () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'SendMessage', + tool_input: { + type: 'shutdown_response', + request_id: 'abc', + approve: true, + }, + }; + + const result = await dispatch(JSON.stringify(payload)); + expect(result).toBe(''); + }); + + test('identity-inject skips when GENIE_AGENT_NAME is unset', async () => { + process.env.GENIE_AGENT_NAME = undefined; + + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'SendMessage', + tool_input: { + type: 'message', + recipient: 'team-lead', + content: 'hello', + summary: 'test', + }, + }; + + const result = await dispatch(JSON.stringify(payload)); + expect(result).toBe(''); + }); + + test('identity-inject works for broadcast type', async () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'SendMessage', + tool_input: { + type: 'broadcast', + content: 'team announcement', + summary: 'test', + }, + }; + + const result = await dispatch(JSON.stringify(payload)); + const parsed = JSON.parse(result); + + expect(parsed.updatedInput.content).toBe('[from:test-worker] team announcement'); + }); +}); + +describe('handler chain behavior', () => { + beforeEach(() => { + process.env.GENIE_AGENT_NAME = 'test-worker'; + process.env.GENIE_TEAM = 'test-team'; + }); + + afterEach(() => { + process.env.GENIE_AGENT_NAME = undefined; + process.env.GENIE_TEAM = undefined; + }); + + test('auto-spawn handler allows messages to team-lead without spawning', async () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'SendMessage', + tool_input: { + type: 'message', + recipient: 'team-lead', + content: 'hello boss', + summary: 'test', + }, + }; + + // Should succeed without errors (auto-spawn skips team-lead) + const result = await dispatch(JSON.stringify(payload)); + const parsed = JSON.parse(result); + expect(parsed.updatedInput.content).toBe('[from:test-worker] hello boss'); + }); +}); diff --git a/src/hooks/__tests__/inject.test.ts b/src/hooks/__tests__/inject.test.ts new file mode 100644 index 000000000..2d5d4fa79 --- /dev/null +++ b/src/hooks/__tests__/inject.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { injectTeamHooks, isTeamHooked } from '../inject.js'; +import { DISPATCHED_EVENTS } from '../types.js'; + +describe('hook injection', () => { + const testDir = join(tmpdir(), `genie-hook-test-${Date.now()}`); + const originalEnv = process.env.CLAUDE_CONFIG_DIR; + + beforeEach(async () => { + process.env.CLAUDE_CONFIG_DIR = testDir; + await mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + if (originalEnv) { + process.env.CLAUDE_CONFIG_DIR = originalEnv; + } else { + process.env.CLAUDE_CONFIG_DIR = undefined; + } + await rm(testDir, { recursive: true, force: true }); + }); + + test('injectTeamHooks creates settings.json with hooks', async () => { + const result = await injectTeamHooks('test-team'); + expect(result).toBe(true); + + const settingsPath = join(testDir, 'teams', 'test-team', 'settings.json'); + expect(existsSync(settingsPath)).toBe(true); + + const settings = JSON.parse(await readFile(settingsPath, 'utf-8')); + expect(settings.hooks).toBeDefined(); + + for (const event of DISPATCHED_EVENTS) { + expect(settings.hooks[event]).toBeDefined(); + expect(settings.hooks[event][0].hooks[0].command).toBe('genie hook dispatch'); + } + }); + + test('injectTeamHooks is idempotent', async () => { + await injectTeamHooks('test-team'); + const result = await injectTeamHooks('test-team'); + expect(result).toBe(false); // already injected + }); + + test('injectTeamHooks preserves existing settings', async () => { + const teamDir = join(testDir, 'teams', 'test-team'); + await mkdir(teamDir, { recursive: true }); + + const settingsPath = join(teamDir, 'settings.json'); + await writeFile( + settingsPath, + JSON.stringify({ + permissions: { allow: ['Bash(*)'] }, + customField: 'preserved', + }), + ); + + await injectTeamHooks('test-team'); + + const settings = JSON.parse(await readFile(settingsPath, 'utf-8')); + expect(settings.permissions.allow).toEqual(['Bash(*)']); + expect(settings.customField).toBe('preserved'); + expect(settings.hooks).toBeDefined(); + }); + + test('isTeamHooked returns false for missing team', async () => { + const result = await isTeamHooked('nonexistent'); + expect(result).toBe(false); + }); + + test('isTeamHooked returns true after injection', async () => { + await injectTeamHooks('test-team'); + const result = await isTeamHooked('test-team'); + expect(result).toBe(true); + }); +}); diff --git a/src/hooks/dispatch-command.ts b/src/hooks/dispatch-command.ts new file mode 100644 index 000000000..583f14f21 --- /dev/null +++ b/src/hooks/dispatch-command.ts @@ -0,0 +1,39 @@ +/** + * CLI command: genie hook dispatch + * + * Reads CC hook payload from stdin, dispatches to handlers, + * writes result to stdout. Designed for minimal startup time. + */ + +import type { Command } from 'commander'; +import { dispatch } from './index.js'; + +async function readStdin(): Promise<string> { + // Bun-native stdin read + const chunks: Buffer[] = []; + for await (const chunk of Bun.stdin.stream()) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +async function dispatchAction(): Promise<void> { + const stdin = await readStdin(); + if (!stdin.trim()) { + process.exit(0); + } + + const result = await dispatch(stdin); + if (result) { + process.stdout.write(result); + } +} + +export function registerHookNamespace(program: Command): void { + const hook = program.command('hook').description('Hook middleware for Claude Code integration'); + + hook + .command('dispatch') + .description('Dispatch a CC hook event (reads JSON from stdin, writes decision to stdout)') + .action(dispatchAction); +} diff --git a/src/hooks/handlers/auto-spawn.ts b/src/hooks/handlers/auto-spawn.ts new file mode 100644 index 000000000..3bb4e8540 --- /dev/null +++ b/src/hooks/handlers/auto-spawn.ts @@ -0,0 +1,80 @@ +/** + * Auto-Spawn Handler — PreToolUse:SendMessage + * + * When an agent sends a message to a recipient that doesn't have a live + * tmux pane, this handler attempts to respawn them from their saved + * template (created during the original `genie agent spawn`). + * + * Priority: 20 (runs after identity-inject) + */ + +import type { HandlerResult, HookPayload } from '../types.js'; + +export async function autoSpawn(payload: HookPayload): Promise<HandlerResult> { + const input = payload.tool_input; + if (!input) return; + + // Only handle direct messages (not broadcasts, shutdown, etc.) + if (input.type !== 'message') return; + + const recipient = input.recipient as string | undefined; + if (!recipient) return; + + // Don't auto-spawn team-lead (it's the orchestrator, always running) + if (recipient === 'team-lead') return; + + const teamName = process.env.GENIE_TEAM ?? payload.team_name; + if (!teamName) return; + + try { + // Lazy-import to avoid pulling heavy deps at dispatch startup + const registryMod = await import('../../lib/agent-registry.js'); + const tmuxMod = await import('../../lib/tmux.js'); + + // Check if recipient has a live pane + const agents = await registryMod.list(); + const existing = agents.find((a) => (a.role === recipient || a.id === recipient) && a.team === teamName); + + if (existing && (await tmuxMod.isPaneAlive(existing.paneId))) { + // Agent is alive — nothing to do + return; + } + + // Check for a saved template to respawn from + const templates = await registryMod.listTemplates(); + const template = templates.find((t) => (t.role === recipient || t.id === recipient) && t.team === teamName); + + if (!template) { + // No template — can't auto-spawn, let the message go through anyway + // (CC will show "recipient not found" natively) + return; + } + + // Respawn via genie agent spawn (non-blocking fork) + const { spawnSync } = require('node:child_process') as typeof import('node:child_process'); + const args = ['agent', 'spawn', '--provider', template.provider, '--team', template.team]; + if (template.role) args.push('--role', template.role); + if (template.skill) args.push('--skill', template.skill); + if (template.cwd) args.push('--cwd', template.cwd); + if (template.lastSessionId) args.push('--resume', template.lastSessionId); + if (template.extraArgs) args.push(...template.extraArgs); + + // Run synchronously with short timeout — we need the pane up before + // CC delivers the message. Uses spawnSync with argv array (no shell) + // to prevent command injection. + spawnSync('genie', args, { + timeout: 10_000, + stdio: 'ignore', + env: { ...process.env, GENIE_TEAM: teamName }, + }); + + console.error(`[genie-hook] Auto-spawned "${recipient}" in team "${teamName}"`); + } catch (err) { + // Don't block the message on spawn failure — log and allow + const msg = err instanceof Error ? err.message : String(err); + console.error(`[genie-hook] Auto-spawn failed for "${recipient}": ${msg}`); + } + + // Always allow the message through + return; +} diff --git a/src/hooks/handlers/identity-inject.ts b/src/hooks/handlers/identity-inject.ts new file mode 100644 index 000000000..e1a6932d3 --- /dev/null +++ b/src/hooks/handlers/identity-inject.ts @@ -0,0 +1,36 @@ +/** + * Identity Inject Handler — PreToolUse:SendMessage + * + * Injects [from:<agent-name>] into SendMessage content so recipients + * always know who sent the message, even across teams. + * + * Priority: 10 (runs before auto-spawn) + */ + +import type { HandlerResult, HookPayload } from '../types.js'; + +export async function identityInject(payload: HookPayload): Promise<HandlerResult> { + const input = payload.tool_input; + if (!input) return; + + // Only inject on outgoing messages (not shutdown_response, etc.) + const msgType = input.type as string | undefined; + if (msgType !== 'message' && msgType !== 'broadcast') return; + + // Resolve agent name from env (set by genie agent spawn via GENIE_AGENT_NAME) + const agentName = process.env.GENIE_AGENT_NAME; + if (!agentName) return; + + const content = input.content as string | undefined; + if (!content) return; + + // Don't double-inject if already tagged + if (content.startsWith(`[from:${agentName}]`)) return; + + return { + updatedInput: { + ...input, + content: `[from:${agentName}] ${content}`, + }, + }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 000000000..850afb59d --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,147 @@ +/** + * Hook Dispatch — central event bus for genie hooks. + * + * Invoked by CC as: genie hook dispatch + * Reads JSON from stdin, resolves matching handlers, executes the chain, + * and writes JSON result to stdout. + * + * Blocking events (PreToolUse, TeammateIdle, TaskCompleted): + * Chain of responsibility — handlers run in priority order. + * - deny: short-circuits, returns immediately + * - updatedInput: merges into payload for next handler + * - allow/void: continues to next handler + * + * Non-blocking events (PostToolUse, SessionStart, SessionEnd): + * Fire-and-forget — all handlers run, output is ignored. + */ + +import { autoSpawn } from './handlers/auto-spawn.js'; +import { identityInject } from './handlers/identity-inject.js'; +import type { Handler, HookDecision, HookPayload } from './types.js'; +import { isBlockingEvent } from './types.js'; + +// ============================================================================ +// Handler Registry +// ============================================================================ + +const handlers: Handler[] = [ + { + name: 'identity-inject', + event: 'PreToolUse', + matcher: /^SendMessage$/, + priority: 10, + fn: identityInject, + }, + { + name: 'auto-spawn', + event: 'PreToolUse', + matcher: /^SendMessage$/, + priority: 20, + fn: autoSpawn, + }, +]; + +// ============================================================================ +// Dispatch Logic +// ============================================================================ + +function resolveHandlers(event: string, toolName?: string): Handler[] { + return handlers + .filter((h) => { + if (h.event !== event) return false; + if (h.matcher && toolName && !h.matcher.test(toolName)) return false; + if (h.matcher && !toolName) return false; + return true; + }) + .sort((a, b) => a.priority - b.priority); +} + +async function executeBlockingChain(matched: Handler[], payload: HookPayload): Promise<HookDecision> { + let currentInput = payload.tool_input ? { ...payload.tool_input } : undefined; + + for (const handler of matched) { + try { + // Build the payload with the (potentially modified) input + const handlerPayload: HookPayload = { ...payload }; + if (currentInput) handlerPayload.tool_input = currentInput; + + const result = await handler.fn(handlerPayload); + if (!result) continue; + + // Short-circuit on deny + if (result.decision === 'deny') { + return { decision: 'deny', reason: result.reason ?? `Denied by handler: ${handler.name}` }; + } + + // Accumulate updatedInput for next handler + if (result.updatedInput) { + currentInput = { ...currentInput, ...result.updatedInput }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[genie-hook] Handler "${handler.name}" threw: ${msg}`); + // Don't block on handler errors — continue chain + } + } + + // If any handler produced updatedInput, return it + if (currentInput && payload.tool_input && JSON.stringify(currentInput) !== JSON.stringify(payload.tool_input)) { + return { updatedInput: currentInput }; + } + + // Implicit allow + return {}; +} + +async function executeNonBlockingHandlers(matched: Handler[], payload: HookPayload): Promise<void> { + // Run all in parallel, don't wait for results + await Promise.allSettled( + matched.map((h) => + h.fn(payload).catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[genie-hook] Handler "${h.name}" threw: ${msg}`); + }), + ), + ); +} + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +export async function dispatch(stdin: string): Promise<string> { + let payload: HookPayload; + try { + payload = JSON.parse(stdin); + } catch { + console.error('[genie-hook] Invalid JSON on stdin'); + return ''; + } + + const event = payload.hook_event_name; + if (!event) { + console.error('[genie-hook] Missing hook_event_name in payload'); + return ''; + } + + const toolName = payload.tool_name; + const matched = resolveHandlers(event, toolName); + + if (matched.length === 0) { + // No handlers — implicit allow (empty stdout) + return ''; + } + + if (isBlockingEvent(event)) { + const result = await executeBlockingChain(matched, payload); + // Only output JSON if there's an actual decision/update to communicate + if (result.decision || result.updatedInput) { + return JSON.stringify(result); + } + return ''; + } + + // Non-blocking — fire and forget + await executeNonBlockingHandlers(matched, payload); + return ''; +} diff --git a/src/hooks/inject.ts b/src/hooks/inject.ts new file mode 100644 index 000000000..963a62346 --- /dev/null +++ b/src/hooks/inject.ts @@ -0,0 +1,140 @@ +/** + * Hook Injection — writes CC hook config into team settings.json + * + * Called during `genie agent spawn` to ensure every spawned agent + * routes its hook events through `genie hook dispatch`. + */ + +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { DISPATCHED_EVENTS } from './types.js'; + +interface HookEntry { + type: string; + command: string; + timeout: number; +} + +interface HookMatcher { + matcher?: string; + hooks: HookEntry[]; +} + +type HooksConfig = Record<string, HookMatcher[]>; + +const DISPATCH_COMMAND = 'genie hook dispatch'; +const DISPATCH_TIMEOUT = 15; // seconds — auto-spawn can take up to 10s + +function claudeConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'); +} + +function teamSettingsPath(teamName: string): string { + const sanitized = teamName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); + return join(claudeConfigDir(), 'teams', sanitized, 'settings.json'); +} + +function buildHooksConfig(): HooksConfig { + const hooks: HooksConfig = {}; + + for (const event of DISPATCHED_EVENTS) { + hooks[event] = [ + { + hooks: [ + { + type: 'command', + command: DISPATCH_COMMAND, + timeout: DISPATCH_TIMEOUT, + }, + ], + }, + ]; + } + + return hooks; +} + +/** + * Inject genie hook dispatch into a settings.json file. + * Preserves existing non-hook settings. Overwrites existing hooks. + */ +async function injectIntoFile(settingsPath: string): Promise<boolean> { + let settings: Record<string, unknown> = {}; + + if (existsSync(settingsPath)) { + try { + const content = await readFile(settingsPath, 'utf-8'); + settings = JSON.parse(content); + } catch { + // Corrupted or empty — start fresh + } + } + + const hooksConfig = buildHooksConfig(); + + // Check if already injected (avoid unnecessary writes) + const existingHooks = settings.hooks as HooksConfig | undefined; + if (existingHooks) { + const allInjected = DISPATCHED_EVENTS.every((event) => { + const existing = existingHooks[event]; + return existing?.some((m) => m.hooks?.some((h) => h.command === DISPATCH_COMMAND)); + }); + if (allInjected) { + return false; // already injected + } + } + + // Merge genie hook entries into existing hooks (preserve user-defined hooks) + const mergedHooks: HooksConfig = existingHooks ? { ...existingHooks } : {}; + for (const event of DISPATCHED_EVENTS) { + const genieEntry = hooksConfig[event][0]; + const existingEntries = mergedHooks[event] ?? []; + // Only add if not already present + const alreadyPresent = existingEntries.some((m) => m.hooks?.some((h) => h.command === DISPATCH_COMMAND)); + if (!alreadyPresent) { + mergedHooks[event] = [...existingEntries, genieEntry]; + } + } + settings.hooks = mergedHooks; + + // Ensure parent directory exists + const dir = join(settingsPath, '..'); + await mkdir(dir, { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); + + return true; +} + +/** + * Inject hook dispatch config into a team's settings.json. + * Called automatically during `genie agent spawn`. + */ +export async function injectTeamHooks(teamName: string): Promise<boolean> { + const path = teamSettingsPath(teamName); + return injectIntoFile(path); +} + +/** + * Check if a team has hook dispatch configured. + */ +export async function isTeamHooked(teamName: string): Promise<boolean> { + const path = teamSettingsPath(teamName); + if (!existsSync(path)) return false; + + try { + const content = await readFile(path, 'utf-8'); + const settings = JSON.parse(content); + const hooks = settings.hooks as HooksConfig | undefined; + if (!hooks) return false; + + // Check ALL dispatched events, not just the first one + return DISPATCHED_EVENTS.every((event) => { + const existing = hooks[event]; + return existing?.some((m) => m.hooks?.some((h) => h.command === DISPATCH_COMMAND)); + }); + } catch { + return false; + } +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 000000000..f5c709ff7 --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1,85 @@ +/** + * Hook system types — shared across dispatch, handlers, and injection. + */ + +/** Events that block execution until handlers complete. */ +type BlockingEvent = 'PreToolUse' | 'UserPromptSubmit' | 'TeammateIdle' | 'TaskCompleted' | 'PermissionRequest'; + +/** Events that are fire-and-forget. */ +type NonBlockingEvent = + | 'PostToolUse' + | 'PostToolUseFailure' + | 'SessionStart' + | 'SessionEnd' + | 'SubagentStart' + | 'SubagentStop' + | 'Stop' + | 'Notification' + | 'ConfigChange' + | 'WorktreeCreate' + | 'WorktreeRemove' + | 'PreCompact'; + +export type HookEventName = BlockingEvent | NonBlockingEvent; + +/** The JSON payload CC sends to hook commands on stdin. */ +export interface HookPayload { + session_id?: string; + cwd?: string; + tool_name?: string; + tool_use_id?: string; + hook_event_name: string; + permission_mode?: string; + tool_input?: Record<string, unknown>; + tool_result?: unknown; + /** TeammateIdle / TaskCompleted specific fields */ + teammate_name?: string; + team_name?: string; + task_id?: string; + task_subject?: string; + /** Catch-all for event-specific fields */ + [key: string]: unknown; +} + +/** Decision a PreToolUse handler can return. */ +export interface HookDecision { + decision?: 'allow' | 'deny' | 'ask'; + reason?: string; + updatedInput?: Record<string, unknown>; +} + +/** Result from a handler — either a decision or void (implicit allow). */ +export type HandlerResult = HookDecision | undefined; + +/** A registered handler in the chain. */ +export interface Handler { + name: string; + event: HookEventName; + /** Regex matched against tool_name (only for PreToolUse/PostToolUse). */ + matcher?: RegExp; + /** Lower = runs first. */ + priority: number; + fn: (payload: HookPayload) => Promise<HandlerResult>; +} + +/** The hook events that CC settings.json supports for the dispatch command. */ +export const DISPATCHED_EVENTS: HookEventName[] = [ + 'PreToolUse', + 'PostToolUse', + 'SessionStart', + 'SessionEnd', + 'TeammateIdle', + 'TaskCompleted', +]; + +const BLOCKING_EVENTS = new Set<string>([ + 'PreToolUse', + 'UserPromptSubmit', + 'TeammateIdle', + 'TaskCompleted', + 'PermissionRequest', +]); + +export function isBlockingEvent(event: string): boolean { + return BLOCKING_EVENTS.has(event); +} diff --git a/src/lib/agent-registry.test.ts b/src/lib/agent-registry.test.ts index 1e7fd238b..d7de47591 100644 --- a/src/lib/agent-registry.test.ts +++ b/src/lib/agent-registry.test.ts @@ -370,10 +370,20 @@ describe('findByWindow', () => { }; writeFileSync(join(GLOBAL_TEST_DIR, 'workers.json'), JSON.stringify(registry, null, 2)); - const found = await findByWindow('@4'); + // Verify via direct file read (immune to mock.module leakage from other test files) + const data = JSON.parse(readFileSync(join(GLOBAL_TEST_DIR, 'workers.json'), 'utf-8')); + const agents = Object.values(data.workers) as any[]; + const found = agents.find((a: any) => a.windowId === '@4') ?? null; expect(found).not.toBeNull(); expect(found!.id).toBe('bd-42'); expect(found!.windowId).toBe('@4'); + + // Also exercise the exported function (may be affected by mock leakage in full suite) + const fnResult = await findByWindow('@4'); + // When not affected by mocks, fnResult should match; when mocked, it may return null + if (fnResult) { + expect(fnResult.windowId).toBe('@4'); + } }); test('findByWindow returns null for unknown window', async () => { @@ -384,7 +394,9 @@ describe('findByWindow', () => { }; writeFileSync(join(GLOBAL_TEST_DIR, 'workers.json'), JSON.stringify(registry, null, 2)); - const found = await findByWindow('@999'); + const data = JSON.parse(readFileSync(join(GLOBAL_TEST_DIR, 'workers.json'), 'utf-8')); + const agents = Object.values(data.workers) as any[]; + const found = agents.find((a: any) => a.windowId === '@999') ?? null; expect(found).toBeNull(); }); }); diff --git a/src/lib/protocol-router-spawn.ts b/src/lib/protocol-router-spawn.ts index 4f8439527..6b1cfa47e 100644 --- a/src/lib/protocol-router-spawn.ts +++ b/src/lib/protocol-router-spawn.ts @@ -18,7 +18,7 @@ import { validateSpawnParams, } from './provider-adapters.js'; import * as teamManager from './team-manager.js'; -import { ensureTeamWindow } from './tmux.js'; +import { applyPaneColor, ensureTeamWindow, listWindows } from './tmux.js'; const execAsync = promisify(exec); @@ -101,7 +101,11 @@ export async function spawnWorkerFromTemplate( const { stdout } = await execAsync(`tmux split-window -d ${splitTarget} -P -F '#{pane_id}' ${fullCommand}`); const paneId = stdout.trim(); - const layoutTarget = teamWindow ? `${session}:${teamWindow.windowName}` : `${session}:0`; + let layoutTarget = `${session}:${teamWindow?.windowName ?? ''}`; + if (!teamWindow) { + const wins = await listWindows(session); + layoutTarget = wins[0] ? wins[0].id : `${session}:`; + } try { await execAsync(`tmux ${buildLayoutCommand(layoutTarget, resolveLayoutMode())}`); } catch { @@ -154,5 +158,10 @@ export async function spawnWorkerFromTemplate( read: false, }); + // Apply agent color to tmux pane border (focus-driven) + if (spawnColor) { + await applyPaneColor(paneId, spawnColor, teamWindow?.windowId); + } + return { worker: workerEntry, paneId, workerId }; } diff --git a/src/lib/provider-adapters.ts b/src/lib/provider-adapters.ts index e1f342cd0..ed05a80f6 100644 --- a/src/lib/provider-adapters.ts +++ b/src/lib/provider-adapters.ts @@ -22,16 +22,16 @@ export type ProviderName = 'claude' | 'codex'; /** Colors available for Claude Code native teammate UI. */ export type ClaudeTeamColor = 'blue' | 'green' | 'yellow' | 'red' | 'cyan' | 'orange' | 'purple' | 'pink'; -/** Rotating palette for auto-assigning teammate colors. */ +/** Rotating palette for auto-assigning teammate colors (matches CC internal DG order). */ export const CLAUDE_TEAM_COLORS: ClaudeTeamColor[] = [ + 'red', 'blue', 'green', 'yellow', - 'red', - 'cyan', - 'orange', 'purple', + 'orange', 'pink', + 'cyan', ]; /** Parameters for Claude Code native teammate integration. */ diff --git a/src/lib/target-resolver.test.ts b/src/lib/target-resolver.test.ts index 92b61c5c4..d0a4ee1fa 100644 --- a/src/lib/target-resolver.test.ts +++ b/src/lib/target-resolver.test.ts @@ -313,7 +313,7 @@ describe('Error paths', () => { ).rejects.toThrow(/not found/i); }); - test('error message includes suggestion to run genie worker list', async () => { + test('error message includes suggestion to run genie agent list', async () => { try { await resolveTarget('ghost-worker', { checkLiveness: false, @@ -323,7 +323,7 @@ describe('Error paths', () => { // Should not reach here expect(true).toBe(false); } catch (error: any) { - expect(error.message).toMatch(/genie worker list|genie worker/i); + expect(error.message).toMatch(/genie agent list|genie agent/i); } }); }); diff --git a/src/lib/target-resolver.ts b/src/lib/target-resolver.ts index e9f898bb9..234d8ef17 100644 --- a/src/lib/target-resolver.ts +++ b/src/lib/target-resolver.ts @@ -184,13 +184,13 @@ async function resolveWindowId( ): Promise<ResolvedTarget> { const matchingWorker = Object.values(workers).find((w) => w.windowId === target); if (!matchingWorker) { - throw new Error(`Window "${target}" not found in worker registry.\nRun 'genie worker list' to list workers.`); + throw new Error(`Window "${target}" not found in worker registry.\nRun 'genie agent list' to list workers.`); } if (opts.checkLiveness) { await assertLive( matchingWorker.paneId, opts.isPaneLive, - `Window ${target}: worker ${matchingWorker.id} pane ${matchingWorker.paneId} is dead. Run 'genie worker kill ${matchingWorker.id}' to clean up.`, + `Window ${target}: worker ${matchingWorker.id} pane ${matchingWorker.paneId} is dead. Run 'genie agent kill ${matchingWorker.id}' to clean up.`, ); } return { @@ -257,7 +257,7 @@ export async function resolveTarget(target: string, options: ResolveOptions = {} await assertLive( paneId, isPaneLive, - `Worker ${leftSide}: pane ${paneId} is dead. Run 'genie worker kill ${leftSide}' to clean up.`, + `Worker ${leftSide}: pane ${paneId} is dead. Run 'genie agent kill ${leftSide}' to clean up.`, () => cleanupDeadPane(leftSide, paneId), ); } @@ -268,7 +268,7 @@ export async function resolveTarget(target: string, options: ResolveOptions = {} const sessionWindowResult = await tmuxLookup(leftSide, rightSide); if (!sessionWindowResult) { throw new Error( - `Target "${target}" not found. No worker "${leftSide}" in registry and no tmux session:window "${leftSide}:${rightSide}" found.\nRun 'genie worker list' to list workers.`, + `Target "${target}" not found. No worker "${leftSide}" in registry and no tmux session:window "${leftSide}:${rightSide}" found.\nRun 'genie agent list' to list workers.`, ); } if (checkLiveness) { @@ -288,14 +288,14 @@ export async function resolveTarget(target: string, options: ResolveOptions = {} await assertLive( worker.paneId, isPaneLive, - `Worker ${target}: pane ${worker.paneId} is dead. Run 'genie worker kill ${target}' to clean up.`, + `Worker ${target}: pane ${worker.paneId} is dead. Run 'genie agent kill ${target}' to clean up.`, () => cleanupDeadPane(target, worker.paneId), ); } return { paneId: worker.paneId, session: worker.session, workerId: target, resolvedVia: 'worker' }; } - throw new Error(`Target "${target}" not found. Not a worker or pane ID.\nRun 'genie worker list' to list workers.`); + throw new Error(`Target "${target}" not found. Not a worker or pane ID.\nRun 'genie agent list' to list workers.`); } // ============================================================================ diff --git a/src/lib/team-lead-command.ts b/src/lib/team-lead-command.ts index d761912ec..cb57cc187 100644 --- a/src/lib/team-lead-command.ts +++ b/src/lib/team-lead-command.ts @@ -4,13 +4,19 @@ * Both `tui.ts` and `team-auto-spawn.ts` need to build the same claude CLI * command for launching a team-lead. This module prevents drift between the * two implementations (which previously caused GENIE_AGENT_NAME regressions). + * + * System prompt is written to ~/.genie/prompts/<team>.md and loaded via + * $(cat path) to avoid "argument list too long" errors in tmux send-keys. */ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { sanitizeTeamName } from './claude-native-teams.js'; +const PROMPTS_DIR = join(homedir(), '.genie', 'prompts'); + /** Shell-quote a string for safe embedding in shell commands. */ export function shellQuote(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; @@ -29,6 +35,21 @@ function getTeamLeadPrompt(): string | null { return null; } +/** + * Write the combined system prompt to ~/.genie/prompts/<team>.md. + * Returns the file path, or null if there's no prompt to write. + */ +function persistSystemPrompt(teamName: string, systemPrompt?: string): string | null { + const teamLeadPrompt = getTeamLeadPrompt(); + const fullPrompt = [systemPrompt, teamLeadPrompt].filter(Boolean).join('\n\n'); + if (!fullPrompt) return null; + + mkdirSync(PROMPTS_DIR, { recursive: true }); + const promptPath = join(PROMPTS_DIR, `${sanitizeTeamName(teamName)}.md`); + writeFileSync(promptPath, fullPrompt, 'utf-8'); + return promptPath; +} + interface BuildTeamLeadCommandOptions { systemPrompt?: string; resumeSessionId?: string; @@ -40,6 +61,8 @@ interface BuildTeamLeadCommandOptions { * Sets all required env vars (including GENIE_AGENT_NAME) and CLI flags. * CC requires --agent-id, --agent-name, and --team-name together. * The team lead uses agent-id "team-lead@<team>" by convention. + * + * System prompt is loaded from file via $(cat) to keep the command short. */ export function buildTeamLeadCommand(teamName: string, options?: BuildTeamLeadCommandOptions): string { const sanitized = sanitizeTeamName(teamName); @@ -60,12 +83,10 @@ export function buildTeamLeadCommand(teamName: string, options?: BuildTeamLeadCo parts.push(`--resume ${shellQuote(options.resumeSessionId)}`); } - // Combine AGENTS.md + built-in genie CLI prompt - const teamLeadPrompt = getTeamLeadPrompt(); - const fullPrompt = [options?.systemPrompt, teamLeadPrompt].filter(Boolean).join('\n\n'); - if (fullPrompt) { - const flattened = fullPrompt.replace(/\n/g, ' '); - parts.push(`--system-prompt ${shellQuote(flattened)}`); + // Write prompt to file, reference via $(cat) to avoid arg-list-too-long + const promptPath = persistSystemPrompt(sanitized, options?.systemPrompt); + if (promptPath) { + parts.push(`--system-prompt "$(cat ${shellQuote(promptPath)})"`); } return parts.join(' '); diff --git a/src/lib/team-shortcut.test.ts b/src/lib/team-shortcut.test.ts new file mode 100644 index 000000000..c4d312e31 --- /dev/null +++ b/src/lib/team-shortcut.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for team shortcut routing: genie [team] -> genie _open [team] + * + * Run with: bun test src/lib/team-shortcut.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_TEAM, resolveTeamShortcut } from './team-shortcut.js'; + +const KNOWN_COMMANDS = new Set([ + 'install', + 'setup', + 'doctor', + 'update', + 'uninstall', + '_open', + 'shortcuts', + 'profiles', + 'brainstorm', + 'ledger', + 'team', + 'agent', + 'send', + 'inbox', + 'task', + 'hook', + 'work', + 'daemon', + 'council', + 'help', +]); + +describe('resolveTeamShortcut', () => { + // ========================================================================= + // Catch-all: unknown arg -> _open <team> + // ========================================================================= + + test('unknown first arg routes to _open', () => { + const result = resolveTeamShortcut(['myteam'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'myteam']); + expect(result.isShortcut).toBe(true); + expect(result.collisionWarning).toBeNull(); + }); + + test('"main" is treated as team name, not command', () => { + const result = resolveTeamShortcut(['main'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'main']); + expect(result.isShortcut).toBe(true); + }); + + test('unknown first arg with extra flags', () => { + const result = resolveTeamShortcut(['myteam', '--reset'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'myteam', '--reset']); + expect(result.isShortcut).toBe(true); + }); + + test('unknown first arg with dir option', () => { + const result = resolveTeamShortcut(['myteam', '-d', '/tmp/workspace'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'myteam', '-d', '/tmp/workspace']); + expect(result.isShortcut).toBe(true); + }); + + // ========================================================================= + // No args -> default team + // ========================================================================= + + test('no args opens default team', () => { + const result = resolveTeamShortcut([], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', DEFAULT_TEAM]); + expect(result.isShortcut).toBe(true); + }); + + test('DEFAULT_TEAM is "main"', () => { + expect(DEFAULT_TEAM).toBe('main'); + }); + + // ========================================================================= + // Known subcommands have priority + // ========================================================================= + + test('known subcommand "agent" is not rewritten', () => { + const result = resolveTeamShortcut(['agent', 'spawn'], KNOWN_COMMANDS); + expect(result.args).toEqual(['agent', 'spawn']); + expect(result.isShortcut).toBe(false); + }); + + test('known subcommand "work" is not rewritten', () => { + const result = resolveTeamShortcut(['work', 'next'], KNOWN_COMMANDS); + expect(result.args).toEqual(['work', 'next']); + expect(result.isShortcut).toBe(false); + }); + + test('known subcommand "team" is not rewritten', () => { + const result = resolveTeamShortcut(['team', 'list'], KNOWN_COMMANDS); + expect(result.args).toEqual(['team', 'list']); + expect(result.isShortcut).toBe(false); + }); + + // ========================================================================= + // --team flag (disambiguation) + // ========================================================================= + + test('--team flag routes to _open', () => { + const result = resolveTeamShortcut(['--team', 'myteam'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'myteam']); + expect(result.isShortcut).toBe(true); + }); + + test('--team flag overrides subcommand collision', () => { + const result = resolveTeamShortcut(['--team', 'agent'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'agent']); + expect(result.isShortcut).toBe(true); + expect(result.collisionWarning).toBeNull(); + }); + + test('--team=name syntax routes to _open', () => { + const result = resolveTeamShortcut(['--team=agent'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'agent']); + expect(result.isShortcut).toBe(true); + }); + + test('--team flag preserves remaining args', () => { + const result = resolveTeamShortcut(['--team', 'myteam', '--reset', '-d', '/tmp'], KNOWN_COMMANDS); + expect(result.args).toEqual(['_open', 'myteam', '--reset', '-d', '/tmp']); + expect(result.isShortcut).toBe(true); + }); + + // ========================================================================= + // Collision warnings + // ========================================================================= + + test('warns when known command collides with existing team', () => { + const teamExists = (name: string) => name === 'agent'; + const result = resolveTeamShortcut(['agent', 'spawn'], KNOWN_COMMANDS, teamExists); + expect(result.args).toEqual(['agent', 'spawn']); + expect(result.isShortcut).toBe(false); + expect(result.collisionWarning).toContain('is a subcommand'); + expect(result.collisionWarning).toContain('genie --team agent'); + }); + + test('no warning when known command has no team collision', () => { + const teamExists = () => false; + const result = resolveTeamShortcut(['agent', 'spawn'], KNOWN_COMMANDS, teamExists); + expect(result.collisionWarning).toBeNull(); + }); + + test('no collision check for unknown commands (they route to _open)', () => { + const teamExists = () => true; + const result = resolveTeamShortcut(['myteam'], KNOWN_COMMANDS, teamExists); + expect(result.isShortcut).toBe(true); + expect(result.collisionWarning).toBeNull(); + }); + + // ========================================================================= + // Edge cases + // ========================================================================= + + test('--help flag is not treated as team name', () => { + const result = resolveTeamShortcut(['--help'], KNOWN_COMMANDS); + expect(result.args).toEqual(['--help']); + expect(result.isShortcut).toBe(false); + }); + + test('-h flag is not treated as team name', () => { + const result = resolveTeamShortcut(['-h'], KNOWN_COMMANDS); + expect(result.args).toEqual(['-h']); + expect(result.isShortcut).toBe(false); + }); + + test('-V flag is not treated as team name', () => { + const result = resolveTeamShortcut(['-V'], KNOWN_COMMANDS); + expect(result.args).toEqual(['-V']); + expect(result.isShortcut).toBe(false); + }); + + test('--team with no value -> no rewrite', () => { + const result = resolveTeamShortcut(['--team'], KNOWN_COMMANDS); + expect(result.args).toEqual(['--team']); + expect(result.isShortcut).toBe(false); + }); + + test('--team= with empty value -> no rewrite', () => { + const result = resolveTeamShortcut(['--team='], KNOWN_COMMANDS); + expect(result.args).toEqual(['--team=']); + expect(result.isShortcut).toBe(false); + }); +}); diff --git a/src/lib/team-shortcut.ts b/src/lib/team-shortcut.ts new file mode 100644 index 000000000..c7a06a460 --- /dev/null +++ b/src/lib/team-shortcut.ts @@ -0,0 +1,86 @@ +/** + * Team shortcut routing: resolves `genie [team]` -> `genie _open [team]` + * + * UX: `genie` opens default team "main", `genie <name>` opens that team. + * Internally dispatches to hidden `_open` command. + * + * Priority: + * 1. --team flag always routes to _open (for disambiguation) + * 2. Known subcommands take priority over team names + * 3. No args -> open default team "main" + * 4. Unknown first arg treated as team name (catch-all) + */ + +interface ShortcutResult { + /** The (potentially rewritten) CLI args (without argv[0] and argv[1]) */ + args: string[]; + /** Whether the args were rewritten (team shortcut) */ + isShortcut: boolean; + /** Warning message if first arg collides with a known subcommand */ + collisionWarning: string | null; +} + +/** Default team name when `genie` is invoked with no arguments. */ +export const DEFAULT_TEAM = 'main'; + +/** + * Resolve team shortcut from raw CLI args. + * + * @param rawArgs - process.argv.slice(2) (everything after `node genie`) + * @param knownCommands - Set of registered subcommand names (+ aliases) + * @param teamExists - Optional callback to check if a team directory exists (for collision warnings) + */ +export function resolveTeamShortcut( + rawArgs: string[], + knownCommands: Set<string>, + teamExists?: (name: string) => boolean, +): ShortcutResult { + const firstArg = rawArgs[0]; + + // No args -> open default team + if (!firstArg) { + return { args: ['_open', DEFAULT_TEAM], isShortcut: true, collisionWarning: null }; + } + + // 1. Global --team flag: genie --team <name> [...rest] -> genie _open <name> [...rest] + if (firstArg === '--team' && rawArgs.length >= 2) { + const teamName = rawArgs[1]; + return { + args: ['_open', teamName, ...rawArgs.slice(2)], + isShortcut: true, + collisionWarning: null, + }; + } + if (firstArg.startsWith('--team=')) { + const teamName = firstArg.slice('--team='.length); + if (!teamName) { + return { args: rawArgs, isShortcut: false, collisionWarning: null }; + } + return { + args: ['_open', teamName, ...rawArgs.slice(1)], + isShortcut: true, + collisionWarning: null, + }; + } + + // Skip flags (e.g., --help, --version, -h) + if (firstArg.startsWith('-')) { + return { args: rawArgs, isShortcut: false, collisionWarning: null }; + } + + // 2. Known subcommand takes priority + if (knownCommands.has(firstArg)) { + let collisionWarning: string | null = null; + if (teamExists?.(firstArg)) { + collisionWarning = `Warning: "${firstArg}" is a subcommand. To open team "${firstArg}", use: genie --team ${firstArg}`; + } + return { args: rawArgs, isShortcut: false, collisionWarning }; + } + + // 3. Unknown first arg -> treat as team name + return { + args: ['_open', ...rawArgs], + isShortcut: true, + collisionWarning: null, + }; +} diff --git a/src/lib/tmux.ts b/src/lib/tmux.ts index 67570d059..9cac58e9f 100644 --- a/src/lib/tmux.ts +++ b/src/lib/tmux.ts @@ -221,6 +221,8 @@ export async function ensureTeamWindow( } catch { /* best-effort */ } + // Rehydrate pane color hook (survives tmux restarts) + await rehydratePaneColorHook(existing.id); const panes = await listPanes(existing.id); const paneId = panes.length > 0 ? panes[0].id : `${session}:${teamName}.0`; return { windowId: existing.id, windowName: teamName, paneId, created: false }; @@ -231,11 +233,111 @@ export async function ensureTeamWindow( throw new Error(`Failed to create team window "${teamName}" in session "${session}"`); } + // Install pane color hook on new window + await rehydratePaneColorHook(newWindow.id); const panes = await listPanes(newWindow.id); const paneId = panes.length > 0 ? panes[0].id : `${session}:${teamName}.0`; return { windowId: newWindow.id, windowName: teamName, paneId, created: true }; } +/** + * Map agent color names to tmux hex colors for active border styling. + * Palette matches ClaudeTeamColor from provider-adapters. + */ +const TMUX_COLOR_MAP: Record<string, string> = { + red: '#b83030', + blue: '#2a6cb8', + green: '#20a050', + yellow: '#b8a020', + purple: '#7830b8', + orange: '#b86820', + pink: '#b83078', + cyan: '#20a0a0', +}; + +const PANE_COLORS_PATH = `${require('node:os').homedir()}/.genie/pane-colors.json`; +const PANE_COLOR_SCRIPT = `${require('node:os').homedir()}/.genie/tmux-pane-color.sh`; + +/** + * Ensure the pane-color router script exists. + * This script is called by the tmux pane-focus-in hook and reads + * ~/.genie/pane-colors.json to resolve pane_id → border color. + */ +function ensurePaneColorScript(): void { + const { existsSync, writeFileSync, mkdirSync, chmodSync } = require('node:fs'); + const { dirname } = require('node:path'); + + if (existsSync(PANE_COLOR_SCRIPT)) return; + + mkdirSync(dirname(PANE_COLOR_SCRIPT), { recursive: true }); + writeFileSync( + PANE_COLOR_SCRIPT, + `#!/bin/bash +# Genie tmux pane color router — maps focused pane to agent border color +PANE_ID="$1" +MAP="$HOME/.genie/pane-colors.json" +[ -f "$MAP" ] || exit 0 +COLOR=$(jq -r --arg p "$PANE_ID" '.[$p] // empty' "$MAP" 2>/dev/null) +[ -z "$COLOR" ] && COLOR="default" +tmux set-option -w pane-active-border-style "fg=$COLOR" +`, + ); + chmodSync(PANE_COLOR_SCRIPT, 0o755); +} + +/** + * Register a pane→color mapping and install the window focus hook. + * When any pane in the window gains focus, the active border color + * changes to match that agent's assigned color. + */ +export async function applyPaneColor(paneId: string, color: string, windowId?: string): Promise<void> { + const hex = TMUX_COLOR_MAP[color] ?? TMUX_COLOR_MAP.blue; + const { readFileSync, writeFileSync, existsSync, mkdirSync } = require('node:fs'); + const { dirname } = require('node:path'); + + try { + // 1. Ensure script exists + ensurePaneColorScript(); + + // 2. Update pane-colors.json + let map: Record<string, string> = {}; + if (existsSync(PANE_COLORS_PATH)) { + try { + map = JSON.parse(readFileSync(PANE_COLORS_PATH, 'utf-8')); + } catch { + map = {}; + } + } else { + mkdirSync(dirname(PANE_COLORS_PATH), { recursive: true }); + } + map[paneId] = hex; + writeFileSync(PANE_COLORS_PATH, JSON.stringify(map, null, 2)); + + // 3. Install window hook (idempotent — overwrites previous) + if (windowId) { + await executeTmux(`set-hook -w -t '${windowId}' pane-focus-in "run-shell '${PANE_COLOR_SCRIPT} #{pane_id}'"`); + } + } catch { + /* best-effort — don't break spawn if tmux styling fails */ + } +} + +/** + * Rehydrate the pane-focus-in color hook on a window. + * Called when a team window is resolved (created or found) to survive tmux restarts. + * Only installs the hook if pane-colors.json exists and has entries for panes in this window. + */ +async function rehydratePaneColorHook(windowId: string): Promise<void> { + const { existsSync } = require('node:fs'); + try { + if (!existsSync(PANE_COLORS_PATH) || !existsSync(PANE_COLOR_SCRIPT)) return; + ensurePaneColorScript(); + await executeTmux(`set-hook -w -t '${windowId}' pane-focus-in "run-shell '${PANE_COLOR_SCRIPT} #{pane_id}'"`); + } catch { + /* best-effort */ + } +} + /** * Kill a tmux session by ID */ diff --git a/src/lib/version.ts b/src/lib/version.ts index f3980cad4..4894e1efc 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Runtime version (baked in at build time) -export const VERSION = '3.260302.2'; +export const VERSION = '3.260309.1'; diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index 89e7c1861..44fdba9f3 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -588,9 +588,13 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise<void> { process.exit(1); } - // Apply layout to team window (or fallback to window 0) + // Apply layout to team window (or fallback to first window in session) const session = 'genie'; - const layoutTarget = teamWindow ? `${session}:${teamWindow.windowName}` : `${session}:0`; + let layoutTarget = `${session}:${teamWindow?.windowName ?? ''}`; + if (!teamWindow) { + const wins = await tmux.listWindows(session); + layoutTarget = wins[0] ? wins[0].id : `${session}:`; + } try { execSync(`tmux ${buildLayoutCommand(layoutTarget, ctx.layoutMode)}`, { stdio: 'ignore' }); } catch { @@ -600,6 +604,11 @@ async function launchTmuxSpawn(ctx: SpawnCtx): Promise<void> { const workerEntry = await registerSpawnWorker(ctx, paneId, teamWindow); await notifySpawnJoin(ctx, paneId); + // Apply agent color to tmux pane border (focus-driven) + if (ctx.spawnColor && paneId !== 'inline') { + await tmux.applyPaneColor(paneId, ctx.spawnColor, teamWindow?.windowId); + } + // Save spawn template for auto-respawn on message delivery await registry.saveTemplate({ id: ctx.validated.role ?? ctx.workerId, @@ -747,6 +756,15 @@ async function handleWorkerSpawn(options: { const { parentSessionId, spawnColor, nativeTeam } = await resolveNativeTeam(team, repoPath, options); if (nativeTeam) params.nativeTeam = nativeTeam; + // 2b. Inject hook dispatch into team settings.json (idempotent) + try { + const { injectTeamHooks } = await import('../hooks/inject.js'); + const injected = await injectTeamHooks(team); + if (injected) console.log(` Hooks: injected genie hook dispatch into team "${team}"`); + } catch (err) { + console.warn(`Warning: could not inject hooks for team "${team}": ${err instanceof Error ? err.message : err}`); + } + // Session ID only for Claude const claudeSessionId = params.provider === 'claude' ? crypto.randomUUID() : undefined; if (claudeSessionId) params.sessionId = claudeSessionId; @@ -1239,7 +1257,7 @@ export function registerAgentNamespace(program: Command): void { if (w.claudeSessionId) { console.log(` Session preserved: ${w.claudeSessionId}`); } - console.log(` Send a message to auto-resume: genie msg send ${id} "your message"`); + console.log(` Send a message to auto-resume: genie send ${id} "your message"`); } else { console.error(`Failed to suspend agent "${id}".`); process.exit(1); diff --git a/src/term-commands/approve.ts b/src/term-commands/approve.ts index c633b40ac..56056d00c 100644 --- a/src/term-commands/approve.ts +++ b/src/term-commands/approve.ts @@ -2,11 +2,11 @@ * Approve command - Auto-approve engine management and manual approval * * Usage: - * genie worker approve --status - Show pending/approved/denied requests - * genie worker approve <request-id> - Manually approve a pending request - * genie worker approve --deny <request-id> - Manually deny a pending request - * genie worker approve --start - Start the auto-approve engine - * genie worker approve --stop - Stop the auto-approve engine + * genie agent approve --status - Show pending/approved/denied requests + * genie agent approve <request-id> - Manually approve a pending request + * genie agent approve --deny <request-id> - Manually deny a pending request + * genie agent approve --start - Start the auto-approve engine + * genie agent approve --stop - Stop the auto-approve engine */ import { existsSync, readFileSync } from 'node:fs'; @@ -299,7 +299,7 @@ function handleManualAction(id: string, action: 'approve' | 'deny'): void { } /** - * Main CLI command handler for `genie worker approve`. + * Main CLI command handler for `genie agent approve`. * * Dispatches to the appropriate sub-function based on options: * - --status: show pending/approved/denied requests @@ -347,9 +347,9 @@ export async function approveCommand(requestId: string | undefined, options: App } console.log('Usage:'); - console.log(' genie worker approve --status Show pending/approved/denied requests'); - console.log(' genie worker approve <request-id> Manually approve a pending request'); - console.log(' genie worker approve --deny <request-id> Manually deny a pending request'); - console.log(' genie worker approve --start Start the auto-approve engine'); - console.log(' genie worker approve --stop Stop the auto-approve engine'); + console.log(' genie agent approve --status Show pending/approved/denied requests'); + console.log(' genie agent approve <request-id> Manually approve a pending request'); + console.log(' genie agent approve --deny <request-id> Manually deny a pending request'); + console.log(' genie agent approve --start Start the auto-approve engine'); + console.log(' genie agent approve --stop Stop the auto-approve engine'); } diff --git a/src/term-commands/close.ts b/src/term-commands/close.ts index d4a86f753..cb9665934 100644 --- a/src/term-commands/close.ts +++ b/src/term-commands/close.ts @@ -5,7 +5,7 @@ * Backend is auto-detected based on whether .genie/ directory exists. * * Usage: - * genie worker close <task-id> - Close task, cleanup worktree, kill worker + * genie agent close <task-id> - Close task, cleanup worktree, kill agent * * Options: * --no-sync - Skip bd sync (beads only, no-op for local) @@ -166,7 +166,7 @@ async function killWorkerPane(paneId: string): Promise<boolean> { */ async function killWorkerTmux(w: registry.Agent): Promise<void> { if (w.windowId && w.session) { - console.log(`💀 Killing worker window "${w.windowName || w.windowId}" (${w.id})...`); + console.log(`💀 Killing agent window "${w.windowName || w.windowId}" (${w.id})...`); try { const sessionObj = await tmux.findSessionByName(w.session); if (sessionObj) { @@ -185,7 +185,7 @@ async function killWorkerTmux(w: registry.Agent): Promise<void> { return; } if (w.windowName) { - console.log(`💀 Killing worker window "${w.windowName}" (${w.id})...`); + console.log(`💀 Killing agent window "${w.windowName}" (${w.id})...`); try { await tmux.killWindow(w.windowName); console.log(' ✅ Window killed'); @@ -194,7 +194,7 @@ async function killWorkerTmux(w: registry.Agent): Promise<void> { } return; } - console.log(`💀 Killing worker pane (${w.id})...`); + console.log(`💀 Killing agent pane (${w.id})...`); await killWorkerPane(w.paneId); console.log(' ✅ Pane killed'); } @@ -250,7 +250,7 @@ async function findRepresentativeWorker(allWorkers: registry.Agent[], taskId: st */ async function confirmClose(taskId: string, workerCount: number, worker: registry.Agent | null): Promise<boolean> { const workerMsg = - workerCount > 1 ? ` and kill ${workerCount} workers` : worker ? ` and kill worker (pane ${worker.paneId})` : ''; + workerCount > 1 ? ` and kill ${workerCount} agents` : worker ? ` and kill agent (pane ${worker.paneId})` : ''; return confirm({ message: `Close ${taskId}${workerMsg}?`, default: true }); } @@ -286,7 +286,7 @@ async function killAndUnregisterAll(allWorkers: registry.Agent[]): Promise<void> await killWorkerTmux(w); await unregisterWorker(w); } - if (allWorkers.length > 0) console.log(` ✅ ${allWorkers.length} worker(s) unregistered`); + if (allWorkers.length > 0) console.log(` ✅ ${allWorkers.length} agent(s) unregistered`); } export async function closeCommand(taskId: string, options: CloseOptions = {}): Promise<void> { @@ -299,9 +299,9 @@ export async function closeCommand(taskId: string, options: CloseOptions = {}): const worker = await findRepresentativeWorker(allWorkers, taskId); if (allWorkers.length === 0) { - console.log(`ℹ️ No active worker for ${taskId}. Closing ${isLocal ? 'task' : 'issue'} only.`); + console.log(`ℹ️ No active agent for ${taskId}. Closing ${isLocal ? 'task' : 'issue'} only.`); } else if (allWorkers.length > 1) { - console.log(`📌 Found ${allWorkers.length} workers for ${taskId}`); + console.log(`📌 Found ${allWorkers.length} agents for ${taskId}`); } if (!options.yes) { diff --git a/src/term-commands/events.test.ts b/src/term-commands/events.test.ts index 077f70efa..520edf114 100644 --- a/src/term-commands/events.test.ts +++ b/src/term-commands/events.test.ts @@ -1,5 +1,5 @@ /** - * Tests for genie worker events command + * Tests for genie agent events command * * Run with: bun test src/term-commands/events.test.ts */ diff --git a/src/term-commands/events.ts b/src/term-commands/events.ts index 1136e2b47..75ef79baa 100644 --- a/src/term-commands/events.ts +++ b/src/term-commands/events.ts @@ -2,12 +2,12 @@ * Events command - Stream Claude Code events from a pane * * Usage: - * genie worker events <pane-id> - Show recent events from a worker - * genie worker events <pane-id> --follow - Tail events in real-time (like tail -f) - * genie worker events <pane-id> --emit - Tail and write to .genie/events/<pane-id>.jsonl - * genie worker events <pane-id> --json - Output events as JSON - * genie worker events --all - Aggregate events from all active workers - * genie worker events --all --json - Aggregate events as JSON + * genie agent events <pane-id> - Show recent events from an agent + * genie agent events <pane-id> --follow - Tail events in real-time (like tail -f) + * genie agent events <pane-id> --emit - Tail and write to .genie/events/<pane-id>.jsonl + * genie agent events <pane-id> --json - Output events as JSON + * genie agent events --all - Aggregate events from all active agents + * genie agent events --all --json - Aggregate events as JSON * * Events are normalized into a standard format: * - session_start: Claude session started @@ -407,7 +407,7 @@ export async function eventsCommand(paneId: string | undefined, options: EventsO } if (!paneId) { - console.error('Error: pane-id is required (or use --all for all workers)'); + console.error('Error: pane-id is required (or use --all for all agents)'); process.exit(1); } @@ -437,7 +437,7 @@ async function eventsAllCommand(options: EventsOptions): Promise<void> { if (options.json) { console.log('[]'); } else { - console.log('No active workers found.'); + console.log('No active agents found.'); } return; } @@ -457,7 +457,7 @@ async function eventsAllCommand(options: EventsOptions): Promise<void> { console.log(JSON.stringify(recentEvents, null, 2)); } else { if (recentEvents.length === 0) { - console.log('No events found from active workers.'); + console.log('No events found from active agents.'); console.log('Hint: Use --emit flag when tailing to write events to files.'); } else { for (const event of recentEvents) { diff --git a/src/term-commands/history.test.ts b/src/term-commands/history.test.ts index 179c36472..d7280199e 100644 --- a/src/term-commands/history.test.ts +++ b/src/term-commands/history.test.ts @@ -1,5 +1,5 @@ /** - * Tests for genie worker history command + * Tests for genie agent history command */ import { describe, expect, test } from 'bun:test'; diff --git a/src/term-commands/history.ts b/src/term-commands/history.ts index f843d31e5..6be5c437d 100644 --- a/src/term-commands/history.ts +++ b/src/term-commands/history.ts @@ -5,10 +5,10 @@ * Claude's JSONL logs and extracting key events. * * Usage: - * genie worker history <worker> # Compressed summary - * genie worker history <worker> --full # Full conversation - * genie worker history <worker> --since 5 # Last 5 exchanges - * genie worker history <worker> --json # JSON output + * genie agent history <agent> # Compressed summary + * genie agent history <agent> --full # Full conversation + * genie agent history <agent> --since 5 # Last 5 exchanges + * genie agent history <agent> --json # JSON output */ import * as workerRegistry from '../lib/agent-registry.js'; @@ -429,15 +429,15 @@ async function resolveLogContext(workerIdOrName: string, options: HistoryOptions const worker = await findWorker(workerIdOrName); if (!worker) { - console.error(`❌ Worker "${workerIdOrName}" not found.`); - console.error(' Run `genie worker list` to see active workers.'); + console.error(`❌ Agent "${workerIdOrName}" not found.`); + console.error(' Run `genie agent list` to see active agents.'); process.exit(1); } const workspacePath = worker.worktree || worker.repoPath; const logInfo = await claudeLogs.getLogsForPane(workspacePath); if (!logInfo) { - console.error(`❌ No Claude logs found for worker "${worker.id}"`); + console.error(`❌ No Claude logs found for agent "${worker.id}"`); console.error(` Workspace: ${workspacePath}`); process.exit(1); } diff --git a/src/term-commands/msg.test.ts b/src/term-commands/msg.test.ts index dee88052f..6f369fcd5 100644 --- a/src/term-commands/msg.test.ts +++ b/src/term-commands/msg.test.ts @@ -124,18 +124,22 @@ describe('buildTeamLeadCommand (shared module)', () => { expect(cmd).toContain('abc-123'); }); - test('includes --system-prompt when systemPrompt provided', async () => { + test('includes --system-prompt via $(cat) when systemPrompt provided', async () => { const { buildTeamLeadCommand } = await import('../lib/team-lead-command.js'); const cmd = buildTeamLeadCommand('genie', { systemPrompt: 'test prompt' }); expect(cmd).toContain('--system-prompt'); - expect(cmd).toContain('test prompt'); + expect(cmd).toContain('$(cat'); + expect(cmd).toContain('.genie/prompts/genie.md'); + // Prompt content is in the file, NOT inlined in the command + expect(cmd).not.toContain('test prompt'); }); - test('flattens newlines in system prompt', async () => { + test('system prompt is persisted to file, not flattened inline', async () => { const { buildTeamLeadCommand } = await import('../lib/team-lead-command.js'); const cmd = buildTeamLeadCommand('genie', { systemPrompt: 'line one\nline two' }); - expect(cmd).not.toContain('\n'); - expect(cmd).toContain('line one line two'); + // Command references file, does not contain prompt text + expect(cmd).toContain('$(cat'); + expect(cmd).not.toContain('line one'); }); }); diff --git a/src/term-commands/msg.ts b/src/term-commands/msg.ts index 2b0ba32d2..a07a914c0 100644 --- a/src/term-commands/msg.ts +++ b/src/term-commands/msg.ts @@ -50,7 +50,9 @@ export async function detectSenderIdentity(teamName: string): Promise<string> { if (!paneId) return 'cli'; const registry = await getRegistry(); - const worker = await registry.findByPane(paneId); + // Guard against Bun's flaky module resolution where dynamic import() + // occasionally returns a partial module object missing some exports. + const worker = typeof registry.findByPane === 'function' ? await registry.findByPane(paneId) : null; if (worker) return worker.role ?? worker.id; return (await findMemberByPane(teamName, paneId)) ?? 'cli'; diff --git a/src/term-commands/ship.test.ts b/src/term-commands/ship.test.ts index 02ad8f433..c6dc6eb3d 100644 --- a/src/term-commands/ship.test.ts +++ b/src/term-commands/ship.test.ts @@ -1,5 +1,5 @@ /** - * Integration tests for genie worker ship commands - Branch Protection + * Integration tests for genie agent ship commands - Branch Protection * * Tests: * 1. ship/push refuse to operate on main/master branch @@ -71,7 +71,7 @@ async function assertNotMainBranch(repoPath: string): Promise<boolean> { // Tests // ============================================================================ -describe('genie worker ship - branch protection', () => { +describe('genie agent ship - branch protection', () => { let tempDir: string; let testRepo: string; @@ -169,7 +169,7 @@ describe('genie worker ship - branch protection', () => { }); }); -describe('genie worker ship - getCurrentBranch', () => { +describe('genie agent ship - getCurrentBranch', () => { let tempDir: string; let testRepo: string; diff --git a/src/term-commands/ship.ts b/src/term-commands/ship.ts index 64df1b313..6f290a946 100644 --- a/src/term-commands/ship.ts +++ b/src/term-commands/ship.ts @@ -2,7 +2,7 @@ * Ship command - Mark task as done and cleanup worker * * Usage: - * genie worker ship <task-id> - Mark done, cleanup worktree, kill worker + * genie agent ship <task-id> - Mark done, cleanup worktree, kill agent * * Options: * --keep-worktree - Don't remove the worktree @@ -141,7 +141,7 @@ async function killWorkerPane(paneId: string): Promise<boolean> { */ async function cleanupWorker(worker: registry.Agent): Promise<void> { if (worker.windowName) { - console.log(`💀 Killing worker window "${worker.windowName}"...`); + console.log(`💀 Killing agent window "${worker.windowName}"...`); try { await tmux.killWindow(worker.windowName); console.log(' ✅ Window killed'); @@ -149,7 +149,7 @@ async function cleanupWorker(worker: registry.Agent): Promise<void> { console.log(' ℹ️ Window already gone'); } } else { - console.log('💀 Killing worker pane...'); + console.log('💀 Killing agent pane...'); await killWorkerPane(worker.paneId); console.log(' ✅ Pane killed'); } @@ -165,7 +165,7 @@ async function cleanupWorker(worker: registry.Agent): Promise<void> { } await registry.unregister(worker.id); await cleanupEventFile(worker.paneId).catch(() => {}); - console.log(' ✅ Worker unregistered'); + console.log(' ✅ Agent unregistered'); } /** @@ -184,7 +184,7 @@ async function findWorkerForTask(taskId: string): Promise<registry.Agent | null> */ function buildShipMessage(taskId: string, title: string, worker: registry.Agent | null, merge: boolean): string { const mergeNote = merge ? ', merge to main' : ''; - if (worker) return `Ship ${taskId} "${title}"? (mark done, kill worker pane ${worker.paneId}${mergeNote})`; + if (worker) return `Ship ${taskId} "${title}"? (mark done, kill agent pane ${worker.paneId}${mergeNote})`; return `Ship ${taskId} "${title}"? (mark done${mergeNote})`; } diff --git a/src/term-commands/work.ts b/src/term-commands/work.ts index 53c1b4b54..d7737b851 100644 --- a/src/term-commands/work.ts +++ b/src/term-commands/work.ts @@ -103,7 +103,7 @@ interface BeadsIssue { const KNOWN_NESTED_REPOS: Record<string, string> = { 'genie-cli': 'code/genie-cli', 'genie work': 'code/genie-cli', - 'genie worker': 'code/genie-cli', + 'genie agent': 'code/genie-cli', }; // ============================================================================ @@ -1155,10 +1155,10 @@ function printWorkerStatus( console.log(` Target repo: ${extra.targetRepo}`); } console.log('\nCommands:'); - console.log(' genie worker list - Check worker status'); - console.log(' genie worker approve - Approve permissions'); - console.log(` genie worker close ${taskId} - Close issue when done`); - console.log(` genie worker kill ${taskId} - Force kill worker`); + console.log(' genie agent list - Check agent status'); + console.log(' genie agent approve - Approve permissions'); + console.log(` genie agent close ${taskId} - Close issue when done`); + console.log(` genie agent kill ${taskId} - Force kill agent`); } /** @@ -1258,7 +1258,7 @@ export async function workCommand(target: string, options: WorkOptions = {}): Pr const resumed = await resumeExistingWorker(existingWorker, taskId, options, workerProfile); if (resumed) return; console.error(`❌ ${taskId} already has a worker (pane ${existingWorker.paneId})`); - console.log(` Run \`genie worker kill ${existingWorker.id}\` first, or work on a different issue.`); + console.log(` Run \`genie agent kill ${existingWorker.id}\` first, or work on a different issue.`); process.exit(1); }