aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/agent-roster
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-15 01:14:46 -0500
committerCraig Jennings <c@cjennings.net>2026-06-15 01:14:46 -0500
commitf8bdf302fd71ba73ae1424d86275e1e223f09cf3 (patch)
tree4f46c33a70d89ecb939d5f87c5fed3412515be58 /.ai/scripts/agent-roster
parente0f914d510c081db45cafaf4fe5c8f7b65e46fec (diff)
downloadrulesets-f8bdf302fd71ba73ae1424d86275e1e223f09cf3.tar.gz
rulesets-f8bdf302fd71ba73ae1424d86275e1e223f09cf3.zip
feat(ai): add agent-roster detection script with tests
agent-roster is the single detection primitive for concurrent same-project Claude sessions: pgrep -x claude, resolve each pid's cwd from /proc, keep those at or inside the project root, and drop the scanner's own ancestry. It exits 0 when alone, 1 when other agents are present (printed pid + cwd), and 2 when the roster can't run. Both the helper launcher and the in-session startup check will call this rather than re-scanning. pgrep and /proc are the system boundary, so I made them injectable (ROSTER_PGREP, ROSTER_PROC, ROSTER_SELF_PID) and the bats exercise the real include/exclude logic against fixtures, no agents spawned. The unavailable paths (no /proc, or pgrep absent) report on stderr and exit 2 rather than a false "alone". This is the first slice of the helper-instance task. Startup and ai --helper wiring follow.
Diffstat (limited to '.ai/scripts/agent-roster')
-rwxr-xr-x.ai/scripts/agent-roster84
1 files changed, 84 insertions, 0 deletions
diff --git a/.ai/scripts/agent-roster b/.ai/scripts/agent-roster
new file mode 100755
index 0000000..f32b744
--- /dev/null
+++ b/.ai/scripts/agent-roster
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# agent-roster — list other live Claude agents working in this project.
+#
+# The single source of "who else is live in this project." Both launchers
+# (ai --helper) and the in-session startup check call this rather than
+# reimplementing the scan, so concurrent-agent detection has one definition.
+#
+# Scan (stateless): enumerate running Claude processes (pgrep -x claude), read
+# each one's working directory from /proc/<pid>/cwd, keep those whose cwd is
+# the project root or inside it, and drop the scanner's own process ancestry
+# (walk parent pids from /proc/self up). What remains is the set of *other*
+# live agents in this project.
+#
+# Usage: agent-roster [project-root] (default: $PWD)
+# Output: one "pid<TAB>cwd" line per other agent
+# Exit: 0 = alone (no other agents)
+# 1 = one or more other agents (and printed)
+# 2 = roster unavailable (no /proc; non-Linux or absent)
+#
+# Known limits, accepted for v1: a session not running as a local process on
+# this machine (a cloud session against the same checkout) is invisible, and
+# the match is on process cwd, so an agent started from outside the project
+# tree isn't seen. Both are edge shapes the operator created deliberately.
+#
+# The boundary (pgrep, /proc, self pid) is injectable so the filtering logic
+# is testable without spawning real agents: ROSTER_PGREP, ROSTER_PROC,
+# ROSTER_SELF_PID. Production defaults need no environment.
+set -euo pipefail
+
+PGREP="${ROSTER_PGREP:-pgrep}"
+PROC="${ROSTER_PROC:-/proc}"
+SELF_PID="${ROSTER_SELF_PID:-$$}"
+
+root="${1:-$PWD}"
+root="${root%/}"
+
+# Linux /proc is the substrate. Absent (non-Linux, or unreadable) means the
+# scan can't run; say so explicitly rather than reporting a false "alone".
+if [ ! -d "$PROC" ]; then
+ echo "agent-roster: roster unavailable (no $PROC; non-Linux or absent)" >&2
+ exit 2
+fi
+
+# pgrep is the enumeration boundary. Without it the scan can't run, and the
+# no-match exit code (1) below is indistinguishable from "tool missing" once
+# swallowed, so check up front rather than report a false "alone".
+if ! command -v "$PGREP" >/dev/null 2>&1; then
+ echo "agent-roster: roster unavailable ($PGREP not found)" >&2
+ exit 2
+fi
+
+# Build the scanner's ancestry set: SELF_PID and every parent up to init.
+# A Claude found by pgrep that lands in this set is the current session (or its
+# launcher chain), not another agent.
+ancestry=" "
+pid="$SELF_PID"
+while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$pid" != "1" ]; do
+ ancestry="${ancestry}${pid} "
+ status="$PROC/$pid/status"
+ [ -r "$status" ] || break
+ pid="$(awk '/^PPid:/{print $2; exit}' "$status")"
+done
+
+found=0
+while read -r candidate; do
+ [ -n "$candidate" ] || continue
+ case "$ancestry" in
+ *" $candidate "*) continue ;; # scanner's own ancestry
+ esac
+ # cwd may be gone if the process exited between pgrep and here; skip it.
+ cwd="$(readlink "$PROC/$candidate/cwd" 2>/dev/null)" || continue
+ [ -n "$cwd" ] || continue
+ # Keep only agents at or inside the project root. The trailing slashes make
+ # the prefix test exact, so /foo/project-other doesn't match /foo/project.
+ case "$cwd/" in
+ "$root"/*) ;;
+ *) continue ;;
+ esac
+ printf '%s\t%s\n' "$candidate" "$cwd"
+ found=1
+done < <("$PGREP" -x claude 2>/dev/null || true)
+
+[ "$found" -eq 1 ] && exit 1
+exit 0