diff options
Diffstat (limited to 'claude-templates/bin')
| -rwxr-xr-x | claude-templates/bin/ai | 209 |
1 files changed, 206 insertions, 3 deletions
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 <dir> 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-<rand4>, 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/<id>.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 "name<TAB>wid" 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 "<project>:<agent-id>", 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/<id>.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 |
