@os-eco/overstory-cli 0.7.9 → 0.8.2

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 (42) hide show
  1. package/README.md +16 -7
  2. package/agents/coordinator.md +41 -0
  3. package/agents/orchestrator.md +239 -0
  4. package/package.json +1 -1
  5. package/src/agents/guard-rules.test.ts +372 -0
  6. package/src/commands/coordinator.test.ts +334 -0
  7. package/src/commands/coordinator.ts +366 -0
  8. package/src/commands/dashboard.test.ts +86 -0
  9. package/src/commands/dashboard.ts +8 -4
  10. package/src/commands/feed.test.ts +8 -0
  11. package/src/commands/init.test.ts +2 -1
  12. package/src/commands/init.ts +2 -2
  13. package/src/commands/inspect.test.ts +156 -1
  14. package/src/commands/inspect.ts +19 -4
  15. package/src/commands/replay.test.ts +8 -0
  16. package/src/commands/sling.ts +218 -121
  17. package/src/commands/status.test.ts +77 -0
  18. package/src/commands/status.ts +6 -3
  19. package/src/commands/stop.test.ts +134 -0
  20. package/src/commands/stop.ts +41 -11
  21. package/src/commands/trace.test.ts +8 -0
  22. package/src/commands/update.test.ts +465 -0
  23. package/src/commands/update.ts +263 -0
  24. package/src/config.test.ts +65 -1
  25. package/src/config.ts +23 -0
  26. package/src/e2e/init-sling-lifecycle.test.ts +3 -2
  27. package/src/index.ts +21 -2
  28. package/src/logging/theme.ts +4 -0
  29. package/src/runtimes/connections.test.ts +74 -0
  30. package/src/runtimes/connections.ts +34 -0
  31. package/src/runtimes/registry.test.ts +1 -1
  32. package/src/runtimes/registry.ts +2 -0
  33. package/src/runtimes/sapling.test.ts +1237 -0
  34. package/src/runtimes/sapling.ts +698 -0
  35. package/src/runtimes/types.ts +45 -0
  36. package/src/types.ts +5 -1
  37. package/src/watchdog/daemon.ts +34 -0
  38. package/src/watchdog/health.test.ts +102 -0
  39. package/src/watchdog/health.ts +140 -69
  40. package/src/worktree/process.test.ts +101 -0
  41. package/src/worktree/process.ts +111 -0
  42. package/src/worktree/tmux.ts +5 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * CLI command: ov update [--agents] [--manifest] [--hooks] [--dry-run] [--json]
