@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.
- package/README.md +11 -8
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +185 -12
- package/src/agents/hooks-deployer.ts +57 -1
- package/src/commands/coordinator.test.ts +74 -5
- package/src/commands/coordinator.ts +27 -3
- package/src/commands/dashboard.ts +84 -18
- package/src/commands/ecosystem.test.ts +101 -0
- package/src/commands/init.test.ts +74 -0
- package/src/commands/init.ts +36 -14
- package/src/commands/sling.test.ts +33 -0
- package/src/commands/sling.ts +106 -38
- package/src/commands/supervisor.ts +2 -0
- package/src/index.ts +1 -1
- package/src/merge/resolver.test.ts +141 -7
- package/src/merge/resolver.ts +61 -8
- package/src/runtimes/claude.test.ts +32 -7
- package/src/runtimes/claude.ts +19 -4
- package/src/runtimes/codex.test.ts +13 -0
- package/src/runtimes/codex.ts +18 -2
- package/src/runtimes/copilot.ts +3 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.ts +3 -0
- package/src/runtimes/opencode.ts +3 -0
- package/src/runtimes/pi.test.ts +1 -1
- package/src/runtimes/pi.ts +3 -0
- package/src/runtimes/registry.test.ts +21 -1
- package/src/runtimes/registry.ts +3 -0
- package/src/runtimes/sapling.ts +3 -0
- package/src/runtimes/types.ts +5 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +178 -0
- package/src/sessions/store.ts +44 -8
- package/src/types.ts +8 -1
- package/src/worktree/tmux.test.ts +150 -0
- package/src/worktree/tmux.ts +126 -23
package/src/runtimes/gemini.ts
CHANGED
|
@@ -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
|
|
package/src/runtimes/opencode.ts
CHANGED
|
@@ -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
|
*
|
package/src/runtimes/pi.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/runtimes/pi.ts
CHANGED
|
@@ -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);
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -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(),
|
package/src/runtimes/sapling.ts
CHANGED
|
@@ -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
|
|
package/src/runtimes/types.ts
CHANGED
|
@@ -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
|
|
|
@@ -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", () => {
|
package/src/sessions/store.ts
CHANGED
|
@@ -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
|
|
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;
|