@os-eco/overstory-cli 0.8.6 → 0.8.7

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.
@@ -39,6 +39,9 @@ export class GeminiRuntime implements AgentRuntime {
39
39
  /** Unique identifier for this runtime. */
40
40
  readonly id = "gemini";
41
41
 
42
+ /** Stability level. Gemini adapter is experimental — not fully validated. */
43
+ readonly stability = "experimental" as const;
44
+
42
45
  /** Relative path to the instruction file within a worktree. */
43
46
  readonly instructionPath = "GEMINI.md";
44
47
 
@@ -41,6 +41,9 @@ export class OpenCodeRuntime implements AgentRuntime {
41
41
  /** Unique identifier for this runtime. */
42
42
  readonly id = "opencode";
43
43
 
44
+ /** Stability level. OpenCode adapter is experimental — not fully validated. */
45
+ readonly stability = "experimental" as const;
46
+
44
47
  /**
45
48
  * Relative path to the instruction file within a worktree.
46
49
  *
@@ -759,7 +759,7 @@ describe("PiRuntime integration: registry resolves 'pi'", () => {
759
759
 
760
760
  test("getRuntime rejects truly unknown runtimes", async () => {
761
761
  const { getRuntime } = await import("./registry.ts");
762
- expect(() => getRuntime("cursor")).toThrow('Unknown runtime: "cursor"');
763
762
  expect(() => getRuntime("nonexistent")).toThrow('Unknown runtime: "nonexistent"');
763
+ expect(() => getRuntime("does-not-exist")).toThrow('Unknown runtime: "does-not-exist"');
764
764
  });
765
765
  });
@@ -35,6 +35,9 @@ export class PiRuntime implements AgentRuntime {
35
35
  /** Unique identifier for this runtime. */
36
36
  readonly id = "pi";
37
37
 
38
+ /** Stability level. Pi adapter is experimental — not fully validated. */
39
+ readonly stability = "experimental" as const;
40
+
38
41
  /** Relative path to the instruction file within a worktree. Pi reads .claude/CLAUDE.md natively. */
39
42
  readonly instructionPath = ".claude/CLAUDE.md";
40
43
 
@@ -3,6 +3,7 @@ import type { OverstoryConfig } from "../types.ts";
3
3
  import { ClaudeRuntime } from "./claude.ts";
4
4
  import { CodexRuntime } from "./codex.ts";
5
5
  import { CopilotRuntime } from "./copilot.ts";
6
+ import { CursorRuntime } from "./cursor.ts";
6
7
  import { GeminiRuntime } from "./gemini.ts";
7
8
  import { OpenCodeRuntime } from "./opencode.ts";
8
9
  import { PiRuntime } from "./pi.ts";
@@ -23,7 +24,7 @@ describe("getRuntime", () => {
23
24
 
24
25
  it("throws with a helpful message for an unknown runtime", () => {
25
26
  expect(() => getRuntime("unknown-runtime")).toThrow(
26
- 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling, opencode',
27
+ 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, cursor, gemini, sapling, opencode',
27
28
  );
28
29
  });
29
30
 
@@ -106,6 +107,25 @@ describe("getRuntime", () => {
106
107
  expect(a).not.toBe(b);
107
108
  });
108
109
 
110
+ it("returns CursorRuntime when name is 'cursor'", () => {
111
+ const runtime = getRuntime("cursor");
112
+ expect(runtime).toBeInstanceOf(CursorRuntime);
113
+ expect(runtime.id).toBe("cursor");
114
+ });
115
+
116
+ it("uses config.runtime.default 'cursor' when name is omitted", () => {
117
+ const config = { runtime: { default: "cursor" } } as OverstoryConfig;
118
+ const runtime = getRuntime(undefined, config);
119
+ expect(runtime).toBeInstanceOf(CursorRuntime);
120
+ expect(runtime.id).toBe("cursor");
121
+ });
122
+
123
+ it("cursor runtime returns a new instance on each call", () => {
124
+ const a = getRuntime("cursor");
125
+ const b = getRuntime("cursor");
126
+ expect(a).not.toBe(b);
127
+ });
128
+
109
129
  it("returns GeminiRuntime when name is 'gemini'", () => {
110
130
  const runtime = getRuntime("gemini");
111
131
  expect(runtime).toBeInstanceOf(GeminiRuntime);
@@ -5,6 +5,7 @@ import type { OverstoryConfig } from "../types.ts";
5
5
  import { ClaudeRuntime } from "./claude.ts";
6
6
  import { CodexRuntime } from "./codex.ts";
7
7
  import { CopilotRuntime } from "./copilot.ts";
8
+ import { CursorRuntime } from "./cursor.ts";
8
9
  import { GeminiRuntime } from "./gemini.ts";
9
10
  import { OpenCodeRuntime } from "./opencode.ts";
10
11
  import { PiRuntime } from "./pi.ts";
@@ -17,6 +18,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
17
18
  ["codex", () => new CodexRuntime()],
18
19
  ["pi", () => new PiRuntime()],
19
20
  ["copilot", () => new CopilotRuntime()],
21
+ ["cursor", () => new CursorRuntime()],
20
22
  ["gemini", () => new GeminiRuntime()],
21
23
  ["sapling", () => new SaplingRuntime()],
22
24
  ["opencode", () => new OpenCodeRuntime()],
@@ -37,6 +39,7 @@ export function getAllRuntimes(): AgentRuntime[] {
37
39
  new CodexRuntime(),
38
40
  new PiRuntime(),
39
41
  new CopilotRuntime(),
42
+ new CursorRuntime(),
40
43
  new GeminiRuntime(),
41
44
  new SaplingRuntime(),
42
45
  new OpenCodeRuntime(),
@@ -345,6 +345,9 @@ export class SaplingRuntime implements AgentRuntime {
345
345
  /** Unique identifier for this runtime. */
346
346
  readonly id = "sapling";
347
347
 
348
+ /** Stability level. Sapling is the primary headless runtime. */
349
+ readonly stability = "stable" as const;
350
+
348
351
  /** Relative path to the instruction file within a worktree. */
349
352
  readonly instructionPath = "SAPLING.md";
350
353
 
@@ -19,6 +19,8 @@ export interface SpawnOpts {
19
19
  appendSystemPromptFile?: string;
20
20
  /** Working directory for the spawned process. */
21
21
  cwd: string;
22
+ /** Additional directories that the runtime may need to write outside cwd. */
23
+ sharedWritableDirs?: string[];
22
24
  /** Additional environment variables to pass to the spawned process. */
23
25
  env: Record<string, string>;
24
26
  }
@@ -147,6 +149,9 @@ export interface AgentRuntime {
147
149
  /** Unique runtime identifier (e.g. "claude", "codex", "pi"). */
148
150
  id: string;
149
151
 
152
+ /** Stability level of this runtime adapter. */
153
+ readonly stability: "stable" | "beta" | "experimental";
154
+
150
155
  /** Relative path to the instruction file within a worktree (e.g. ".claude/CLAUDE.md"). */
151
156
  readonly instructionPath: string;
152
157
 
@@ -87,6 +87,7 @@ describe("SQL schema consistency", () => {
87
87
  const expected = [
88
88
  "agent_count",
89
89
  "completed_at",
90
+ "coordinator_name",
90
91
  "coordinator_session_id",
91
92
  "id",
92
93
  "started_at",
@@ -587,6 +587,48 @@ describe("edge cases", () => {
587
587
  });
588
588
  });
589
589
 
590
+ // ============================================================
591
+ // SessionStore migration: coordinator_name in runs table
592
+ // ============================================================
593
+
594
+ describe("createSessionStore migrates runs table coordinator_name", () => {
595
+ test("opens successfully when existing runs table lacks coordinator_name", async () => {
596
+ // Close the store created by beforeEach so we can recreate the DB manually.
597
+ store.close();
598
+
599
+ const { Database: Db } = await import("bun:sqlite");
600
+ const legacyDb = new Db(dbPath);
601
+ // Drop runs table and recreate WITHOUT coordinator_name (simulates pre-migration DB).
602
+ legacyDb.exec("DROP TABLE IF EXISTS runs");
603
+ legacyDb.exec(`
604
+ CREATE TABLE runs (
605
+ id TEXT PRIMARY KEY,
606
+ started_at TEXT NOT NULL,
607
+ completed_at TEXT,
608
+ agent_count INTEGER NOT NULL DEFAULT 0,
609
+ coordinator_session_id TEXT,
610
+ status TEXT NOT NULL DEFAULT 'active'
611
+ )
612
+ `);
613
+ legacyDb.exec(
614
+ "INSERT INTO runs (id, started_at, status) VALUES ('legacy-run', '2026-01-01T00:00:00.000Z', 'active')",
615
+ );
616
+ legacyDb.close();
617
+
618
+ // createSessionStore should migrate the table and create indexes without error.
619
+ const migratedStore = createSessionStore(dbPath);
620
+ try {
621
+ // Verify the store works (no "no such column" error).
622
+ expect(migratedStore.getAll()).toBeArray();
623
+ } finally {
624
+ migratedStore.close();
625
+ }
626
+
627
+ // Re-assign so afterEach cleanup works.
628
+ store = createSessionStore(join(tempDir, "unused.db"));
629
+ });
630
+ });
631
+
590
632
  // ============================================================
591
633
  // RunStore Tests
592
634
  // ============================================================
@@ -892,6 +934,142 @@ describe("RunStore", () => {
892
934
  });
893
935
  });
894
936
 
937
+ // === coordinatorName ===
938
+
939
+ describe("coordinatorName", () => {
940
+ test("creates run with coordinatorName and retrieves it", () => {
941
+ runStore.createRun(makeRun({ coordinatorName: "coordinator" }));
942
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
943
+ expect(result?.coordinatorName).toBe("coordinator");
944
+ });
945
+
946
+ test("creates run with null coordinatorName", () => {
947
+ runStore.createRun(makeRun({ coordinatorName: null }));
948
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
949
+ expect(result?.coordinatorName).toBeNull();
950
+ });
951
+
952
+ test("creates run without coordinatorName defaults to null", () => {
953
+ runStore.createRun(makeRun());
954
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
955
+ expect(result?.coordinatorName).toBeNull();
956
+ });
957
+ });
958
+
959
+ // === getActiveRunForCoordinator ===
960
+
961
+ describe("getActiveRunForCoordinator", () => {
962
+ test("returns the active run for the given coordinator", () => {
963
+ runStore.createRun(
964
+ makeRun({
965
+ id: "run-coord-a",
966
+ coordinatorName: "coordinator-a",
967
+ startedAt: "2026-02-13T10:00:00.000Z",
968
+ }),
969
+ );
970
+ runStore.createRun(
971
+ makeRun({
972
+ id: "run-coord-b",
973
+ coordinatorName: "coordinator-b",
974
+ startedAt: "2026-02-13T11:00:00.000Z",
975
+ }),
976
+ );
977
+
978
+ const result = runStore.getActiveRunForCoordinator("coordinator-a");
979
+ expect(result?.id).toBe("run-coord-a");
980
+ });
981
+
982
+ test("returns null when no active run for coordinator", () => {
983
+ runStore.createRun(makeRun({ id: "run-coord-a", coordinatorName: "coordinator-a" }));
984
+ runStore.completeRun("run-coord-a", "completed");
985
+
986
+ const result = runStore.getActiveRunForCoordinator("coordinator-a");
987
+ expect(result).toBeNull();
988
+ });
989
+
990
+ test("returns null for unknown coordinator", () => {
991
+ runStore.createRun(makeRun({ id: "run-coord-a", coordinatorName: "coordinator-a" }));
992
+ const result = runStore.getActiveRunForCoordinator("other-coordinator");
993
+ expect(result).toBeNull();
994
+ });
995
+
996
+ test("returns most recent active run when coordinator has multiple", () => {
997
+ runStore.createRun(
998
+ makeRun({
999
+ id: "run-early",
1000
+ coordinatorName: "coordinator",
1001
+ startedAt: "2026-02-13T08:00:00.000Z",
1002
+ }),
1003
+ );
1004
+ runStore.createRun(
1005
+ makeRun({
1006
+ id: "run-late",
1007
+ coordinatorName: "coordinator",
1008
+ startedAt: "2026-02-13T12:00:00.000Z",
1009
+ }),
1010
+ );
1011
+
1012
+ const result = runStore.getActiveRunForCoordinator("coordinator");
1013
+ expect(result?.id).toBe("run-late");
1014
+ });
1015
+
1016
+ test("ignores runs for other coordinators", () => {
1017
+ runStore.createRun(makeRun({ id: "run-a", coordinatorName: "coordinator-a" }));
1018
+ runStore.createRun(
1019
+ makeRun({
1020
+ id: "run-b",
1021
+ coordinatorName: "coordinator-b",
1022
+ startedAt: "2026-02-13T11:00:00.000Z",
1023
+ }),
1024
+ );
1025
+
1026
+ const result = runStore.getActiveRunForCoordinator("coordinator-a");
1027
+ expect(result?.id).toBe("run-a");
1028
+ expect(result?.coordinatorName).toBe("coordinator-a");
1029
+ });
1030
+ });
1031
+
1032
+ // === migration: coordinator_name column ===
1033
+
1034
+ describe("migration: coordinator_name column", () => {
1035
+ test("adds coordinator_name column to existing runs table without it", async () => {
1036
+ // Create a store, close it, then manually drop the coordinator_name column
1037
+ // by creating a fresh DB without it, simulating a pre-migration schema.
1038
+ runStore.close();
1039
+
1040
+ const { Database: Db } = await import("bun:sqlite");
1041
+ const legacyDb = new Db(dbPath);
1042
+ legacyDb.exec("DROP TABLE IF EXISTS runs");
1043
+ legacyDb.exec(`
1044
+ CREATE TABLE runs (
1045
+ id TEXT PRIMARY KEY,
1046
+ started_at TEXT NOT NULL,
1047
+ completed_at TEXT,
1048
+ agent_count INTEGER NOT NULL DEFAULT 0,
1049
+ coordinator_session_id TEXT,
1050
+ status TEXT NOT NULL DEFAULT 'active'
1051
+ )
1052
+ `);
1053
+ legacyDb.exec(
1054
+ "INSERT INTO runs (id, started_at, status) VALUES ('legacy-run', '2026-01-01T00:00:00.000Z', 'active')",
1055
+ );
1056
+ legacyDb.close();
1057
+
1058
+ // Opening a new RunStore should run the migration and add coordinator_name
1059
+ const migratedStore = createRunStore(dbPath);
1060
+ try {
1061
+ const run = migratedStore.getRun("legacy-run");
1062
+ expect(run).not.toBeNull();
1063
+ expect(run?.coordinatorName).toBeNull();
1064
+ } finally {
1065
+ migratedStore.close();
1066
+ }
1067
+
1068
+ // Re-assign store so afterEach cleanup doesn't double-close
1069
+ runStore = createRunStore(join(tempDir, "unused-run.db"));
1070
+ });
1071
+ });
1072
+
895
1073
  // === close ===
896
1074
 
897
1075
  describe("close", () => {
@@ -66,6 +66,7 @@ interface RunRow {
66
66
  completed_at: string | null;
67
67
  agent_count: number;
68
68
  coordinator_session_id: string | null;
69
+ coordinator_name: string | null;
69
70
  status: string;
70
71
  }
71
72
 
@@ -102,12 +103,14 @@ CREATE TABLE IF NOT EXISTS runs (
102
103
  completed_at TEXT,
103
104
  agent_count INTEGER NOT NULL DEFAULT 0,
104
105
  coordinator_session_id TEXT,
106
+ coordinator_name TEXT,
105
107
  status TEXT NOT NULL DEFAULT 'active'
106
108
  CHECK(status IN ('active','completed','failed'))
107
109
  )`;
108
110
 
109
111
  const CREATE_RUNS_INDEXES = `
110
- CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status)`;
112
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
113
+ CREATE INDEX IF NOT EXISTS idx_runs_coordinator ON runs(coordinator_name)`;
111
114
 
112
115
  /** Convert a database row (snake_case) to an AgentSession object (camelCase). */
113
116
  function rowToSession(row: SessionRow): AgentSession {
@@ -140,6 +143,7 @@ function rowToRun(row: RunRow): Run {
140
143
  completedAt: row.completed_at,
141
144
  agentCount: row.agent_count,
142
145
  coordinatorSessionId: row.coordinator_session_id,
146
+ coordinatorName: row.coordinator_name,
143
147
  status: row.status as RunStatus,
144
148
  };
145
149
  }
@@ -182,16 +186,18 @@ export function createSessionStore(dbPath: string): SessionStore {
182
186
  db.exec("PRAGMA synchronous = NORMAL");
183
187
  db.exec("PRAGMA busy_timeout = 5000");
184
188
 
185
- // Create schema
189
+ // Create schema (tables first, then migrations, then indexes)
186
190
  db.exec(CREATE_TABLE);
187
- db.exec(CREATE_INDEXES);
188
191
  db.exec(CREATE_RUNS_TABLE);
189
- db.exec(CREATE_RUNS_INDEXES);
190
192
 
191
- // Migrate: rename bead_id task_id on existing tables
193
+ // Migrate existing tables BEFORE creating indexes that reference new columns.
192
194
  migrateBeadIdToTaskId(db);
193
- // Migrate: add transcript_path column to existing tables
194
195
  migrateAddTranscriptPath(db);
196
+ migrateAddCoordinatorName(db);
197
+
198
+ // Now safe to create indexes (all columns exist).
199
+ db.exec(CREATE_INDEXES);
200
+ db.exec(CREATE_RUNS_INDEXES);
195
201
 
196
202
  // Prepare statements for frequent operations
197
203
  const upsertStmt = db.prepare<
@@ -420,6 +426,18 @@ export function createSessionStore(dbPath: string): SessionStore {
420
426
  };
421
427
  }
422
428
 
429
+ /**
430
+ * Migrate an existing runs table to add the coordinator_name column.
431
+ * Safe to call multiple times — only adds the column if it does not exist.
432
+ */
433
+ function migrateAddCoordinatorName(db: Database): void {
434
+ const rows = db.prepare("PRAGMA table_info(runs)").all() as Array<{ name: string }>;
435
+ const existingColumns = new Set(rows.map((r) => r.name));
436
+ if (!existingColumns.has("coordinator_name")) {
437
+ db.exec("ALTER TABLE runs ADD COLUMN coordinator_name TEXT");
438
+ }
439
+ }
440
+
423
441
  /**
424
442
  * Create a new RunStore backed by a SQLite database at the given path.
425
443
  *
@@ -436,6 +454,11 @@ export function createRunStore(dbPath: string): RunStore {
436
454
 
437
455
  // Create schema (idempotent — safe if SessionStore already created these)
438
456
  db.exec(CREATE_RUNS_TABLE);
457
+
458
+ // Migrate: add coordinator_name column BEFORE creating indexes that reference it.
459
+ // The migration is a no-op on new databases (column already in CREATE_RUNS_TABLE).
460
+ migrateAddCoordinatorName(db);
461
+
439
462
  db.exec(CREATE_RUNS_INDEXES);
440
463
 
441
464
  // Prepare statements for frequent operations
@@ -447,11 +470,12 @@ export function createRunStore(dbPath: string): RunStore {
447
470
  $completed_at: string | null;
448
471
  $agent_count: number;
449
472
  $coordinator_session_id: string | null;
473
+ $coordinator_name: string | null;
450
474
  $status: string;
451
475
  }
452
476
  >(`
453
- INSERT INTO runs (id, started_at, completed_at, agent_count, coordinator_session_id, status)
454
- VALUES ($id, $started_at, $completed_at, $agent_count, $coordinator_session_id, $status)
477
+ INSERT INTO runs (id, started_at, completed_at, agent_count, coordinator_session_id, coordinator_name, status)
478
+ VALUES ($id, $started_at, $completed_at, $agent_count, $coordinator_session_id, $coordinator_name, $status)
455
479
  `);
456
480
 
457
481
  const getRunStmt = db.prepare<RunRow, { $id: string }>(`
@@ -464,6 +488,12 @@ export function createRunStore(dbPath: string): RunStore {
464
488
  LIMIT 1
465
489
  `);
466
490
 
491
+ const getActiveRunForCoordinatorStmt = db.prepare<RunRow, { $coordinator_name: string }>(`
492
+ SELECT * FROM runs WHERE status = 'active' AND coordinator_name = $coordinator_name
493
+ ORDER BY started_at DESC
494
+ LIMIT 1
495
+ `);
496
+
467
497
  const incrementAgentCountStmt = db.prepare<void, { $id: string }>(`
468
498
  UPDATE runs SET agent_count = agent_count + 1 WHERE id = $id
469
499
  `);
@@ -483,6 +513,7 @@ export function createRunStore(dbPath: string): RunStore {
483
513
  $completed_at: null,
484
514
  $agent_count: run.agentCount ?? 0,
485
515
  $coordinator_session_id: run.coordinatorSessionId,
516
+ $coordinator_name: run.coordinatorName ?? null,
486
517
  $status: run.status,
487
518
  });
488
519
  },
@@ -497,6 +528,11 @@ export function createRunStore(dbPath: string): RunStore {
497
528
  return row ? rowToRun(row) : null;
498
529
  },
499
530
 
531
+ getActiveRunForCoordinator(coordinatorName: string): Run | null {
532
+ const row = getActiveRunForCoordinatorStmt.get({ $coordinator_name: coordinatorName });
533
+ return row ? rowToRun(row) : null;
534
+ },
535
+
500
536
  listRuns(opts?: { limit?: number; status?: RunStatus }): Run[] {
501
537
  const conditions: string[] = [];
502
538
  const params: Record<string, string | number> = {};
package/src/types.ts CHANGED
@@ -391,6 +391,8 @@ export interface MergeResult {
391
391
  tier: ResolutionTier;
392
392
  conflictFiles: string[];
393
393
  errorMessage: string | null;
394
+ /** Warnings about files where auto-resolve was skipped to prevent content loss. */
395
+ warnings: string[];
394
396
  }
395
397
 
396
398
  /** Parsed conflict pattern from a single mulch record. */
@@ -586,12 +588,15 @@ export interface Run {
586
588
  completedAt: string | null;
587
589
  agentCount: number;
588
590
  coordinatorSessionId: string | null;
591
+ coordinatorName: string | null; // which coordinator owns this run
589
592
  status: RunStatus;
590
593
  }
591
594
 
592
595
  /** Input for creating a new run. */
593
- export type InsertRun = Omit<Run, "completedAt" | "agentCount"> & {
596
+ export type InsertRun = Omit<Run, "completedAt" | "agentCount" | "coordinatorName"> & {
594
597
  agentCount?: number;
598
+ /** Which coordinator owns this run. Defaults to null when not provided. */
599
+ coordinatorName?: string | null;
595
600
  };
596
601
 
597
602
  /** Interface for run management operations. */
@@ -602,6 +607,8 @@ export interface RunStore {
602
607
  getRun(id: string): Run | null;
603
608
  /** Get the most recently started active run. */
604
609
  getActiveRun(): Run | null;
610
+ /** Get the most recently started active run for a specific coordinator. */
611
+ getActiveRunForCoordinator(coordinatorName: string): Run | null;
605
612
  /** List runs, optionally limited. */
606
613
  listRuns(opts?: { limit?: number; status?: RunStatus }): Run[];
607
614
  /** Increment agent count for a run. */
@@ -242,6 +242,53 @@ describe("createSession", () => {
242
242
  const tmuxCmd = cmd[7] as string;
243
243
  expect(tmuxCmd).toContain("echo test");
244
244
  });
245
+
246
+ test("retries list-panes on transient failure", async () => {
247
+ let callCount = 0;
248
+ spawnSpy.mockImplementation(() => {
249
+ callCount++;
250
+ if (callCount === 1) {
251
+ // which overstory
252
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
253
+ }
254
+ if (callCount === 2) {
255
+ // tmux new-session
256
+ return mockSpawnResult("", "", 0);
257
+ }
258
+ if (callCount === 3) {
259
+ // First list-panes fails (WSL2 race)
260
+ return mockSpawnResult("", "can't find pane\n", 1);
261
+ }
262
+ // Second list-panes succeeds
263
+ return mockSpawnResult("42\n", "", 0);
264
+ });
265
+
266
+ const pid = await createSession("retry-session", "/work/dir", "echo hello");
267
+ expect(pid).toBe(42);
268
+ // which + new-session + list-panes(fail) + list-panes(ok)
269
+ expect(spawnSpy).toHaveBeenCalledTimes(4);
270
+ });
271
+
272
+ test("throws after exhausting all list-panes retries", async () => {
273
+ let callCount = 0;
274
+ spawnSpy.mockImplementation(() => {
275
+ callCount++;
276
+ if (callCount === 1) {
277
+ // which overstory
278
+ return mockSpawnResult("/usr/local/bin/overstory\n", "", 0);
279
+ }
280
+ if (callCount === 2) {
281
+ // tmux new-session
282
+ return mockSpawnResult("", "", 0);
283
+ }
284
+ // All list-panes attempts fail
285
+ return mockSpawnResult("", "can't find pane\n", 1);
286
+ });
287
+
288
+ await expect(
289
+ createSession("retry-exhaust", "/work/dir", "echo hello", undefined, 2),
290
+ ).rejects.toThrow(/failed to retrieve PID/);
291
+ });
245
292
  });
246
293
 
247
294
  describe("listSessions", () => {
@@ -932,6 +979,38 @@ describe("sendKeys", () => {
932
979
  spawnSpy.mockImplementation(() => mockSpawnResult("", "some other error\n", 1));
933
980
  await expect(sendKeys("overstory-agent-fake", "hello")).rejects.toThrow(/Failed to send keys/);
934
981
  });
982
+
983
+ test("retries on transient 'can't find pane' error", async () => {
984
+ let callCount = 0;
985
+ spawnSpy.mockImplementation(() => {
986
+ callCount++;
987
+ if (callCount === 1) {
988
+ // First send-keys fails with transient pane error
989
+ return mockSpawnResult("", "can't find pane\n", 1);
990
+ }
991
+ // Second attempt succeeds
992
+ return mockSpawnResult("", "", 0);
993
+ });
994
+
995
+ await sendKeys("overstory-retry-agent", "hello world");
996
+ expect(spawnSpy).toHaveBeenCalledTimes(2);
997
+ });
998
+
999
+ test("does not retry on permanent 'session not found' error", async () => {
1000
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "cant find session: gone-agent\n", 1));
1001
+
1002
+ await expect(sendKeys("gone-agent", "hello", 3)).rejects.toThrow(/does not exist/);
1003
+ // Only called once — no retries for permanent errors
1004
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1005
+ });
1006
+
1007
+ test("throws after exhausting retries on transient error", async () => {
1008
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find pane\n", 1));
1009
+
1010
+ await expect(sendKeys("overstory-exhaust", "hello", 2)).rejects.toThrow(/not found after/);
1011
+ // Initial + 2 retries = 3 calls
1012
+ expect(spawnSpy).toHaveBeenCalledTimes(3);
1013
+ });
935
1014
  });
936
1015
 
937
1016
  describe("capturePaneContent", () => {
@@ -992,6 +1071,13 @@ describe("capturePaneContent", () => {
992
1071
 
993
1072
  /** Claude-like detectReady for tests — matches the existing hardcoded behavior. */
994
1073
  function claudeDetectReady(paneContent: string): ReadyState {
1074
+ if (
1075
+ paneContent.includes("WARNING: Claude Code running in Bypass Permissions mode") &&
1076
+ paneContent.includes("1. No, exit") &&
1077
+ paneContent.includes("2. Yes, I accept")
1078
+ ) {
1079
+ return { phase: "dialog", action: "type:2" };
1080
+ }
995
1081
  if (paneContent.includes("trust this folder")) {
996
1082
  return { phase: "dialog", action: "Enter" };
997
1083
  }
@@ -1211,6 +1297,70 @@ describe("waitForTuiReady", () => {
1211
1297
  expect(trustCall).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
1212
1298
  });
1213
1299
 
1300
+ test("detects bypass permissions dialog and types 2 before Enter", async () => {
1301
+ const sendKeysCalls: string[][] = [];
1302
+ let captureCallCount = 0;
1303
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1304
+ const cmd = args[0] as string[];
1305
+ if (cmd[1] === "capture-pane") {
1306
+ captureCallCount++;
1307
+ if (captureCallCount === 1) {
1308
+ return mockSpawnResult(
1309
+ "WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
1310
+ "",
1311
+ 0,
1312
+ );
1313
+ }
1314
+ return mockSpawnResult('Try "help"\nshift+tab', "", 0);
1315
+ }
1316
+ if (cmd[1] === "send-keys") {
1317
+ sendKeysCalls.push(cmd);
1318
+ return mockSpawnResult("", "", 0);
1319
+ }
1320
+ return mockSpawnResult("", "", 0);
1321
+ });
1322
+
1323
+ const ready = await waitForTuiReady("overstory-agent", claudeDetectReady, 10_000, 500);
1324
+
1325
+ expect(ready).toBe(true);
1326
+ expect(sendKeysCalls).toHaveLength(2);
1327
+ expect(sendKeysCalls[0]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "2"]);
1328
+ expect(sendKeysCalls[1]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
1329
+ });
1330
+
1331
+ test("retries typed bypass dialog action when the same dialog persists", async () => {
1332
+ const sendKeysCalls: string[][] = [];
1333
+ let captureCallCount = 0;
1334
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1335
+ const cmd = args[0] as string[];
1336
+ if (cmd[1] === "capture-pane") {
1337
+ captureCallCount++;
1338
+ if (captureCallCount <= 3) {
1339
+ return mockSpawnResult(
1340
+ "WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
1341
+ "",
1342
+ 0,
1343
+ );
1344
+ }
1345
+ return mockSpawnResult('Try "help"\nshift+tab', "", 0);
1346
+ }
1347
+ if (cmd[1] === "send-keys") {
1348
+ sendKeysCalls.push(cmd);
1349
+ return mockSpawnResult("", "", 0);
1350
+ }
1351
+ return mockSpawnResult("", "", 0);
1352
+ });
1353
+
1354
+ const ready = await waitForTuiReady("overstory-agent", claudeDetectReady, 10_000, 500);
1355
+
1356
+ expect(ready).toBe(true);
1357
+ expect(sendKeysCalls).toHaveLength(4);
1358
+ expect(sendKeysCalls[0]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "2"]);
1359
+ expect(sendKeysCalls[1]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
1360
+ expect(sendKeysCalls[2]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "2"]);
1361
+ expect(sendKeysCalls[3]).toEqual(["tmux", "send-keys", "-t", "overstory-agent", "", "Enter"]);
1362
+ });
1363
+
1214
1364
  test("handles trust dialog only once (trustHandled flag)", async () => {
1215
1365
  const sendKeysCalls: string[][] = [];
1216
1366
  let captureCallCount = 0;