aboutsummaryrefslogtreecommitdiff
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
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.
-rwxr-xr-x.ai/scripts/agent-roster84
-rw-r--r--.ai/scripts/tests/agent-roster.bats141
-rwxr-xr-xclaude-templates/.ai/scripts/agent-roster84
-rw-r--r--claude-templates/.ai/scripts/tests/agent-roster.bats141
-rw-r--r--todo.org2
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"* ]]
+}
diff --git a/todo.org b/todo.org
index 8eb0b02..43f6d2a 100644
--- a/todo.org
+++ b/todo.org
@@ -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