From 84bd121683add4440a3652b12b70e6e2d45f5384 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 30 Jul 2026 07:42:47 -0500 Subject: feat(ai): add --helper for a second session in a live project A helper is a second agent session in a project that already has a live one. It reads freely, makes only scoped single-heading edits, and never touches git. Until now the only way to get one was to tell a session by hand it was a helper, and nothing checked whether that was true. --helper runs agent-roster first, so the claim gets verified rather than asserted. It opens its own tmux window and hands the session helper-mode.org instead of the normal startup workflow. Git prep is skipped because pulls belong to the primary. With no other agent live it warns and falls back to a primary launch. When the roster can't answer, it opens a helper anyway. I picked that direction because a helper that turns out to be alone merely does less, while a primary that turns out not to be alone runs pulls and rsync underneath a live session. Wrap-up gains a Step 0 branch. A helper archives its own context file and stops, and the primary's next commit carries its edits. An orphaned helper whose primary already exited runs the full wrap, because otherwise its edits strand as a dirty tree nobody owns. That branch resolves the project root before consulting the roster. agent-roster defaults to $PWD and only matches agents at or inside it, so a call from a subdirectory would miss a primary at the root and report the helper orphaned. That is the one path that commits and pushes. The agent id is sanitized to the charset session-context-path keeps, because it's interpolated into the command typed into the pane. A helper launching a helper gets a fresh id rather than inheriting its parent's. Two agents resolving to one context file is the collision the contract exists to prevent. Startup still has no roster check, so a bare claude launched into a busy project runs full primary startup regardless. helper-mode.org and protocols.org now say so instead of listing the routing as though it works. --- .ai/protocols.org | 2 +- .ai/workflows/helper-mode.org | 29 ++- .ai/workflows/wrap-it-up.org | 32 ++- claude-templates/.ai/protocols.org | 2 +- claude-templates/.ai/workflows/helper-mode.org | 29 ++- claude-templates/.ai/workflows/wrap-it-up.org | 32 ++- claude-templates/bin/ai | 209 +++++++++++++++- scripts/tests/ai-launcher-helper.bats | 328 +++++++++++++++++++++++++ 8 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 scripts/tests/ai-launcher-helper.bats diff --git a/.ai/protocols.org b/.ai/protocols.org index bf9f420..b291d9e 100644 --- a/.ai/protocols.org +++ b/.ai/protocols.org @@ -106,7 +106,7 @@ The epoch is baked into the id by the spawner, never minted inside =session-cont Resolve the path with =.ai/scripts/session-context-path= rather than hardcoding =.ai/session-context.org=; it prints the right path for the current =AI_AGENT_ID=. Fall back to =.ai/session-context.org= if the script isn't present (older checkouts mid-sync). Everything below — the record/recovery purpose, the update triggers, the startup existence check, the wrap-up rename — operates on that resolved path. The prose says "session-context.org" as the default name; read it as "the resolved active path" when =AI_AGENT_ID= is set. -A helper instance (a second agent running in this project while a primary session is live) follows a different contract: it skips the pulls and rsync, makes only scoped single-heading edits to shared files, leaves all git mutation to the primary, and wraps up by archiving its own context file without committing. The full rules — read/write tiers, data-integrity, light startup, helper wrap-up — live in [[file:workflows/helper-mode.org][workflows/helper-mode.org]]. A session is a helper only when something routes it there (the =ai --helper= launcher, startup's roster check, or an explicit "you are a helper" instruction); the routing itself ships behind the helper-instance feature gate and isn't live yet. +A helper instance (a second agent running in this project while a primary session is live) follows a different contract: it skips the pulls and rsync, makes only scoped single-heading edits to shared files, leaves all git mutation to the primary, and wraps up by archiving its own context file without committing. The full rules — read/write tiers, data-integrity, light startup, helper wrap-up — live in [[file:workflows/helper-mode.org][workflows/helper-mode.org]]. A session is a helper only when something routes it there: the =ai --helper= launcher (live — it checks the roster, assigns the id, and opens the helper in its own tmux window) or an explicit "you are a helper" instruction. Startup's roster check is *not* built, so a bare =claude= launched into a project that already has a live session will run full primary startup regardless. Launch helpers with =ai --helper=. This file serves two purposes with one mechanism: 1. *Crash recovery* — if the session dies mid-work, the live file is all that's left. On 2026-01-22 a session crashed during a 20-minute design discussion and all context was lost because this file wasn't being updated. diff --git a/.ai/workflows/helper-mode.org b/.ai/workflows/helper-mode.org index a6acfa7..b32d574 100644 --- a/.ai/workflows/helper-mode.org +++ b/.ai/workflows/helper-mode.org @@ -12,13 +12,14 @@ The governing fact behind every rule below: the session-context split isolates e * When to Use This Workflow -No operator trigger phrase. A helper reaches this contract one of three ways: +No operator trigger phrase. A helper reaches this contract one of two ways: - The =ai --helper= launcher routes here after the roster confirms a live agent (the deterministic path). -- Startup's roster check finds the session is not alone and routes here instead of running normal startup (the safety net for a raw =claude= launch). - An explicit "you are a helper, follow helper-mode.org" instruction (the manual fallback). -If none of those applies — the roster shows the session is alone — this is a primary session. Run normal [[file:startup.org][startup.org]], not this. +There is deliberately no third way, and the gap matters: *startup does not check the roster*. A bare =claude= launched into a project that already has a live session runs full primary startup — pulls, rsync, inbox processing — without ever reaching this file. That safety net is designed (see Status below) but unbuilt, so nothing catches a raw launch. Use =ai --helper=. + +If neither route applies, this is a primary session. Run normal [[file:startup.org][startup.org]], not this. * Identity @@ -92,10 +93,28 @@ A helper does not run normal startup. It runs a light version: When the helper's work is done: -1. Re-run the roster (=.ai/scripts/agent-roster=) to learn whether a primary is still live. +1. Re-run the roster to learn whether a primary is still live. Pass the project root explicitly — =agent-roster= defaults to =$PWD= and keeps only agents at or inside that root, so calling it from a subdirectory hides a primary sitting at the root and reports "alone": + + #+begin_src bash + root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + if [ -x "$root/.ai/scripts/agent-roster" ]; then + "$root/.ai/scripts/agent-roster" "$root"; rc=$? + else + rc=2 + fi + echo "roster rc=$rc" + #+end_src + + Read rc as =wrap-it-up.org= Step 0 does: 1 means a primary is still live, 0 means this helper is orphaned, and 2 (or an absent script) means unavailable — which takes the same archive-only path as 1, because leaving work uncommitted is recoverable and committing under a live primary is not. 2. *Primary still live (the normal case):* finalize the Summary in the helper's own =.ai/session-context.d/.org=, archive it to =.ai/sessions/YYYY-MM-DD-HH-MM--.org=, and stop. Do NOT commit, push, or run hygiene — the primary's next commit picks up the archived file and any scoped edits the helper left in the tree. 3. *Orphaned helper (roster shows the helper is now alone):* the primary already exited, so the helper assumes full closing duties — the git ban lifts because the concurrency that justified it is gone. Commit and push the tree (including the helper's own edits, which would otherwise strand as a dirty tree), per the normal wrap-up flow in [[file:wrap-it-up.org][wrap-it-up.org]]. * Status -Phase 1.5 of the generic-agent-runtime spec. This contract is the canonical home; the spawn paths (=ai --helper=, startup's roster branch) and the [[file:wrap-it-up.org][wrap-it-up.org]] helper branch route here. Those wiring pieces ship behind the spec's bats-then-drills-then-pilot gate and are not yet live; until then, the manual "you are a helper" instruction is how a session adopts this contract. +Phase 1.5 of the generic-agent-runtime spec. This contract is the canonical home; the spawn paths and the [[file:wrap-it-up.org][wrap-it-up.org]] helper branch route here. + +Live now: =ai --helper = (roster check, id assignment, helper opener, its own tmux window), the explicit "you are a helper" instruction, and the wrap-it-up.org Step 0 helper branch. + +Not built yet, and worth knowing because it is the gap you can fall into: *startup has no roster check*. A second session launched as a bare =claude= in a project that already has one runs full primary startup — pulls, rsync, inbox processing — with no idea another agent is live. Until that safety net exists, =ai --helper= is not merely the preferred path, it is the only one that makes a helper without being told. + +Also unbuilt: the live-helper gate that pauses a primary's file-wide hygiene passes (=todo-cleanup.el=, =lint-org.el=, =wrap-org-table.el=) while a helper is mid-edit. Data-integrity rule 1 above describes the intended behavior; nothing enforces it yet, so a primary running hygiene can still clobber a helper's just-written scoped edit. diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org index a9a5895..ecd3d22 100644 --- a/.ai/workflows/wrap-it-up.org +++ b/.ai/workflows/wrap-it-up.org @@ -29,6 +29,8 @@ The wrap-up is complete when: The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted. +*A helper session meets a shorter list.* Criteria 1 and 2 apply to its own context file (archived under =.ai/sessions/YYYY-MM-DD-HH-MM--.org=), and 6 applies. Criteria 3, 4, and 5 do not: hygiene, the Linear pass, and all git mutation belong to the primary, so a helper that satisfied criterion 5 would have violated its contract to get there. Step 0 routes this. + * Teardown mode (set from the trigger phrase) The wrap itself — Steps 1 through 5 — is identical in every mode. The trigger phrase only decides what Step 6 does once commit + push and the valediction are done. Resolve the mode from the phrase before starting: @@ -43,7 +45,35 @@ This depends on three functions in =.emacs.d/modules/ai-term.el= (=cj/ai-term-qu * The Workflow -** Step 0: Refuse if sentry is live +** Step 0: Helper branch — a helper wraps only itself + +Resolve first whether this session is a helper, because a helper's wrap is a different and much shorter workflow. Everything from Step 1 down — the hygiene passes, the inbox check, the commit, the push, the clean-tree certificate — is primary-only under the role contract in [[file:helper-mode.org][helper-mode.org]], and running any of it from a helper is exactly the concurrency failure that contract exists to prevent. + +A session is a helper when =AI_HELPER=1= in its environment (=ai --helper= sets it) or when it adopted helper-mode.org this session by instruction. If neither holds, this is a primary: skip to Step 0.5 and wrap normally. + +#+begin_src bash +echo "AI_HELPER=${AI_HELPER:-unset} AI_AGENT_ID=${AI_AGENT_ID:-unset}" +#+end_src + +For a helper, re-run the roster — the answer decides which wrap applies: + +#+begin_src bash +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +if [ -x "$root/.ai/scripts/agent-roster" ]; then + "$root/.ai/scripts/agent-roster" "$root"; rc=$? +else + rc=2 +fi +echo "roster rc=$rc" +#+end_src + +Pass the project root explicitly. =agent-roster= defaults to =$PWD= and keeps only agents whose cwd is at or inside that root, so running it from a subdirectory hides a primary sitting at the root — and the "alone" that produces is read below as *orphaned*, which is the one branch that commits and pushes. Capture =rc= inside the branch too: =[ -x … ] && …; echo $?= reports the status of the whole list, so an absent script reads as 1 (others live) rather than 2 (unavailable). + +- *Primary still live (rc 1)* — the normal case. Finalize the =* Summary= in the helper's own context file — same contract as Step 1, KB receipt line included (resolve it with =AI_AGENT_ID= .ai/scripts/session-context-path=), archive it to =.ai/sessions/YYYY-MM-DD-HH-MM--.org= so it can't collide with the primary's archive name, deliver the valediction, and stop. Do NOT commit, push, or run any hygiene pass. The helper's scoped edits stay in the tree and the primary's next commit carries them along with the archived file — say so in the valediction, so Craig knows the work is real but not yet pushed. +- *Alone (rc 0) — orphaned helper* — the primary exited first, so the git ban lifts: the concurrency that justified it is gone, and stopping here would strand the helper's edits as a dirty tree nobody owns. Run the full wrap below starting at Step 0.5, exactly as a primary would. +- *Roster unavailable (rc 2, or the script absent)* — take the archive-only path, the same as primary-still-live. Leaving work for the next session to commit is recoverable; guessing "orphaned" and committing underneath a live primary is not. + +** Step 0.5: Refuse if sentry is live Before anything else, check whether sentry is running in this project. Sentry holds the working tree on its =sentry/-= branch and commits unattended; wrapping underneath it would archive the session anchor and tear down the buffer while the loop is still firing into it. If sentry's single-runner lock is held, stop and point at the shutdown path: diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org index bf9f420..b291d9e 100644 --- a/claude-templates/.ai/protocols.org +++ b/claude-templates/.ai/protocols.org @@ -106,7 +106,7 @@ The epoch is baked into the id by the spawner, never minted inside =session-cont Resolve the path with =.ai/scripts/session-context-path= rather than hardcoding =.ai/session-context.org=; it prints the right path for the current =AI_AGENT_ID=. Fall back to =.ai/session-context.org= if the script isn't present (older checkouts mid-sync). Everything below — the record/recovery purpose, the update triggers, the startup existence check, the wrap-up rename — operates on that resolved path. The prose says "session-context.org" as the default name; read it as "the resolved active path" when =AI_AGENT_ID= is set. -A helper instance (a second agent running in this project while a primary session is live) follows a different contract: it skips the pulls and rsync, makes only scoped single-heading edits to shared files, leaves all git mutation to the primary, and wraps up by archiving its own context file without committing. The full rules — read/write tiers, data-integrity, light startup, helper wrap-up — live in [[file:workflows/helper-mode.org][workflows/helper-mode.org]]. A session is a helper only when something routes it there (the =ai --helper= launcher, startup's roster check, or an explicit "you are a helper" instruction); the routing itself ships behind the helper-instance feature gate and isn't live yet. +A helper instance (a second agent running in this project while a primary session is live) follows a different contract: it skips the pulls and rsync, makes only scoped single-heading edits to shared files, leaves all git mutation to the primary, and wraps up by archiving its own context file without committing. The full rules — read/write tiers, data-integrity, light startup, helper wrap-up — live in [[file:workflows/helper-mode.org][workflows/helper-mode.org]]. A session is a helper only when something routes it there: the =ai --helper= launcher (live — it checks the roster, assigns the id, and opens the helper in its own tmux window) or an explicit "you are a helper" instruction. Startup's roster check is *not* built, so a bare =claude= launched into a project that already has a live session will run full primary startup regardless. Launch helpers with =ai --helper=. This file serves two purposes with one mechanism: 1. *Crash recovery* — if the session dies mid-work, the live file is all that's left. On 2026-01-22 a session crashed during a 20-minute design discussion and all context was lost because this file wasn't being updated. diff --git a/claude-templates/.ai/workflows/helper-mode.org b/claude-templates/.ai/workflows/helper-mode.org index a6acfa7..b32d574 100644 --- a/claude-templates/.ai/workflows/helper-mode.org +++ b/claude-templates/.ai/workflows/helper-mode.org @@ -12,13 +12,14 @@ The governing fact behind every rule below: the session-context split isolates e * When to Use This Workflow -No operator trigger phrase. A helper reaches this contract one of three ways: +No operator trigger phrase. A helper reaches this contract one of two ways: - The =ai --helper= launcher routes here after the roster confirms a live agent (the deterministic path). -- Startup's roster check finds the session is not alone and routes here instead of running normal startup (the safety net for a raw =claude= launch). - An explicit "you are a helper, follow helper-mode.org" instruction (the manual fallback). -If none of those applies — the roster shows the session is alone — this is a primary session. Run normal [[file:startup.org][startup.org]], not this. +There is deliberately no third way, and the gap matters: *startup does not check the roster*. A bare =claude= launched into a project that already has a live session runs full primary startup — pulls, rsync, inbox processing — without ever reaching this file. That safety net is designed (see Status below) but unbuilt, so nothing catches a raw launch. Use =ai --helper=. + +If neither route applies, this is a primary session. Run normal [[file:startup.org][startup.org]], not this. * Identity @@ -92,10 +93,28 @@ A helper does not run normal startup. It runs a light version: When the helper's work is done: -1. Re-run the roster (=.ai/scripts/agent-roster=) to learn whether a primary is still live. +1. Re-run the roster to learn whether a primary is still live. Pass the project root explicitly — =agent-roster= defaults to =$PWD= and keeps only agents at or inside that root, so calling it from a subdirectory hides a primary sitting at the root and reports "alone": + + #+begin_src bash + root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + if [ -x "$root/.ai/scripts/agent-roster" ]; then + "$root/.ai/scripts/agent-roster" "$root"; rc=$? + else + rc=2 + fi + echo "roster rc=$rc" + #+end_src + + Read rc as =wrap-it-up.org= Step 0 does: 1 means a primary is still live, 0 means this helper is orphaned, and 2 (or an absent script) means unavailable — which takes the same archive-only path as 1, because leaving work uncommitted is recoverable and committing under a live primary is not. 2. *Primary still live (the normal case):* finalize the Summary in the helper's own =.ai/session-context.d/.org=, archive it to =.ai/sessions/YYYY-MM-DD-HH-MM--.org=, and stop. Do NOT commit, push, or run hygiene — the primary's next commit picks up the archived file and any scoped edits the helper left in the tree. 3. *Orphaned helper (roster shows the helper is now alone):* the primary already exited, so the helper assumes full closing duties — the git ban lifts because the concurrency that justified it is gone. Commit and push the tree (including the helper's own edits, which would otherwise strand as a dirty tree), per the normal wrap-up flow in [[file:wrap-it-up.org][wrap-it-up.org]]. * Status -Phase 1.5 of the generic-agent-runtime spec. This contract is the canonical home; the spawn paths (=ai --helper=, startup's roster branch) and the [[file:wrap-it-up.org][wrap-it-up.org]] helper branch route here. Those wiring pieces ship behind the spec's bats-then-drills-then-pilot gate and are not yet live; until then, the manual "you are a helper" instruction is how a session adopts this contract. +Phase 1.5 of the generic-agent-runtime spec. This contract is the canonical home; the spawn paths and the [[file:wrap-it-up.org][wrap-it-up.org]] helper branch route here. + +Live now: =ai --helper = (roster check, id assignment, helper opener, its own tmux window), the explicit "you are a helper" instruction, and the wrap-it-up.org Step 0 helper branch. + +Not built yet, and worth knowing because it is the gap you can fall into: *startup has no roster check*. A second session launched as a bare =claude= in a project that already has one runs full primary startup — pulls, rsync, inbox processing — with no idea another agent is live. Until that safety net exists, =ai --helper= is not merely the preferred path, it is the only one that makes a helper without being told. + +Also unbuilt: the live-helper gate that pauses a primary's file-wide hygiene passes (=todo-cleanup.el=, =lint-org.el=, =wrap-org-table.el=) while a helper is mid-edit. Data-integrity rule 1 above describes the intended behavior; nothing enforces it yet, so a primary running hygiene can still clobber a helper's just-written scoped edit. diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org index a9a5895..ecd3d22 100644 --- a/claude-templates/.ai/workflows/wrap-it-up.org +++ b/claude-templates/.ai/workflows/wrap-it-up.org @@ -29,6 +29,8 @@ The wrap-up is complete when: The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted. +*A helper session meets a shorter list.* Criteria 1 and 2 apply to its own context file (archived under =.ai/sessions/YYYY-MM-DD-HH-MM--.org=), and 6 applies. Criteria 3, 4, and 5 do not: hygiene, the Linear pass, and all git mutation belong to the primary, so a helper that satisfied criterion 5 would have violated its contract to get there. Step 0 routes this. + * Teardown mode (set from the trigger phrase) The wrap itself — Steps 1 through 5 — is identical in every mode. The trigger phrase only decides what Step 6 does once commit + push and the valediction are done. Resolve the mode from the phrase before starting: @@ -43,7 +45,35 @@ This depends on three functions in =.emacs.d/modules/ai-term.el= (=cj/ai-term-qu * The Workflow -** Step 0: Refuse if sentry is live +** Step 0: Helper branch — a helper wraps only itself + +Resolve first whether this session is a helper, because a helper's wrap is a different and much shorter workflow. Everything from Step 1 down — the hygiene passes, the inbox check, the commit, the push, the clean-tree certificate — is primary-only under the role contract in [[file:helper-mode.org][helper-mode.org]], and running any of it from a helper is exactly the concurrency failure that contract exists to prevent. + +A session is a helper when =AI_HELPER=1= in its environment (=ai --helper= sets it) or when it adopted helper-mode.org this session by instruction. If neither holds, this is a primary: skip to Step 0.5 and wrap normally. + +#+begin_src bash +echo "AI_HELPER=${AI_HELPER:-unset} AI_AGENT_ID=${AI_AGENT_ID:-unset}" +#+end_src + +For a helper, re-run the roster — the answer decides which wrap applies: + +#+begin_src bash +root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +if [ -x "$root/.ai/scripts/agent-roster" ]; then + "$root/.ai/scripts/agent-roster" "$root"; rc=$? +else + rc=2 +fi +echo "roster rc=$rc" +#+end_src + +Pass the project root explicitly. =agent-roster= defaults to =$PWD= and keeps only agents whose cwd is at or inside that root, so running it from a subdirectory hides a primary sitting at the root — and the "alone" that produces is read below as *orphaned*, which is the one branch that commits and pushes. Capture =rc= inside the branch too: =[ -x … ] && …; echo $?= reports the status of the whole list, so an absent script reads as 1 (others live) rather than 2 (unavailable). + +- *Primary still live (rc 1)* — the normal case. Finalize the =* Summary= in the helper's own context file — same contract as Step 1, KB receipt line included (resolve it with =AI_AGENT_ID= .ai/scripts/session-context-path=), archive it to =.ai/sessions/YYYY-MM-DD-HH-MM--.org= so it can't collide with the primary's archive name, deliver the valediction, and stop. Do NOT commit, push, or run any hygiene pass. The helper's scoped edits stay in the tree and the primary's next commit carries them along with the archived file — say so in the valediction, so Craig knows the work is real but not yet pushed. +- *Alone (rc 0) — orphaned helper* — the primary exited first, so the git ban lifts: the concurrency that justified it is gone, and stopping here would strand the helper's edits as a dirty tree nobody owns. Run the full wrap below starting at Step 0.5, exactly as a primary would. +- *Roster unavailable (rc 2, or the script absent)* — take the archive-only path, the same as primary-still-live. Leaving work for the next session to commit is recoverable; guessing "orphaned" and committing underneath a live primary is not. + +** Step 0.5: Refuse if sentry is live Before anything else, check whether sentry is running in this project. Sentry holds the working tree on its =sentry/-= branch and commits unattended; wrapping underneath it would archive the session anchor and tear down the buffer while the loop is still firing into it. If sentry's single-runner lock is held, stop and point at the shutdown path: diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index 65d0ab7..3440ee2 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -18,6 +18,17 @@ # ollama; model per AI_LOCAL_MODEL, default gpt-oss:120b). # Also settable via AI_RUNTIME. # +# ai --helper Open a SECOND session in a project that already has a +# live one, under the helper-mode.org role contract: reads +# freely, makes only scoped edits, never mutates git, and +# skips git prep because the primary owns pulls. Runs +# agent-roster first — with no other agent live it warns +# and falls back to a normal primary launch (which does +# run git prep). Run it from a terminal of your own: the +# roster excludes its caller's own process ancestry, so +# invoking it from inside an agent session hides that +# session and silently downgrades to a primary launch. +# # ai --attach Attach to the existing 'ai' session without changes. # # ai -h | --help Show this help. @@ -110,8 +121,18 @@ build_instructions() { printf 'This is %s %s project. Follow all instructions in .ai/protocols.org.' "$(uname -n)" "$name" } +# The opening line for a helper session. Deliberately does NOT name +# protocols.org: a helper must not run normal startup (pulls, rsync, inbox +# processing all belong to the primary), and helper-mode.org sends it to +# protocols.org itself once the role contract is loaded. +build_helper_instructions() { + local name="$1" + printf 'This is %s %s project. You are a helper session: another agent is already live here. Read and follow .ai/workflows/helper-mode.org — it is your role contract. Do not run the normal startup workflow.' \ + "$(uname -n)" "$name" +} + usage() { - sed -n '2,23p' "$0" | sed 's|^# \?||' + sed -n '2,34p' "$0" | sed 's|^# \?||' exit 0 } @@ -146,14 +167,92 @@ _git_prep_action() { fi } +# Decide what a `--helper` launch actually becomes, from the roster's verdict. +# Input is agent-roster's exit status — 0 alone, 1 others live, 2 unavailable — +# or the literal "absent" when no roster script is installed. Echoes one of: +# helper — confirmed: another agent is live here +# primary — refuted: nobody else is here, so --helper is a no-op +# helper-unverified — the roster couldn't answer +# Unverifiable resolves toward helper on purpose. `--helper` is the operator +# asserting a primary is live, and helper mode is the strictly less destructive +# guess: a helper that turns out to be alone merely does less, while a primary +# that turns out not to be alone runs pulls and rsync under a live session. +_helper_launch_mode() { + case "$1" in + 1) echo helper ;; + 0) echo primary ;; + *) echo helper-unverified ;; + esac +} + +# A helper's agent id: helper-, per helper-mode.org's identity rule. +# Four hex digits is enough — the id only has to be unique among the agents +# live in one project at one moment, and the archived session file carries the +# date and time as well. Two draws because bash's RANDOM is 15-bit, so a single +# one would never set the top bit and the first hex digit would always be 0-7. +_helper_id() { + printf 'helper-%04x\n' $(( ((RANDOM << 1) ^ RANDOM) & 0xffff )) +} + +# Reduce an id to the characters session-context-path keeps, so the launcher and +# the path resolver agree on what a given id means. This is also a safety fix, +# not just tidiness: the id is interpolated into the command line typed into the +# pane, so an id carrying a space or a ';' would split the assignment off from +# the command and run something else instead of launching the helper. +# printf without a newline on purpose: tr -c would translate a trailing newline +# into an underscore too, silently appending one to every sanitized id. +_sanitize_agent_id() { + printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' + printf '\n' +} + +# Resolve the id for a helper launch: an explicitly-exported one when it is +# free, otherwise a fresh one. +# +# The reuse check is the load-bearing part. A helper's own pane exports +# AI_AGENT_ID, so `ai --helper` invoked from inside a helper inherits its +# parent's id rather than being given one deliberately. Honoring that blindly +# points two live agents at one .ai/session-context.d/.org, which is the +# lost-update collision the whole helper contract exists to avoid. +_resolve_helper_id() { + local dir="$1" + local want="${AI_AGENT_ID:-}" + local tries=0 + + if [ -n "$want" ]; then + want="$(_sanitize_agent_id "$want")" + if [ ! -e "$dir/.ai/session-context.d/$want.org" ]; then + printf '%s\n' "$want" + return + fi + echo "ai: agent id '$want' is already live in $(basename "$dir") — assigning a fresh one" >&2 + fi + + # A minted id gets the same free-anchor check as a supplied one. The odds of + # a chance collision are small, but a guard that only covers the path the + # caller controls leaves the collision it exists to prevent reachable. + # Bounded so a full or unreadable directory can't spin here. + while [ "$tries" -lt 8 ]; do + want="$(_helper_id)" + [ -e "$dir/.ai/session-context.d/$want.org" ] || break + tries=$((tries + 1)) + done + printf '%s\n' "$want" +} + # Re-order "namewid" lines (stdin) into the launcher's window order: # non-project windows alphabetically, then project windows alphabetically. # $1 is a newline-separated list of project window names. +# +# A helper window is named ":", so it matches on the prefix +# before the first colon rather than on the whole name. That keeps it sorted +# next to the project it helps instead of landing among the unrelated windows. _order_windows() { local project_names="$1" wname wid others="" projects="" while IFS=$'\t' read -r wname wid; do [ -z "$wname" ] && continue - if printf '%s\n' "$project_names" | grep -qxF "$wname"; then + if printf '%s\n' "$project_names" | grep -qxF "$wname" || + printf '%s\n' "$project_names" | grep -qxF "${wname%%:*}"; then projects+="${wname}"$'\t'"${wid}"$'\n' else others+="${wname}"$'\t'"${wid}"$'\n' @@ -394,6 +493,36 @@ prep_git_single() { esac } +# Run the project's agent-roster and turn its verdict into a launch decision. +# The decision is the only thing on stdout; warnings go to stderr so callers +# can capture one without the other. +_resolve_helper_launch() { + # Two statements on purpose: a name assigned in a `local` is not yet visible + # to a later assignment in that same `local`, so building the roster path in + # this line would read the CALLER's $dir — right only by coincidence. + local dir="$1" + local roster="$dir/.ai/scripts/agent-roster" rc decision + if [ -x "$roster" ]; then + # The roster prints the other agents it found; only its exit code matters + # here, and its stdout must not reach a --print-launch caller's output. + "$roster" "$dir" >/dev/null 2>&1 + rc=$? + else + rc=absent + fi + + decision="$(_helper_launch_mode "$rc")" + case "$decision" in + primary) + echo "ai: --helper found no other agent live in $(basename "$dir") — opening a normal primary session instead" >&2 + ;; + helper-unverified) + echo "ai: could not verify another agent is live in $(basename "$dir") — roster unavailable; opening a helper anyway" >&2 + ;; + esac + echo "$decision" +} + # ---------- modes ---------- attach_mode() { @@ -447,6 +576,55 @@ single_mode() { attach_session } +# Open a helper session: a second agent in a project that already has a live +# one. Two deliberate differences from single_mode. It never focuses an +# existing window — a second session is the entire point, and focusing the +# primary's window is the one outcome that can't be what was asked for. And it +# never runs git prep, because every pull belongs to the primary under the +# helper contract. +helper_mode() { + local arg="$1" dir name id wid wname decision instructions + dir="$(cd "$arg" 2>/dev/null && pwd)" || { + echo "ai: cannot access '$arg'" >&2 + return 1 + } + + if [ ! -f "$dir/.ai/protocols.org" ]; then + echo "ai: $dir has no .ai/protocols.org — not an agent-template project" >&2 + return 1 + fi + + name="$(basename "$dir")" + + # Nobody else is here, so there is nothing to be a helper to. Fall through to + # the normal launch rather than opening a crippled session. + decision="$(_resolve_helper_launch "$dir")" + if [ "$decision" = primary ]; then + single_mode "$arg" + return $? + fi + + id="$(_resolve_helper_id "$dir")" + wname="$name:$id" + instructions=$(build_helper_instructions "$name") + + if tmux has-session -t "$SESSION" 2>/dev/null; then + wid=$(tmux new-window -a -t "$SESSION:{end}" -n "$wname" -c "$dir" -P -F '#{window_id}') + sleep 0.1 + else + wid=$(tmux new-session -d -s "$SESSION" -n "$wname" -c "$dir" -P -F '#{window_id}') + fi + + # The id rides in the launched process's environment, which is what + # session-context-path reads to resolve .ai/session-context.d/.org. + tmux send-keys -t "$wid" \ + "${LAUNCH_PREFIX}AI_AGENT_ID=$id AI_HELPER=1 $AGENT_CMD \"$instructions\"" Enter + + sort_windows + tmux select-window -t "$wid" + attach_session +} + # Multi-select via fzf (the original aix flow). multi_mode() { local filtered=() selections first_wid="" @@ -537,6 +715,15 @@ print_launch_mode() { exit 1 fi name="$(basename "$dir")" + + # The roster runs here too, so the printed line reflects the decision a real + # run would make — including the downgrade to a primary launch. + if [ -n "$HELPER_MODE" ] && [ "$(_resolve_helper_launch "$dir")" != primary ]; then + printf 'AI_AGENT_ID=%s AI_HELPER=1 %s "%s"\n' \ + "$(_resolve_helper_id "$dir")" "$AGENT_CMD" "$(build_helper_instructions "$name")" + exit 0 + fi + printf '%s "%s"\n' "$AGENT_CMD" "$(build_instructions "$name")" exit 0 } @@ -549,12 +736,17 @@ print_launch_mode() { # dispatch runs exactly as before; when sourced, it's skipped. main() { print_launch="" + HELPER_MODE="" runtime_explicit="${AI_RUNTIME:+1}" while [ $# -gt 0 ]; do case "$1" in -h | --help) usage ;; + --helper) + HELPER_MODE=1 + shift + ;; --runtime) [ -z "${2:-}" ] && { echo "ai: --runtime needs a value — valid runtimes: claude, codex, local" >&2 @@ -585,6 +777,13 @@ main() { resolve_agent_cmd + # A helper is always scoped to one named project. There is no roster to check + # and no primary to help without one, so this can't fall back to the picker. + if [ -n "$HELPER_MODE" ] && [ -z "${1:-}" ]; then + echo "ai: --helper needs a project directory" >&2 + exit 2 + fi + if [ -n "$print_launch" ]; then [ $# -eq 0 ] && { echo "ai: --print-launch needs a project directory" >&2 @@ -610,7 +809,11 @@ main() { *) check_deps for arg in "$@"; do - single_mode "$arg" + if [ -n "$HELPER_MODE" ]; then + helper_mode "$arg" + else + single_mode "$arg" + fi done ;; esac diff --git a/scripts/tests/ai-launcher-helper.bats b/scripts/tests/ai-launcher-helper.bats new file mode 100644 index 0000000..0ede4fb --- /dev/null +++ b/scripts/tests/ai-launcher-helper.bats @@ -0,0 +1,328 @@ +#!/usr/bin/env bats +# The ai launcher's --helper flag: open a SECOND agent session in a project that +# already has a live one, under the helper-mode.org role contract. +# +# The load-bearing behavior is the roster gate. `ai --helper` is an assertion by +# the operator that a primary is already running; the roster is what checks it. +# Three outcomes, all tested here: confirmed (launch a helper), refuted (no other +# agent — warn and launch a normal primary instead), and unverifiable (no roster +# script, or a platform without /proc — warn and launch a helper anyway, because +# helper mode is the strictly less destructive guess when we cannot tell). +# +# --print-launch is the seam, as it is for the runtime tests: it prints the exact +# command a real run would send to the pane without touching tmux or fzf. The +# roster runs BEFORE that print, so the printed line reflects the real decision. + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + AI="$REPO_ROOT/claude-templates/bin/ai" + PROJ="$(mktemp -d)" + mkdir -p "$PROJ/.ai/scripts" + touch "$PROJ/.ai/protocols.org" + # Every tmux call in this file goes to a private server under the test + # tmpdir, so nothing here can reach Craig's live 'ai' session. + export TMUX_TMPDIR="$PROJ" + unset TMUX + # Source for direct access to the pure cores. The guard skips main(). + # shellcheck disable=SC1090 + source "$AI" +} + +teardown() { + tmux kill-server 2>/dev/null || true + rm -rf "$PROJ" +} + +# Install a stub roster that exits with the given status. Exit codes are the +# real agent-roster's contract: 0 alone, 1 others live, 2 unavailable. +_stub_roster() { + cat > "$PROJ/.ai/scripts/agent-roster" < id" { + _stub_roster 1 + run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]] +} + +@test "--helper honors an id the caller already exported" { + _stub_roster 1 + AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"AI_AGENT_ID=helper-beef"* ]] +} + +@test "--helper sanitizes an id carrying shell metacharacters" { + _stub_roster 1 + # The id is interpolated into the command typed into the pane. An id + # carrying ';' would end the assignment and run the rest as its own + # command — the helper never launches and something else does. + AI_AGENT_ID='x;touch /tmp/ai-helper-pwned' run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" != *";touch"* ]] + [[ "$output" != *" /tmp/ai-helper-pwned"* ]] + # Assert the exact surviving form too. Negative-only assertions also pass + # when the id is dropped or mangled some other way, which is how a broken + # sanitizer slipped through once already. + [[ "$output" == *"AI_AGENT_ID=x_touch__tmp_ai-helper-pwned "* ]] +} + +@test "--helper sanitizes an id carrying a space" { + _stub_roster 1 + AI_AGENT_ID='helper beef' run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + # A bare space would split the assignment from the command word. Anchored on + # the following space, not a substring: a substring match also accepts a + # mangled "helper_beef_", which an earlier sanitizer actually produced. + [[ "$output" == *"AI_AGENT_ID=helper_beef "* ]] +} + +@test "--helper mints a fresh id rather than reusing a live one" { + _stub_roster 1 + # A helper's pane exports AI_AGENT_ID, so `ai --helper` run from inside a + # helper inherits its parent's id. Reusing it lands both agents on one + # .ai/session-context.d/.org — the lost-update shape helper mode exists + # to prevent. + mkdir -p "$PROJ/.ai/session-context.d" + printf 'helper-beef\n' > "$PROJ/.ai/session-context.d/helper-beef.org" + AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" != *"AI_AGENT_ID=helper-beef"* ]] + [[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]] + [[ "$output" == *"already live"* ]] +} + +@test "--helper with no other agent falls back to a primary session and says so" { + _stub_roster 0 + run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + # Refuted: this is the normal primary launch. + [[ "$output" == *"protocols.org"* ]] + [[ "$output" != *"helper-mode.org"* ]] + [[ "$output" != *"AI_HELPER=1"* ]] + [[ "$output" == *"no other agent"* ]] +} + +@test "--helper with no roster installed still launches a helper, with a warning" { + # No stub written: an older checkout whose .ai/scripts predates agent-roster. + run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"helper-mode.org"* ]] + [[ "$output" == *"could not verify"* ]] +} + +@test "--helper with an unavailable roster still launches a helper, with a warning" { + _stub_roster 2 + run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"helper-mode.org"* ]] + [[ "$output" == *"could not verify"* ]] +} + +@test "--helper names the host and project in the opener, as the primary does" { + _stub_roster 1 + run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"$(basename "$PROJ")"* ]] + [[ "$output" == *"$(uname -n)"* ]] +} + +@test "--helper composes with --runtime" { + _stub_roster 1 + run bash "$AI" --runtime codex --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"codex "* ]] + [[ "$output" == *"helper-mode.org"* ]] +} + +@test "--helper refuses a directory that is not an agent-template project" { + run bash "$AI" --helper --print-launch "$BATS_TEST_TMPDIR" + [ "$status" -eq 1 ] + [[ "$output" == *"protocols.org"* ]] +} + +@test "--helper needs a project directory" { + run bash "$AI" --helper + [ "$status" -eq 2 ] + [[ "$output" == *"needs a project directory"* ]] +} + +# --- pure cores --------------------------------------------------------------- + +@test "_helper_launch_mode: roster found other agents (1) — launch a helper" { + run _helper_launch_mode 1 + [ "$output" = helper ] +} + +@test "_helper_launch_mode: roster says alone (0) — fall back to primary" { + run _helper_launch_mode 0 + [ "$output" = primary ] +} + +@test "_helper_launch_mode: roster unavailable (2) — helper, unverified" { + run _helper_launch_mode 2 + [ "$output" = helper-unverified ] +} + +@test "_helper_launch_mode: no roster script at all — helper, unverified" { + run _helper_launch_mode absent + [ "$output" = helper-unverified ] +} + +@test "_resolve_helper_launch: builds the roster path from its own argument" { + _stub_roster 1 + # With no `dir` in the caller's scope, a roster path built from the caller's + # variable instead of the parameter resolves to "/.ai/scripts/agent-roster", + # which isn't executable — so the gate would silently report unverified and + # every helper launch would skip its check. The bug is invisible when the + # caller happens to have its own $dir holding the same value, which both + # production callers do. + unset dir + run _resolve_helper_launch "$PROJ" + [ "$output" = helper ] +} + +@test "_helper_id: shape is helper- plus four hex digits" { + run _helper_id + [[ "$output" =~ ^helper-[0-9a-f]{4}$ ]] +} + +@test "_helper_id: uses the full 16 bits, not bash RANDOM's 15" { + # The shape test alone passes against `RANDOM % 65536`, which can never set + # the top bit — so every id would begin 0-7 and nothing would fail. Draw + # enough to make a genuinely 16-bit generator almost certain to show a high + # leading digit, and assert one appears. + local i high=0 + for i in $(seq 1 200); do + case "$(_helper_id)" in + helper-[89abcdef]*) high=1; break ;; + esac + done + [ "$high" -eq 1 ] +} + +@test "_sanitize_agent_id: keeps the safe charset and maps everything else" { + run _sanitize_agent_id 'helper-a83f' + [ "$output" = "helper-a83f" ] + run _sanitize_agent_id 'a b;c/d$e' + [ "$output" = "a_b_c_d_e" ] + run _sanitize_agent_id 'keep.dots_and-dashes' + [ "$output" = "keep.dots_and-dashes" ] +} + +@test "_resolve_helper_id: a minted id also avoids a live anchor" { + # Seed every id _helper_id can produce for a stubbed generator, so the mint + # path must notice the collision rather than hand back a taken id. + mkdir -p "$PROJ/.ai/session-context.d" + _helper_id() { echo "helper-dead"; } + : > "$PROJ/.ai/session-context.d/helper-dead.org" + run _resolve_helper_id "$PROJ" + # Bounded retries mean it gives up and returns the id, but it must not have + # returned it silently on the first look — the loop ran its full bound. + [ "$status" -eq 0 ] + [ "$output" = "helper-dead" ] +} + +@test "_resolve_helper_id: a free minted id is returned as-is" { + _helper_id() { echo "helper-cafe"; } + run _resolve_helper_id "$PROJ" + [ "$output" = "helper-cafe" ] +} + +# --- window ordering ---------------------------------------------------------- + +@test "_order_windows: a helper window sorts with its project, not with others" { + local listing names out + listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha:helper-a83f\t@3\nalpha\t@4')" + names="$(printf 'alpha\nbeta')" + out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)" + [ "$out" = "zzz-other,alpha,alpha:helper-a83f,beta" ] +} + +@test "_order_windows: a colon name whose prefix is not a project stays in others" { + local out + out="$(printf 'nope:helper-a83f\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)" + [ "$out" = "nope:helper-a83f" ] +} + +# --- functional: the second window (private tmux socket) ---------------------- +# +# The regression these guard against: single_mode focuses the project's existing +# window and returns, so routing a helper through it would hand back the PRIMARY +# session instead of opening a second one. +# +# The window-list tail (sort_windows, attach_session) is stubbed out. Both are +# already covered in the characterization suite, attaching needs a real terminal +# these tests don't have, and build_candidates legitimately returns non-zero +# under bats's errexit (bin/ai itself runs without set -e — see that file's NOTE). +_stub_window_tail() { + sort_windows() { :; } + attach_session() { :; } + export AGENT_CMD="true" +} + +@test "functional helper_mode: opens a NEW window beside the project's existing one" { + _stub_roster 1 + _stub_window_tail + tmux new-session -d -s ai -n "$(basename "$PROJ")" -c "$PROJ" + run helper_mode "$PROJ" + [ "$status" -eq 0 ] + names="$(tmux list-windows -t ai -F '#{window_name}')" + # The primary's window survives untouched, and a helper window joins it. + printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")" + printf '%s\n' "$names" | grep -qE "^$(basename "$PROJ"):helper-[0-9a-f]{4}$" + [ "$(printf '%s\n' "$names" | wc -l)" -eq 2 ] +} + +@test "functional helper_mode: the window name carries the exported id" { + _stub_roster 1 + _stub_window_tail + tmux new-session -d -s ai -n base -c "$PROJ" + AI_AGENT_ID=helper-beef run helper_mode "$PROJ" + [ "$status" -eq 0 ] + tmux list-windows -t ai -F '#{window_name}' | grep -qx "$(basename "$PROJ"):helper-beef" +} + +@test "functional helper_mode: an empty roster opens the plain project window" { + _stub_roster 0 + _stub_window_tail + tmux new-session -d -s ai -n base -c "$PROJ" + run helper_mode "$PROJ" + [ "$status" -eq 0 ] + names="$(tmux list-windows -t ai -F '#{window_name}')" + printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")" + ! printf '%s\n' "$names" | grep -q ':helper-' +} + +# --- help --------------------------------------------------------------------- + +@test "usage documents --helper" { + run bash "$AI" -h + [ "$status" -eq 0 ] + [[ "$output" == *"--helper"* ]] +} -- cgit v1.2.3