3
+ *
4
+ * Refreshes .overstory/ managed files from the installed npm package without
5
+ * requiring a full `ov init`. Distinct from `ov upgrade` (which updates the
6
+ * npm package itself).
7
+ *
8
+ * Managed files refreshed:
9
+ * - Agent definitions (.overstory/agent-defs/*.md)
10
+ * - agent-manifest.json
11
+ * - hooks.json
12
+ * - .gitignore
13
+ * - README.md
14
+ *
15
+ * Does NOT touch: config.yaml, config.local.yaml, SQLite databases,
16
+ * agents/, worktrees/, specs/, logs/, or .claude/settings.local.json.
17
+ */
18
+
19
+ import { mkdir, readdir } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { Command } from "commander";
22
+ import { ValidationError } from "../errors.ts";
23
+ import { jsonOutput } from "../json.ts";
24
+ import { printHint, printSuccess } from "../logging/color.ts";
25
+ import {
26
+ buildAgentManifest,
27
+ buildHooksJson,
28
+ OVERSTORY_GITIGNORE,
29
+ OVERSTORY_README,
30
+ writeOverstoryGitignore,
31
+ writeOverstoryReadme,
32
+ } from "./init.ts";
33
+
34
+ export interface UpdateOptions {
35
+ agents?: boolean;
36
+ manifest?: boolean;
37
+ hooks?: boolean;
38
+ dryRun?: boolean;
39
+ json?: boolean;
40
+ }
41
+
42
+ /** Agent def files to exclude (deprecated). */
43
+ const EXCLUDED_AGENT_DEFS = new Set(["supervisor.md"]);
44
+
45
+ interface UpdateResult {
46
+ agentDefs: { updated: string[]; unchanged: string[] };
47
+ manifest: { updated: boolean };
48
+ hooks: { updated: boolean };
49
+ gitignore: { updated: boolean };
50
+ readme: { updated: boolean };
51
+ }
52
+
53
+ /**
54
+ * Entry point for `ov update [flags]`.
55
+ */
56
+ export async function executeUpdate(opts: UpdateOptions): Promise<void> {
57
+ const json = opts.json ?? false;
58
+ const dryRun = opts.dryRun ?? false;
59
+
60
+ const projectRoot = process.cwd();
61
+ const overstoryDir = join(projectRoot, ".overstory");
62
+
63
+ // Verify .overstory/config.yaml exists (already initialized)
64
+ const configFile = Bun.file(join(overstoryDir, "config.yaml"));
65
+ if (!(await configFile.exists())) {
66
+ throw new ValidationError("Not initialized. Run 'ov init' first to set up .overstory/.", {
67
+ field: "config.yaml",
68
+ });
69
+ }
70
+
71
+ // Determine what to refresh. No flags = refresh all.
72
+ const hasGranularFlags = opts.agents || opts.manifest || opts.hooks;
73
+ const doAgents = hasGranularFlags ? (opts.agents ?? false) : true;
74
+ const doManifest = hasGranularFlags ? (opts.manifest ?? false) : true;
75
+ const doHooks = hasGranularFlags ? (opts.hooks ?? false) : true;
76
+ const doGitignore = !hasGranularFlags;
77
+ const doReadme = !hasGranularFlags;
78
+
79
+ const result: UpdateResult = {
80
+ agentDefs: { updated: [], unchanged: [] },
81
+ manifest: { updated: false },
82
+ hooks: { updated: false },
83
+ gitignore: { updated: false },
84
+ readme: { updated: false },
85
+ };
86
+
87
+ // 1. Refresh agent definitions
88
+ if (doAgents) {
89
+ const sourceDir = join(import.meta.dir, "..", "..", "agents");
90
+ const targetDir = join(overstoryDir, "agent-defs");
91
+
92
+ await mkdir(targetDir, { recursive: true });
93
+
94
+ const sourceFiles = await readdir(sourceDir);
95
+ for (const fileName of sourceFiles) {
96
+ if (!fileName.endsWith(".md")) continue;
97
+ if (EXCLUDED_AGENT_DEFS.has(fileName)) continue;
98
+
99
+ const sourceContent = await Bun.file(join(sourceDir, fileName)).text();
100
+ const targetPath = join(targetDir, fileName);
101
+ const targetFile = Bun.file(targetPath);
102
+
103
+ let needsUpdate = true;
104
+ if (await targetFile.exists()) {
105
+ const existing = await targetFile.text();
106
+ if (existing === sourceContent) {
107
+ needsUpdate = false;
108
+ }
109
+ }
110
+
111
+ if (needsUpdate) {
112
+ if (!dryRun) {
113
+ await Bun.write(targetPath, sourceContent);
114
+ }
115
+ result.agentDefs.updated.push(fileName);
116
+ } else {
117
+ result.agentDefs.unchanged.push(fileName);
118
+ }
119
+ }
120
+ }
121
+
122
+ // 2. Refresh agent-manifest.json
123
+ if (doManifest) {
124
+ const manifestPath = join(overstoryDir, "agent-manifest.json");
125
+ const newContent = `${JSON.stringify(buildAgentManifest(), null, "\t")}\n`;
126
+ const manifestFile = Bun.file(manifestPath);
127
+
128
+ let needsUpdate = true;
129
+ if (await manifestFile.exists()) {
130
+ const existing = await manifestFile.text();
131
+ if (existing === newContent) {
132
+ needsUpdate = false;
133
+ }
134
+ }
135
+
136
+ if (needsUpdate) {
137
+ if (!dryRun) {
138
+ await Bun.write(manifestPath, newContent);
139
+ }
140
+ result.manifest.updated = true;
141
+ }
142
+ }
143
+
144
+ // 3. Refresh hooks.json
145
+ if (doHooks) {
146
+ const hooksPath = join(overstoryDir, "hooks.json");
147
+ const newContent = buildHooksJson();
148
+ const hooksFile = Bun.file(hooksPath);
149
+
150
+ let needsUpdate = true;
151
+ if (await hooksFile.exists()) {
152
+ const existing = await hooksFile.text();
153
+ if (existing === newContent) {
154
+ needsUpdate = false;
155
+ }
156
+ }
157
+
158
+ if (needsUpdate) {
159
+ if (!dryRun) {
160
+ await Bun.write(hooksPath, newContent);
161
+ }
162
+ result.hooks.updated = true;
163
+ }
164
+ }
165
+
166
+ // 4. Refresh .gitignore
167
+ if (doGitignore) {
168
+ const gitignorePath = join(overstoryDir, ".gitignore");
169
+ const gitignoreFile = Bun.file(gitignorePath);
170
+
171
+ let needsUpdate = true;
172
+ if (await gitignoreFile.exists()) {
173
+ const existing = await gitignoreFile.text();
174
+ if (existing === OVERSTORY_GITIGNORE) {
175
+ needsUpdate = false;
176
+ }
177
+ }
178
+
179
+ if (needsUpdate) {
180
+ if (!dryRun) {
181
+ await writeOverstoryGitignore(overstoryDir);
182
+ }
183
+ result.gitignore.updated = true;
184
+ }
185
+ }
186
+
187
+ // 5. Refresh README.md
188
+ if (doReadme) {
189
+ const readmePath = join(overstoryDir, "README.md");
190
+ const readmeFile = Bun.file(readmePath);
191
+
192
+ let needsUpdate = true;
193
+ if (await readmeFile.exists()) {
194
+ const existing = await readmeFile.text();
195
+ if (existing === OVERSTORY_README) {
196
+ needsUpdate = false;
197
+ }
198
+ }
199
+
200
+ if (needsUpdate) {
201
+ if (!dryRun) {
202
+ await writeOverstoryReadme(overstoryDir);
203
+ }
204
+ result.readme.updated = true;
205
+ }
206
+ }
207
+
208
+ // Output
209
+ if (json) {
210
+ jsonOutput("update", { dryRun, ...result });
211
+ return;
212
+ }
213
+
214
+ const prefix = dryRun ? "Would update" : "Updated";
215
+ let anyChanged = false;
216
+
217
+ if (result.agentDefs.updated.length > 0) {
218
+ anyChanged = true;
219
+ for (const f of result.agentDefs.updated) {
220
+ printSuccess(prefix, `agent-defs/${f}`);
221
+ }
222
+ }
223
+
224
+ if (result.manifest.updated) {
225
+ anyChanged = true;
226
+ printSuccess(prefix, "agent-manifest.json");
227
+ }
228
+
229
+ if (result.hooks.updated) {
230
+ anyChanged = true;
231
+ printSuccess(prefix, "hooks.json");
232
+ if (!dryRun) {
233
+ printHint("If hooks are deployed, run 'ov hooks install --force' to redeploy");
234
+ }
235
+ }
236
+
237
+ if (result.gitignore.updated) {
238
+ anyChanged = true;
239
+ printSuccess(prefix, ".gitignore");
240
+ }
241
+
242
+ if (result.readme.updated) {
243
+ anyChanged = true;
244
+ printSuccess(prefix, "README.md");
245
+ }
246
+
247
+ if (!anyChanged) {
248
+ printSuccess("Already up to date");
249
+ }
250
+ }
251
+
252
+ export function createUpdateCommand(): Command {
253
+ return new Command("update")
254
+ .description("Refresh .overstory/ managed files from the installed package")
255
+ .option("--agents", "Refresh agent definition files only")
256
+ .option("--manifest", "Refresh agent-manifest.json only")
257
+ .option("--hooks", "Refresh hooks.json only")
258
+ .option("--dry-run", "Show what would change without writing")
259
+ .option("--json", "Output as JSON")
260
+ .action(async (opts: UpdateOptions) => {
261
+ await executeUpdate(opts);
262
+ });
263
+ }
@@ -2,7 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdir, mkdtemp, realpath } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { DEFAULT_CONFIG, DEFAULT_QUALITY_GATES, loadConfig, resolveProjectRoot } from "./config.ts";
5
+ import {
6
+ clearProjectRootOverride,
7
+ DEFAULT_CONFIG,
8
+ DEFAULT_QUALITY_GATES,
9
+ loadConfig,
10
+ resolveProjectRoot,
11
+ setProjectRootOverride,
12
+ } from "./config.ts";
6
13
  import { ValidationError } from "./errors.ts";
