1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
|
#!/usr/bin/env bats
# The ai launcher's --helper flag: open a SECOND agent session in a project that
# already has a live one, under the helper-mode.org role contract.
#
# The load-bearing behavior is the roster gate. `ai --helper` is an assertion by
# the operator that a primary is already running; the roster is what checks it.
# Three outcomes, all tested here: confirmed (launch a helper), refuted (no other
# agent — warn and launch a normal primary instead), and unverifiable (no roster
# script, or a platform without /proc — warn and launch a helper anyway, because
# helper mode is the strictly less destructive guess when we cannot tell).
#
# --print-launch is the seam, as it is for the runtime tests: it prints the exact
# command a real run would send to the pane without touching tmux or fzf. The
# roster runs BEFORE that print, so the printed line reflects the real decision.
setup() {
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
AI="$REPO_ROOT/claude-templates/bin/ai"
PROJ="$(mktemp -d)"
mkdir -p "$PROJ/.ai/scripts"
touch "$PROJ/.ai/protocols.org"
# Every tmux call in this file goes to a private server under the test
# tmpdir, so nothing here can reach Craig's live 'ai' session.
export TMUX_TMPDIR="$PROJ"
unset TMUX
# Source for direct access to the pure cores. The guard skips main().
# shellcheck disable=SC1090
source "$AI"
}
teardown() {
tmux kill-server 2>/dev/null || true
rm -rf "$PROJ"
}
# Install a stub roster that exits with the given status. Exit codes are the
# real agent-roster's contract: 0 alone, 1 others live, 2 unavailable.
_stub_roster() {
cat > "$PROJ/.ai/scripts/agent-roster" <<STUB
#!/bin/bash
exit $1
STUB
chmod +x "$PROJ/.ai/scripts/agent-roster"
}
# --- the roster gate, end to end through --print-launch ------------------------
@test "--helper with a live primary launches under the helper contract" {
_stub_roster 1
run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"helper-mode.org"* ]]
# The helper opener replaces the primary one; it must not send the session
# to protocols.org, whose startup would run pulls, rsync, and inbox work.
[[ "$output" != *"protocols.org"* ]]
}
@test "--helper exports the agent id and the helper flag into the pane" {
_stub_roster 1
run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"AI_AGENT_ID=helper-"* ]]
[[ "$output" == *"AI_HELPER=1"* ]]
}
@test "--helper assigns a helper-<rand4> id" {
_stub_roster 1
run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]]
}
@test "--helper honors an id the caller already exported" {
_stub_roster 1
AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"AI_AGENT_ID=helper-beef"* ]]
}
@test "--helper sanitizes an id carrying shell metacharacters" {
_stub_roster 1
# The id is interpolated into the command typed into the pane. An id
# carrying ';' would end the assignment and run the rest as its own
# command — the helper never launches and something else does.
AI_AGENT_ID='x;touch /tmp/ai-helper-pwned' run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" != *";touch"* ]]
[[ "$output" != *" /tmp/ai-helper-pwned"* ]]
# Assert the exact surviving form too. Negative-only assertions also pass
# when the id is dropped or mangled some other way, which is how a broken
# sanitizer slipped through once already.
[[ "$output" == *"AI_AGENT_ID=x_touch__tmp_ai-helper-pwned "* ]]
}
@test "--helper sanitizes an id carrying a space" {
_stub_roster 1
AI_AGENT_ID='helper beef' run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
# A bare space would split the assignment from the command word. Anchored on
# the following space, not a substring: a substring match also accepts a
# mangled "helper_beef_", which an earlier sanitizer actually produced.
[[ "$output" == *"AI_AGENT_ID=helper_beef "* ]]
}
@test "--helper mints a fresh id rather than reusing a live one" {
_stub_roster 1
# A helper's pane exports AI_AGENT_ID, so `ai --helper` run from inside a
# helper inherits its parent's id. Reusing it lands both agents on one
# .ai/session-context.d/<id>.org — the lost-update shape helper mode exists
# to prevent.
mkdir -p "$PROJ/.ai/session-context.d"
printf 'helper-beef\n' > "$PROJ/.ai/session-context.d/helper-beef.org"
AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" != *"AI_AGENT_ID=helper-beef"* ]]
[[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]]
[[ "$output" == *"already live"* ]]
}
@test "--helper with no other agent falls back to a primary session and says so" {
_stub_roster 0
run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
# Refuted: this is the normal primary launch.
[[ "$output" == *"protocols.org"* ]]
[[ "$output" != *"helper-mode.org"* ]]
[[ "$output" != *"AI_HELPER=1"* ]]
[[ "$output" == *"no other agent"* ]]
}
@test "--helper with no roster installed still launches a helper, with a warning" {
# No stub written: an older checkout whose .ai/scripts predates agent-roster.
run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"helper-mode.org"* ]]
[[ "$output" == *"could not verify"* ]]
}
@test "--helper with an unavailable roster still launches a helper, with a warning" {
_stub_roster 2
run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"helper-mode.org"* ]]
[[ "$output" == *"could not verify"* ]]
}
@test "--helper names the host and project in the opener, as the primary does" {
_stub_roster 1
run bash "$AI" --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"$(basename "$PROJ")"* ]]
[[ "$output" == *"$(uname -n)"* ]]
}
@test "--helper composes with --runtime" {
_stub_roster 1
run bash "$AI" --runtime codex --helper --print-launch "$PROJ"
[ "$status" -eq 0 ]
[[ "$output" == *"codex "* ]]
[[ "$output" == *"helper-mode.org"* ]]
}
@test "--helper refuses a directory that is not an agent-template project" {
run bash "$AI" --helper --print-launch "$BATS_TEST_TMPDIR"
[ "$status" -eq 1 ]
[[ "$output" == *"protocols.org"* ]]
}
@test "--helper needs a project directory" {
run bash "$AI" --helper
[ "$status" -eq 2 ]
[[ "$output" == *"needs a project directory"* ]]
}
# --- pure cores ---------------------------------------------------------------
@test "_helper_launch_mode: roster found other agents (1) — launch a helper" {
run _helper_launch_mode 1
[ "$output" = helper ]
}
@test "_helper_launch_mode: roster says alone (0) — fall back to primary" {
run _helper_launch_mode 0
[ "$output" = primary ]
}
@test "_helper_launch_mode: roster unavailable (2) — helper, unverified" {
run _helper_launch_mode 2
[ "$output" = helper-unverified ]
}
@test "_helper_launch_mode: no roster script at all — helper, unverified" {
run _helper_launch_mode absent
[ "$output" = helper-unverified ]
}
@test "_resolve_helper_launch: builds the roster path from its own argument" {
_stub_roster 1
# With no `dir` in the caller's scope, a roster path built from the caller's
# variable instead of the parameter resolves to "/.ai/scripts/agent-roster",
# which isn't executable — so the gate would silently report unverified and
# every helper launch would skip its check. The bug is invisible when the
# caller happens to have its own $dir holding the same value, which both
# production callers do.
unset dir
run _resolve_helper_launch "$PROJ"
[ "$output" = helper ]
}
@test "_helper_id: shape is helper- plus four hex digits" {
run _helper_id
[[ "$output" =~ ^helper-[0-9a-f]{4}$ ]]
}
@test "_helper_id: uses the full 16 bits, not bash RANDOM's 15" {
# The shape test alone passes against `RANDOM % 65536`, which can never set
# the top bit — so every id would begin 0-7 and nothing would fail. Draw
# enough to make a genuinely 16-bit generator almost certain to show a high
# leading digit, and assert one appears.
local i high=0
for i in $(seq 1 200); do
case "$(_helper_id)" in
helper-[89abcdef]*) high=1; break ;;
esac
done
[ "$high" -eq 1 ]
}
@test "_sanitize_agent_id: keeps the safe charset and maps everything else" {
run _sanitize_agent_id 'helper-a83f'
[ "$output" = "helper-a83f" ]
run _sanitize_agent_id 'a b;c/d$e'
[ "$output" = "a_b_c_d_e" ]
run _sanitize_agent_id 'keep.dots_and-dashes'
[ "$output" = "keep.dots_and-dashes" ]
}
@test "_resolve_helper_id: a minted id also avoids a live anchor" {
# Seed every id _helper_id can produce for a stubbed generator, so the mint
# path must notice the collision rather than hand back a taken id.
mkdir -p "$PROJ/.ai/session-context.d"
_helper_id() { echo "helper-dead"; }
: > "$PROJ/.ai/session-context.d/helper-dead.org"
run _resolve_helper_id "$PROJ"
# Bounded retries mean it gives up and returns the id, but it must not have
# returned it silently on the first look — the loop ran its full bound.
[ "$status" -eq 0 ]
[ "$output" = "helper-dead" ]
}
@test "_resolve_helper_id: a free minted id is returned as-is" {
_helper_id() { echo "helper-cafe"; }
run _resolve_helper_id "$PROJ"
[ "$output" = "helper-cafe" ]
}
# --- window ordering ----------------------------------------------------------
@test "_order_windows: a helper window sorts with its project, not with others" {
local listing names out
listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha:helper-a83f\t@3\nalpha\t@4')"
names="$(printf 'alpha\nbeta')"
out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)"
[ "$out" = "zzz-other,alpha,alpha:helper-a83f,beta" ]
}
@test "_order_windows: a colon name whose prefix is not a project stays in others" {
local out
out="$(printf 'nope:helper-a83f\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)"
[ "$out" = "nope:helper-a83f" ]
}
# --- functional: the second window (private tmux socket) ----------------------
#
# The regression these guard against: single_mode focuses the project's existing
# window and returns, so routing a helper through it would hand back the PRIMARY
# session instead of opening a second one.
#
# The window-list tail (sort_windows, attach_session) is stubbed out. Both are
# already covered in the characterization suite, attaching needs a real terminal
# these tests don't have, and build_candidates legitimately returns non-zero
# under bats's errexit (bin/ai itself runs without set -e — see that file's NOTE).
_stub_window_tail() {
sort_windows() { :; }
attach_session() { :; }
export AGENT_CMD="true"
}
@test "functional helper_mode: opens a NEW window beside the project's existing one" {
_stub_roster 1
_stub_window_tail
tmux new-session -d -s ai -n "$(basename "$PROJ")" -c "$PROJ"
run helper_mode "$PROJ"
[ "$status" -eq 0 ]
names="$(tmux list-windows -t ai -F '#{window_name}')"
# The primary's window survives untouched, and a helper window joins it.
printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")"
printf '%s\n' "$names" | grep -qE "^$(basename "$PROJ"):helper-[0-9a-f]{4}$"
[ "$(printf '%s\n' "$names" | wc -l)" -eq 2 ]
}
@test "functional helper_mode: the window name carries the exported id" {
_stub_roster 1
_stub_window_tail
tmux new-session -d -s ai -n base -c "$PROJ"
AI_AGENT_ID=helper-beef run helper_mode "$PROJ"
[ "$status" -eq 0 ]
tmux list-windows -t ai -F '#{window_name}' | grep -qx "$(basename "$PROJ"):helper-beef"
}
@test "functional helper_mode: an empty roster opens the plain project window" {
_stub_roster 0
_stub_window_tail
tmux new-session -d -s ai -n base -c "$PROJ"
run helper_mode "$PROJ"
[ "$status" -eq 0 ]
names="$(tmux list-windows -t ai -F '#{window_name}')"
printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")"
! printf '%s\n' "$names" | grep -q ':helper-'
}
# --- help ---------------------------------------------------------------------
@test "usage documents --helper" {
run bash "$AI" -h
[ "$status" -eq 0 ]
[[ "$output" == *"--helper"* ]]
}
|