diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-15 01:14:46 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-15 01:14:46 -0500 |
| commit | f8bdf302fd71ba73ae1424d86275e1e223f09cf3 (patch) | |
| tree | 4f46c33a70d89ecb939d5f87c5fed3412515be58 /.ai/scripts/agent-roster | |
| parent | e0f914d510c081db45cafaf4fe5c8f7b65e46fec (diff) | |
| download | rulesets-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-roster | 84 |
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 |
