@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
});
|