@katyella/legio 0.1.0

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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,1238 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
+ import { EventEmitter } from "node:events";
4
+ import { mkdir, readFile, stat } from "node:fs/promises";
5
+ import { PassThrough } from "node:stream";
6
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
7
+ import { AgentError } from "../errors.ts";
8
+ import {
9
+ capturePaneContent,
10
+ createSession,
11
+ getDescendantPids,
12
+ getPanePid,
13
+ hasTuiMarkers,
14
+ isProcessAlive,
15
+ isSessionAlive,
16
+ killProcessTree,
17
+ killSession,
18
+ listSessions,
19
+ readTerminalLog,
20
+ sendKeys,
21
+ startPipePane,
22
+ stopPipePane,
23
+ waitForTuiReady,
24
+ } from "./tmux.ts";
25
+
26
+ /**
27
+ * tmux tests use child_process mocks — legitimate exception to "never mock what you can use for real".
28
+ * Real tmux operations would hijack the developer's session and are unavailable in CI.
29
+ * fs/promises is mocked because readTerminalLog reads actual files; using temp files would be
30
+ * slower and less isolated for unit tests of the tailing logic.
31
+ */
32
+
33
+ vi.mock("node:child_process", () => ({
34
+ spawn: vi.fn(),
35
+ }));
36
+
37
+ vi.mock("node:fs/promises", () => ({
38
+ mkdir: vi.fn(),
39
+ readFile: vi.fn(),
40
+ stat: vi.fn(),
41
+ }));
42
+
43
+ const mockSpawn = vi.mocked(spawn);
44
+ const mockMkdir = vi.mocked(mkdir);
45
+ const mockReadFile = vi.mocked(readFile);
46
+ const mockStat = vi.mocked(stat);
47
+
48
+ /**
49
+ * Helper to create a mock ChildProcess return value.
50
+ *
51
+ * Creates an EventEmitter with stdout/stderr PassThrough streams that
52
+ * emit data and close events asynchronously via process.nextTick.
53
+ */
54
+ function createMockProcess(stdout: string, stderr: string, exitCode: number): ChildProcess {
55
+ const proc = new EventEmitter();
56
+ const stdoutStream = new PassThrough();
57
+ const stderrStream = new PassThrough();
58
+ Object.assign(proc, {
59
+ stdout: stdoutStream,
60
+ stderr: stderrStream,
61
+ stdin: null,
62
+ pid: 12345,
63
+ kill: vi.fn(),
64
+ });
65
+ process.nextTick(() => {
66
+ if (stdout) stdoutStream.push(Buffer.from(stdout));
67
+ stdoutStream.push(null);
68
+ if (stderr) stderrStream.push(Buffer.from(stderr));
69
+ stderrStream.push(null);
70
+ proc.emit("close", exitCode);
71
+ });
72
+ return proc as unknown as ChildProcess;
73
+ }
74
+
75
+ describe("createSession", () => {
76
+ beforeEach(() => {
77
+ mockSpawn.mockReset();
78
+ });
79
+
80
+ test("creates session and returns pane PID", async () => {
81
+ let callCount = 0;
82
+ mockSpawn.mockImplementation(() => {
83
+ callCount++;
84
+ if (callCount === 1) {
85
+ // which legio — return a bin path
86
+ return createMockProcess("/usr/local/bin/legio\n", "", 0);
87
+ }
88
+ if (callCount === 2) {
89
+ // tmux new-session
90
+ return createMockProcess("", "", 0);
91
+ }
92
+ // tmux list-panes -t legio-auth -F '#{pane_pid}'
93
+ return createMockProcess("42\n", "", 0);
94
+ });
95
+
96
+ const pid = await createSession(
97
+ "legio-auth",
98
+ "/repo/worktrees/auth",
99
+ "claude --task 'do work'",
100
+ );
101
+
102
+ expect(pid).toBe(42);
103
+ });
104
+
105
+ test("passes correct args to tmux new-session with PATH wrapping", async () => {
106
+ let callCount = 0;
107
+ mockSpawn.mockImplementation(() => {
108
+ callCount++;
109
+ if (callCount === 1) {
110
+ // which legio
111
+ return createMockProcess("/usr/local/bin/legio\n", "", 0);
112
+ }
113
+ if (callCount === 2) {
114
+ return createMockProcess("", "", 0);
115
+ }
116
+ return createMockProcess("1234\n", "", 0);
117
+ });
118
+
119
+ await createSession("my-session", "/work/dir", "echo hello");
120
+
121
+ // Call 0 is 'which legio', call 1 is 'tmux new-session'
122
+ const tmuxCallArgs = mockSpawn.mock.calls[1] as unknown[];
123
+ const command = tmuxCallArgs[0] as string;
124
+ const args = tmuxCallArgs[1] as string[];
125
+ expect(command).toBe("tmux");
126
+ expect(args[0]).toBe("new-session");
127
+ expect(args).toContain("-s");
128
+ expect(args).toContain("my-session");
129
+ expect(args).toContain("-c");
130
+ expect(args).toContain("/work/dir");
131
+ // The command is the last arg, wrapped with unset prefix
132
+ const wrappedCmd = args[args.length - 1] as string;
133
+ expect(wrappedCmd).toContain("echo hello");
134
+ // PATH is injected via -e flag, not shell export
135
+ expect(args).toContain("-e");
136
+ const eIndex = args.indexOf("-e");
137
+ const pathArg = args[eIndex + 1] as string;
138
+ expect(pathArg).toMatch(/^PATH=.*\/usr\/local\/bin/);
139
+
140
+ const opts = tmuxCallArgs[2] as { cwd: string };
141
+ expect(opts.cwd).toBe("/work/dir");
142
+ });
143
+
144
+ test("calls list-panes after creating to get pane PID", async () => {
145
+ let callCount = 0;
146
+ mockSpawn.mockImplementation(() => {
147
+ callCount++;
148
+ if (callCount === 1) {
149
+ // which legio
150
+ return createMockProcess("/usr/local/bin/legio\n", "", 0);
151
+ }
152
+ if (callCount === 2) {
153
+ return createMockProcess("", "", 0);
154
+ }
155
+ return createMockProcess("7777\n", "", 0);
156
+ });
157
+
158
+ await createSession("test-agent", "/tmp", "ls");
159
+
160
+ // 3 calls: which legio, tmux new-session, tmux list-panes
161
+ expect(mockSpawn).toHaveBeenCalledTimes(3);
162
+ const thirdCallArgs = mockSpawn.mock.calls[2] as unknown[];
163
+ const command = thirdCallArgs[0] as string;
164
+ const args = thirdCallArgs[1] as string[];
165
+ expect([command, ...args]).toEqual([
166
+ "tmux",
167
+ "list-panes",
168
+ "-t",
169
+ "test-agent",
170
+ "-F",
171
+ "#{pane_pid}",
172
+ ]);
173
+ });
174
+
175
+ test("throws AgentError if session creation fails", async () => {
176
+ let callCount = 0;
177
+ mockSpawn.mockImplementation(() => {
178
+ callCount++;
179
+ if (callCount === 1) {
180
+ // which legio
181
+ return createMockProcess("/usr/local/bin/legio\n", "", 0);
182
+ }
183
+ return createMockProcess("", "duplicate session: my-session", 1);
184
+ });
185
+
186
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
187
+ });
188
+
189
+ test("throws AgentError if list-panes fails after creation", async () => {
190
+ let callCount = 0;
191
+ mockSpawn.mockImplementation(() => {
192
+ callCount++;
193
+ if (callCount === 1) {
194
+ // which legio
195
+ return createMockProcess("/usr/local/bin/legio\n", "", 0);
196
+ }
197
+ if (callCount === 2) {
198
+ // new-session succeeds
199
+ return createMockProcess("", "", 0);
200
+ }
201
+ // list-panes fails
202
+ return createMockProcess("", "error listing panes", 1);
203
+ });
204
+
205
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
206
+ });
207
+
208
+ test("throws AgentError if pane PID output is empty", async () => {
209
+ let callCount = 0;
210
+ mockSpawn.mockImplementation(() => {
211
+ callCount++;
212
+ if (callCount === 1) {
213
+ // which legio
214
+ return createMockProcess("/usr/local/bin/legio\n", "", 0);
215
+ }
216
+ if (callCount === 2) {
217
+ return createMockProcess("", "", 0);
218
+ }
219
+ // list-panes returns empty output
220
+ return createMockProcess("", "", 0);
221
+ });
222
+
223
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
224
+ });
225
+
226
+ test("AgentError includes session name context", async () => {
227
+ let callCount = 0;
228
+ mockSpawn.mockImplementation(() => {
229
+ callCount++;
230
+ if (callCount === 1) {
231
+ // which legio
232
+ return createMockProcess("/usr/local/bin/legio\n", "", 0);
233
+ }
234
+ return createMockProcess("", "duplicate session: agent-foo", 1);
235
+ });
236
+
237
+ try {
238
+ await createSession("agent-foo", "/tmp", "ls");
239
+ expect(true).toBe(false);
240
+ } catch (err: unknown) {
241
+ expect(err).toBeInstanceOf(AgentError);
242
+ const agentErr = err as AgentError;
243
+ expect(agentErr.message).toContain("agent-foo");
244
+ expect(agentErr.agentName).toBe("agent-foo");
245
+ }
246
+ });
247
+
248
+ test("still creates session when which legio fails (uses fallback)", async () => {
249
+ let callCount = 0;
250
+ mockSpawn.mockImplementation(() => {
251
+ callCount++;
252
+ if (callCount === 1) {
253
+ // which legio fails
254
+ return createMockProcess("", "legio not found", 1);
255
+ }
256
+ if (callCount === 2) {
257
+ // tmux new-session
258
+ return createMockProcess("", "", 0);
259
+ }
260
+ // tmux list-panes
261
+ return createMockProcess("5555\n", "", 0);
262
+ });
263
+
264
+ const pid = await createSession("fallback-agent", "/tmp", "echo test");
265
+ expect(pid).toBe(5555);
266
+
267
+ // The tmux command (last arg) should contain the original command
268
+ const tmuxCallArgs = mockSpawn.mock.calls[1] as unknown[];
269
+ const args = tmuxCallArgs[1] as string[];
270
+ const tmuxCmd = args[args.length - 1] as string;
271
+ expect(tmuxCmd).toContain("echo test");
272
+ });
273
+ });
274
+
275
+ describe("listSessions", () => {
276
+ beforeEach(() => {
277
+ mockSpawn.mockReset();
278
+ });
279
+
280
+ test("parses session list output", async () => {
281
+ mockSpawn.mockImplementation(() => createMockProcess("legio-auth:42\nlegio-data:99\n", "", 0));
282
+
283
+ const sessions = await listSessions();
284
+
285
+ expect(sessions).toHaveLength(2);
286
+ expect(sessions[0]?.name).toBe("legio-auth");
287
+ expect(sessions[0]?.pid).toBe(42);
288
+ expect(sessions[1]?.name).toBe("legio-data");
289
+ expect(sessions[1]?.pid).toBe(99);
290
+ });
291
+
292
+ test("returns empty array when no server running", async () => {
293
+ mockSpawn.mockImplementation(() =>
294
+ createMockProcess("", "no server running on /tmp/tmux-501/default", 1),
295
+ );
296
+
297
+ const sessions = await listSessions();
298
+
299
+ expect(sessions).toHaveLength(0);
300
+ });
301
+
302
+ test("returns empty array when 'no sessions' in stderr", async () => {
303
+ mockSpawn.mockImplementation(() => createMockProcess("", "no sessions", 1));
304
+
305
+ const sessions = await listSessions();
306
+
307
+ expect(sessions).toHaveLength(0);
308
+ });
309
+
310
+ test("throws AgentError on other tmux failures", async () => {
311
+ mockSpawn.mockImplementation(() => createMockProcess("", "protocol version mismatch", 1));
312
+
313
+ await expect(listSessions()).rejects.toThrow(AgentError);
314
+ });
315
+
316
+ test("skips malformed lines", async () => {
317
+ mockSpawn.mockImplementation(() =>
318
+ createMockProcess("valid-session:123\nmalformed-no-colon\n:no-name\n\n", "", 0),
319
+ );
320
+
321
+ const sessions = await listSessions();
322
+
323
+ expect(sessions).toHaveLength(1);
324
+ expect(sessions[0]?.name).toBe("valid-session");
325
+ expect(sessions[0]?.pid).toBe(123);
326
+ });
327
+
328
+ test("passes correct args to tmux", async () => {
329
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
330
+
331
+ await listSessions();
332
+
333
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
334
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
335
+ const command = callArgs[0] as string;
336
+ const args = callArgs[1] as string[];
337
+ expect([command, ...args]).toEqual(["tmux", "list-sessions", "-F", "#{session_name}:#{pid}"]);
338
+ });
339
+ });
340
+
341
+ describe("getPanePid", () => {
342
+ beforeEach(() => {
343
+ mockSpawn.mockReset();
344
+ });
345
+
346
+ test("returns PID from tmux display-message", async () => {
347
+ mockSpawn.mockImplementation(() => createMockProcess("42\n", "", 0));
348
+
349
+ const pid = await getPanePid("legio-auth");
350
+
351
+ expect(pid).toBe(42);
352
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
353
+ const command = callArgs[0] as string;
354
+ const args = callArgs[1] as string[];
355
+ expect([command, ...args]).toEqual([
356
+ "tmux",
357
+ "display-message",
358
+ "-p",
359
+ "-t",
360
+ "legio-auth",
361
+ "#{pane_pid}",
362
+ ]);
363
+ });
364
+
365
+ test("returns null when session does not exist", async () => {
366
+ mockSpawn.mockImplementation(() => createMockProcess("", "can't find session: gone", 1));
367
+
368
+ const pid = await getPanePid("gone");
369
+
370
+ expect(pid).toBeNull();
371
+ });
372
+
373
+ test("returns null when output is empty", async () => {
374
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
375
+
376
+ const pid = await getPanePid("empty-output");
377
+
378
+ expect(pid).toBeNull();
379
+ });
380
+
381
+ test("returns null when output is not a number", async () => {
382
+ mockSpawn.mockImplementation(() => createMockProcess("not-a-pid\n", "", 0));
383
+
384
+ const pid = await getPanePid("bad-output");
385
+
386
+ expect(pid).toBeNull();
387
+ });
388
+ });
389
+
390
+ describe("getDescendantPids", () => {
391
+ beforeEach(() => {
392
+ mockSpawn.mockReset();
393
+ });
394
+
395
+ test("returns empty array when process has no children", async () => {
396
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 1));
397
+
398
+ const pids = await getDescendantPids(100);
399
+
400
+ expect(pids).toEqual([]);
401
+ });
402
+
403
+ test("returns direct children when they have no grandchildren", async () => {
404
+ let callCount = 0;
405
+ mockSpawn.mockImplementation(() => {
406
+ callCount++;
407
+ if (callCount === 1) {
408
+ // pgrep -P 100 → children 200, 300
409
+ return createMockProcess("200\n300\n", "", 0);
410
+ }
411
+ // pgrep -P 200 and pgrep -P 300 → no grandchildren
412
+ return createMockProcess("", "", 1);
413
+ });
414
+
415
+ const pids = await getDescendantPids(100);
416
+
417
+ expect(pids).toEqual([200, 300]);
418
+ });
419
+
420
+ test("returns descendants in depth-first order (deepest first)", async () => {
421
+ // Tree: 100 → 200 → 400
422
+ // → 300
423
+ let callCount = 0;
424
+ mockSpawn.mockImplementation(() => {
425
+ callCount++;
426
+ if (callCount === 1) {
427
+ // pgrep -P 100 → children 200, 300
428
+ return createMockProcess("200\n300\n", "", 0);
429
+ }
430
+ if (callCount === 2) {
431
+ // pgrep -P 200 → child 400
432
+ return createMockProcess("400\n", "", 0);
433
+ }
434
+ if (callCount === 3) {
435
+ // pgrep -P 400 → no children
436
+ return createMockProcess("", "", 1);
437
+ }
438
+ // pgrep -P 300 → no children
439
+ return createMockProcess("", "", 1);
440
+ });
441
+
442
+ const pids = await getDescendantPids(100);
443
+
444
+ // Deepest-first: 400 (grandchild), then 200, 300 (direct children)
445
+ expect(pids).toEqual([400, 200, 300]);
446
+ });
447
+
448
+ test("handles deeply nested tree", async () => {
449
+ // Tree: 1 → 2 → 3 → 4
450
+ let callCount = 0;
451
+ mockSpawn.mockImplementation(() => {
452
+ callCount++;
453
+ if (callCount === 1) {
454
+ // pgrep -P 1 → 2
455
+ return createMockProcess("2\n", "", 0);
456
+ }
457
+ if (callCount === 2) {
458
+ // pgrep -P 2 → 3
459
+ return createMockProcess("3\n", "", 0);
460
+ }
461
+ if (callCount === 3) {
462
+ // pgrep -P 3 → 4
463
+ return createMockProcess("4\n", "", 0);
464
+ }
465
+ // pgrep -P 4 → no children
466
+ return createMockProcess("", "", 1);
467
+ });
468
+
469
+ const pids = await getDescendantPids(1);
470
+
471
+ // Deepest-first: 4, 3, 2
472
+ expect(pids).toEqual([4, 3, 2]);
473
+ });
474
+
475
+ test("skips non-numeric pgrep output lines", async () => {
476
+ mockSpawn.mockImplementation((_command: string, args: readonly string[]) => {
477
+ if (args[1] === "100") {
478
+ return createMockProcess("200\nnot-a-pid\n300\n", "", 0);
479
+ }
480
+ return createMockProcess("", "", 1);
481
+ });
482
+
483
+ const pids = await getDescendantPids(100);
484
+
485
+ expect(pids).toEqual([200, 300]);
486
+ });
487
+ });
488
+
489
+ describe("isProcessAlive", () => {
490
+ test("returns true for current process (self-check)", () => {
491
+ // process.pid is always alive
492
+ expect(isProcessAlive(process.pid)).toBe(true);
493
+ });
494
+
495
+ test("returns false for a non-existent PID", () => {
496
+ // PID 2147483647 (max int32) is extremely unlikely to exist
497
+ expect(isProcessAlive(2147483647)).toBe(false);
498
+ });
499
+ });
500
+
501
+ describe("killProcessTree", () => {
502
+ let killSpy: ReturnType<typeof vi.spyOn>;
503
+
504
+ beforeEach(() => {
505
+ mockSpawn.mockReset();
506
+ killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
507
+ });
508
+
509
+ afterEach(() => {
510
+ killSpy.mockRestore();
511
+ });
512
+
513
+ test("sends SIGTERM to root when no descendants", async () => {
514
+ // pgrep -P 100 → no children
515
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 1));
516
+
517
+ await killProcessTree(100, 0);
518
+
519
+ expect(killSpy).toHaveBeenCalledWith(100, "SIGTERM");
520
+ });
521
+
522
+ test("sends SIGTERM deepest-first then SIGKILL survivors", async () => {
523
+ // Tree: 100 → 200 → 300
524
+ let pgrepCallCount = 0;
525
+ mockSpawn.mockImplementation(() => {
526
+ pgrepCallCount++;
527
+ if (pgrepCallCount === 1) {
528
+ // pgrep -P 100 → 200
529
+ return createMockProcess("200\n", "", 0);
530
+ }
531
+ if (pgrepCallCount === 2) {
532
+ // pgrep -P 200 → 300
533
+ return createMockProcess("300\n", "", 0);
534
+ }
535
+ // pgrep -P 300 → no children
536
+ return createMockProcess("", "", 1);
537
+ });
538
+
539
+ const signals: Array<{ pid: number; signal: string }> = [];
540
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
541
+ signals.push({ pid, signal: String(signal) });
542
+ return true;
543
+ });
544
+
545
+ await killProcessTree(100, 0);
546
+
547
+ // Phase 1 (SIGTERM): deepest-first → 300, 200, then root 100
548
+ // Phase 2 (SIGKILL): isProcessAlive check (signal 0), then SIGKILL for survivors
549
+ const sigterms = signals.filter((s) => s.signal === "SIGTERM");
550
+ expect(sigterms).toEqual([
551
+ { pid: 300, signal: "SIGTERM" },
552
+ { pid: 200, signal: "SIGTERM" },
553
+ { pid: 100, signal: "SIGTERM" },
554
+ ]);
555
+ });
556
+
557
+ test("sends SIGKILL to survivors after grace period", async () => {
558
+ // Tree: 100 → 200 (no grandchildren)
559
+ let pgrepCallCount = 0;
560
+ mockSpawn.mockImplementation(() => {
561
+ pgrepCallCount++;
562
+ if (pgrepCallCount === 1) {
563
+ return createMockProcess("200\n", "", 0);
564
+ }
565
+ return createMockProcess("", "", 1);
566
+ });
567
+
568
+ const signals: Array<{ pid: number; signal: string | number }> = [];
569
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
570
+ signals.push({ pid, signal });
571
+ // signal 0 is the isProcessAlive check — simulate processes still alive
572
+ return true;
573
+ });
574
+
575
+ await killProcessTree(100, 10); // 10ms grace period for test speed
576
+
577
+ // Should have: SIGTERM(200), SIGTERM(100), alive-check(200), SIGKILL(200),
578
+ // alive-check(100), SIGKILL(100)
579
+ const sigkills = signals.filter((s) => s.signal === "SIGKILL");
580
+ expect(sigkills.length).toBe(2);
581
+ expect(sigkills[0]).toEqual({ pid: 200, signal: "SIGKILL" });
582
+ expect(sigkills[1]).toEqual({ pid: 100, signal: "SIGKILL" });
583
+ });
584
+
585
+ test("skips SIGKILL for processes that died during grace period", async () => {
586
+ let pgrepCallCount = 0;
587
+ mockSpawn.mockImplementation(() => {
588
+ pgrepCallCount++;
589
+ if (pgrepCallCount === 1) {
590
+ return createMockProcess("200\n", "", 0);
591
+ }
592
+ return createMockProcess("", "", 1);
593
+ });
594
+
595
+ const signals: Array<{ pid: number; signal: string | number }> = [];
596
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
597
+ signals.push({ pid, signal });
598
+ // signal 0 (isProcessAlive) — processes are dead
599
+ if (signal === 0) {
600
+ throw new Error("ESRCH");
601
+ }
602
+ return true;
603
+ });
604
+
605
+ await killProcessTree(100, 10);
606
+
607
+ // Should have SIGTERM calls but no SIGKILL (processes died)
608
+ const sigkills = signals.filter((s) => s.signal === "SIGKILL");
609
+ expect(sigkills).toEqual([]);
610
+ });
611
+
612
+ test("silently handles SIGTERM errors for already-dead processes", async () => {
613
+ // No children
614
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 1));
615
+
616
+ killSpy.mockImplementation(() => {
617
+ throw new Error("ESRCH: No such process");
618
+ });
619
+
620
+ // Should not throw
621
+ await killProcessTree(100, 0);
622
+ });
623
+ });
624
+
625
+ describe("killSession", () => {
626
+ let killSpy: ReturnType<typeof vi.spyOn>;
627
+
628
+ beforeEach(() => {
629
+ mockSpawn.mockReset();
630
+ killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
631
+ });
632
+
633
+ afterEach(() => {
634
+ killSpy.mockRestore();
635
+ });
636
+
637
+ test("gets pane PID, kills process tree, then kills tmux session", async () => {
638
+ const cmds: string[][] = [];
639
+ mockSpawn.mockImplementation((command: string, args: readonly string[]) => {
640
+ cmds.push([command, ...args]);
641
+
642
+ if (command === "tmux" && args[0] === "display-message") {
643
+ // getPanePid → returns PID 500
644
+ return createMockProcess("500\n", "", 0);
645
+ }
646
+ if (command === "pgrep") {
647
+ // getDescendantPids → no children
648
+ return createMockProcess("", "", 1);
649
+ }
650
+ if (command === "tmux" && args[0] === "kill-session") {
651
+ return createMockProcess("", "", 0);
652
+ }
653
+ return createMockProcess("", "", 0);
654
+ });
655
+
656
+ await killSession("legio-auth");
657
+
658
+ // Should have called: tmux display-message, pgrep, tmux kill-session
659
+ expect(cmds[0]).toEqual(["tmux", "display-message", "-p", "-t", "legio-auth", "#{pane_pid}"]);
660
+ expect(cmds[1]).toEqual(["pgrep", "-P", "500"]);
661
+ const lastCmd = cmds[cmds.length - 1];
662
+ expect(lastCmd).toEqual(["tmux", "kill-session", "-t", "legio-auth"]);
663
+
664
+ // Should have sent SIGTERM to root PID 500
665
+ expect(killSpy).toHaveBeenCalledWith(500, "SIGTERM");
666
+ });
667
+
668
+ test("skips process cleanup when pane PID is not available", async () => {
669
+ const cmds: string[][] = [];
670
+ mockSpawn.mockImplementation((command: string, args: readonly string[]) => {
671
+ cmds.push([command, ...args]);
672
+
673
+ if (command === "tmux" && args[0] === "display-message") {
674
+ // getPanePid → session not found
675
+ return createMockProcess("", "can't find session", 1);
676
+ }
677
+ if (command === "tmux" && args[0] === "pipe-pane") {
678
+ return createMockProcess("", "", 0);
679
+ }
680
+ if (command === "tmux" && args[0] === "kill-session") {
681
+ return createMockProcess("", "", 0);
682
+ }
683
+ return createMockProcess("", "", 0);
684
+ });
685
+
686
+ await killSession("legio-auth");
687
+
688
+ // Should call: tmux display-message, tmux pipe-pane (stopPipePane), tmux kill-session
689
+ expect(cmds).toHaveLength(3);
690
+ expect(cmds[0]?.[1]).toBe("display-message");
691
+ expect(cmds[1]?.[1]).toBe("pipe-pane");
692
+ expect(cmds[2]?.[1]).toBe("kill-session");
693
+ // No process.kill calls since we had no PID
694
+ expect(killSpy).not.toHaveBeenCalled();
695
+ });
696
+
697
+ test("succeeds silently when session is already gone after process cleanup", async () => {
698
+ mockSpawn.mockImplementation((command: string, args: readonly string[]) => {
699
+ if (command === "tmux" && args[0] === "display-message") {
700
+ return createMockProcess("500\n", "", 0);
701
+ }
702
+ if (command === "pgrep") {
703
+ return createMockProcess("", "", 1);
704
+ }
705
+ if (command === "tmux" && args[0] === "kill-session") {
706
+ // Session already gone after process cleanup
707
+ return createMockProcess("", "can't find session: legio-auth", 1);
708
+ }
709
+ return createMockProcess("", "", 0);
710
+ });
711
+
712
+ // Should not throw — session disappearing is expected
713
+ await killSession("legio-auth");
714
+ });
715
+
716
+ test("throws AgentError on unexpected tmux kill-session failure", async () => {
717
+ mockSpawn.mockImplementation((command: string, args: readonly string[]) => {
718
+ if (command === "tmux" && args[0] === "display-message") {
719
+ return createMockProcess("", "can't find session", 1);
720
+ }
721
+ if (command === "tmux" && args[0] === "kill-session") {
722
+ return createMockProcess("", "server exited unexpectedly", 1);
723
+ }
724
+ return createMockProcess("", "", 0);
725
+ });
726
+
727
+ await expect(killSession("broken-session")).rejects.toThrow(AgentError);
728
+ });
729
+
730
+ test("AgentError contains session name on failure", async () => {
731
+ mockSpawn.mockImplementation((command: string, args: readonly string[]) => {
732
+ if (command === "tmux" && args[0] === "display-message") {
733
+ return createMockProcess("", "error", 1);
734
+ }
735
+ if (command === "tmux" && args[0] === "kill-session") {
736
+ return createMockProcess("", "server exited unexpectedly", 1);
737
+ }
738
+ return createMockProcess("", "", 0);
739
+ });
740
+
741
+ try {
742
+ await killSession("ghost-agent");
743
+ expect(true).toBe(false);
744
+ } catch (err: unknown) {
745
+ expect(err).toBeInstanceOf(AgentError);
746
+ const agentErr = err as AgentError;
747
+ expect(agentErr.message).toContain("ghost-agent");
748
+ expect(agentErr.agentName).toBe("ghost-agent");
749
+ }
750
+ });
751
+ });
752
+
753
+ describe("isSessionAlive", () => {
754
+ beforeEach(() => {
755
+ mockSpawn.mockReset();
756
+ });
757
+
758
+ test("returns true when session exists (exit 0)", async () => {
759
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
760
+
761
+ const alive = await isSessionAlive("legio-auth");
762
+
763
+ expect(alive).toBe(true);
764
+ });
765
+
766
+ test("returns false when session does not exist (non-zero exit)", async () => {
767
+ mockSpawn.mockImplementation(() => createMockProcess("", "can't find session: nonexistent", 1));
768
+
769
+ const alive = await isSessionAlive("nonexistent");
770
+
771
+ expect(alive).toBe(false);
772
+ });
773
+
774
+ test("passes correct args to tmux has-session", async () => {
775
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
776
+
777
+ await isSessionAlive("my-agent");
778
+
779
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
780
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
781
+ const command = callArgs[0] as string;
782
+ const args = callArgs[1] as string[];
783
+ expect([command, ...args]).toEqual(["tmux", "has-session", "-t", "my-agent"]);
784
+ });
785
+ });
786
+
787
+ describe("sendKeys", () => {
788
+ beforeEach(() => {
789
+ mockSpawn.mockReset();
790
+ });
791
+
792
+ test("passes correct args to tmux send-keys", async () => {
793
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
794
+
795
+ await sendKeys("legio-auth", "echo hello world");
796
+
797
+ // Two calls: text with -l flag, then Enter separately
798
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
799
+ const call1 = mockSpawn.mock.calls[0] as unknown[];
800
+ expect([call1[0] as string, ...(call1[1] as string[])]).toEqual([
801
+ "tmux",
802
+ "send-keys",
803
+ "-t",
804
+ "legio-auth",
805
+ "-l",
806
+ "echo hello world",
807
+ ]);
808
+ const call2 = mockSpawn.mock.calls[1] as unknown[];
809
+ expect([call2[0] as string, ...(call2[1] as string[])]).toEqual([
810
+ "tmux",
811
+ "send-keys",
812
+ "-t",
813
+ "legio-auth",
814
+ "Enter",
815
+ ]);
816
+ });
817
+
818
+ test("sends text with -l literal flag", async () => {
819
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
820
+
821
+ await sendKeys("legio-auth", "hello");
822
+
823
+ const call1 = mockSpawn.mock.calls[0] as unknown[];
824
+ const args = call1[1] as string[];
825
+ expect(args).toContain("-l");
826
+ });
827
+
828
+ test("flattens newlines in keys to spaces", async () => {
829
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
830
+
831
+ await sendKeys("legio-agent", "line1\nline2\nline3");
832
+
833
+ // Two calls: text with -l flag (newlines flattened), then Enter
834
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
835
+ const call1 = mockSpawn.mock.calls[0] as unknown[];
836
+ expect([call1[0] as string, ...(call1[1] as string[])]).toEqual([
837
+ "tmux",
838
+ "send-keys",
839
+ "-t",
840
+ "legio-agent",
841
+ "-l",
842
+ "line1 line2 line3",
843
+ ]);
844
+ const call2 = mockSpawn.mock.calls[1] as unknown[];
845
+ expect([call2[0] as string, ...(call2[1] as string[])]).toEqual([
846
+ "tmux",
847
+ "send-keys",
848
+ "-t",
849
+ "legio-agent",
850
+ "Enter",
851
+ ]);
852
+ });
853
+
854
+ test("sends Enter with empty string (follow-up submission)", async () => {
855
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
856
+
857
+ await sendKeys("legio-agent", "");
858
+
859
+ // Only one call: just Enter, no text step for empty string
860
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
861
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
862
+ expect([callArgs[0] as string, ...(callArgs[1] as string[])]).toEqual([
863
+ "tmux",
864
+ "send-keys",
865
+ "-t",
866
+ "legio-agent",
867
+ "Enter",
868
+ ]);
869
+ });
870
+
871
+ test("throws AgentError on failure", async () => {
872
+ mockSpawn.mockImplementation(() => createMockProcess("", "session not found: dead-agent", 1));
873
+
874
+ await expect(sendKeys("dead-agent", "echo test")).rejects.toThrow(AgentError);
875
+ });
876
+
877
+ test("fails on text step error and does not proceed to Enter", async () => {
878
+ let callCount = 0;
879
+ mockSpawn.mockImplementation(() => {
880
+ callCount++;
881
+ if (callCount === 1) {
882
+ // Text step fails
883
+ return createMockProcess("", "session not found: my-agent", 1);
884
+ }
885
+ // Enter step should NOT be reached
886
+ return createMockProcess("", "", 0);
887
+ });
888
+
889
+ await expect(sendKeys("my-agent", "some text")).rejects.toThrow(AgentError);
890
+ // Only the text step was called; Enter step was skipped
891
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
892
+ });
893
+
894
+ test("AgentError contains session name on failure", async () => {
895
+ mockSpawn.mockImplementation(() => createMockProcess("", "session not found: my-agent", 1));
896
+
897
+ try {
898
+ await sendKeys("my-agent", "test command");
899
+ expect(true).toBe(false);
900
+ } catch (err: unknown) {
901
+ expect(err).toBeInstanceOf(AgentError);
902
+ const agentErr = err as AgentError;
903
+ expect(agentErr.message).toContain("my-agent");
904
+ expect(agentErr.agentName).toBe("my-agent");
905
+ }
906
+ });
907
+
908
+ test("error message says 'server not running' when tmux has no server", async () => {
909
+ mockSpawn.mockImplementation(() =>
910
+ createMockProcess("", "no server running on /tmp/tmux-501/default", 1),
911
+ );
912
+
913
+ try {
914
+ await sendKeys("dead-agent", "echo test");
915
+ expect(true).toBe(false);
916
+ } catch (err: unknown) {
917
+ expect(err).toBeInstanceOf(AgentError);
918
+ const agentErr = err as AgentError;
919
+ expect(agentErr.message).toContain("Tmux server not running");
920
+ }
921
+ });
922
+
923
+ test("error message says 'not found' when session does not exist", async () => {
924
+ mockSpawn.mockImplementation(() => createMockProcess("", "session not found: dead-agent", 1));
925
+
926
+ try {
927
+ await sendKeys("dead-agent", "echo test");
928
+ expect(true).toBe(false);
929
+ } catch (err: unknown) {
930
+ expect(err).toBeInstanceOf(AgentError);
931
+ const agentErr = err as AgentError;
932
+ expect(agentErr.message).toContain("not found");
933
+ }
934
+ });
935
+
936
+ test("error message includes stderr for unrecognized tmux errors", async () => {
937
+ mockSpawn.mockImplementation(() => createMockProcess("", "unexpected tmux protocol error", 1));
938
+
939
+ try {
940
+ await sendKeys("agent", "echo test");
941
+ expect(true).toBe(false);
942
+ } catch (err: unknown) {
943
+ expect(err).toBeInstanceOf(AgentError);
944
+ const agentErr = err as AgentError;
945
+ expect(agentErr.message).toContain("unexpected tmux protocol error");
946
+ }
947
+ });
948
+
949
+ test("throws AgentError when Enter step fails", async () => {
950
+ let callCount = 0;
951
+ mockSpawn.mockImplementation(() => {
952
+ callCount++;
953
+ if (callCount === 1) {
954
+ // Text step succeeds
955
+ return createMockProcess("", "", 0);
956
+ }
957
+ // Enter step fails
958
+ return createMockProcess("", "session not found: some-agent", 1);
959
+ });
960
+
961
+ await expect(sendKeys("some-agent", "hello")).rejects.toThrow(AgentError);
962
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
963
+ });
964
+ });
965
+
966
+ describe("capturePaneContent", () => {
967
+ beforeEach(() => {
968
+ mockSpawn.mockReset();
969
+ });
970
+
971
+ test("returns pane content on success", async () => {
972
+ mockSpawn.mockImplementation(() => createMockProcess("> type your message\n", "", 0));
973
+
974
+ const content = await capturePaneContent("legio-auth");
975
+
976
+ expect(content).toBe("> type your message\n");
977
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
978
+ const command = callArgs[0] as string;
979
+ const args = callArgs[1] as string[];
980
+ expect([command, ...args]).toEqual(["tmux", "capture-pane", "-t", "legio-auth", "-p"]);
981
+ });
982
+
983
+ test("returns empty string when session does not exist", async () => {
984
+ mockSpawn.mockImplementation(() => createMockProcess("", "can't find session: gone", 1));
985
+
986
+ const content = await capturePaneContent("gone");
987
+
988
+ expect(content).toBe("");
989
+ });
990
+
991
+ test("returns empty string when capture-pane fails", async () => {
992
+ mockSpawn.mockImplementation(() => createMockProcess("", "no server running", 1));
993
+
994
+ const content = await capturePaneContent("any-session");
995
+
996
+ expect(content).toBe("");
997
+ });
998
+ });
999
+
1000
+ describe("hasTuiMarkers", () => {
1001
+ test("returns true for 'bypass permissions' marker", () => {
1002
+ expect(hasTuiMarkers("Claude Code — bypass permissions")).toBe(true);
1003
+ });
1004
+
1005
+ test("returns true for horizontal line box-drawing character ─", () => {
1006
+ expect(hasTuiMarkers("────────────────────────────────")).toBe(true);
1007
+ });
1008
+
1009
+ test("returns true for bold horizontal line box-drawing character ━", () => {
1010
+ expect(hasTuiMarkers("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")).toBe(true);
1011
+ });
1012
+
1013
+ test("returns true for top-left corner box-drawing character ╭", () => {
1014
+ expect(hasTuiMarkers("╭─ Claude Code ──────────────────╮")).toBe(true);
1015
+ });
1016
+
1017
+ test("returns true for bottom-left corner box-drawing character ╰", () => {
1018
+ expect(hasTuiMarkers("╰────────────────────────────────╯")).toBe(true);
1019
+ });
1020
+
1021
+ test("returns false for plain shell prompt", () => {
1022
+ expect(hasTuiMarkers("user@host:~$ ")).toBe(false);
1023
+ });
1024
+
1025
+ test("returns false for empty string", () => {
1026
+ expect(hasTuiMarkers("")).toBe(false);
1027
+ });
1028
+
1029
+ test("returns false for generic non-TUI content", () => {
1030
+ expect(hasTuiMarkers("> ready for input")).toBe(false);
1031
+ });
1032
+ });
1033
+
1034
+ describe("startPipePane", () => {
1035
+ beforeEach(() => {
1036
+ mockSpawn.mockReset();
1037
+ mockMkdir.mockReset();
1038
+ mockMkdir.mockResolvedValue(undefined);
1039
+ });
1040
+
1041
+ test("creates log directory and calls tmux pipe-pane with correct args", async () => {
1042
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
1043
+
1044
+ await startPipePane("legio-auth", "/tmp/legio/logs/auth/terminal.log");
1045
+
1046
+ expect(mockMkdir).toHaveBeenCalledWith("/tmp/legio/logs/auth", { recursive: true });
1047
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
1048
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
1049
+ const command = callArgs[0] as string;
1050
+ const args = callArgs[1] as string[];
1051
+ expect(command).toBe("tmux");
1052
+ expect(args[0]).toBe("pipe-pane");
1053
+ expect(args).toContain("-t");
1054
+ expect(args).toContain("legio-auth");
1055
+ const shellCmd = args[args.length - 1] as string;
1056
+ expect(shellCmd).toContain("cat >>");
1057
+ expect(shellCmd).toContain("/tmp/legio/logs/auth/terminal.log");
1058
+ });
1059
+
1060
+ test("throws AgentError if tmux pipe-pane fails", async () => {
1061
+ mockSpawn.mockImplementation(() => createMockProcess("", "can't find session: legio-auth", 1));
1062
+
1063
+ await expect(startPipePane("legio-auth", "/tmp/terminal.log")).rejects.toThrow(AgentError);
1064
+ });
1065
+
1066
+ test("AgentError includes session name on failure", async () => {
1067
+ mockSpawn.mockImplementation(() => createMockProcess("", "no server running", 1));
1068
+
1069
+ try {
1070
+ await startPipePane("my-agent", "/tmp/terminal.log");
1071
+ expect(true).toBe(false);
1072
+ } catch (err: unknown) {
1073
+ expect(err).toBeInstanceOf(AgentError);
1074
+ const agentErr = err as AgentError;
1075
+ expect(agentErr.agentName).toBe("my-agent");
1076
+ }
1077
+ });
1078
+ });
1079
+
1080
+ describe("stopPipePane", () => {
1081
+ beforeEach(() => {
1082
+ mockSpawn.mockReset();
1083
+ });
1084
+
1085
+ test("calls tmux pipe-pane with session name and no command arg", async () => {
1086
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
1087
+
1088
+ await stopPipePane("legio-auth");
1089
+
1090
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
1091
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
1092
+ const command = callArgs[0] as string;
1093
+ const args = callArgs[1] as string[];
1094
+ expect([command, ...args]).toEqual(["tmux", "pipe-pane", "-t", "legio-auth"]);
1095
+ });
1096
+
1097
+ test("does not throw when tmux returns non-zero exit code", async () => {
1098
+ mockSpawn.mockImplementation(() => createMockProcess("", "can't find session: gone", 1));
1099
+
1100
+ // Should not throw — stopPipePane is best-effort
1101
+ await expect(stopPipePane("gone-session")).resolves.toBeUndefined();
1102
+ });
1103
+ });
1104
+
1105
+ describe("readTerminalLog", () => {
1106
+ beforeEach(() => {
1107
+ mockStat.mockReset();
1108
+ mockReadFile.mockReset();
1109
+ });
1110
+
1111
+ test("returns null when log file does not exist", async () => {
1112
+ mockStat.mockRejectedValue(new Error("ENOENT: no such file"));
1113
+
1114
+ const result = await readTerminalLog("/tmp/missing.log");
1115
+
1116
+ expect(result).toBeNull();
1117
+ expect(mockReadFile).not.toHaveBeenCalled();
1118
+ });
1119
+
1120
+ test("returns full content when no tailLines specified", async () => {
1121
+ mockStat.mockResolvedValue({} as Awaited<ReturnType<typeof stat>>);
1122
+ mockReadFile.mockResolvedValue("line1\nline2\nline3\n" as never);
1123
+
1124
+ const result = await readTerminalLog("/tmp/terminal.log");
1125
+
1126
+ expect(result).toBe("line1\nline2\nline3\n");
1127
+ });
1128
+
1129
+ test("returns last N lines when tailLines specified", async () => {
1130
+ mockStat.mockResolvedValue({} as Awaited<ReturnType<typeof stat>>);
1131
+ mockReadFile.mockResolvedValue("a\nb\nc\nd\ne\n" as never);
1132
+
1133
+ const result = await readTerminalLog("/tmp/terminal.log", 3);
1134
+
1135
+ expect(result).toBe("c\nd\ne\n");
1136
+ });
1137
+
1138
+ test("returns full content when tailLines >= line count", async () => {
1139
+ mockStat.mockResolvedValue({} as Awaited<ReturnType<typeof stat>>);
1140
+ mockReadFile.mockResolvedValue("line1\nline2\n" as never);
1141
+
1142
+ const result = await readTerminalLog("/tmp/terminal.log", 100);
1143
+
1144
+ expect(result).toBe("line1\nline2\n");
1145
+ });
1146
+
1147
+ test("passes log path to readFile with utf-8 encoding", async () => {
1148
+ mockStat.mockResolvedValue({} as Awaited<ReturnType<typeof stat>>);
1149
+ mockReadFile.mockResolvedValue("content" as never);
1150
+
1151
+ await readTerminalLog("/my/log/path.log");
1152
+
1153
+ expect(mockReadFile).toHaveBeenCalledWith("/my/log/path.log", "utf-8");
1154
+ });
1155
+ });
1156
+
1157
+ describe("waitForTuiReady", () => {
1158
+ beforeEach(() => {
1159
+ mockSpawn.mockReset();
1160
+ });
1161
+
1162
+ test("resolves immediately when pane already has TUI markers", async () => {
1163
+ mockSpawn.mockImplementation(() => createMockProcess("╭─ Claude Code ──╮\n", "", 0));
1164
+
1165
+ await waitForTuiReady("legio-agent", { postReadyDelay: 0 });
1166
+
1167
+ // Should call capture-pane once and return without timeout
1168
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
1169
+ const callArgs = mockSpawn.mock.calls[0] as unknown[];
1170
+ const command = callArgs[0] as string;
1171
+ const args = callArgs[1] as string[];
1172
+ expect([command, ...args]).toEqual(["tmux", "capture-pane", "-t", "legio-agent", "-p"]);
1173
+ });
1174
+
1175
+ test("polls until pane shows TUI markers", async () => {
1176
+ let callCount = 0;
1177
+ mockSpawn.mockImplementation(() => {
1178
+ callCount++;
1179
+ if (callCount <= 2) {
1180
+ // First two polls: empty pane
1181
+ return createMockProcess("", "", 0);
1182
+ }
1183
+ // Third poll: TUI marker appeared
1184
+ return createMockProcess("╭─ Claude Code ────────────────────╮\n", "", 0);
1185
+ });
1186
+
1187
+ await waitForTuiReady("legio-agent", { timeout: 5_000, interval: 10, postReadyDelay: 0 });
1188
+
1189
+ // Should have polled 3 times
1190
+ expect(mockSpawn).toHaveBeenCalledTimes(3);
1191
+ });
1192
+
1193
+ test("does not trigger ready on plain shell prompt (regression)", async () => {
1194
+ let callCount = 0;
1195
+ mockSpawn.mockImplementation(() => {
1196
+ callCount++;
1197
+ if (callCount <= 2) {
1198
+ // Shell prompt only — no TUI markers
1199
+ return createMockProcess("user@host:~$ ", "", 0);
1200
+ }
1201
+ // Eventually return TUI content
1202
+ return createMockProcess("╭─ Claude Code ──╮\n", "", 0);
1203
+ });
1204
+
1205
+ await waitForTuiReady("legio-agent", { timeout: 5_000, interval: 10, postReadyDelay: 0 });
1206
+
1207
+ // Polled past the shell prompt, resolved only when TUI markers appeared
1208
+ expect(mockSpawn).toHaveBeenCalledTimes(3);
1209
+ });
1210
+
1211
+ test("resolves without throwing when timeout expires with no TUI markers", async () => {
1212
+ // Always return empty content (no TUI markers)
1213
+ mockSpawn.mockImplementation(() => createMockProcess("", "", 0));
1214
+
1215
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
1216
+
1217
+ // Short timeout so test is fast
1218
+ await expect(
1219
+ waitForTuiReady("legio-agent", { timeout: 100, interval: 10, postReadyDelay: 0 }),
1220
+ ).resolves.toBeUndefined();
1221
+
1222
+ // Should emit a warning on timeout
1223
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("Warning"));
1224
+
1225
+ stderrSpy.mockRestore();
1226
+ });
1227
+
1228
+ test("applies postReadyDelay after marker detection", async () => {
1229
+ mockSpawn.mockImplementation(() => createMockProcess("╭─ Claude Code ──╮\n", "", 0));
1230
+
1231
+ const start = Date.now();
1232
+ await waitForTuiReady("legio-agent", { timeout: 5_000, interval: 10, postReadyDelay: 100 });
1233
+ const elapsed = Date.now() - start;
1234
+
1235
+ // Should have waited at least ~100ms for the post-ready delay
1236
+ expect(elapsed).toBeGreaterThanOrEqual(80);
1237
+ });
1238
+ });