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 | |
| 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.
| -rwxr-xr-x | .ai/scripts/agent-roster | 84 | ||||
| -rw-r--r-- | .ai/scripts/tests/agent-roster.bats | 141 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/agent-roster | 84 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/agent-roster.bats | 141 | ||||
| -rw-r--r-- | todo.org | 2 |
5 files changed, 451 insertions, 1 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 diff --git a/.ai/scripts/tests/agent-roster.bats b/.ai/scripts/tests/agent-roster.bats new file mode 100644 index 0000000..939a7df --- /dev/null +++ b/.ai/scripts/tests/agent-roster.bats @@ -0,0 +1,141 @@ +#!/usr/bin/env bats +# Tests for agent-roster: report other live Claude agents in a project. +# +# pgrep and /proc are the system boundary, so the test injects both and runs +# the real include/exclude logic against fixtures — no Claude processes are +# spawned. Injection points: +# ROSTER_PGREP command standing in for pgrep (a stub printing $FAKE_PIDS) +# ROSTER_PROC proc dir (a fixture of <pid>/cwd symlinks + <pid>/status) +# ROSTER_SELF_PID the scanner's own pid, so the ancestry walk is testable +# +# Fixture process tree: pid 1000 (the scanner) is a child of 999 (the current +# session's claude), which is a child of init (1). So 999 must always be +# excluded as scanner ancestry; other claude pids are judged by cwd. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + ROSTER="$SCRIPT_DIR/agent-roster" + + PROC="$BATS_TEST_TMPDIR/proc" + ROOT="$BATS_TEST_TMPDIR/project" + mkdir -p "$PROC" "$ROOT/sub" "$BATS_TEST_TMPDIR/elsewhere" + + PGREP_STUB="$BATS_TEST_TMPDIR/pgrep" + cat >"$PGREP_STUB" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' $FAKE_PIDS +EOF + chmod +x "$PGREP_STUB" + + SELF=1000 + # scanner (1000) <- session claude (999) <- init (1) + mkproc 1000 "$ROOT" 999 + mkproc 999 "$ROOT" 1 +} + +# mkproc PID CWD PPID — register a fake process in the fixture proc dir. +mkproc() { + mkdir -p "$PROC/$1" + ln -sf "$2" "$PROC/$1/cwd" + printf 'PPid:\t%s\n' "$3" >"$PROC/$1/status" +} + +run_roster() { + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$PROC" ROSTER_SELF_PID="$SELF" \ + FAKE_PIDS="$FAKE_PIDS" run "$ROSTER" "$ROOT" +} + +@test "agent-roster: alone (only the session's own claude) exits 0, no output" { + FAKE_PIDS="999" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: one other agent in-project is printed, exit 1" { + mkproc 2000 "$ROOT" 1 + FAKE_PIDS="999 2000" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] + [[ "$output" == *"$ROOT"* ]] +} + +@test "agent-roster: two other agents both printed, exit 1" { + mkproc 2000 "$ROOT" 1 + mkproc 2001 "$ROOT/sub" 1 + FAKE_PIDS="999 2000 2001" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] + [[ "$output" == *"2001"* ]] + [ "${#lines[@]}" -eq 2 ] +} + +@test "agent-roster: the scanner's session-claude ancestor is excluded even with matching cwd" { + # 999 has cwd == ROOT but is scanner ancestry; must not appear. + FAKE_PIDS="999" + run_roster + [ "$status" -eq 0 ] + [[ "$output" != *"999"* ]] +} + +@test "agent-roster: cwd outside the project root is excluded" { + mkproc 3000 "$BATS_TEST_TMPDIR/elsewhere" 1 + FAKE_PIDS="999 3000" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: cwd in a subdirectory of root is included" { + mkproc 2002 "$ROOT/sub" 1 + FAKE_PIDS="999 2002" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2002"* ]] +} + +@test "agent-roster: a sibling path sharing a prefix is not a false match" { + # ROOT is .../project; .../project-other must not count as inside it. + mkdir -p "$BATS_TEST_TMPDIR/project-other" + mkproc 3100 "$BATS_TEST_TMPDIR/project-other" 1 + FAKE_PIDS="999 3100" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: a pid that vanished between pgrep and the proc read is skipped" { + # 4000 has no fixture dir, simulating a process gone by readlink time. + FAKE_PIDS="999 4000" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: missing proc reports unavailable on stderr, exit 2, never silent-alone" { + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$BATS_TEST_TMPDIR/nonexistent" \ + ROSTER_SELF_PID="$SELF" FAKE_PIDS="999 2000" run "$ROSTER" "$ROOT" + [ "$status" -eq 2 ] + [[ "$output" == *"roster unavailable"* ]] +} + +@test "agent-roster: a missing pgrep reports unavailable, exit 2, never silent-alone" { + # If pgrep itself is absent, the scan can't run; reporting "alone" would be a + # false negative the "never silent-alone" invariant forbids. + mkproc 2000 "$ROOT" 1 + ROSTER_PGREP="$BATS_TEST_TMPDIR/no-such-pgrep" ROSTER_PROC="$PROC" \ + ROSTER_SELF_PID="$SELF" FAKE_PIDS="999 2000" run "$ROSTER" "$ROOT" + [ "$status" -eq 2 ] + [[ "$output" == *"roster unavailable"* ]] +} + +@test "agent-roster: defaults project root to PWD when no argument is given" { + mkproc 2000 "$ROOT" 1 + FAKE_PIDS="999 2000" + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$PROC" ROSTER_SELF_PID="$SELF" \ + FAKE_PIDS="$FAKE_PIDS" run env -C "$ROOT" "$ROSTER" + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] +} diff --git a/claude-templates/.ai/scripts/agent-roster b/claude-templates/.ai/scripts/agent-roster new file mode 100755 index 0000000..f32b744 --- /dev/null +++ b/claude-templates/.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 diff --git a/claude-templates/.ai/scripts/tests/agent-roster.bats b/claude-templates/.ai/scripts/tests/agent-roster.bats new file mode 100644 index 0000000..939a7df --- /dev/null +++ b/claude-templates/.ai/scripts/tests/agent-roster.bats @@ -0,0 +1,141 @@ +#!/usr/bin/env bats +# Tests for agent-roster: report other live Claude agents in a project. +# +# pgrep and /proc are the system boundary, so the test injects both and runs +# the real include/exclude logic against fixtures — no Claude processes are +# spawned. Injection points: +# ROSTER_PGREP command standing in for pgrep (a stub printing $FAKE_PIDS) +# ROSTER_PROC proc dir (a fixture of <pid>/cwd symlinks + <pid>/status) +# ROSTER_SELF_PID the scanner's own pid, so the ancestry walk is testable +# +# Fixture process tree: pid 1000 (the scanner) is a child of 999 (the current +# session's claude), which is a child of init (1). So 999 must always be +# excluded as scanner ancestry; other claude pids are judged by cwd. + +setup() { + SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + ROSTER="$SCRIPT_DIR/agent-roster" + + PROC="$BATS_TEST_TMPDIR/proc" + ROOT="$BATS_TEST_TMPDIR/project" + mkdir -p "$PROC" "$ROOT/sub" "$BATS_TEST_TMPDIR/elsewhere" + + PGREP_STUB="$BATS_TEST_TMPDIR/pgrep" + cat >"$PGREP_STUB" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' $FAKE_PIDS +EOF + chmod +x "$PGREP_STUB" + + SELF=1000 + # scanner (1000) <- session claude (999) <- init (1) + mkproc 1000 "$ROOT" 999 + mkproc 999 "$ROOT" 1 +} + +# mkproc PID CWD PPID — register a fake process in the fixture proc dir. +mkproc() { + mkdir -p "$PROC/$1" + ln -sf "$2" "$PROC/$1/cwd" + printf 'PPid:\t%s\n' "$3" >"$PROC/$1/status" +} + +run_roster() { + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$PROC" ROSTER_SELF_PID="$SELF" \ + FAKE_PIDS="$FAKE_PIDS" run "$ROSTER" "$ROOT" +} + +@test "agent-roster: alone (only the session's own claude) exits 0, no output" { + FAKE_PIDS="999" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: one other agent in-project is printed, exit 1" { + mkproc 2000 "$ROOT" 1 + FAKE_PIDS="999 2000" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] + [[ "$output" == *"$ROOT"* ]] +} + +@test "agent-roster: two other agents both printed, exit 1" { + mkproc 2000 "$ROOT" 1 + mkproc 2001 "$ROOT/sub" 1 + FAKE_PIDS="999 2000 2001" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] + [[ "$output" == *"2001"* ]] + [ "${#lines[@]}" -eq 2 ] +} + +@test "agent-roster: the scanner's session-claude ancestor is excluded even with matching cwd" { + # 999 has cwd == ROOT but is scanner ancestry; must not appear. + FAKE_PIDS="999" + run_roster + [ "$status" -eq 0 ] + [[ "$output" != *"999"* ]] +} + +@test "agent-roster: cwd outside the project root is excluded" { + mkproc 3000 "$BATS_TEST_TMPDIR/elsewhere" 1 + FAKE_PIDS="999 3000" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: cwd in a subdirectory of root is included" { + mkproc 2002 "$ROOT/sub" 1 + FAKE_PIDS="999 2002" + run_roster + [ "$status" -eq 1 ] + [[ "$output" == *"2002"* ]] +} + +@test "agent-roster: a sibling path sharing a prefix is not a false match" { + # ROOT is .../project; .../project-other must not count as inside it. + mkdir -p "$BATS_TEST_TMPDIR/project-other" + mkproc 3100 "$BATS_TEST_TMPDIR/project-other" 1 + FAKE_PIDS="999 3100" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: a pid that vanished between pgrep and the proc read is skipped" { + # 4000 has no fixture dir, simulating a process gone by readlink time. + FAKE_PIDS="999 4000" + run_roster + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "agent-roster: missing proc reports unavailable on stderr, exit 2, never silent-alone" { + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$BATS_TEST_TMPDIR/nonexistent" \ + ROSTER_SELF_PID="$SELF" FAKE_PIDS="999 2000" run "$ROSTER" "$ROOT" + [ "$status" -eq 2 ] + [[ "$output" == *"roster unavailable"* ]] +} + +@test "agent-roster: a missing pgrep reports unavailable, exit 2, never silent-alone" { + # If pgrep itself is absent, the scan can't run; reporting "alone" would be a + # false negative the "never silent-alone" invariant forbids. + mkproc 2000 "$ROOT" 1 + ROSTER_PGREP="$BATS_TEST_TMPDIR/no-such-pgrep" ROSTER_PROC="$PROC" \ + ROSTER_SELF_PID="$SELF" FAKE_PIDS="999 2000" run "$ROSTER" "$ROOT" + [ "$status" -eq 2 ] + [[ "$output" == *"roster unavailable"* ]] +} + +@test "agent-roster: defaults project root to PWD when no argument is given" { + mkproc 2000 "$ROOT" 1 + FAKE_PIDS="999 2000" + ROSTER_PGREP="$PGREP_STUB" ROSTER_PROC="$PROC" ROSTER_SELF_PID="$SELF" \ + FAKE_PIDS="$FAKE_PIDS" run env -C "$ROOT" "$ROSTER" + [ "$status" -eq 1 ] + [[ "$output" == *"2000"* ]] +} @@ -43,7 +43,7 @@ Optional wrap-up step that surfaces filed keepers belonging to another project, Spec: [[file:docs/design/wrapup-routing-spec.org]]. Source proposal: [[file:docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org]] (archsetup handoff 2026-06-13). Next: =spec-review=. -** TODO [#B] Helper-instance support — concurrent same-project Claude :feature:spec: +** DOING [#B] Helper-instance support — concurrent same-project Claude :feature:spec: :PROPERTIES: :CREATED: [2026-06-11 Thu] :LAST_REVIEWED: 2026-06-12 |
