@os-eco/overstory-cli 0.6.1 → 0.6.4
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/README.md +7 -6
- package/package.json +12 -4
- package/src/agents/hooks-deployer.test.ts +94 -16
- package/src/agents/hooks-deployer.ts +18 -0
- package/src/agents/manifest.test.ts +86 -0
- package/src/commands/agents.test.ts +3 -3
- package/src/commands/agents.ts +59 -88
- package/src/commands/clean.test.ts +31 -46
- package/src/commands/clean.ts +28 -49
- package/src/commands/completions.ts +14 -0
- package/src/commands/coordinator.test.ts +131 -24
- package/src/commands/coordinator.ts +100 -63
- package/src/commands/costs.test.ts +2 -2
- package/src/commands/costs.ts +96 -75
- package/src/commands/dashboard.test.ts +2 -2
- package/src/commands/dashboard.ts +73 -93
- package/src/commands/doctor.test.ts +2 -2
- package/src/commands/doctor.ts +92 -79
- package/src/commands/errors.test.ts +2 -2
- package/src/commands/errors.ts +56 -50
- package/src/commands/feed.test.ts +2 -2
- package/src/commands/feed.ts +86 -83
- package/src/commands/group.ts +167 -177
- package/src/commands/hooks.test.ts +2 -2
- package/src/commands/hooks.ts +52 -42
- package/src/commands/init.test.ts +19 -19
- package/src/commands/init.ts +7 -16
- package/src/commands/inspect.test.ts +2 -2
- package/src/commands/inspect.ts +54 -57
- package/src/commands/log.test.ts +5 -10
- package/src/commands/log.ts +90 -84
- package/src/commands/logs.test.ts +1 -1
- package/src/commands/logs.ts +101 -104
- package/src/commands/mail.ts +157 -169
- package/src/commands/merge.test.ts +20 -58
- package/src/commands/merge.ts +13 -43
- package/src/commands/metrics.test.ts +2 -2
- package/src/commands/metrics.ts +33 -34
- package/src/commands/monitor.test.ts +3 -3
- package/src/commands/monitor.ts +56 -61
- package/src/commands/nudge.ts +41 -89
- package/src/commands/prime.test.ts +15 -47
- package/src/commands/prime.ts +7 -44
- package/src/commands/replay.test.ts +2 -2
- package/src/commands/replay.ts +79 -86
- package/src/commands/run.ts +97 -77
- package/src/commands/sling.test.ts +196 -0
- package/src/commands/sling.ts +24 -54
- package/src/commands/spec.test.ts +13 -39
- package/src/commands/spec.ts +30 -99
- package/src/commands/status.ts +46 -42
- package/src/commands/stop.test.ts +21 -39
- package/src/commands/stop.ts +18 -33
- package/src/commands/supervisor.test.ts +3 -5
- package/src/commands/supervisor.ts +136 -157
- package/src/commands/trace.test.ts +9 -9
- package/src/commands/trace.ts +54 -77
- package/src/commands/watch.test.ts +2 -2
- package/src/commands/watch.ts +38 -45
- package/src/commands/worktree.test.ts +8 -8
- package/src/commands/worktree.ts +63 -46
- package/src/config.test.ts +96 -0
- package/src/doctor/databases.test.ts +22 -2
- package/src/doctor/databases.ts +16 -0
- package/src/doctor/dependencies.test.ts +55 -1
- package/src/doctor/dependencies.ts +113 -18
- package/src/e2e/init-sling-lifecycle.test.ts +6 -6
- package/src/index.ts +223 -213
- package/src/logging/color.test.ts +74 -91
- package/src/logging/color.ts +52 -46
- package/src/logging/reporter.test.ts +10 -10
- package/src/logging/reporter.ts +6 -5
- package/src/merge/queue.test.ts +66 -0
- package/src/merge/queue.ts +15 -0
- package/src/schema-consistency.test.ts +239 -0
- package/src/sessions/compat.ts +1 -1
- package/src/sessions/store.test.ts +37 -0
- package/src/sessions/store.ts +11 -0
- package/src/worktree/tmux.test.ts +98 -9
- package/src/worktree/tmux.ts +18 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL schema consistency tests.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that SQL CREATE TABLE column names match the TypeScript row interfaces
|
|
5
|
+
* and row-to-object conversion functions across all four SQLite stores.
|
|
6
|
+
* Prevents regressions like the bead_id/task_id column rename that caused runtime failures.
|
|
7
|
+
*
|
|
8
|
+
* Strategy: create each store (which runs CREATE TABLE), then open a second
|
|
9
|
+
* read-only connection to the same temp file and query PRAGMA table_info().
|
|
10
|
+
* bun:sqlite with WAL mode allows concurrent readers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { createEventStore } from "./events/store.ts";
|
|
19
|
+
import { createMailStore } from "./mail/store.ts";
|
|
20
|
+
import { createMergeQueue } from "./merge/queue.ts";
|
|
21
|
+
import { createMetricsStore } from "./metrics/store.ts";
|
|
22
|
+
import { createSessionStore } from "./sessions/store.ts";
|
|
23
|
+
|
|
24
|
+
/** Extract sorted column names from a table via PRAGMA table_info(). */
|
|
25
|
+
function getTableColumns(db: Database, tableName: string): string[] {
|
|
26
|
+
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
|
|
27
|
+
return rows.map((r) => r.name).sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("SQL schema consistency", () => {
|
|
31
|
+
let tmpDir: string;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
tmpDir = await mkdtemp(join(tmpdir(), "overstory-schema-test-"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("SessionStore", () => {
|
|
42
|
+
test("sessions table columns match SessionRow interface", () => {
|
|
43
|
+
const dbPath = join(tmpDir, "sessions.db");
|
|
44
|
+
const store = createSessionStore(dbPath);
|
|
45
|
+
|
|
46
|
+
const db = new Database(dbPath, { readonly: true });
|
|
47
|
+
const actual = getTableColumns(db, "sessions");
|
|
48
|
+
db.close();
|
|
49
|
+
store.close();
|
|
50
|
+
|
|
51
|
+
// Columns from SessionRow interface in src/sessions/store.ts
|
|
52
|
+
const expected = [
|
|
53
|
+
"agent_name",
|
|
54
|
+
"branch_name",
|
|
55
|
+
"capability",
|
|
56
|
+
"depth",
|
|
57
|
+
"escalation_level",
|
|
58
|
+
"id",
|
|
59
|
+
"last_activity",
|
|
60
|
+
"parent_agent",
|
|
61
|
+
"pid",
|
|
62
|
+
"run_id",
|
|
63
|
+
"stalled_since",
|
|
64
|
+
"started_at",
|
|
65
|
+
"state",
|
|
66
|
+
"task_id",
|
|
67
|
+
"tmux_session",
|
|
68
|
+
"worktree_path",
|
|
69
|
+
].sort();
|
|
70
|
+
|
|
71
|
+
expect(actual).toEqual(expected);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("runs table columns match RunRow interface", () => {
|
|
75
|
+
const dbPath = join(tmpDir, "sessions.db");
|
|
76
|
+
const store = createSessionStore(dbPath);
|
|
77
|
+
|
|
78
|
+
const db = new Database(dbPath, { readonly: true });
|
|
79
|
+
const actual = getTableColumns(db, "runs");
|
|
80
|
+
db.close();
|
|
81
|
+
store.close();
|
|
82
|
+
|
|
83
|
+
// Columns from RunRow interface in src/sessions/store.ts
|
|
84
|
+
const expected = [
|
|
85
|
+
"agent_count",
|
|
86
|
+
"completed_at",
|
|
87
|
+
"coordinator_session_id",
|
|
88
|
+
"id",
|
|
89
|
+
"started_at",
|
|
90
|
+
"status",
|
|
91
|
+
].sort();
|
|
92
|
+
|
|
93
|
+
expect(actual).toEqual(expected);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("EventStore", () => {
|
|
98
|
+
test("events table columns match EventRow interface", () => {
|
|
99
|
+
const dbPath = join(tmpDir, "events.db");
|
|
100
|
+
const store = createEventStore(dbPath);
|
|
101
|
+
|
|
102
|
+
const db = new Database(dbPath, { readonly: true });
|
|
103
|
+
const actual = getTableColumns(db, "events");
|
|
104
|
+
db.close();
|
|
105
|
+
store.close();
|
|
106
|
+
|
|
107
|
+
// Columns from EventRow interface in src/events/store.ts
|
|
108
|
+
const expected = [
|
|
109
|
+
"agent_name",
|
|
110
|
+
"created_at",
|
|
111
|
+
"data",
|
|
112
|
+
"event_type",
|
|
113
|
+
"id",
|
|
114
|
+
"level",
|
|
115
|
+
"run_id",
|
|
116
|
+
"session_id",
|
|
117
|
+
"tool_args",
|
|
118
|
+
"tool_duration_ms",
|
|
119
|
+
"tool_name",
|
|
120
|
+
].sort();
|
|
121
|
+
|
|
122
|
+
expect(actual).toEqual(expected);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("MetricsStore", () => {
|
|
127
|
+
test("sessions table columns match metrics SessionRow interface", () => {
|
|
128
|
+
const dbPath = join(tmpDir, "metrics.db");
|
|
129
|
+
const store = createMetricsStore(dbPath);
|
|
130
|
+
|
|
131
|
+
const db = new Database(dbPath, { readonly: true });
|
|
132
|
+
const actual = getTableColumns(db, "sessions");
|
|
133
|
+
db.close();
|
|
134
|
+
store.close();
|
|
135
|
+
|
|
136
|
+
// Columns from SessionRow interface in src/metrics/store.ts
|
|
137
|
+
const expected = [
|
|
138
|
+
"agent_name",
|
|
139
|
+
"cache_creation_tokens",
|
|
140
|
+
"cache_read_tokens",
|
|
141
|
+
"capability",
|
|
142
|
+
"completed_at",
|
|
143
|
+
"duration_ms",
|
|
144
|
+
"estimated_cost_usd",
|
|
145
|
+
"exit_code",
|
|
146
|
+
"input_tokens",
|
|
147
|
+
"merge_result",
|
|
148
|
+
"model_used",
|
|
149
|
+
"output_tokens",
|
|
150
|
+
"parent_agent",
|
|
151
|
+
"run_id",
|
|
152
|
+
"started_at",
|
|
153
|
+
"task_id",
|
|
154
|
+
].sort();
|
|
155
|
+
|
|
156
|
+
expect(actual).toEqual(expected);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("token_snapshots table columns match SnapshotRow interface", () => {
|
|
160
|
+
const dbPath = join(tmpDir, "metrics.db");
|
|
161
|
+
const store = createMetricsStore(dbPath);
|
|
162
|
+
|
|
163
|
+
const db = new Database(dbPath, { readonly: true });
|
|
164
|
+
const actual = getTableColumns(db, "token_snapshots");
|
|
165
|
+
db.close();
|
|
166
|
+
store.close();
|
|
167
|
+
|
|
168
|
+
// Columns from SnapshotRow interface in src/metrics/store.ts
|
|
169
|
+
const expected = [
|
|
170
|
+
"agent_name",
|
|
171
|
+
"cache_creation_tokens",
|
|
172
|
+
"cache_read_tokens",
|
|
173
|
+
"created_at",
|
|
174
|
+
"estimated_cost_usd",
|
|
175
|
+
"id",
|
|
176
|
+
"input_tokens",
|
|
177
|
+
"model_used",
|
|
178
|
+
"output_tokens",
|
|
179
|
+
].sort();
|
|
180
|
+
|
|
181
|
+
expect(actual).toEqual(expected);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("MailStore", () => {
|
|
186
|
+
test("messages table columns match MessageRow interface", () => {
|
|
187
|
+
const dbPath = join(tmpDir, "mail.db");
|
|
188
|
+
const store = createMailStore(dbPath);
|
|
189
|
+
|
|
190
|
+
const db = new Database(dbPath, { readonly: true });
|
|
191
|
+
const actual = getTableColumns(db, "messages");
|
|
192
|
+
db.close();
|
|
193
|
+
store.close();
|
|
194
|
+
|
|
195
|
+
// Columns from MessageRow interface in src/mail/store.ts
|
|
196
|
+
const expected = [
|
|
197
|
+
"body",
|
|
198
|
+
"created_at",
|
|
199
|
+
"from_agent",
|
|
200
|
+
"id",
|
|
201
|
+
"payload",
|
|
202
|
+
"priority",
|
|
203
|
+
"read",
|
|
204
|
+
"subject",
|
|
205
|
+
"thread_id",
|
|
206
|
+
"to_agent",
|
|
207
|
+
"type",
|
|
208
|
+
].sort();
|
|
209
|
+
|
|
210
|
+
expect(actual).toEqual(expected);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("MergeQueue", () => {
|
|
215
|
+
test("merge_queue table columns match MergeQueueRow interface", () => {
|
|
216
|
+
const dbPath = join(tmpDir, "merge-queue.db");
|
|
217
|
+
const queue = createMergeQueue(dbPath);
|
|
218
|
+
|
|
219
|
+
const db = new Database(dbPath, { readonly: true });
|
|
220
|
+
const actual = getTableColumns(db, "merge_queue");
|
|
221
|
+
db.close();
|
|
222
|
+
queue.close();
|
|
223
|
+
|
|
224
|
+
// Columns from MergeQueueRow interface in src/merge/queue.ts
|
|
225
|
+
const expected = [
|
|
226
|
+
"agent_name",
|
|
227
|
+
"branch_name",
|
|
228
|
+
"enqueued_at",
|
|
229
|
+
"files_modified",
|
|
230
|
+
"id",
|
|
231
|
+
"resolved_tier",
|
|
232
|
+
"status",
|
|
233
|
+
"task_id",
|
|
234
|
+
].sort();
|
|
235
|
+
|
|
236
|
+
expect(actual).toEqual(expected);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
package/src/sessions/compat.ts
CHANGED
|
@@ -86,7 +86,7 @@ export function openSessionStore(overstoryDir: string): {
|
|
|
86
86
|
// If the DB already existed AND has data, it is authoritative -- no migration needed.
|
|
87
87
|
// If the DB file exists but is empty (e.g., created by init before any sessions were
|
|
88
88
|
// recorded), fall through to check sessions.json for importable records (overstory-036f).
|
|
89
|
-
if (dbExists && store.
|
|
89
|
+
if (dbExists && store.count() > 0) {
|
|
90
90
|
return { store, migrated: false };
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -231,6 +231,43 @@ describe("getAll", () => {
|
|
|
231
231
|
});
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
// === count ===
|
|
235
|
+
|
|
236
|
+
describe("count", () => {
|
|
237
|
+
test("returns 0 on empty database", () => {
|
|
238
|
+
expect(store.count()).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("returns correct count after inserts", () => {
|
|
242
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
|
|
243
|
+
expect(store.count()).toBe(1);
|
|
244
|
+
|
|
245
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
|
|
246
|
+
expect(store.count()).toBe(2);
|
|
247
|
+
|
|
248
|
+
store.upsert(makeSession({ agentName: "a3", id: "s-3" }));
|
|
249
|
+
expect(store.count()).toBe(3);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("count reflects removals", () => {
|
|
253
|
+
store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
|
|
254
|
+
store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
|
|
255
|
+
|
|
256
|
+
store.remove("a1");
|
|
257
|
+
expect(store.count()).toBe(1);
|
|
258
|
+
|
|
259
|
+
store.remove("a2");
|
|
260
|
+
expect(store.count()).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("count matches getAll().length", () => {
|
|
264
|
+
for (let i = 0; i < 5; i++) {
|
|
265
|
+
store.upsert(makeSession({ agentName: `agent-${i}`, id: `s-${i}` }));
|
|
266
|
+
}
|
|
267
|
+
expect(store.count()).toBe(store.getAll().length);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
234
271
|
// === getByRun ===
|
|
235
272
|
|
|
236
273
|
describe("getByRun", () => {
|
package/src/sessions/store.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface SessionStore {
|
|
|
18
18
|
getActive(): AgentSession[];
|
|
19
19
|
/** Get all sessions regardless of state. */
|
|
20
20
|
getAll(): AgentSession[];
|
|
21
|
+
/** Get the total number of sessions. Lightweight alternative to getAll().length. */
|
|
22
|
+
count(): number;
|
|
21
23
|
/** Get sessions belonging to a specific run. */
|
|
22
24
|
getByRun(runId: string): AgentSession[];
|
|
23
25
|
/** Update only the state of a session. */
|
|
@@ -233,6 +235,10 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
233
235
|
SELECT * FROM sessions ORDER BY started_at ASC
|
|
234
236
|
`);
|
|
235
237
|
|
|
238
|
+
const countStmt = db.prepare<{ cnt: number }, Record<string, never>>(
|
|
239
|
+
"SELECT COUNT(*) as cnt FROM sessions",
|
|
240
|
+
);
|
|
241
|
+
|
|
236
242
|
const getByRunStmt = db.prepare<SessionRow, { $run_id: string }>(`
|
|
237
243
|
SELECT * FROM sessions WHERE run_id = $run_id ORDER BY started_at ASC
|
|
238
244
|
`);
|
|
@@ -299,6 +305,11 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
299
305
|
return rows.map(rowToSession);
|
|
300
306
|
},
|
|
301
307
|
|
|
308
|
+
count(): number {
|
|
309
|
+
const row = countStmt.get({});
|
|
310
|
+
return row?.cnt ?? 0;
|
|
311
|
+
},
|
|
312
|
+
|
|
302
313
|
getByRun(runId: string): AgentSession[] {
|
|
303
314
|
const rows = getByRunStmt.all({ $run_id: runId });
|
|
304
315
|
return rows.map(rowToSession);
|
|
@@ -3,6 +3,7 @@ import { AgentError } from "../errors.ts";
|
|
|
3
3
|
import {
|
|
4
4
|
capturePaneContent,
|
|
5
5
|
createSession,
|
|
6
|
+
ensureTmuxAvailable,
|
|
6
7
|
getDescendantPids,
|
|
7
8
|
getPanePid,
|
|
8
9
|
isProcessAlive,
|
|
@@ -961,21 +962,26 @@ describe("waitForTuiReady", () => {
|
|
|
961
962
|
});
|
|
962
963
|
|
|
963
964
|
test("returns true after content appears on later poll", async () => {
|
|
964
|
-
let
|
|
965
|
-
spawnSpy.mockImplementation(() => {
|
|
966
|
-
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
965
|
+
let captureCallCount = 0;
|
|
966
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
967
|
+
const cmd = args[0] as string[];
|
|
968
|
+
if (cmd[1] === "capture-pane") {
|
|
969
|
+
captureCallCount++;
|
|
970
|
+
if (captureCallCount <= 3) {
|
|
971
|
+
// First 3 capture-pane polls: empty pane (TUI still loading)
|
|
972
|
+
return mockSpawnResult("", "", 0);
|
|
973
|
+
}
|
|
974
|
+
// 4th poll: content appears
|
|
975
|
+
return mockSpawnResult("Welcome to Claude Code!", "", 0);
|
|
970
976
|
}
|
|
971
|
-
//
|
|
972
|
-
return mockSpawnResult("
|
|
977
|
+
// has-session: session is alive throughout
|
|
978
|
+
return mockSpawnResult("", "", 0);
|
|
973
979
|
});
|
|
974
980
|
|
|
975
981
|
const ready = await waitForTuiReady("overstory-agent", 10_000, 500);
|
|
976
982
|
|
|
977
983
|
expect(ready).toBe(true);
|
|
978
|
-
// Should have slept 3 times (3 empty polls before content appeared)
|
|
984
|
+
// Should have slept 3 times (3 empty capture-pane polls before content appeared)
|
|
979
985
|
expect(sleepSpy).toHaveBeenCalledTimes(3);
|
|
980
986
|
});
|
|
981
987
|
|
|
@@ -1006,4 +1012,87 @@ describe("waitForTuiReady", () => {
|
|
|
1006
1012
|
|
|
1007
1013
|
expect(ready).toBe(true);
|
|
1008
1014
|
});
|
|
1015
|
+
|
|
1016
|
+
test("returns false immediately when session is dead", async () => {
|
|
1017
|
+
// capture-pane fails (session dead), has-session also fails (session dead)
|
|
1018
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1019
|
+
const cmd = args[0] as string[];
|
|
1020
|
+
if (cmd[1] === "capture-pane") {
|
|
1021
|
+
return mockSpawnResult("", "can't find session", 1);
|
|
1022
|
+
}
|
|
1023
|
+
// has-session: session is dead
|
|
1024
|
+
return mockSpawnResult("", "can't find session", 1);
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
const ready = await waitForTuiReady("dead-session", 15_000, 500);
|
|
1028
|
+
|
|
1029
|
+
expect(ready).toBe(false);
|
|
1030
|
+
// Should NOT have polled the full timeout (no sleeps — returned immediately)
|
|
1031
|
+
expect(sleepSpy).not.toHaveBeenCalled();
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
test("continues polling when session is alive but pane is empty", async () => {
|
|
1035
|
+
let captureCallCount = 0;
|
|
1036
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1037
|
+
const cmd = args[0] as string[];
|
|
1038
|
+
if (cmd[1] === "capture-pane") {
|
|
1039
|
+
captureCallCount++;
|
|
1040
|
+
// Pane stays empty for all polls (session alive but TUI not rendered yet)
|
|
1041
|
+
return mockSpawnResult("", "", 0);
|
|
1042
|
+
}
|
|
1043
|
+
// has-session: session is alive
|
|
1044
|
+
return mockSpawnResult("", "", 0);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// Use a short timeout so the test doesn't take long
|
|
1048
|
+
const ready = await waitForTuiReady("loading-session", 1_000, 500);
|
|
1049
|
+
|
|
1050
|
+
expect(ready).toBe(false);
|
|
1051
|
+
// Should have polled multiple times (not returned early)
|
|
1052
|
+
expect(captureCallCount).toBeGreaterThan(1);
|
|
1053
|
+
expect(sleepSpy).toHaveBeenCalled();
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
describe("ensureTmuxAvailable", () => {
|
|
1058
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
1059
|
+
|
|
1060
|
+
beforeEach(() => {
|
|
1061
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
afterEach(() => {
|
|
1065
|
+
spawnSpy.mockRestore();
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test("succeeds when tmux is available", async () => {
|
|
1069
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("tmux 3.3a\n", "", 0));
|
|
1070
|
+
|
|
1071
|
+
// Should not throw
|
|
1072
|
+
await ensureTmuxAvailable();
|
|
1073
|
+
|
|
1074
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
1075
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1076
|
+
const cmd = callArgs[0] as string[];
|
|
1077
|
+
expect(cmd).toEqual(["tmux", "-V"]);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("throws AgentError when tmux is not installed", async () => {
|
|
1081
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "tmux: command not found", 1));
|
|
1082
|
+
|
|
1083
|
+
await expect(ensureTmuxAvailable()).rejects.toThrow(AgentError);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("AgentError message mentions tmux not installed", async () => {
|
|
1087
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 127));
|
|
1088
|
+
|
|
1089
|
+
try {
|
|
1090
|
+
await ensureTmuxAvailable();
|
|
1091
|
+
expect(true).toBe(false); // Should have thrown
|
|
1092
|
+
} catch (err: unknown) {
|
|
1093
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
1094
|
+
const agentErr = err as AgentError;
|
|
1095
|
+
expect(agentErr.message).toContain("tmux is not installed");
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1009
1098
|
});
|
package/src/worktree/tmux.ts
CHANGED
|
@@ -455,11 +455,29 @@ export async function waitForTuiReady(
|
|
|
455
455
|
if (content !== null) {
|
|
456
456
|
return true;
|
|
457
457
|
}
|
|
458
|
+
// Check if session died — no point waiting if it's gone
|
|
459
|
+
const alive = await isSessionAlive(name);
|
|
460
|
+
if (!alive) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
458
463
|
await Bun.sleep(pollIntervalMs);
|
|
459
464
|
}
|
|
460
465
|
return false;
|
|
461
466
|
}
|
|
462
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Verify that tmux is installed and executable.
|
|
470
|
+
* Throws AgentError with a clear message if tmux is not available.
|
|
471
|
+
*/
|
|
472
|
+
export async function ensureTmuxAvailable(): Promise<void> {
|
|
473
|
+
const { exitCode } = await runCommand(["tmux", "-V"]);
|
|
474
|
+
if (exitCode !== 0) {
|
|
475
|
+
throw new AgentError(
|
|
476
|
+
"tmux is not installed or not on PATH. Install tmux to use overstory agent orchestration.",
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
463
481
|
/**
|
|
464
482
|
* Send keys to a tmux session.
|
|
465
483
|
*
|