aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/bin/ai
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates/bin/ai')
-rwxr-xr-xclaude-templates/bin/ai209
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