@os-eco/overstory-cli 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,1009 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { AgentError } from "../errors.ts";
3
+ import {
4
+ capturePaneContent,
5
+ createSession,
6
+ getDescendantPids,
7
+ getPanePid,
8
+ isProcessAlive,
9
+ isSessionAlive,
10
+ killProcessTree,
11
+ killSession,
12
+ listSessions,
13
+ sendKeys,
14
+ waitForTuiReady,
15
+ } from "./tmux.ts";
16
+
17
+ /**
18
+ * tmux tests use Bun.spawn mocks — legitimate exception to "never mock what you can use for real".
19
+ * Real tmux operations would hijack the developer's session and are unavailable in CI.
20
+ */
21
+
22
+ /**
23
+ * Helper to create a mock Bun.spawn return value.
24
+ *
25
+ * The actual code reads stdout/stderr via `new Response(proc.stdout).text()`
26
+ * and `new Response(proc.stderr).text()`, so we need ReadableStreams.
27
+ */
28
+ function mockSpawnResult(
29
+ stdout: string,
30
+ stderr: string,
31
+ exitCode: number,
32
+ ): {
33
+ stdout: ReadableStream<Uint8Array>;
34
+ stderr: ReadableStream<Uint8Array>;
35
+ exited: Promise<number>;
36
+ pid: number;
37
+ } {
38
+ return {
39
+ stdout: new Response(stdout).body as ReadableStream<Uint8Array>,
40
+ stderr: new Response(stderr).body as ReadableStream<Uint8Array>,
41
+ exited: Promise.resolve(exitCode),
42
+ pid: 12345,
43
+ };
44
+ }
45
+
46
+ describe("createSession", () => {
47
+ let spawnSpy: ReturnType<typeof spyOn>;
48
+
49
+ beforeEach(() => {
50
+ spawnSpy = spyOn(Bun, "spawn");
51
+ });
52
+
53
+ afterEach(() => {
54
+ spawnSpy.mockRestore();
55
+ });
56
+
57
+ test("creates session and returns pane PID", async () => {
58
+ let callCount = 0;
59
+ spawnSpy.mockImplementation(() => {
60
+ callCount++;
61
+ if (callCount === 1) {
62
+ // which overstory — return a bin path
63
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
64
+ }
65
+ if (callCount === 2) {
66
+ // tmux new-session
67
+ return mockSpawnResult("", "", 0);
68
+ }
69
+ // tmux list-panes -t overstory-auth -F '#{pane_pid}'
70
+ return mockSpawnResult("42\n", "", 0);
71
+ });
72
+
73
+ const pid = await createSession(
74
+ "overstory-auth",
75
+ "/repo/worktrees/auth",
76
+ "claude --task 'do work'",
77
+ );
78
+
79
+ expect(pid).toBe(42);
80
+ });
81
+
82
+ test("passes correct args to tmux new-session with PATH wrapping", async () => {
83
+ let callCount = 0;
84
+ spawnSpy.mockImplementation(() => {
85
+ callCount++;
86
+ if (callCount === 1) {
87
+ // which overstory
88
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
89
+ }
90
+ if (callCount === 2) {
91
+ return mockSpawnResult("", "", 0);
92
+ }
93
+ return mockSpawnResult("1234\n", "", 0);
94
+ });
95
+
96
+ await createSession("my-session", "/work/dir", "echo hello");
97
+
98
+ // Call 0 is 'which overstory', call 1 is 'tmux new-session'
99
+ const tmuxCallArgs = spawnSpy.mock.calls[1] as unknown[];
100
+ const cmd = tmuxCallArgs[0] as string[];
101
+ expect(cmd[0]).toBe("tmux");
102
+ expect(cmd[1]).toBe("new-session");
103
+ expect(cmd[3]).toBe("-s");
104
+ expect(cmd[4]).toBe("my-session");
105
+ expect(cmd[5]).toBe("-c");
106
+ expect(cmd[6]).toBe("/work/dir");
107
+ // The command should be wrapped with PATH export
108
+ const wrappedCmd = cmd[7] as string;
109
+ expect(wrappedCmd).toContain("echo hello");
110
+ expect(wrappedCmd).toContain("export PATH=");
111
+
112
+ const opts = tmuxCallArgs[1] as { cwd: string };
113
+ expect(opts.cwd).toBe("/work/dir");
114
+ });
115
+
116
+ test("calls list-panes after creating to get pane PID", async () => {
117
+ let callCount = 0;
118
+ spawnSpy.mockImplementation(() => {
119
+ callCount++;
120
+ if (callCount === 1) {
121
+ // which overstory
122
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
123
+ }
124
+ if (callCount === 2) {
125
+ return mockSpawnResult("", "", 0);
126
+ }
127
+ return mockSpawnResult("7777\n", "", 0);
128
+ });
129
+
130
+ await createSession("test-agent", "/tmp", "ls");
131
+
132
+ // 3 calls: which overstory, tmux new-session, tmux list-panes
133
+ expect(spawnSpy).toHaveBeenCalledTimes(3);
134
+ const thirdCallArgs = spawnSpy.mock.calls[2] as unknown[];
135
+ const cmd = thirdCallArgs[0] as string[];
136
+ expect(cmd).toEqual(["tmux", "list-panes", "-t", "test-agent", "-F", "#{pane_pid}"]);
137
+ });
138
+
139
+ test("throws AgentError if session creation fails", async () => {
140
+ let callCount = 0;
141
+ spawnSpy.mockImplementation(() => {
142
+ callCount++;
143
+ if (callCount === 1) {
144
+ // which overstory
145
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
146
+ }
147
+ return mockSpawnResult("", "duplicate session: my-session", 1);
148
+ });
149
+
150
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
151
+ });
152
+
153
+ test("throws AgentError if list-panes fails after creation", async () => {
154
+ let callCount = 0;
155
+ spawnSpy.mockImplementation(() => {
156
+ callCount++;
157
+ if (callCount === 1) {
158
+ // which overstory
159
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
160
+ }
161
+ if (callCount === 2) {
162
+ // new-session succeeds
163
+ return mockSpawnResult("", "", 0);
164
+ }
165
+ // list-panes fails
166
+ return mockSpawnResult("", "error listing panes", 1);
167
+ });
168
+
169
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
170
+ });
171
+
172
+ test("throws AgentError if pane PID output is empty", async () => {
173
+ let callCount = 0;
174
+ spawnSpy.mockImplementation(() => {
175
+ callCount++;
176
+ if (callCount === 1) {
177
+ // which overstory
178
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
179
+ }
180
+ if (callCount === 2) {
181
+ return mockSpawnResult("", "", 0);
182
+ }
183
+ // list-panes returns empty output
184
+ return mockSpawnResult("", "", 0);
185
+ });
186
+
187
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
188
+ });
189
+
190
+ test("AgentError includes session name context", async () => {
191
+ let callCount = 0;
192
+ spawnSpy.mockImplementation(() => {
193
+ callCount++;
194
+ if (callCount === 1) {
195
+ // which overstory
196
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
197
+ }
198
+ return mockSpawnResult("", "duplicate session: agent-foo", 1);
199
+ });
200
+
201
+ try {
202
+ await createSession("agent-foo", "/tmp", "ls");
203
+ expect(true).toBe(false);
204
+ } catch (err: unknown) {
205
+ expect(err).toBeInstanceOf(AgentError);
206
+ const agentErr = err as AgentError;
207
+ expect(agentErr.message).toContain("agent-foo");
208
+ expect(agentErr.agentName).toBe("agent-foo");
209
+ }
210
+ });
211
+
212
+ test("still creates session when which overstory fails (uses fallback)", async () => {
213
+ let callCount = 0;
214
+ spawnSpy.mockImplementation(() => {
215
+ callCount++;
216
+ if (callCount === 1) {
217
+ // which overstory fails
218
+ return mockSpawnResult("", "overstory not found", 1);
219
+ }
220
+ if (callCount === 2) {
221
+ // tmux new-session
222
+ return mockSpawnResult("", "", 0);
223
+ }
224
+ // tmux list-panes
225
+ return mockSpawnResult("5555\n", "", 0);
226
+ });
227
+
228
+ const pid = await createSession("fallback-agent", "/tmp", "echo test");
229
+ expect(pid).toBe(5555);
230
+
231
+ // The tmux command should contain the original command
232
+ const tmuxCallArgs = spawnSpy.mock.calls[1] as unknown[];
233
+ const cmd = tmuxCallArgs[0] as string[];
234
+ const tmuxCmd = cmd[7] as string;
235
+ expect(tmuxCmd).toContain("echo test");
236
+ });
237
+ });
238
+
239
+ describe("listSessions", () => {
240
+ let spawnSpy: ReturnType<typeof spyOn>;
241
+
242
+ beforeEach(() => {
243
+ spawnSpy = spyOn(Bun, "spawn");
244
+ });
245
+
246
+ afterEach(() => {
247
+ spawnSpy.mockRestore();
248
+ });
249
+
250
+ test("parses session list output", async () => {
251
+ spawnSpy.mockImplementation(() =>
252
+ mockSpawnResult("overstory-auth:42\noverstory-data:99\n", "", 0),
253
+ );
254
+
255
+ const sessions = await listSessions();
256
+
257
+ expect(sessions).toHaveLength(2);
258
+ expect(sessions[0]?.name).toBe("overstory-auth");
259
+ expect(sessions[0]?.pid).toBe(42);
260
+ expect(sessions[1]?.name).toBe("overstory-data");
261
+ expect(sessions[1]?.pid).toBe(99);
262
+ });
263
+
264
+ test("returns empty array when no server running", async () => {
265
+ spawnSpy.mockImplementation(() =>
266
+ mockSpawnResult("", "no server running on /tmp/tmux-501/default", 1),
267
+ );
268
+
269
+ const sessions = await listSessions();
270
+
271
+ expect(sessions).toHaveLength(0);
272
+ });
273
+
274
+ test("returns empty array when 'no sessions' in stderr", async () => {
275
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "no sessions", 1));
276
+
277
+ const sessions = await listSessions();
278
+
279
+ expect(sessions).toHaveLength(0);
280
+ });
281
+
282
+ test("throws AgentError on other tmux failures", async () => {
283
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "protocol version mismatch", 1));
284
+
285
+ await expect(listSessions()).rejects.toThrow(AgentError);
286
+ });
287
+
288
+ test("skips malformed lines", async () => {
289
+ spawnSpy.mockImplementation(() =>
290
+ mockSpawnResult("valid-session:123\nmalformed-no-colon\n:no-name\n\n", "", 0),
291
+ );
292
+
293
+ const sessions = await listSessions();
294
+
295
+ expect(sessions).toHaveLength(1);
296
+ expect(sessions[0]?.name).toBe("valid-session");
297
+ expect(sessions[0]?.pid).toBe(123);
298
+ });
299
+
300
+ test("passes correct args to tmux", async () => {
301
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
302
+
303
+ await listSessions();
304
+
305
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
306
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
307
+ const cmd = callArgs[0] as string[];
308
+ expect(cmd).toEqual(["tmux", "list-sessions", "-F", "#{session_name}:#{pid}"]);
309
+ });
310
+ });
311
+
312
+ describe("getPanePid", () => {
313
+ let spawnSpy: ReturnType<typeof spyOn>;
314
+
315
+ beforeEach(() => {
316
+ spawnSpy = spyOn(Bun, "spawn");
317
+ });
318
+
319
+ afterEach(() => {
320
+ spawnSpy.mockRestore();
321
+ });
322
+
323
+ test("returns PID from tmux display-message", async () => {
324
+ spawnSpy.mockImplementation(() => mockSpawnResult("42\n", "", 0));
325
+
326
+ const pid = await getPanePid("overstory-auth");
327
+
328
+ expect(pid).toBe(42);
329
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
330
+ const cmd = callArgs[0] as string[];
331
+ expect(cmd).toEqual(["tmux", "display-message", "-p", "-t", "overstory-auth", "#{pane_pid}"]);
332
+ });
333
+
334
+ test("returns null when session does not exist", async () => {
335
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: gone", 1));
336
+
337
+ const pid = await getPanePid("gone");
338
+
339
+ expect(pid).toBeNull();
340
+ });
341
+
342
+ test("returns null when output is empty", async () => {
343
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
344
+
345
+ const pid = await getPanePid("empty-output");
346
+
347
+ expect(pid).toBeNull();
348
+ });
349
+
350
+ test("returns null when output is not a number", async () => {
351
+ spawnSpy.mockImplementation(() => mockSpawnResult("not-a-pid\n", "", 0));
352
+
353
+ const pid = await getPanePid("bad-output");
354
+
355
+ expect(pid).toBeNull();
356
+ });
357
+ });
358
+
359
+ describe("getDescendantPids", () => {
360
+ let spawnSpy: ReturnType<typeof spyOn>;
361
+
362
+ beforeEach(() => {
363
+ spawnSpy = spyOn(Bun, "spawn");
364
+ });
365
+
366
+ afterEach(() => {
367
+ spawnSpy.mockRestore();
368
+ });
369
+
370
+ test("returns empty array when process has no children", async () => {
371
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
372
+
373
+ const pids = await getDescendantPids(100);
374
+
375
+ expect(pids).toEqual([]);
376
+ });
377
+
378
+ test("returns direct children when they have no grandchildren", async () => {
379
+ let callCount = 0;
380
+ spawnSpy.mockImplementation(() => {
381
+ callCount++;
382
+ if (callCount === 1) {
383
+ // pgrep -P 100 → children 200, 300
384
+ return mockSpawnResult("200\n300\n", "", 0);
385
+ }
386
+ // pgrep -P 200 and pgrep -P 300 → no grandchildren
387
+ return mockSpawnResult("", "", 1);
388
+ });
389
+
390
+ const pids = await getDescendantPids(100);
391
+
392
+ expect(pids).toEqual([200, 300]);
393
+ });
394
+
395
+ test("returns descendants in depth-first order (deepest first)", async () => {
396
+ // Tree: 100 → 200 → 400
397
+ // → 300
398
+ let callCount = 0;
399
+ spawnSpy.mockImplementation(() => {
400
+ callCount++;
401
+ if (callCount === 1) {
402
+ // pgrep -P 100 → children 200, 300
403
+ return mockSpawnResult("200\n300\n", "", 0);
404
+ }
405
+ if (callCount === 2) {
406
+ // pgrep -P 200 → child 400
407
+ return mockSpawnResult("400\n", "", 0);
408
+ }
409
+ if (callCount === 3) {
410
+ // pgrep -P 400 → no children
411
+ return mockSpawnResult("", "", 1);
412
+ }
413
+ // pgrep -P 300 → no children
414
+ return mockSpawnResult("", "", 1);
415
+ });
416
+
417
+ const pids = await getDescendantPids(100);
418
+
419
+ // Deepest-first: 400 (grandchild), then 200, 300 (direct children)
420
+ expect(pids).toEqual([400, 200, 300]);
421
+ });
422
+
423
+ test("handles deeply nested tree", async () => {
424
+ // Tree: 1 → 2 → 3 → 4
425
+ let callCount = 0;
426
+ spawnSpy.mockImplementation(() => {
427
+ callCount++;
428
+ if (callCount === 1) {
429
+ // pgrep -P 1 → 2
430
+ return mockSpawnResult("2\n", "", 0);
431
+ }
432
+ if (callCount === 2) {
433
+ // pgrep -P 2 → 3
434
+ return mockSpawnResult("3\n", "", 0);
435
+ }
436
+ if (callCount === 3) {
437
+ // pgrep -P 3 → 4
438
+ return mockSpawnResult("4\n", "", 0);
439
+ }
440
+ // pgrep -P 4 → no children
441
+ return mockSpawnResult("", "", 1);
442
+ });
443
+
444
+ const pids = await getDescendantPids(1);
445
+
446
+ // Deepest-first: 4, 3, 2
447
+ expect(pids).toEqual([4, 3, 2]);
448
+ });
449
+
450
+ test("skips non-numeric pgrep output lines", async () => {
451
+ spawnSpy.mockImplementation((...args: unknown[]) => {
452
+ const cmd = (args[0] as string[])[2];
453
+ if (cmd === "100") {
454
+ return mockSpawnResult("200\nnot-a-pid\n300\n", "", 0);
455
+ }
456
+ return mockSpawnResult("", "", 1);
457
+ });
458
+
459
+ const pids = await getDescendantPids(100);
460
+
461
+ expect(pids).toEqual([200, 300]);
462
+ });
463
+ });
464
+
465
+ describe("isProcessAlive", () => {
466
+ test("returns true for current process (self-check)", () => {
467
+ // process.pid is always alive
468
+ expect(isProcessAlive(process.pid)).toBe(true);
469
+ });
470
+
471
+ test("returns false for a non-existent PID", () => {
472
+ // PID 2147483647 (max int32) is extremely unlikely to exist
473
+ expect(isProcessAlive(2147483647)).toBe(false);
474
+ });
475
+ });
476
+
477
+ describe("killProcessTree", () => {
478
+ let spawnSpy: ReturnType<typeof spyOn>;
479
+ let killSpy: ReturnType<typeof spyOn>;
480
+
481
+ beforeEach(() => {
482
+ spawnSpy = spyOn(Bun, "spawn");
483
+ killSpy = spyOn(process, "kill");
484
+ });
485
+
486
+ afterEach(() => {
487
+ spawnSpy.mockRestore();
488
+ killSpy.mockRestore();
489
+ });
490
+
491
+ test("sends SIGTERM to root when no descendants", async () => {
492
+ // pgrep -P 100 → no children
493
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
494
+ killSpy.mockImplementation(() => true);
495
+
496
+ await killProcessTree(100, 0);
497
+
498
+ expect(killSpy).toHaveBeenCalledWith(100, "SIGTERM");
499
+ });
500
+
501
+ test("sends SIGTERM deepest-first then SIGKILL survivors", async () => {
502
+ // Tree: 100 → 200 → 300
503
+ let pgrepCallCount = 0;
504
+ spawnSpy.mockImplementation(() => {
505
+ pgrepCallCount++;
506
+ if (pgrepCallCount === 1) {
507
+ // pgrep -P 100 → 200
508
+ return mockSpawnResult("200\n", "", 0);
509
+ }
510
+ if (pgrepCallCount === 2) {
511
+ // pgrep -P 200 → 300
512
+ return mockSpawnResult("300\n", "", 0);
513
+ }
514
+ // pgrep -P 300 → no children
515
+ return mockSpawnResult("", "", 1);
516
+ });
517
+
518
+ const signals: Array<{ pid: number; signal: string }> = [];
519
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
520
+ signals.push({ pid, signal: String(signal) });
521
+ return true;
522
+ });
523
+
524
+ await killProcessTree(100, 0);
525
+
526
+ // Phase 1 (SIGTERM): deepest-first → 300, 200, then root 100
527
+ // Phase 2 (SIGKILL): isProcessAlive check (signal 0), then SIGKILL for survivors
528
+ const sigterms = signals.filter((s) => s.signal === "SIGTERM");
529
+ expect(sigterms).toEqual([
530
+ { pid: 300, signal: "SIGTERM" },
531
+ { pid: 200, signal: "SIGTERM" },
532
+ { pid: 100, signal: "SIGTERM" },
533
+ ]);
534
+ });
535
+
536
+ test("sends SIGKILL to survivors after grace period", async () => {
537
+ // Tree: 100 → 200 (no grandchildren)
538
+ let pgrepCallCount = 0;
539
+ spawnSpy.mockImplementation(() => {
540
+ pgrepCallCount++;
541
+ if (pgrepCallCount === 1) {
542
+ return mockSpawnResult("200\n", "", 0);
543
+ }
544
+ return mockSpawnResult("", "", 1);
545
+ });
546
+
547
+ const signals: Array<{ pid: number; signal: string | number }> = [];
548
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
549
+ signals.push({ pid, signal });
550
+ // signal 0 is the isProcessAlive check — simulate processes still alive
551
+ return true;
552
+ });
553
+
554
+ await killProcessTree(100, 10); // 10ms grace period for test speed
555
+
556
+ // Should have: SIGTERM(200), SIGTERM(100), alive-check(200), SIGKILL(200),
557
+ // alive-check(100), SIGKILL(100)
558
+ const sigkills = signals.filter((s) => s.signal === "SIGKILL");
559
+ expect(sigkills.length).toBe(2);
560
+ expect(sigkills[0]).toEqual({ pid: 200, signal: "SIGKILL" });
561
+ expect(sigkills[1]).toEqual({ pid: 100, signal: "SIGKILL" });
562
+ });
563
+
564
+ test("skips SIGKILL for processes that died during grace period", async () => {
565
+ // No children
566
+ spawnSpy.mockImplementation(() => mockSpawnResult("200\n", "", 0));
567
+ // First call for pgrep children of 200
568
+ let pgrepCallCount = 0;
569
+ spawnSpy.mockImplementation(() => {
570
+ pgrepCallCount++;
571
+ if (pgrepCallCount === 1) {
572
+ return mockSpawnResult("200\n", "", 0);
573
+ }
574
+ return mockSpawnResult("", "", 1);
575
+ });
576
+
577
+ const signals: Array<{ pid: number; signal: string | number }> = [];
578
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
579
+ signals.push({ pid, signal });
580
+ // signal 0 (isProcessAlive) — processes are dead
581
+ if (signal === 0) {
582
+ throw new Error("ESRCH");
583
+ }
584
+ return true;
585
+ });
586
+
587
+ await killProcessTree(100, 10);
588
+
589
+ // Should have SIGTERM calls but no SIGKILL (processes died)
590
+ const sigkills = signals.filter((s) => s.signal === "SIGKILL");
591
+ expect(sigkills).toEqual([]);
592
+ });
593
+
594
+ test("silently handles SIGTERM errors for already-dead processes", async () => {
595
+ // No children
596
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
597
+
598
+ killSpy.mockImplementation(() => {
599
+ throw new Error("ESRCH: No such process");
600
+ });
601
+
602
+ // Should not throw
603
+ await killProcessTree(100, 0);
604
+ });
605
+ });
606
+
607
+ describe("killSession", () => {
608
+ let spawnSpy: ReturnType<typeof spyOn>;
609
+ let killSpy: ReturnType<typeof spyOn>;
610
+
611
+ beforeEach(() => {
612
+ spawnSpy = spyOn(Bun, "spawn");
613
+ killSpy = spyOn(process, "kill");
614
+ });
615
+
616
+ afterEach(() => {
617
+ spawnSpy.mockRestore();
618
+ killSpy.mockRestore();
619
+ });
620
+
621
+ test("gets pane PID, kills process tree, then kills tmux session", async () => {
622
+ const cmds: string[][] = [];
623
+ spawnSpy.mockImplementation((...args: unknown[]) => {
624
+ const cmd = args[0] as string[];
625
+ cmds.push(cmd);
626
+
627
+ if (cmd[0] === "tmux" && cmd[1] === "display-message") {
628
+ // getPanePid → returns PID 500
629
+ return mockSpawnResult("500\n", "", 0);
630
+ }
631
+ if (cmd[0] === "pgrep") {
632
+ // getDescendantPids → no children
633
+ return mockSpawnResult("", "", 1);
634
+ }
635
+ if (cmd[0] === "tmux" && cmd[1] === "kill-session") {
636
+ return mockSpawnResult("", "", 0);
637
+ }
638
+ return mockSpawnResult("", "", 0);
639
+ });
640
+
641
+ killSpy.mockImplementation(() => true);
642
+
643
+ await killSession("overstory-auth");
644
+
645
+ // Should have called: tmux display-message, pgrep, tmux kill-session
646
+ expect(cmds[0]).toEqual([
647
+ "tmux",
648
+ "display-message",
649
+ "-p",
650
+ "-t",
651
+ "overstory-auth",
652
+ "#{pane_pid}",
653
+ ]);
654
+ expect(cmds[1]).toEqual(["pgrep", "-P", "500"]);
655
+ const lastCmd = cmds[cmds.length - 1];
656
+ expect(lastCmd).toEqual(["tmux", "kill-session", "-t", "overstory-auth"]);
657
+
658
+ // Should have sent SIGTERM to root PID 500
659
+ expect(killSpy).toHaveBeenCalledWith(500, "SIGTERM");
660
+ });
661
+
662
+ test("skips process cleanup when pane PID is not available", async () => {
663
+ const cmds: string[][] = [];
664
+ spawnSpy.mockImplementation((...args: unknown[]) => {
665
+ const cmd = args[0] as string[];
666
+ cmds.push(cmd);
667
+
668
+ if (cmd[0] === "tmux" && cmd[1] === "display-message") {
669
+ // getPanePid → session not found
670
+ return mockSpawnResult("", "can't find session", 1);
671
+ }
672
+ if (cmd[0] === "tmux" && cmd[1] === "kill-session") {
673
+ return mockSpawnResult("", "", 0);
674
+ }
675
+ return mockSpawnResult("", "", 0);
676
+ });
677
+
678
+ await killSession("overstory-auth");
679
+
680
+ // Should go straight to tmux kill-session (no pgrep calls)
681
+ expect(cmds).toHaveLength(2);
682
+ expect(cmds[0]?.[1]).toBe("display-message");
683
+ expect(cmds[1]?.[1]).toBe("kill-session");
684
+ // No process.kill calls since we had no PID
685
+ expect(killSpy).not.toHaveBeenCalled();
686
+ });
687
+
688
+ test("succeeds silently when session is already gone after process cleanup", async () => {
689
+ spawnSpy.mockImplementation((...args: unknown[]) => {
690
+ const cmd = args[0] as string[];
691
+ if (cmd[0] === "tmux" && cmd[1] === "display-message") {
692
+ return mockSpawnResult("500\n", "", 0);
693
+ }
694
+ if (cmd[0] === "pgrep") {
695
+ return mockSpawnResult("", "", 1);
696
+ }
697
+ if (cmd[0] === "tmux" && cmd[1] === "kill-session") {
698
+ // Session already gone after process cleanup
699
+ return mockSpawnResult("", "can't find session: overstory-auth", 1);
700
+ }
701
+ return mockSpawnResult("", "", 0);
702
+ });
703
+
704
+ killSpy.mockImplementation(() => true);
705
+
706
+ // Should not throw — session disappearing is expected
707
+ await killSession("overstory-auth");
708
+ });
709
+
710
+ test("throws AgentError on unexpected tmux kill-session failure", async () => {
711
+ spawnSpy.mockImplementation((...args: unknown[]) => {
712
+ const cmd = args[0] as string[];
713
+ if (cmd[0] === "tmux" && cmd[1] === "display-message") {
714
+ return mockSpawnResult("", "can't find session", 1);
715
+ }
716
+ if (cmd[0] === "tmux" && cmd[1] === "kill-session") {
717
+ return mockSpawnResult("", "server exited unexpectedly", 1);
718
+ }
719
+ return mockSpawnResult("", "", 0);
720
+ });
721
+
722
+ await expect(killSession("broken-session")).rejects.toThrow(AgentError);
723
+ });
724
+
725
+ test("AgentError contains session name on failure", async () => {
726
+ spawnSpy.mockImplementation((...args: unknown[]) => {
727
+ const cmd = args[0] as string[];
728
+ if (cmd[0] === "tmux" && cmd[1] === "display-message") {
729
+ return mockSpawnResult("", "error", 1);
730
+ }
731
+ if (cmd[0] === "tmux" && cmd[1] === "kill-session") {
732
+ return mockSpawnResult("", "server exited unexpectedly", 1);
733
+ }
734
+ return mockSpawnResult("", "", 0);
735
+ });
736
+
737
+ try {
738
+ await killSession("ghost-agent");
739
+ expect(true).toBe(false);
740
+ } catch (err: unknown) {
741
+ expect(err).toBeInstanceOf(AgentError);
742
+ const agentErr = err as AgentError;
743
+ expect(agentErr.message).toContain("ghost-agent");
744
+ expect(agentErr.agentName).toBe("ghost-agent");
745
+ }
746
+ });
747
+ });
748
+
749
+ describe("isSessionAlive", () => {
750
+ let spawnSpy: ReturnType<typeof spyOn>;
751
+
752
+ beforeEach(() => {
753
+ spawnSpy = spyOn(Bun, "spawn");
754
+ });
755
+
756
+ afterEach(() => {
757
+ spawnSpy.mockRestore();
758
+ });
759
+
760
+ test("returns true when session exists (exit 0)", async () => {
761
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
762
+
763
+ const alive = await isSessionAlive("overstory-auth");
764
+
765
+ expect(alive).toBe(true);
766
+ });
767
+
768
+ test("returns false when session does not exist (non-zero exit)", async () => {
769
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: nonexistent", 1));
770
+
771
+ const alive = await isSessionAlive("nonexistent");
772
+
773
+ expect(alive).toBe(false);
774
+ });
775
+
776
+ test("passes correct args to tmux has-session", async () => {
777
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
778
+
779
+ await isSessionAlive("my-agent");
780
+
781
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
782
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
783
+ const cmd = callArgs[0] as string[];
784
+ expect(cmd).toEqual(["tmux", "has-session", "-t", "my-agent"]);
785
+ });
786
+ });
787
+
788
+ describe("sendKeys", () => {
789
+ let spawnSpy: ReturnType<typeof spyOn>;
790
+
791
+ beforeEach(() => {
792
+ spawnSpy = spyOn(Bun, "spawn");
793
+ });
794
+
795
+ afterEach(() => {
796
+ spawnSpy.mockRestore();
797
+ });
798
+
799
+ test("passes correct args to tmux send-keys", async () => {
800
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
801
+
802
+ await sendKeys("overstory-auth", "echo hello world");
803
+
804
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
805
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
806
+ const cmd = callArgs[0] as string[];
807
+ expect(cmd).toEqual(["tmux", "send-keys", "-t", "overstory-auth", "echo hello world", "Enter"]);
808
+ });
809
+
810
+ test("flattens newlines in keys to spaces", async () => {
811
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
812
+
813
+ await sendKeys("overstory-agent", "line1\nline2\nline3");
814
+
815
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
816
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
817
+ const cmd = callArgs[0] as string[];
818
+ expect(cmd).toEqual([
819
+ "tmux",
820
+ "send-keys",
821
+ "-t",
822
+ "overstory-agent",
823
+ "line1 line2 line3",
824
+ "Enter",
825
+ ]);
826
+ });
827
+
828
+ test("throws AgentError on failure", async () => {
829
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found: dead-agent", 1));
830
+
831
+ await expect(sendKeys("dead-agent", "echo test")).rejects.toThrow(AgentError);
832
+ });
833
+
834
+ test("AgentError contains session name on failure", async () => {
835
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found: my-agent", 1));
836
+
837
+ try {
838
+ await sendKeys("my-agent", "test command");
839
+ expect(true).toBe(false);
840
+ } catch (err: unknown) {
841
+ expect(err).toBeInstanceOf(AgentError);
842
+ const agentErr = err as AgentError;
843
+ expect(agentErr.message).toContain("my-agent");
844
+ expect(agentErr.agentName).toBe("my-agent");
845
+ }
846
+ });
847
+
848
+ test("sends Enter with empty string (follow-up submission)", async () => {
849
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
850
+
851
+ await sendKeys("overstory-agent", "");
852
+
853
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
854
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
855
+ const cmd = callArgs[0] as string[];
856
+ expect(cmd).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
857
+ });
858
+
859
+ test("throws descriptive error when tmux server is not running", async () => {
860
+ spawnSpy.mockImplementation(() =>
861
+ mockSpawnResult("", "no server running on /tmp/tmux-0/default\n", 1),
862
+ );
863
+ await expect(sendKeys("overstory-agent-fake", "hello")).rejects.toThrow(
864
+ /Tmux server is not running/,
865
+ );
866
+ });
867
+
868
+ test("throws descriptive error when session not found", async () => {
869
+ spawnSpy.mockImplementation(() =>
870
+ mockSpawnResult("", "cant find session: overstory-agent-fake\n", 1),
871
+ );
872
+ await expect(sendKeys("overstory-agent-fake", "hello")).rejects.toThrow(/does not exist/);
873
+ });
874
+
875
+ test("throws generic error for other failures", async () => {
876
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "some other error\n", 1));
877
+ await expect(sendKeys("overstory-agent-fake", "hello")).rejects.toThrow(/Failed to send keys/);
878
+ });
879
+ });
880
+
881
+ describe("capturePaneContent", () => {
882
+ let spawnSpy: ReturnType<typeof spyOn>;
883
+
884
+ beforeEach(() => {
885
+ spawnSpy = spyOn(Bun, "spawn");
886
+ });
887
+
888
+ afterEach(() => {
889
+ spawnSpy.mockRestore();
890
+ });
891
+
892
+ test("returns trimmed content on success", async () => {
893
+ spawnSpy.mockImplementation(() => mockSpawnResult(" Welcome to Claude Code! \n\n", "", 0));
894
+
895
+ const content = await capturePaneContent("overstory-agent");
896
+
897
+ expect(content).toBe("Welcome to Claude Code!");
898
+ });
899
+
900
+ test("passes correct args to tmux capture-pane", async () => {
901
+ spawnSpy.mockImplementation(() => mockSpawnResult("some content", "", 0));
902
+
903
+ await capturePaneContent("my-session", 100);
904
+
905
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
906
+ const cmd = callArgs[0] as string[];
907
+ expect(cmd).toEqual(["tmux", "capture-pane", "-t", "my-session", "-p", "-S", "-100"]);
908
+ });
909
+
910
+ test("uses default 50 lines when not specified", async () => {
911
+ spawnSpy.mockImplementation(() => mockSpawnResult("content", "", 0));
912
+
913
+ await capturePaneContent("my-session");
914
+
915
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
916
+ const cmd = callArgs[0] as string[];
917
+ expect(cmd[6]).toBe("-50");
918
+ });
919
+
920
+ test("returns null when capture-pane fails", async () => {
921
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: gone", 1));
922
+
923
+ const content = await capturePaneContent("gone");
924
+
925
+ expect(content).toBeNull();
926
+ });
927
+
928
+ test("returns null when pane is empty (whitespace only)", async () => {
929
+ spawnSpy.mockImplementation(() => mockSpawnResult(" \n\n \n", "", 0));
930
+
931
+ const content = await capturePaneContent("empty-pane");
932
+
933
+ expect(content).toBeNull();
934
+ });
935
+ });
936
+
937
+ describe("waitForTuiReady", () => {
938
+ let spawnSpy: ReturnType<typeof spyOn>;
939
+ let sleepSpy: ReturnType<typeof spyOn>;
940
+
941
+ beforeEach(() => {
942
+ spawnSpy = spyOn(Bun, "spawn");
943
+ // Mock Bun.sleep to avoid real delays in tests.
944
+ // Cast needed because Bun.sleep has overloads that confuse spyOn's type inference.
945
+ sleepSpy = spyOn(Bun as Record<string, unknown>, "sleep").mockResolvedValue(undefined);
946
+ });
947
+
948
+ afterEach(() => {
949
+ spawnSpy.mockRestore();
950
+ sleepSpy.mockRestore();
951
+ });
952
+
953
+ test("returns true immediately when pane has content on first poll", async () => {
954
+ spawnSpy.mockImplementation(() => mockSpawnResult("Claude Code ready", "", 0));
955
+
956
+ const ready = await waitForTuiReady("overstory-agent", 5_000, 500);
957
+
958
+ expect(ready).toBe(true);
959
+ // Should not have needed to sleep (content found on first poll)
960
+ expect(sleepSpy).not.toHaveBeenCalled();
961
+ });
962
+
963
+ test("returns true after content appears on later poll", async () => {
964
+ let callCount = 0;
965
+ spawnSpy.mockImplementation(() => {
966
+ callCount++;
967
+ if (callCount <= 3) {
968
+ // First 3 polls: empty pane (TUI still loading)
969
+ return mockSpawnResult("", "", 0);
970
+ }
971
+ // 4th poll: content appears
972
+ return mockSpawnResult("Welcome to Claude Code!", "", 0);
973
+ });
974
+
975
+ const ready = await waitForTuiReady("overstory-agent", 10_000, 500);
976
+
977
+ expect(ready).toBe(true);
978
+ // Should have slept 3 times (3 empty polls before content appeared)
979
+ expect(sleepSpy).toHaveBeenCalledTimes(3);
980
+ });
981
+
982
+ test("returns false when timeout expires without content", async () => {
983
+ // Pane always empty
984
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
985
+
986
+ const ready = await waitForTuiReady("overstory-agent", 2_000, 500);
987
+
988
+ expect(ready).toBe(false);
989
+ // 2000ms / 500ms = 4 polls, 4 sleeps
990
+ expect(sleepSpy).toHaveBeenCalledTimes(4);
991
+ });
992
+
993
+ test("returns false when capture-pane always fails", async () => {
994
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found", 1));
995
+
996
+ const ready = await waitForTuiReady("dead-session", 1_000, 500);
997
+
998
+ expect(ready).toBe(false);
999
+ });
1000
+
1001
+ test("uses default timeout and poll interval", async () => {
1002
+ // Return content immediately
1003
+ spawnSpy.mockImplementation(() => mockSpawnResult("ready", "", 0));
1004
+
1005
+ const ready = await waitForTuiReady("overstory-agent");
1006
+
1007
+ expect(ready).toBe(true);
1008
+ });
1009
+ });