diff options
Diffstat (limited to 'claude-templates/bin/ai')
| -rwxr-xr-x | claude-templates/bin/ai | 592 |
1 files changed, 497 insertions, 95 deletions
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai index 5a806ec..3440ee2 100755 --- a/claude-templates/bin/ai +++ b/claude-templates/bin/ai @@ -1,16 +1,34 @@ #!/bin/bash -# ai — Claude Code session launcher (unified aix + hey) +# ai — agent session launcher (unified aix + hey) # # Usage: -# ai Select one or more projects via fzf and open each in -# an 'ai' tmux session window (creates session if needed). +# ai Pick the agent first (claude, codex/ChatGPT, or any +# local ollama model), then select one or more projects +# via fzf; each opens in an 'ai' tmux session window. # Git-aware: fetches, annotates with ↑/↓/dirty, auto-pulls -# clean-and-behind repos before opening. +# clean-and-behind repos before opening. --runtime or +# AI_RUNTIME skips the agent pick. # # ai <dir>... Single-project mode. Opens each given directory directly # in the 'ai' session (new window or switch to existing). # Use '.' for current directory. Git prep per dir. # +# ai --runtime <rt> Launch with a different agent CLI: claude (default), +# codex, or local (codex --oss against this machine's +# 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. @@ -25,7 +43,65 @@ # would kill the script. SESSION="ai" -CLAUDE_CMD="claude" +RUNTIME="${AI_RUNTIME:-claude}" +LOCAL_MODEL="${AI_LOCAL_MODEL:-gpt-oss:120b}" + +# Map the runtime name to the agent CLI a pane launches. All three take the +# opening instructions as a positional prompt. AGENT_BIN is the binary the +# dependency check probes; AGENT_CMD is the full launch command (the local +# runtime rides codex's open-source provider against the machine's ollama — +# model per AI_LOCAL_MODEL, default gpt-oss:120b, verified on ratio's +# Strix Halo 2026-07-13). +resolve_agent_cmd() { + case "$RUNTIME" in + claude) + AGENT_BIN="claude" + AGENT_CMD="claude" + ;; + codex) + AGENT_BIN="codex" + AGENT_CMD="codex" + ;; + local) + AGENT_BIN="codex" + AGENT_CMD="codex --oss --local-provider=ollama -m $LOCAL_MODEL" + ;; + *) + echo "ai: unknown runtime '$RUNTIME' — valid runtimes: claude, codex, local" >&2 + exit 2 + ;; + esac +} + +# One line per launchable agent, claude first (Enter-Enter keeps the old +# muscle memory). Local models appear only when both codex (the CLI that +# drives them) and a live ollama answer; a dead server just drops the lines. +build_runtime_choices() { + command -v claude >/dev/null 2>&1 && echo "claude — Claude Code" + command -v codex >/dev/null 2>&1 && echo "codex — ChatGPT (Codex CLI)" + if command -v codex >/dev/null 2>&1 && command -v ollama >/dev/null 2>&1; then + timeout 3 ollama list 2>/dev/null | tail -n +2 | awk 'NF {print "local:" $1 " — ollama"}' + fi +} + +# Interactive runtime pick for the bare-`ai` flow. Sets RUNTIME (and +# LOCAL_MODEL for a local pick) and re-resolves the agent command. +# Returns 1 when the pick is cancelled. +pick_runtime() { + local choice + choice=$(build_runtime_choices | fzf --height=30% --reverse --prompt='agent> ') || return 1 + [ -z "$choice" ] && return 1 + case "$choice" in + claude*) RUNTIME="claude" ;; + codex*) RUNTIME="codex" ;; + local:*) + RUNTIME="local" + LOCAL_MODEL="${choice#local:}" + LOCAL_MODEL="${LOCAL_MODEL%% *}" + ;; + esac + resolve_agent_cmd +} # Run in the pane's shell just before Claude launches. `stty susp undef` clears # the tty's SIGTSTP (C-z) character for this pane only, so an accidental C-z is @@ -45,17 +121,152 @@ 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,20p' "$0" | sed 's|^# \?||' + sed -n '2,34p' "$0" | sed 's|^# \?||' exit 0 } -for cmd in fzf tmux claude; do - if ! command -v "$cmd" &>/dev/null; then - echo "ai: $cmd is not installed" >&2 - exit 1 +check_deps() { + for cmd in fzf tmux "$AGENT_BIN"; do + if ! command -v "$cmd" &>/dev/null; then + echo "ai: $cmd is not installed" >&2 + exit 1 + fi + done +} + +# ---------- pure decision cores (no tmux/git I/O; unit-tested directly) ---------- + +# Decide what a git-prep pass should do from a repo's already-computed state. +# Inputs: has_upstream (1/0), dirty (1/0), ahead, behind. Echoes one of: +# none — no upstream, or in sync: nothing to do +# pull — clean and purely behind: safe to fast-forward +# report — ahead, dirty, or behind-while-dirty: show a summary, don't pull +_git_prep_action() { + local has_upstream="$1" dirty="$2" ahead="$3" behind="$4" + [ "$has_upstream" -eq 1 ] || { + echo none + return + } + if [ "$dirty" -eq 0 ] && [ "$ahead" -eq 0 ] && [ "$behind" -gt 0 ]; then + echo pull + elif [ "$ahead" -gt 0 ] || [ "$behind" -gt 0 ] || [ "$dirty" -eq 1 ]; then + echo report + else + echo none + 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 -done + + # 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" || + printf '%s\n' "$project_names" | grep -qxF "${wname%%:*}"; then + projects+="${wname}"$'\t'"${wid}"$'\n' + else + others+="${wname}"$'\t'"${wid}"$'\n' + fi + done + others=$(printf '%s' "$others" | sort -t$'\t' -k1,1f) + projects=$(printf '%s' "$projects" | sort -t$'\t' -k1,1f) + printf '%s\n%s\n' "$others" "$projects" | sed '/^$/d' +} + +# Emit the window id whose name (field 1 of "name<TAB>wid" stdin) equals $1. +_match_window_id() { + awk -F'\t' -v n="$1" '$1 == n { print $2; exit }' +} # ---------- shared helpers ---------- @@ -73,13 +284,16 @@ create_window() { wid=$(tmux new-window -a -t "$SESSION:{end}" -n "$name" -c "$dir" -P -F '#{window_id}') sleep 0.1 instructions=$(build_instructions "$name") - tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter + tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter echo "$wid" } # Add a directory to candidates only if it's a Claude-template project. maybe_add_candidate() { local dir="$1" + # The "~/" is a deliberate literal display prefix, re-expanded downstream via + # ${c/#\~/$HOME}; it must not expand here, so SC2088 doesn't apply. + # shellcheck disable=SC2088 [ -f "$dir/.ai/protocols.org" ] && candidates+=("~/${dir#"$HOME"/}") } @@ -87,6 +301,7 @@ maybe_add_candidate() { build_candidates() { candidates=() maybe_add_candidate "$HOME/.emacs.d" + maybe_add_candidate "$HOME/.dotfiles" if [ -d "$HOME/code" ]; then while IFS= read -r d; do maybe_add_candidate "$d" @@ -115,12 +330,36 @@ fetch_candidates() { wait } +# Resolve the shared state gate installed beside this launcher. Keeping the +# policy in one executable prevents startup, the picker, and wrap-up from +# developing different meanings of "safe to sync." +_git_gate_path() { + local gate="${GIT_WORKTREE_GATE:-}" + [ -n "$gate" ] || gate="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/git-worktree-gate" + [ -x "$gate" ] && printf '%s\n' "$gate" +} + +# True (exit 0) when strict wrap would reject the worktree. +_git_is_dirty() { + local dir="$1" gate + gate="$(_git_gate_path)" || return 0 + ! "$gate" strict "$dir" >/dev/null 2>&1 +} + +# True (exit 0) when startup sync must stop. Untracked inbox deliveries are +# safe queue input; every tracked, staged, or other untracked change blocks. +_git_blocks_sync() { + local dir="$1" gate + gate="$(_git_gate_path)" || return 0 + ! "$gate" sync-safe "$dir" >/dev/null 2>&1 +} + # Return " (↑N ↓N dirty)" or " (✓)" if clean. git_status_indicator() { local dir="$1" upstream ahead=0 behind=0 parts=() [ -d "$dir/.git" ] || return 0 - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) if [ -n "$upstream" ]; then ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) @@ -130,10 +369,12 @@ git_status_indicator() { parts+=("no upstream") fi - if ! git -C "$dir" diff --quiet 2>/dev/null \ - || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ - || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then - parts+=("dirty") + if _git_is_dirty "$dir"; then + if _git_blocks_sync "$dir"; then + parts+=("dirty") + else + parts+=("inbox") + fi fi if [ ${#parts[@]} -gt 0 ]; then @@ -155,27 +396,21 @@ annotate_candidates() { candidates=("${annotated[@]}") } -# Pull if clean, behind, not ahead. No-op otherwise. +# Pull if sync-safe, behind, not ahead. Inbox-only queue input is sync-safe. auto_pull_if_clean() { local dir="$1" upstream ahead behind [ -d "$dir/.git" ] || return 0 + _git_blocks_sync "$dir" && return 0 - if ! git -C "$dir" diff --quiet 2>/dev/null \ - || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ - || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then - return 0 - fi - - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) [ -z "$upstream" ] && return 0 ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) - [ "${ahead:-0}" -gt 0 ] 2>/dev/null && return 0 - behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) - [ "${behind:-0}" -eq 0 ] 2>/dev/null && return 0 - git -C "$dir" pull --ff-only --quiet 2>/dev/null || true + # dirty=0 and has_upstream=1 are guaranteed by the early returns above. + [ "$(_git_prep_action 1 0 "${ahead:-0}" "${behind:-0}")" = pull ] && + git -C "$dir" pull --ff-only --quiet 2>/dev/null || true } # Strip " (annotation)" suffix from fzf output so downstream gets raw paths. @@ -188,7 +423,7 @@ read_selections() { # Re-order windows: non-project windows at base-index, projects alphabetically after. sort_windows() { - local windows others="" projects="" base_idx project_names="" + local windows base_idx project_names="" ordered base_idx=$(tmux show-option -gv base-index 2>/dev/null || echo 0) windows=$(tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}') @@ -197,83 +432,95 @@ sort_windows() { project_names+="$(basename "${c/#\~/$HOME}")"$'\n' done - while IFS=$'\t' read -r wname wid; do - [ -z "$wname" ] && continue - if echo "$project_names" | grep -qxF "$wname"; then - projects+="${wname}"$'\t'"${wid}"$'\n' - else - others+="${wname}"$'\t'"${wid}"$'\n' - fi - done <<<"$windows" - others=$(echo -n "$others" | sort -t$'\t' -k1,1f) - projects=$(echo -n "$projects" | sort -t$'\t' -k1,1f) - - local all - all=$(printf '%s\n' "$others" "$projects" | sed '/^$/d') + ordered=$(printf '%s\n' "$windows" | _order_windows "$project_names") + [ -z "$ordered" ] && return 0 + # First pass parks every window above the live range so the second pass can + # reassign the target indices without colliding with a window already there. local i=900 while IFS=$'\t' read -r _n wid; do + [ -z "$wid" ] && continue tmux move-window -s "$wid" -t "$SESSION:$i" i=$((i + 1)) - done <<<"$all" + done <<<"$ordered" i=$base_idx - if [ -n "$others" ]; then - while IFS=$'\t' read -r _n wid; do - tmux move-window -s "$wid" -t "$SESSION:$i" - i=$((i + 1)) - done <<<"$others" - fi - if [ -n "$projects" ]; then - while IFS=$'\t' read -r _n wid; do - tmux move-window -s "$wid" -t "$SESSION:$i" - i=$((i + 1)) - done <<<"$projects" - fi + while IFS=$'\t' read -r _n wid; do + [ -z "$wid" ] && continue + tmux move-window -s "$wid" -t "$SESSION:$i" + i=$((i + 1)) + done <<<"$ordered" } # Find existing window id in ai session by window name; empty if none. find_window_id() { - local name="$1" - tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}' 2>/dev/null \ - | awk -F'\t' -v n="$name" '$1 == n {print $2; exit}' + tmux list-windows -t "$SESSION" -F '#{window_name}'$'\t''#{window_id}' 2>/dev/null | + _match_window_id "$1" } # Git prep for a single directory. Uses FETCH_HEAD cache to skip back-to-back # fetches. Pulls automatically if clean-and-behind; prints one-line summary # if diverged/dirty/ahead. prep_git_single() { - local dir="$1" gitdir upstream ahead=0 behind=0 dirty="" age fetch_stale=1 parts=() + local dir="$1" gitdir upstream ahead=0 behind=0 dirty=0 age fetch_stale=1 parts=() git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 gitdir=$(git -C "$dir" rev-parse --git-dir 2>/dev/null) if [ -f "$gitdir/FETCH_HEAD" ]; then - age=$(( $(date +%s) - $(stat -c %Y "$gitdir/FETCH_HEAD" 2>/dev/null || echo 0) )) + age=$(($(date +%s) - $(stat -c %Y "$gitdir/FETCH_HEAD" 2>/dev/null || echo 0))) [ "$age" -lt 600 ] && fetch_stale=0 fi [ "$fetch_stale" -eq 1 ] && git -C "$dir" fetch --quiet 2>/dev/null || true - upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true) + upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true) [ -z "$upstream" ] && return 0 ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0) behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) + _git_blocks_sync "$dir" && dirty=1 + + case "$(_git_prep_action 1 "$dirty" "${ahead:-0}" "${behind:-0}")" in + pull) + echo "ai: pulling $behind commit(s) from $upstream..." >&2 + git -C "$dir" pull --ff-only --quiet + ;; + report) + [ "${ahead:-0}" -gt 0 ] && parts+=("↑$ahead") + [ "${behind:-0}" -gt 0 ] && parts+=("↓$behind") + [ "$dirty" -eq 1 ] && parts+=("dirty") + echo "ai: $(basename "$dir") — ${parts[*]}" >&2 + ;; + esac +} - if ! git -C "$dir" diff --quiet 2>/dev/null \ - || ! git -C "$dir" diff --cached --quiet 2>/dev/null \ - || [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then - dirty="dirty" +# 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 - if [ -z "$dirty" ] && [ "${ahead:-0}" -eq 0 ] && [ "${behind:-0}" -gt 0 ]; then - echo "ai: pulling $behind commit(s) from $upstream..." >&2 - git -C "$dir" pull --ff-only --quiet - elif [ "${ahead:-0}" -gt 0 ] || [ "${behind:-0}" -gt 0 ] || [ -n "$dirty" ]; then - [ "${ahead:-0}" -gt 0 ] && parts+=("↑$ahead") - [ "${behind:-0}" -gt 0 ] && parts+=("↓$behind") - [ -n "$dirty" ] && parts+=("$dirty") - echo "ai: $(basename "$dir") — ${parts[*]}" >&2 - 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 ---------- @@ -290,7 +537,10 @@ attach_mode() { # Open a single project (or focus existing window). single_mode() { local arg="$1" dir name wid existing - dir="$(cd "$arg" 2>/dev/null && pwd)" || { echo "ai: cannot access '$arg'" >&2; return 1; } + 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 a Claude-template project" >&2 @@ -318,9 +568,58 @@ single_mode() { local instructions wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}') instructions=$(build_instructions "$name") - tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter + tmux send-keys -t "$wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter + fi + + sort_windows + tmux select-window -t "$wid" + 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 @@ -378,12 +677,12 @@ multi_mode() { local instructions first_wid=$(tmux new-session -d -s "$SESSION" -n "$name" -c "$dir" -P -F '#{window_id}') instructions=$(build_instructions "$name") - tmux send-keys -t "$first_wid" "${LAUNCH_PREFIX}$CLAUDE_CMD \"$instructions\"" Enter + tmux send-keys -t "$first_wid" "${LAUNCH_PREFIX}$AGENT_CMD \"$instructions\"" Enter for entry in "${selected[@]:1}"; do dir="${entry/#\~/$HOME}" name="$(basename "$dir")" auto_pull_if_clean "$dir" - create_window "$dir" "$name" > /dev/null + create_window "$dir" "$name" >/dev/null done else # Add windows to existing session @@ -402,21 +701,124 @@ multi_mode() { attach_session } +# Print the launch command a real run would send to the pane, then exit. +# Exists for the launcher's bats tests: exercises runtime resolution and the +# opening line with no tmux or fzf involved. +print_launch_mode() { + local arg="$1" dir name + dir="$(cd "$arg" 2>/dev/null && pwd)" || { + echo "ai: cannot access '$arg'" >&2 + exit 1 + } + if [ ! -f "$dir/.ai/protocols.org" ]; then + echo "ai: $dir has no .ai/protocols.org — not an agent-template project" >&2 + 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 +} + # ---------- dispatch ---------- -case "${1:-}" in - -h|--help) - usage - ;; - --attach) - attach_mode - ;; - "") - multi_mode - ;; - *) - for arg in "$@"; do - single_mode "$arg" - done - ;; -esac +# Argument parsing + mode dispatch. Wrapped so the file can be sourced (by the +# launcher's bats tests) to exercise individual functions without running a +# real launch. When executed as a program, BASH_SOURCE[0] equals $0 and the +# 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 + exit 2 + } + RUNTIME="$2" + runtime_explicit=1 + shift 2 + ;; + --runtime=*) + RUNTIME="${1#--runtime=}" + runtime_explicit=1 + shift + ;; + --print-launch) + print_launch=1 + shift + ;; + --print-runtimes) + build_runtime_choices + exit 0 + ;; + *) + break + ;; + esac + done + + 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 + exit 2 + } + print_launch_mode "$1" + fi + + case "${1:-}" in + --attach) + check_deps + attach_mode + ;; + "") + # Bare `ai`: pick the agent first (skipped when --runtime or AI_RUNTIME + # already chose), then the familiar project multi-select. + if [ -z "$runtime_explicit" ]; then + pick_runtime || exit 0 + fi + check_deps + multi_mode + ;; + *) + check_deps + for arg in "$@"; do + if [ -n "$HELPER_MODE" ]; then + helper_mode "$arg" + else + single_mode "$arg" + fi + done + ;; + esac +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi |
