@os-eco/overstory-cli 0.6.11 → 0.7.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 (46) hide show
  1. package/README.md +7 -9
  2. package/agents/lead.md +20 -19
  3. package/package.json +5 -3
  4. package/src/agents/overlay.test.ts +23 -0
  5. package/src/agents/overlay.ts +5 -4
  6. package/src/commands/coordinator.ts +21 -9
  7. package/src/commands/costs.test.ts +1 -1
  8. package/src/commands/costs.ts +13 -20
  9. package/src/commands/dashboard.ts +38 -138
  10. package/src/commands/doctor.test.ts +1 -1
  11. package/src/commands/doctor.ts +2 -2
  12. package/src/commands/ecosystem.ts +2 -1
  13. package/src/commands/errors.test.ts +4 -5
  14. package/src/commands/errors.ts +4 -62
  15. package/src/commands/feed.test.ts +2 -2
  16. package/src/commands/feed.ts +12 -106
  17. package/src/commands/inspect.ts +10 -44
  18. package/src/commands/logs.ts +7 -63
  19. package/src/commands/metrics.test.ts +2 -2
  20. package/src/commands/metrics.ts +3 -17
  21. package/src/commands/monitor.ts +17 -7
  22. package/src/commands/replay.test.ts +2 -2
  23. package/src/commands/replay.ts +12 -135
  24. package/src/commands/run.ts +7 -23
  25. package/src/commands/sling.test.ts +53 -0
  26. package/src/commands/sling.ts +25 -10
  27. package/src/commands/status.ts +4 -17
  28. package/src/commands/supervisor.ts +18 -8
  29. package/src/commands/trace.test.ts +5 -6
  30. package/src/commands/trace.ts +11 -109
  31. package/src/config.ts +10 -0
  32. package/src/index.ts +2 -1
  33. package/src/logging/format.ts +214 -0
  34. package/src/logging/theme.ts +132 -0
  35. package/src/metrics/store.test.ts +46 -0
  36. package/src/metrics/store.ts +11 -0
  37. package/src/mulch/client.test.ts +20 -0
  38. package/src/mulch/client.ts +312 -45
  39. package/src/runtimes/claude.test.ts +616 -0
  40. package/src/runtimes/claude.ts +218 -0
  41. package/src/runtimes/registry.test.ts +53 -0
  42. package/src/runtimes/registry.ts +33 -0
  43. package/src/runtimes/types.ts +125 -0
  44. package/src/types.ts +4 -0
  45. package/src/worktree/tmux.test.ts +28 -13
  46. package/src/worktree/tmux.ts +14 -28
@@ -0,0 +1,616 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ResolvedModel } from "../types.ts";
6
+ import { ClaudeRuntime } from "./claude.ts";
7
+ import type { SpawnOpts } from "./types.ts";
8
+
9
+ describe("ClaudeRuntime", () => {
10
+ const runtime = new ClaudeRuntime();
11
+
12
+ describe("id and instructionPath", () => {
13
+ test("id is 'claude'", () => {
14
+ expect(runtime.id).toBe("claude");
15
+ });
16
+
17
+ test("instructionPath is .claude/CLAUDE.md", () => {
18
+ expect(runtime.instructionPath).toBe(".claude/CLAUDE.md");
19
+ });
20
+ });
21
+
22
+ describe("buildSpawnCommand", () => {
23
+ test("basic command with bypass permission mode", () => {
24
+ const opts: SpawnOpts = {
25
+ model: "sonnet",
26
+ permissionMode: "bypass",
27
+ cwd: "/tmp/worktree",
28
+ env: {},
29
+ };
30
+ const cmd = runtime.buildSpawnCommand(opts);
31
+ expect(cmd).toBe("claude --model sonnet --permission-mode bypassPermissions");
32
+ });
33
+
34
+ test("basic command with ask permission mode", () => {
35
+ const opts: SpawnOpts = {
36
+ model: "opus",
37
+ permissionMode: "ask",
38
+ cwd: "/tmp/worktree",
39
+ env: {},
40
+ };
41
+ const cmd = runtime.buildSpawnCommand(opts);
42
+ expect(cmd).toBe("claude --model opus --permission-mode default");
43
+ });
44
+
45
+ test("with appendSystemPrompt (no quotes in prompt)", () => {
46
+ const opts: SpawnOpts = {
47
+ model: "sonnet",
48
+ permissionMode: "bypass",
49
+ cwd: "/tmp/worktree",
50
+ env: {},
51
+ appendSystemPrompt: "You are a builder agent.",
52
+ };
53
+ const cmd = runtime.buildSpawnCommand(opts);
54
+ expect(cmd).toBe(
55
+ "claude --model sonnet --permission-mode bypassPermissions --append-system-prompt 'You are a builder agent.'",
56
+ );
57
+ });
58
+
59
+ test("with appendSystemPrompt containing single quotes", () => {
60
+ const opts: SpawnOpts = {
61
+ model: "sonnet",
62
+ permissionMode: "bypass",
63
+ cwd: "/tmp/worktree",
64
+ env: {},
65
+ appendSystemPrompt: "Don't touch the user's files",
66
+ };
67
+ const cmd = runtime.buildSpawnCommand(opts);
68
+ // POSIX single-quote escape: end quote, backslash-quote, start quote → '\\''
69
+ expect(cmd).toContain("--append-system-prompt");
70
+ expect(cmd).toBe(
71
+ "claude --model sonnet --permission-mode bypassPermissions --append-system-prompt 'Don'\\''t touch the user'\\''s files'",
72
+ );
73
+ });
74
+
75
+ test("without appendSystemPrompt omits the flag", () => {
76
+ const opts: SpawnOpts = {
77
+ model: "haiku",
78
+ permissionMode: "bypass",
79
+ cwd: "/tmp/worktree",
80
+ env: {},
81
+ };
82
+ const cmd = runtime.buildSpawnCommand(opts);
83
+ expect(cmd).not.toContain("--append-system-prompt");
84
+ });
85
+
86
+ test("cwd and env are not embedded in command string", () => {
87
+ const opts: SpawnOpts = {
88
+ model: "sonnet",
89
+ permissionMode: "bypass",
90
+ cwd: "/some/specific/path",
91
+ env: { ANTHROPIC_API_KEY: "sk-test-123" },
92
+ };
93
+ const cmd = runtime.buildSpawnCommand(opts);
94
+ expect(cmd).not.toContain("/some/specific/path");
95
+ expect(cmd).not.toContain("sk-test-123");
96
+ expect(cmd).not.toContain("ANTHROPIC_API_KEY");
97
+ });
98
+
99
+ test("produces identical output for the same inputs (deterministic)", () => {
100
+ const opts: SpawnOpts = {
101
+ model: "sonnet",
102
+ permissionMode: "bypass",
103
+ cwd: "/tmp/worktree",
104
+ env: {},
105
+ appendSystemPrompt: "You are a scout.",
106
+ };
107
+ const cmd1 = runtime.buildSpawnCommand(opts);
108
+ const cmd2 = runtime.buildSpawnCommand(opts);
109
+ expect(cmd1).toBe(cmd2);
110
+ });
111
+
112
+ test("all model names pass through unchanged", () => {
113
+ for (const model of ["sonnet", "opus", "haiku", "claude-sonnet-4-6", "openrouter/gpt-5"]) {
114
+ const opts: SpawnOpts = {
115
+ model,
116
+ permissionMode: "bypass",
117
+ cwd: "/tmp",
118
+ env: {},
119
+ };
120
+ const cmd = runtime.buildSpawnCommand(opts);
121
+ expect(cmd).toContain(`--model ${model}`);
122
+ }
123
+ });
124
+
125
+ test("systemPrompt field is ignored (only appendSystemPrompt is used)", () => {
126
+ const opts: SpawnOpts = {
127
+ model: "sonnet",
128
+ permissionMode: "bypass",
129
+ cwd: "/tmp",
130
+ env: {},
131
+ systemPrompt: "This should not appear",
132
+ };
133
+ const cmd = runtime.buildSpawnCommand(opts);
134
+ expect(cmd).not.toContain("This should not appear");
135
+ expect(cmd).not.toContain("--system-prompt");
136
+ });
137
+ });
138
+
139
+ describe("buildPrintCommand", () => {
140
+ test("basic command without model", () => {
141
+ const argv = runtime.buildPrintCommand("Summarize this diff");
142
+ expect(argv).toEqual(["claude", "--print", "-p", "Summarize this diff"]);
143
+ });
144
+
145
+ test("command with model override", () => {
146
+ const argv = runtime.buildPrintCommand("Classify this error", "haiku");
147
+ expect(argv).toEqual(["claude", "--print", "-p", "Classify this error", "--model", "haiku"]);
148
+ });
149
+
150
+ test("model undefined omits --model flag", () => {
151
+ const argv = runtime.buildPrintCommand("Hello", undefined);
152
+ expect(argv).not.toContain("--model");
153
+ });
154
+ });
155
+
156
+ describe("detectReady", () => {
157
+ test("returns loading for empty pane", () => {
158
+ const state = runtime.detectReady("");
159
+ expect(state).toEqual({ phase: "loading" });
160
+ });
161
+
162
+ test("returns loading for partial content (prompt only, no status bar)", () => {
163
+ const state = runtime.detectReady("Welcome to Claude Code!\n\u276f");
164
+ expect(state).toEqual({ phase: "loading" });
165
+ });
166
+
167
+ test("returns loading for partial content (status bar only, no prompt)", () => {
168
+ const state = runtime.detectReady("bypass permissions");
169
+ expect(state).toEqual({ phase: "loading" });
170
+ });
171
+
172
+ test("returns ready for prompt indicator ❯ + bypass permissions", () => {
173
+ const state = runtime.detectReady("Welcome to Claude Code!\n\u276f\nbypass permissions");
174
+ expect(state).toEqual({ phase: "ready" });
175
+ });
176
+
177
+ test('returns ready for Try " + bypass permissions', () => {
178
+ const state = runtime.detectReady('Try "help" to get started\nbypass permissions');
179
+ expect(state).toEqual({ phase: "ready" });
180
+ });
181
+
182
+ test("returns ready for prompt indicator + shift+tab", () => {
183
+ const state = runtime.detectReady("Claude Code\n\u276f\nshift+tab to chat");
184
+ expect(state).toEqual({ phase: "ready" });
185
+ });
186
+
187
+ test('returns ready for Try " + shift+tab', () => {
188
+ const state = runtime.detectReady('Try "help"\nshift+tab');
189
+ expect(state).toEqual({ phase: "ready" });
190
+ });
191
+
192
+ test("returns dialog for trust dialog", () => {
193
+ const state = runtime.detectReady("Do you trust this folder? trust this folder");
194
+ expect(state).toEqual({ phase: "dialog", action: "Enter" });
195
+ });
196
+
197
+ test("trust dialog takes precedence over ready indicators", () => {
198
+ const state = runtime.detectReady("trust this folder\n\u276f\nbypass permissions");
199
+ expect(state).toEqual({ phase: "dialog", action: "Enter" });
200
+ });
201
+
202
+ test("returns loading for random pane content", () => {
203
+ const state = runtime.detectReady("Loading Claude Code...\nPlease wait");
204
+ expect(state).toEqual({ phase: "loading" });
205
+ });
206
+ });
207
+
208
+ describe("buildEnv", () => {
209
+ test("returns empty object when model has no env", () => {
210
+ const model: ResolvedModel = { model: "sonnet" };
211
+ const env = runtime.buildEnv(model);
212
+ expect(env).toEqual({});
213
+ });
214
+
215
+ test("returns model.env when present", () => {
216
+ const model: ResolvedModel = {
217
+ model: "sonnet",
218
+ env: { ANTHROPIC_API_KEY: "sk-test-123", ANTHROPIC_BASE_URL: "https://api.example.com" },
219
+ };
220
+ const env = runtime.buildEnv(model);
221
+ expect(env).toEqual({
222
+ ANTHROPIC_API_KEY: "sk-test-123",
223
+ ANTHROPIC_BASE_URL: "https://api.example.com",
224
+ });
225
+ });
226
+
227
+ test("returns empty object when model.env is undefined", () => {
228
+ const model: ResolvedModel = { model: "opus", env: undefined };
229
+ const env = runtime.buildEnv(model);
230
+ expect(env).toEqual({});
231
+ });
232
+ });
233
+
234
+ describe("deployConfig", () => {
235
+ let tempDir: string;
236
+
237
+ beforeEach(async () => {
238
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-claude-test-"));
239
+ });
240
+
241
+ afterEach(async () => {
242
+ await rm(tempDir, { recursive: true, force: true });
243
+ });
244
+
245
+ test("writes overlay to .claude/CLAUDE.md when overlay is provided", async () => {
246
+ const worktreePath = join(tempDir, "worktree");
247
+
248
+ await runtime.deployConfig(
249
+ worktreePath,
250
+ { content: "# Agent Overlay\nThis is the overlay content." },
251
+ {
252
+ agentName: "test-builder",
253
+ capability: "builder",
254
+ worktreePath,
255
+ },
256
+ );
257
+
258
+ const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
259
+ const content = await Bun.file(overlayPath).text();
260
+ expect(content).toBe("# Agent Overlay\nThis is the overlay content.");
261
+ });
262
+
263
+ test("writes settings.local.json when overlay is provided", async () => {
264
+ const worktreePath = join(tempDir, "worktree");
265
+
266
+ await runtime.deployConfig(
267
+ worktreePath,
268
+ { content: "# Overlay" },
269
+ {
270
+ agentName: "test-builder",
271
+ capability: "builder",
272
+ worktreePath,
273
+ },
274
+ );
275
+
276
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
277
+ const exists = await Bun.file(settingsPath).exists();
278
+ expect(exists).toBe(true);
279
+
280
+ const parsed = JSON.parse(await Bun.file(settingsPath).text());
281
+ expect(parsed.hooks).toBeDefined();
282
+ });
283
+
284
+ test("skips overlay write when overlay is undefined (hooks-only)", async () => {
285
+ const worktreePath = join(tempDir, "worktree");
286
+
287
+ await runtime.deployConfig(worktreePath, undefined, {
288
+ agentName: "coordinator",
289
+ capability: "coordinator",
290
+ worktreePath,
291
+ });
292
+
293
+ // CLAUDE.md should NOT exist (no overlay written)
294
+ const overlayPath = join(worktreePath, ".claude", "CLAUDE.md");
295
+ const overlayExists = await Bun.file(overlayPath).exists();
296
+ expect(overlayExists).toBe(false);
297
+
298
+ // But settings.local.json SHOULD exist (hooks deployed)
299
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
300
+ const settingsExists = await Bun.file(settingsPath).exists();
301
+ expect(settingsExists).toBe(true);
302
+ });
303
+
304
+ test("settings.local.json contains agent name", async () => {
305
+ const worktreePath = join(tempDir, "worktree");
306
+
307
+ await runtime.deployConfig(worktreePath, undefined, {
308
+ agentName: "my-supervisor",
309
+ capability: "supervisor",
310
+ worktreePath,
311
+ });
312
+
313
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
314
+ const content = await Bun.file(settingsPath).text();
315
+ expect(content).toContain("my-supervisor");
316
+ expect(content).not.toContain("{{AGENT_NAME}}");
317
+ });
318
+
319
+ test("settings.local.json is valid JSON with hooks", async () => {
320
+ const worktreePath = join(tempDir, "worktree");
321
+
322
+ await runtime.deployConfig(
323
+ worktreePath,
324
+ { content: "# Overlay" },
325
+ {
326
+ agentName: "json-test",
327
+ capability: "builder",
328
+ worktreePath,
329
+ },
330
+ );
331
+
332
+ const settingsPath = join(worktreePath, ".claude", "settings.local.json");
333
+ const content = await Bun.file(settingsPath).text();
334
+ const parsed = JSON.parse(content);
335
+ expect(parsed.hooks).toBeDefined();
336
+ expect(typeof parsed.hooks).toBe("object");
337
+ });
338
+
339
+ test("different capabilities produce different guard sets", async () => {
340
+ const builderPath = join(tempDir, "builder-wt");
341
+ const scoutPath = join(tempDir, "scout-wt");
342
+
343
+ await runtime.deployConfig(
344
+ builderPath,
345
+ { content: "# Builder" },
346
+ { agentName: "test-builder", capability: "builder", worktreePath: builderPath },
347
+ );
348
+
349
+ await runtime.deployConfig(
350
+ scoutPath,
351
+ { content: "# Scout" },
352
+ { agentName: "test-scout", capability: "scout", worktreePath: scoutPath },
353
+ );
354
+
355
+ const builderSettings = await Bun.file(
356
+ join(builderPath, ".claude", "settings.local.json"),
357
+ ).text();
358
+ const scoutSettings = await Bun.file(
359
+ join(scoutPath, ".claude", "settings.local.json"),
360
+ ).text();
361
+
362
+ // Scout should have file-modification guards that builder doesn't
363
+ // Scout is non-implementation, builder is implementation
364
+ expect(scoutSettings).not.toBe(builderSettings);
365
+ });
366
+ });
367
+
368
+ describe("parseTranscript", () => {
369
+ let tempDir: string;
370
+
371
+ beforeEach(async () => {
372
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-transcript-test-"));
373
+ });
374
+
375
+ afterEach(async () => {
376
+ await rm(tempDir, { recursive: true, force: true });
377
+ });
378
+
379
+ test("returns null for non-existent file", async () => {
380
+ const result = await runtime.parseTranscript(join(tempDir, "does-not-exist.jsonl"));
381
+ expect(result).toBeNull();
382
+ });
383
+
384
+ test("parses a valid transcript with one assistant turn", async () => {
385
+ const transcriptPath = join(tempDir, "session.jsonl");
386
+ const entry = JSON.stringify({
387
+ type: "assistant",
388
+ message: {
389
+ model: "claude-sonnet-4-6",
390
+ usage: {
391
+ input_tokens: 100,
392
+ output_tokens: 50,
393
+ cache_read_input_tokens: 500,
394
+ cache_creation_input_tokens: 200,
395
+ },
396
+ },
397
+ });
398
+ await Bun.write(transcriptPath, `${entry}\n`);
399
+
400
+ const result = await runtime.parseTranscript(transcriptPath);
401
+ expect(result).not.toBeNull();
402
+ expect(result?.inputTokens).toBe(100);
403
+ expect(result?.outputTokens).toBe(50);
404
+ expect(result?.model).toBe("claude-sonnet-4-6");
405
+ });
406
+
407
+ test("aggregates multiple assistant turns", async () => {
408
+ const transcriptPath = join(tempDir, "session.jsonl");
409
+ const entry1 = JSON.stringify({
410
+ type: "assistant",
411
+ message: {
412
+ model: "claude-sonnet-4-6",
413
+ usage: { input_tokens: 100, output_tokens: 50 },
414
+ },
415
+ });
416
+ const entry2 = JSON.stringify({
417
+ type: "assistant",
418
+ message: {
419
+ model: "claude-sonnet-4-6",
420
+ usage: { input_tokens: 200, output_tokens: 75 },
421
+ },
422
+ });
423
+ await Bun.write(transcriptPath, `${entry1}\n${entry2}\n`);
424
+
425
+ const result = await runtime.parseTranscript(transcriptPath);
426
+ expect(result).not.toBeNull();
427
+ expect(result?.inputTokens).toBe(300);
428
+ expect(result?.outputTokens).toBe(125);
429
+ });
430
+
431
+ test("skips non-assistant entries", async () => {
432
+ const transcriptPath = join(tempDir, "session.jsonl");
433
+ const userEntry = JSON.stringify({ type: "user", message: { content: "hello" } });
434
+ const assistantEntry = JSON.stringify({
435
+ type: "assistant",
436
+ message: {
437
+ model: "claude-sonnet-4-6",
438
+ usage: { input_tokens: 50, output_tokens: 25 },
439
+ },
440
+ });
441
+ await Bun.write(transcriptPath, `${userEntry}\n${assistantEntry}\n`);
442
+
443
+ const result = await runtime.parseTranscript(transcriptPath);
444
+ expect(result).not.toBeNull();
445
+ expect(result?.inputTokens).toBe(50);
446
+ expect(result?.outputTokens).toBe(25);
447
+ });
448
+
449
+ test("returns null for malformed file", async () => {
450
+ const transcriptPath = join(tempDir, "bad.jsonl");
451
+ await Bun.write(transcriptPath, "not json at all\n{broken");
452
+
453
+ const result = await runtime.parseTranscript(transcriptPath);
454
+ // parseTranscriptUsage should handle gracefully; result may have 0 tokens
455
+ // If it throws, ClaudeRuntime catches and returns null
456
+ if (result !== null) {
457
+ expect(result.inputTokens).toBe(0);
458
+ expect(result.outputTokens).toBe(0);
459
+ }
460
+ });
461
+ });
462
+ });
463
+
464
+ describe("ClaudeRuntime integration: spawn command matches pre-refactor behavior", () => {
465
+ const runtime = new ClaudeRuntime();
466
+
467
+ test("sling-style spawn: bypass mode, no system prompt", () => {
468
+ const cmd = runtime.buildSpawnCommand({
469
+ model: "sonnet",
470
+ permissionMode: "bypass",
471
+ cwd: "/project/.overstory/worktrees/builder-1",
472
+ env: { OVERSTORY_AGENT_NAME: "builder-1" },
473
+ });
474
+ // Pre-refactor: `claude --model ${model} --permission-mode bypassPermissions`
475
+ expect(cmd).toBe("claude --model sonnet --permission-mode bypassPermissions");
476
+ });
477
+
478
+ test("coordinator-style spawn: bypass mode with appendSystemPrompt", () => {
479
+ const baseDefinition = "# Coordinator\nYou are the coordinator agent.";
480
+ const cmd = runtime.buildSpawnCommand({
481
+ model: "opus",
482
+ permissionMode: "bypass",
483
+ cwd: "/project",
484
+ appendSystemPrompt: baseDefinition,
485
+ env: { OVERSTORY_AGENT_NAME: "coordinator" },
486
+ });
487
+ // Pre-refactor: `claude --model ${model} --permission-mode bypassPermissions --append-system-prompt '...'`
488
+ expect(cmd).toBe(
489
+ `claude --model opus --permission-mode bypassPermissions --append-system-prompt '# Coordinator\nYou are the coordinator agent.'`,
490
+ );
491
+ });
492
+
493
+ test("supervisor-style spawn: identical to coordinator pattern", () => {
494
+ const baseDefinition = "# Supervisor\nYou manage a project.";
495
+ const cmd = runtime.buildSpawnCommand({
496
+ model: "opus",
497
+ permissionMode: "bypass",
498
+ cwd: "/project",
499
+ appendSystemPrompt: baseDefinition,
500
+ env: { OVERSTORY_AGENT_NAME: "supervisor-1" },
501
+ });
502
+ expect(cmd).toContain("--model opus");
503
+ expect(cmd).toContain("--permission-mode bypassPermissions");
504
+ expect(cmd).toContain("--append-system-prompt");
505
+ expect(cmd).toContain("# Supervisor");
506
+ });
507
+
508
+ test("monitor-style spawn: sonnet model with appendSystemPrompt", () => {
509
+ const baseDefinition = "# Monitor\nYou patrol the fleet.";
510
+ const cmd = runtime.buildSpawnCommand({
511
+ model: "sonnet",
512
+ permissionMode: "bypass",
513
+ cwd: "/project",
514
+ appendSystemPrompt: baseDefinition,
515
+ env: { OVERSTORY_AGENT_NAME: "monitor" },
516
+ });
517
+ expect(cmd).toBe(
518
+ `claude --model sonnet --permission-mode bypassPermissions --append-system-prompt '# Monitor\nYou patrol the fleet.'`,
519
+ );
520
+ });
521
+ });
522
+
523
+ describe("ClaudeRuntime integration: detectReady matches pre-refactor tmux behavior", () => {
524
+ const runtime = new ClaudeRuntime();
525
+
526
+ // These test cases mirror the exact pane content strings used in tmux.test.ts
527
+ // to verify the callback produces identical behavior to the old hardcoded detection.
528
+
529
+ test("ready: 'Try \"help\" to get started' + 'bypass permissions'", () => {
530
+ const state = runtime.detectReady('Try "help" to get started\nbypass permissions');
531
+ expect(state.phase).toBe("ready");
532
+ });
533
+
534
+ test("ready: ❯ + 'bypass permissions'", () => {
535
+ const state = runtime.detectReady("Welcome to Claude Code!\n\n\u276f\nbypass permissions");
536
+ expect(state.phase).toBe("ready");
537
+ });
538
+
539
+ test("ready: 'Try \"help\"' + 'shift+tab'", () => {
540
+ const state = runtime.detectReady('Try "help"\nshift+tab');
541
+ expect(state.phase).toBe("ready");
542
+ });
543
+
544
+ test("not ready: only prompt (no status bar)", () => {
545
+ const state = runtime.detectReady("Welcome to Claude Code!\n\u276f");
546
+ expect(state.phase).toBe("loading");
547
+ });
548
+
549
+ test("not ready: only status bar (no prompt)", () => {
550
+ const state = runtime.detectReady("bypass permissions");
551
+ expect(state.phase).toBe("loading");
552
+ });
553
+
554
+ test("dialog: trust this folder", () => {
555
+ const state = runtime.detectReady("Do you trust this folder? trust this folder");
556
+ expect(state.phase).toBe("dialog");
557
+ expect((state as { phase: "dialog"; action: string }).action).toBe("Enter");
558
+ });
559
+ });
560
+
561
+ describe("ClaudeRuntime integration: buildEnv matches pre-refactor env injection", () => {
562
+ const runtime = new ClaudeRuntime();
563
+
564
+ test("native Anthropic model: passes env through", () => {
565
+ const model: ResolvedModel = {
566
+ model: "sonnet",
567
+ env: { ANTHROPIC_API_KEY: "sk-ant-test" },
568
+ };
569
+ const env = runtime.buildEnv(model);
570
+ expect(env).toEqual({ ANTHROPIC_API_KEY: "sk-ant-test" });
571
+ });
572
+
573
+ test("gateway model: passes gateway env through", () => {
574
+ const model: ResolvedModel = {
575
+ model: "openrouter/gpt-5",
576
+ env: { OPENROUTER_API_KEY: "sk-or-test", OPENAI_BASE_URL: "https://openrouter.ai/api/v1" },
577
+ };
578
+ const env = runtime.buildEnv(model);
579
+ expect(env).toEqual({
580
+ OPENROUTER_API_KEY: "sk-or-test",
581
+ OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
582
+ });
583
+ });
584
+
585
+ test("model without env: returns empty object (safe to spread)", () => {
586
+ const model: ResolvedModel = { model: "sonnet" };
587
+ const env = runtime.buildEnv(model);
588
+ expect(env).toEqual({});
589
+ // Must be safe to spread into createSession env
590
+ const combined = { ...env, OVERSTORY_AGENT_NAME: "builder-1" };
591
+ expect(combined).toEqual({ OVERSTORY_AGENT_NAME: "builder-1" });
592
+ });
593
+ });
594
+
595
+ describe("ClaudeRuntime integration: registry resolves 'claude' as default", () => {
596
+ // Import registry here to test the full resolution path
597
+ test("getRuntime() returns ClaudeRuntime", async () => {
598
+ const { getRuntime } = await import("./registry.ts");
599
+ const rt = getRuntime();
600
+ expect(rt).toBeInstanceOf(ClaudeRuntime);
601
+ expect(rt.id).toBe("claude");
602
+ expect(rt.instructionPath).toBe(".claude/CLAUDE.md");
603
+ });
604
+
605
+ test("getRuntime('claude') returns ClaudeRuntime", async () => {
606
+ const { getRuntime } = await import("./registry.ts");
607
+ const rt = getRuntime("claude");
608
+ expect(rt).toBeInstanceOf(ClaudeRuntime);
609
+ });
610
+
611
+ test("getRuntime rejects unknown runtimes", async () => {
612
+ const { getRuntime } = await import("./registry.ts");
613
+ expect(() => getRuntime("codex")).toThrow('Unknown runtime: "codex"');
614
+ expect(() => getRuntime("pi")).toThrow('Unknown runtime: "pi"');
615
+ });
616
+ });