7
14
  import { cleanupTempDir, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
8
15
 
@@ -961,6 +968,63 @@ describe("resolveProjectRoot", () => {
961
968
  });
962
969
  });
963
970
 
971
+ describe("projectRootOverride", () => {
972
+ let tempDir: string;
973
+
974
+ beforeEach(async () => {
975
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-test-"));
976
+ clearProjectRootOverride();
977
+ });
978
+
979
+ afterEach(async () => {
980
+ clearProjectRootOverride();
981
+ await cleanupTempDir(tempDir);
982
+ });
983
+
984
+ test("setProjectRootOverride makes resolveProjectRoot return the override", async () => {
985
+ setProjectRootOverride(tempDir);
986
+ const result = await resolveProjectRoot("/some/other/dir");
987
+ expect(result).toBe(tempDir);
988
+ });
989
+
990
+ test("clearProjectRootOverride restores normal resolution", async () => {
991
+ setProjectRootOverride("/completely/fake/path");
992
+ clearProjectRootOverride();
993
+ // After clearing, normal resolution returns startDir when no .overstory present
994
+ const result = await resolveProjectRoot(tempDir);
995
+ expect(result).toBe(tempDir);
996
+ });
997
+
998
+ test("loadConfig respects project root override", async () => {
999
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
1000
+ await Bun.write(
1001
+ join(tempDir, ".overstory", "config.yaml"),
1002
+ "project:\n canonicalBranch: override-branch\n",
1003
+ );
1004
+ setProjectRootOverride(tempDir);
1005
+ const config = await loadConfig("/completely/different/path");
1006
+ expect(config.project.root).toBe(tempDir);
1007
+ expect(config.project.canonicalBranch).toBe("override-branch");
1008
+ });
1009
+
1010
+ test("override takes precedence over worktree resolution", async () => {
1011
+ // Even if we're in a worktree, the override wins
1012
+ const otherDir = await mkdtemp(join(tmpdir(), "overstory-other-"));
1013
+ try {
1014
+ await mkdir(join(otherDir, ".overstory"), { recursive: true });
1015
+ await Bun.write(
1016
+ join(otherDir, ".overstory", "config.yaml"),
1017
+ "project:\n canonicalBranch: other-branch\n",
1018
+ );
1019
+ setProjectRootOverride(otherDir);
1020
+ const result = await resolveProjectRoot(tempDir);
1021
+ expect(result).toBe(otherDir);
1022
+ } finally {
1023
+ await cleanupTempDir(otherDir);
1024
+ }
1025
+ });
1026
+ });
1027
+
964
1028
  describe("DEFAULT_CONFIG", () => {
965
1029
  test("has all required top-level keys", () => {
966
1030
  expect(DEFAULT_CONFIG.project).toBeDefined();
package/src/config.ts CHANGED
@@ -2,6 +2,24 @@ import { dirname, join, resolve } from "node:path";
2
2
  import { ConfigError, ValidationError } from "./errors.ts";
3
3
  import type { OverstoryConfig, QualityGate, TaskTrackerBackend } from "./types.ts";
4
4
 
5
+ // Module-level project root override (set by --project global flag)
6
+ let _projectRootOverride: string | undefined;
7
+
8
+ /** Override project root for all config resolution (used by --project global flag). */
9
+ export function setProjectRootOverride(path: string): void {
10
+ _projectRootOverride = path;
11
+ }
12
+
13
+ /** Get the current project root override, if any. */
14
+ export function getProjectRootOverride(): string | undefined {
15
+ return _projectRootOverride;
16
+ }
17
+
18
+ /** Clear the project root override (used in tests and cleanup). */
19
+ export function clearProjectRootOverride(): void {
20
+ _projectRootOverride = undefined;
21
+ }
22
+
5
23
  /**
6
24
  * Default configuration with all fields populated.
7
25
  * Used as the base; file-loaded values are merged on top.
@@ -777,6 +795,11 @@ async function mergeLocalConfig(
777
795
  * @returns The resolved project root containing `.overstory/`
778
796
  */
779
797
  export async function resolveProjectRoot(startDir: string): Promise<string> {
798
+ // Check for explicit override first (set by --project global flag)
799
+ if (_projectRootOverride !== undefined) {
800
+ return _projectRootOverride;
801
+ }
802
+
780
803
  const { existsSync } = require("node:fs") as typeof import("node:fs");
781
804
 
782
805
  // Check git worktree FIRST. When running from an agent worktree
@@ -30,6 +30,7 @@ const EXPECTED_AGENT_DEFS = [
30
30
  "lead.md",
31
31
  "merger.md",
32
32
  "monitor.md",
33
+ "orchestrator.md",
33
34
  "reviewer.md",
34
35
  "scout.md",
35
36
  ];
@@ -81,7 +82,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
81
82
  const gitignoreFile = Bun.file(join(overstoryDir, ".gitignore"));
82
83
  expect(await gitignoreFile.exists()).toBe(true);
83
84
 
84
- // agent-defs/ contains all 7 agent definition files (supervisor deprecated)
85
+ // agent-defs/ contains all 8 agent definition files (supervisor deprecated)
85
86
  const agentDefsDir = join(overstoryDir, "agent-defs");
86
87
  const agentDefFiles = (await readdir(agentDefsDir)).filter((f) => f.endsWith(".md")).sort();
87
88
  expect(agentDefFiles).toEqual(EXPECTED_AGENT_DEFS);
@@ -113,7 +114,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
113
114
  expect(config.project.name).toBeTruthy();
114
115
  });
115
116
 
116
- test("manifest loads successfully with all 7 agents (supervisor deprecated)", async () => {
117
+ test("manifest loads successfully with all 8 agents (supervisor deprecated)", async () => {
117
118
  await initCommand({ _spawner: noopSpawner });
118
119
 
119
120
  const manifestPath = join(tempDir, ".overstory", "agent-manifest.json");
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * Usage: ov <command> [args...]
8
8
  */
9
9
 
10
+ import { existsSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
10
12
  import { Command, Help } from "commander";
11
13
  import { createAgentsCommand } from "./commands/agents.ts";
12
14
  import { cleanCommand } from "./commands/clean.ts";
@@ -38,14 +40,16 @@ import { createStatusCommand } from "./commands/status.ts";
38
40
  import { stopCommand } from "./commands/stop.ts";
39
41
  import { createSupervisorCommand } from "./commands/supervisor.ts";
40
42
  import { traceCommand } from "./commands/trace.ts";
43
+ import { createUpdateCommand } from "./commands/update.ts";
41
44
  import { createUpgradeCommand } from "./commands/upgrade.ts";
42
45
  import { createWatchCommand } from "./commands/watch.ts";
43
46
  import { createWorktreeCommand } from "./commands/worktree.ts";
44
- import { OverstoryError, WorktreeError } from "./errors.ts";
47
+ import { setProjectRootOverride } from "./config.ts";
48
+ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
45
49
  import { jsonError } from "./json.ts";
46
50
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
47
51
 
48
- export const VERSION = "0.7.9";
52
+ export const VERSION = "0.8.2";
49
53
 
50
54
  const rawArgs = process.argv.slice(2);
51
55
 
@@ -95,6 +99,7 @@ const COMMANDS = [
95
99
  "run",
96
100
  "costs",
97
101
  "metrics",
102
+ "update",
98
103
  "upgrade",
99
104
  "completions",
100
105
  ];
@@ -144,6 +149,7 @@ program
144
149
  .option("--json", "JSON output")
145
150
  .option("--verbose", "Verbose output")
146
151
  .option("--timing", "Print command execution time to stderr")
152
+ .option("--project <path>", "Target project root (overrides auto-detection)")
147
153
  .addHelpCommand(false)
148
154
  .configureHelp({
149
155
  formatHelp(cmd, helper): string {
@@ -199,6 +205,17 @@ program.hook("preAction", (thisCmd) => {
199
205
  if (opts.quiet) {
200
206
  setQuiet(true);
201
207
  }
208
+ const projectFlag = opts.project as string | undefined;
209
+ if (projectFlag !== undefined) {
210
+ const resolvedProject = resolve(process.cwd(), projectFlag);
211
+ if (!existsSync(join(resolvedProject, ".overstory", "config.yaml"))) {
212
+ throw new ConfigError(
213
+ `'${resolvedProject}' is not an overstory project (missing .overstory/config.yaml). Run 'ov init' first.`,
214
+ { configPath: join(resolvedProject, ".overstory", "config.yaml") },
215
+ );
216
+ }
217
+ setProjectRootOverride(resolvedProject);
218
+ }
202
219
  if (opts.timing) {
203
220
  timingStart = performance.now();
204
221
  }
@@ -390,6 +407,8 @@ program.addCommand(createCostsCommand());
390
407
 
391
408
  program.addCommand(createMetricsCommand());
392
409
 
410
+ program.addCommand(createUpdateCommand());
411
+
393
412
  program.addCommand(createUpgradeCommand());
394
413
 
395
414
  // Handle unknown commands with Levenshtein fuzzy-match suggestions
@@ -66,6 +66,10 @@ const EVENT_LABELS: Record<EventType, EventLabel> = {
66
66
  spawn: { compact: "SPAWN", full: "SPAWN ", color: color.magenta },
67
67
  error: { compact: "ERROR", full: "ERROR ", color: color.red },
68
68
  custom: { compact: "CUSTM", full: "CUSTOM ", color: color.gray },
69
+ turn_start: { compact: "TURN+", full: "TURN START", color: color.green },
70
+ turn_end: { compact: "TURN-", full: "TURN END ", color: color.yellow },
71
+ progress: { compact: "PROG ", full: "PROGRESS ", color: color.cyan },
72
+ result: { compact: "RSULT", full: "RESULT ", color: color.green },
69
73
  };
70
74
 
71
75
  /** Returns the EventLabel for a given event type. */
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { getConnection, removeConnection, setConnection } from "./connections.ts";
3
+ import type { ConnectionState, RuntimeConnection } from "./types.ts";
4
+
5
+ /** Minimal RuntimeConnection stub for testing the registry. */
6
+ function makeConn(onClose?: () => void): RuntimeConnection {
7
+ return {
8
+ sendPrompt: async (_text: string) => {},
9
+ followUp: async (_text: string) => {},
10
+ abort: async () => {},
11
+ getState: async (): Promise<ConnectionState> => ({ status: "idle" }),
12
+ close: () => {
13
+ if (onClose) onClose();
14
+ },
15
+ };
16
+ }
17
+
18
+ describe("connection registry", () => {
19
+ // Reset registry between tests by removing any entries set during each test.
20
+ // We track names used so we can clean up without affecting other entries.
21
+ const usedNames: string[] = [];
22
+
23
+ afterEach(() => {
24
+ for (const name of usedNames.splice(0)) {
25
+ const conn = getConnection(name);
26
+ if (conn) {
27
+ removeConnection(name);
28
+ }
29
+ }
30
+ });
31
+
32
+ test("set and get returns the registered connection", () => {
33
+ const conn = makeConn();
34
+ usedNames.push("agent-alpha");
35
+ setConnection("agent-alpha", conn);
36
+ expect(getConnection("agent-alpha")).toBe(conn);
37
+ });
38
+
39
+ test("get unknown returns undefined", () => {
40
+ expect(getConnection("does-not-exist-xyz")).toBeUndefined();
41
+ });
42
+
43
+ test("removeConnection calls close() on the connection", () => {
44
+ let closed = false;
45
+ const conn = makeConn(() => {
46
+ closed = true;
47
+ });
48
+ usedNames.push("agent-beta");
49
+ setConnection("agent-beta", conn);
50
+ removeConnection("agent-beta");
51
+ expect(closed).toBe(true);
52
+ });
53
+
54
+ test("removeConnection deletes the entry (get returns undefined after)", () => {
55
+ const conn = makeConn();
56
+ usedNames.push("agent-gamma");
57
+ setConnection("agent-gamma", conn);
58
+ removeConnection("agent-gamma");
59
+ expect(getConnection("agent-gamma")).toBeUndefined();
60
+ });
61
+
62
+ test("removeConnection on unknown name is a no-op (does not throw)", () => {
63
+ expect(() => removeConnection("never-registered-xyz")).not.toThrow();
64
+ });
65
+
66
+ test("setConnection overwrites an existing entry", () => {
67
+ const conn1 = makeConn();
68
+ const conn2 = makeConn();
69
+ usedNames.push("agent-delta");
70
+ setConnection("agent-delta", conn1);
71
+ setConnection("agent-delta", conn2);
72
+ expect(getConnection("agent-delta")).toBe(conn2);
73
+ });
74
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Module-level connection registry for active RuntimeConnection instances.
3
+ *
4
+ * Tracks RPC connections to headless agent processes (e.g., Sapling).
5
+ * Keyed by agent name — same namespace as AgentSession.agentName.
6
+ *
7
+ * Thread safety: single-threaded Bun runtime; no locking needed.
8
+ */
9
+
10
+ import type { RuntimeConnection } from "./types.ts";
11
+
12
+ const connections = new Map<string, RuntimeConnection>();
13
+
14
+ /** Retrieve the active connection for a given agent, or undefined if none. */
15
+ export function getConnection(agentName: string): RuntimeConnection | undefined {
16
+ return connections.get(agentName);
17
+ }
18
+
19
+ /** Register a connection for a given agent. Overwrites any existing entry. */
20
+ export function setConnection(agentName: string, conn: RuntimeConnection): void {
21
+ connections.set(agentName, conn);
22
+ }
23
+
24
+ /**
25
+ * Remove the connection for a given agent, calling close() first.
26
+ * Safe to call if no connection exists (no-op).
27
+ */
28
+ export function removeConnection(agentName: string): void {
29
+ const conn = connections.get(agentName);
30
+ if (conn) {
31
+ conn.close();
32
+ connections.delete(agentName);
33
+ }
34
+ }
@@ -22,7 +22,7 @@ describe("getRuntime", () => {
22
22
 
23
23
  it("throws with a helpful message for an unknown runtime", () => {
24
24
  expect(() => getRuntime("unknown-runtime")).toThrow(
25
- 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini',
25
+ 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling',
26
26
  );
27
27
  });
28
28
 
@@ -7,6 +7,7 @@ import { CodexRuntime } from "./codex.ts";
7
7
  import { CopilotRuntime } from "./copilot.ts";
8
8
  import { GeminiRuntime } from "./gemini.ts";
9
9
  import { PiRuntime } from "./pi.ts";
10
+ import { SaplingRuntime } from "./sapling.ts";
10
11
  import type { AgentRuntime } from "./types.ts";
11
12
 
12
13
  /** Registry of config-independent runtime adapters (name → factory). */
@@ -16,6 +17,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
16
17
  ["pi", () => new PiRuntime()],
17
18
  ["copilot", () => new CopilotRuntime()],
18
19
  ["gemini", () => new GeminiRuntime()],
20
+ ["sapling", () => new SaplingRuntime()],
19
21
  ]);
20
22
 
21
23
  /**