@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.
@@ -40,7 +40,13 @@ import { openSessionStore } from "../sessions/compat.ts";
40
40
  import type { SessionStore } from "../sessions/store.ts";
41
41
  import { createTrackerClient, resolveBackend } from "../tracker/factory.ts";
42
42
  import type { TrackerIssue } from "../tracker/types.ts";
43
- import type { EventStore, MailMessage, StoredEvent } from "../types.ts";
43
+ import type {
44
+ AgentSession,
45
+ EventStore,
46
+ MailMessage,
47
+ OverstoryConfig,
48
+ StoredEvent,
49
+ } from "../types.ts";
44
50
  import { evaluateHealth } from "../watchdog/health.ts";
45
51
  import { isProcessAlive } from "../worktree/tmux.ts";
46
52
  import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
@@ -296,6 +302,14 @@ interface TrackerCache {
296
302
  let trackerCache: TrackerCache | null = null;
297
303
  const TRACKER_CACHE_TTL_MS = 10_000; // 10 seconds
298
304
 
305
+ /** Session data cached between ticks — stale-on-error fallback. */
306
+ interface SessionDataCache {
307
+ sessions: AgentSession[];
308
+ }
309
+
310
+ /** Module-level session cache (persists across poll ticks, used as fallback on SQLite errors). */
311
+ let sessionDataCache: SessionDataCache | null = null;
312
+
299
313
  interface DashboardData {
300
314
  currentRunId?: string | null;
301
315
  status: StatusData;
@@ -309,6 +323,8 @@ interface DashboardData {
309
323
  tasks: TrackerIssue[];
310
324
  recentEvents: StoredEvent[];
311
325
  feedColorMap: Map<string, (s: string) => string>;
326
+ /** Runtime config for resolving per-capability runtime names in the agent panel. */
327
+ runtimeConfig?: OverstoryConfig["runtime"];
312
328
  }
313
329
 
314
330
  /**
@@ -336,9 +352,17 @@ async function loadDashboardData(
336
352
  runId?: string | null,
337
353
  thresholds?: { staleMs: number; zombieMs: number },
338
354
  eventBuffer?: EventBuffer,
355
+ runtimeConfig?: OverstoryConfig["runtime"],
339
356
  ): Promise<DashboardData> {
340
- // Get all sessions from the pre-opened session store
341
- const allSessions = stores.sessionStore.getAll();
357
+ // Get all sessions from the pre-opened session store — fall back to cache on SQLite errors.
358
+ let allSessions: AgentSession[];
359
+ try {
360
+ allSessions = stores.sessionStore.getAll();
361
+ sessionDataCache = { sessions: allSessions };
362
+ } catch {
363
+ // SQLite lock contention or I/O error — use last known sessions
364
+ allSessions = sessionDataCache?.sessions ?? [];
365
+ }
342
366
 
343
367
  // Get worktrees and tmux sessions via cached subprocess helpers
344
368
  const worktrees = await getCachedWorktrees(root);
@@ -347,18 +371,22 @@ async function loadDashboardData(
347
371
  // Evaluate health for active agents using the same logic as the watchdog.
348
372
  const tmuxSessionNames = new Set(tmuxSessions.map((s) => s.name));
349
373
  const healthThresholds = thresholds ?? { staleMs: 300_000, zombieMs: 600_000 };
350
- for (const session of allSessions) {
351
- if (session.state === "completed") continue;
352
- const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
353
- const check = evaluateHealth(session, tmuxAlive, healthThresholds);
354
- if (check.state !== session.state) {
355
- try {
356
- stores.sessionStore.updateState(session.agentName, check.state);
357
- session.state = check.state;
358
- } catch {
359
- // Best effort: don't fail dashboard if update fails
374
+ try {
375
+ for (const session of allSessions) {
376
+ if (session.state === "completed") continue;
377
+ const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
378
+ const check = evaluateHealth(session, tmuxAlive, healthThresholds);
379
+ if (check.state !== session.state) {
380
+ try {
381
+ stores.sessionStore.updateState(session.agentName, check.state);
382
+ session.state = check.state;
383
+ } catch {
384
+ // Best effort: don't fail dashboard if update fails
385
+ }
360
386
  }
361
387
  }
388
+ } catch {
389
+ // Best effort: evaluateHealth loop should not crash the dashboard
362
390
  }
363
391
 
364
392
  // If run-scoped, filter agents to only those belonging to the current run.
@@ -519,6 +547,7 @@ async function loadDashboardData(
519
547
  tasks,
520
548
  recentEvents,
521
549
  feedColorMap,
550
+ runtimeConfig,
522
551
  };
523
552
  }
524
553
 
@@ -536,6 +565,18 @@ function renderHeader(width: number, interval: number, currentRunId?: string | n
536
565
  return `${line}\n${separator}`;
537
566
  }
538
567
 
568
+ /**
569
+ * Resolve the runtime name for a given capability from config.
570
+ * Mirrors the lookup chain in runtimes/registry.ts getRuntime():
571
+ * capabilities[cap] > runtime.default > "claude"
572
+ */
573
+ function resolveRuntimeName(
574
+ capability: string,
575
+ runtimeConfig?: OverstoryConfig["runtime"],
576
+ ): string {
577
+ return runtimeConfig?.capabilities?.[capability] ?? runtimeConfig?.default ?? "claude";
578
+ }
579
+
539
580
  /**
540
581
  * Render the agent panel (left 60%, dynamic height).
541
582
  */
@@ -556,7 +597,7 @@ export function renderAgentPanel(
556
597
  output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
557
598
 
558
599
  // Column headers
559
- const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Live `;
600
+ const colStr = `${dimBox.vertical} St Name Capability Runtime State Task ID Duration Live `;
560
601
  const colPadding = " ".repeat(
561
602
  Math.max(0, leftWidth - visibleLength(colStr) - visibleLength(dimBox.vertical)),
562
603
  );
@@ -588,6 +629,8 @@ export function renderAgentPanel(
588
629
  const stateColorFn = stateColor(agent.state);
589
630
  const name = accent(pad(truncate(agent.agentName, 15), 15));
590
631
  const capability = pad(truncate(agent.capability, 12), 12);
632
+ const runtimeName = resolveRuntimeName(agent.capability, data.runtimeConfig);
633
+ const runtime = pad(truncate(runtimeName, 8), 8);
591
634
  const state = pad(agent.state, 10);
592
635
  const taskId = accent(pad(truncate(agent.taskId, 16), 16));
593
636
  const endTime =
@@ -602,7 +645,7 @@ export function renderAgentPanel(
602
645
  : data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
603
646
  const aliveDot = alive ? color.green(">") : color.red("x");
604
647
 
605
- const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
648
+ const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${color.dim(runtime)} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
606
649
  const linePadding = " ".repeat(
607
650
  Math.max(0, leftWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
608
651
  );
@@ -1020,10 +1063,33 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1020
1063
  process.exit(0);
1021
1064
  });
1022
1065
 
1023
- // Poll loop
1066
+ // Poll loop — errors are caught per-tick so transient DB failures never crash the dashboard.
1067
+ let lastGoodData: DashboardData | null = null;
1068
+ let lastErrorMsg: string | null = null;
1024
1069
  while (running) {
1025
- const data = await loadDashboardData(root, stores, runId, thresholds, eventBuffer);
1026
- renderDashboard(data, interval);
1070
+ try {
1071
+ const data = await loadDashboardData(
1072
+ root,
1073
+ stores,
1074
+ runId,
1075
+ thresholds,
1076
+ eventBuffer,
1077
+ config.runtime,
1078
+ );
1079
+ lastGoodData = data;
1080
+ lastErrorMsg = null;
1081
+ renderDashboard(data, interval);
1082
+ } catch (err) {
1083
+ // Render last good frame so the TUI stays alive, then show the error inline.
1084
+ if (lastGoodData) {
1085
+ renderDashboard(lastGoodData, interval);
1086
+ }
1087
+ lastErrorMsg = err instanceof Error ? err.message : String(err);
1088
+ const w = process.stdout.columns ?? 100;
1089
+ const h = process.stdout.rows ?? 30;
1090
+ const errLine = `${CURSOR.cursorTo(h, 1)}\x1b[31m⚠ DB error (retrying):\x1b[0m ${truncate(lastErrorMsg, w - 30)}`;
1091
+ process.stdout.write(errLine);
1092
+ }
1027
1093
  await Bun.sleep(interval);
1028
1094
  }
1029
1095
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tests for the ov ecosystem command.
3
+ *
4
+ * Structural tests for CLI registration, plus a smoke test that runs the
5
+ * actual command and verifies the JSON output shape. The smoke test hits
6
+ * real CLIs and the npm registry, so it requires network access.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { createEcosystemCommand, executeEcosystem } from "./ecosystem.ts";
11
+
12
+ describe("createEcosystemCommand — CLI structure", () => {
13
+ test("command has correct name", () => {
14
+ const cmd = createEcosystemCommand();
15
+ expect(cmd.name()).toBe("ecosystem");
16
+ });
17
+
18
+ test("description mentions os-eco", () => {
19
+ const cmd = createEcosystemCommand();
20
+ expect(cmd.description().toLowerCase()).toContain("os-eco");
21
+ });
22
+
23
+ test("has --json option", () => {
24
+ const cmd = createEcosystemCommand();
25
+ const optionNames = cmd.options.map((o) => o.long);
26
+ expect(optionNames).toContain("--json");
27
+ });
28
+
29
+ test("returns a Command instance", () => {
30
+ const cmd = createEcosystemCommand();
31
+ expect(typeof cmd.parse).toBe("function");
32
+ });
33
+ });
34
+
35
+ describe("executeEcosystem — JSON output shape", () => {
36
+ test("--json produces valid JSON with expected structure", async () => {
37
+ // Capture stdout
38
+ const chunks: string[] = [];
39
+ const originalWrite = process.stdout.write;
40
+ process.stdout.write = (chunk: string | Uint8Array) => {
41
+ chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
42
+ return true;
43
+ };
44
+
45
+ try {
46
+ await executeEcosystem({ json: true });
47
+ } finally {
48
+ process.stdout.write = originalWrite;
49
+ }
50
+
51
+ const output = chunks.join("");
52
+ const parsed = JSON.parse(output.trim());
53
+
54
+ // Envelope
55
+ expect(parsed.success).toBe(true);
56
+ expect(parsed.command).toBe("ecosystem");
57
+
58
+ // Tools array
59
+ expect(Array.isArray(parsed.tools)).toBe(true);
60
+ expect(parsed.tools.length).toBeGreaterThan(0);
61
+
62
+ // Each tool has required fields
63
+ for (const tool of parsed.tools) {
64
+ expect(typeof tool.name).toBe("string");
65
+ expect(typeof tool.cli).toBe("string");
66
+ expect(typeof tool.npm).toBe("string");
67
+ expect(typeof tool.installed).toBe("boolean");
68
+ }
69
+
70
+ // Summary
71
+ expect(typeof parsed.summary).toBe("object");
72
+ expect(typeof parsed.summary.total).toBe("number");
73
+ expect(typeof parsed.summary.installed).toBe("number");
74
+ expect(typeof parsed.summary.missing).toBe("number");
75
+ expect(typeof parsed.summary.outdated).toBe("number");
76
+ expect(parsed.summary.total).toBe(parsed.tools.length);
77
+ }, 30_000); // Network calls may be slow
78
+
79
+ test("includes overstory in tool list", async () => {
80
+ const chunks: string[] = [];
81
+ const originalWrite = process.stdout.write;
82
+ process.stdout.write = (chunk: string | Uint8Array) => {
83
+ chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
84
+ return true;
85
+ };
86
+
87
+ try {
88
+ await executeEcosystem({ json: true });
89
+ } finally {
90
+ process.stdout.write = originalWrite;
91
+ }
92
+
93
+ const parsed = JSON.parse(chunks.join("").trim());
94
+ const overstory = parsed.tools.find((t: { name: string }) => t.name === "overstory");
95
+ expect(overstory).toBeDefined();
96
+ // In CI, `ov` may not be globally installed — only assert version when installed
97
+ if (overstory.installed) {
98
+ expect(overstory.version).toBeDefined();
99
+ }
100
+ }, 30_000);
101
+ });
@@ -839,6 +839,80 @@ describe("initCommand: scaffold commit", () => {
839
839
  });
840
840
  });
841
841
 
842
+ describe("initCommand: spawner error resilience", () => {
843
+ let tempDir: string;
844
+ let originalCwd: string;
845
+ let originalWrite: typeof process.stdout.write;
846
+
847
+ beforeEach(async () => {
848
+ tempDir = await createTempGitRepo();
849
+ originalCwd = process.cwd();
850
+ process.chdir(tempDir);
851
+ originalWrite = process.stdout.write;
852
+ process.stdout.write = (() => true) as typeof process.stdout.write;
853
+ });
854
+
855
+ afterEach(async () => {
856
+ process.chdir(originalCwd);
857
+ process.stdout.write = originalWrite;
858
+ await cleanupTempDir(tempDir);
859
+ });
860
+
861
+ test("spawner that throws ENOENT does not crash init — degrades gracefully", async () => {
862
+ const throwingSpawner: Spawner = async (args) => {
863
+ const key = args.join(" ");
864
+ // Allow git operations through (git add, git diff, git commit)
865
+ if (key.startsWith("git")) return { exitCode: 0, stdout: "", stderr: "" };
866
+ // Simulate ecosystem tool binary not found (ENOENT)
867
+ throw new Error(`spawn ENOENT: ${args[0]}: not found`);
868
+ };
869
+
870
+ // Should not throw — graceful degradation
871
+ await expect(initCommand({ _spawner: throwingSpawner })).resolves.toBeUndefined();
872
+
873
+ // Core .overstory files should still be created
874
+ const configPath = join(tempDir, ".overstory", "config.yaml");
875
+ expect(await Bun.file(configPath).exists()).toBe(true);
876
+ });
877
+
878
+ test("throwing spawner causes all ecosystem tools to be skipped", async () => {
879
+ const calls: string[][] = [];
880
+ const throwingSpawner: Spawner = async (args) => {
881
+ calls.push(args);
882
+ const key = args.join(" ");
883
+ if (key.startsWith("git")) return { exitCode: 0, stdout: "", stderr: "" };
884
+ throw new Error("spawn ENOENT");
885
+ };
886
+
887
+ await initCommand({ _spawner: throwingSpawner });
888
+
889
+ // init and onboard should NOT be called when --version throws
890
+ expect(calls).not.toContainEqual(["ml", "init"]);
891
+ expect(calls).not.toContainEqual(["sd", "init"]);
892
+ expect(calls).not.toContainEqual(["cn", "init"]);
893
+ expect(calls).not.toContainEqual(["ml", "onboard"]);
894
+ expect(calls).not.toContainEqual(["sd", "onboard"]);
895
+ expect(calls).not.toContainEqual(["cn", "onboard"]);
896
+ });
897
+
898
+ test("spawner that throws only on init (not --version) still skips gracefully", async () => {
899
+ // --version succeeds (tool appears installed), but init itself throws
900
+ const throwingInitSpawner: Spawner = async (args) => {
901
+ const key = args.join(" ");
902
+ if (key.startsWith("git")) return { exitCode: 0, stdout: "", stderr: "" };
903
+ if (key.endsWith("--version")) return { exitCode: 0, stdout: "1.0.0", stderr: "" };
904
+ if (key.endsWith("onboard")) return { exitCode: 0, stdout: "", stderr: "" };
905
+ // init itself throws
906
+ throw new Error("spawn ENOENT on init");
907
+ };
908
+
909
+ await expect(initCommand({ _spawner: throwingInitSpawner })).resolves.toBeUndefined();
910
+
911
+ const configPath = join(tempDir, ".overstory", "config.yaml");
912
+ expect(await Bun.file(configPath).exists()).toBe(true);
913
+ });
914
+ });
915
+
842
916
  describe("initCommand: .gitattributes setup", () => {
843
917
  let tempDir: string;
844
918
  let originalCwd: string;
@@ -32,15 +32,21 @@ export type Spawner = (
32
32
  ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
33
33
 
34
34
  const defaultSpawner: Spawner = async (args, opts) => {
35
- const proc = Bun.spawn(args, {
36
- cwd: opts?.cwd,
37
- stdout: "pipe",
38
- stderr: "pipe",
39
- });
40
- const exitCode = await proc.exited;
41
- const stdout = await new Response(proc.stdout).text();
42
- const stderr = await new Response(proc.stderr).text();
43
- return { exitCode, stdout, stderr };
35
+ try {
36
+ const proc = Bun.spawn(args, {
37
+ cwd: opts?.cwd,
38
+ stdout: "pipe",
39
+ stderr: "pipe",
40
+ });
41
+ const exitCode = await proc.exited;
42
+ const stdout = await new Response(proc.stdout).text();
43
+ const stderr = await new Response(proc.stderr).text();
44
+ return { exitCode, stdout, stderr };
45
+ } catch (err) {
46
+ // Binary not found (ENOENT) or other spawn failure — treat as non-zero exit
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ return { exitCode: 1, stdout: "", stderr: message };
49
+ }
44
50
  };
45
51
 
46
52
  interface SiblingTool {
@@ -80,8 +86,12 @@ export function resolveToolSet(opts: InitOptions): SiblingTool[] {
80
86
  }
81
87
 
82
88
  async function isToolInstalled(cli: string, spawner: Spawner): Promise<boolean> {
83
- const result = await spawner([cli, "--version"]);
84
- return result.exitCode === 0;
89
+ try {
90
+ const result = await spawner([cli, "--version"]);
91
+ return result.exitCode === 0;
92
+ } catch {
93
+ return false;
94
+ }
85
95
  }
86
96
 
87
97
  async function initSiblingTool(
@@ -98,7 +108,15 @@ async function initSiblingTool(
98
108
  return "skipped";
99
109
  }
100
110
 
101
- const result = await spawner([tool.cli, ...tool.initCmd], { cwd: projectRoot });
111
+ let result: { exitCode: number; stdout: string; stderr: string };
112
+ try {
113
+ result = await spawner([tool.cli, ...tool.initCmd], { cwd: projectRoot });
114
+ } catch (err) {
115
+ // Spawn failure (e.g. ENOENT) — treat as not installed
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ printWarning(`${tool.name} init failed`, message);
118
+ return "skipped";
119
+ }
102
120
  if (result.exitCode !== 0) {
103
121
  // Check if dot directory already exists (already initialized)
104
122
  try {
@@ -123,8 +141,12 @@ async function onboardTool(
123
141
  const installed = await isToolInstalled(tool.cli, spawner);
124
142
  if (!installed) return "current";
125
143
 
126
- const result = await spawner([tool.cli, ...tool.onboardCmd], { cwd: projectRoot });
127
- return result.exitCode === 0 ? "appended" : "current";
144
+ try {
145
+ const result = await spawner([tool.cli, ...tool.onboardCmd], { cwd: projectRoot });
146
+ return result.exitCode === 0 ? "appended" : "current";
147
+ } catch {
148
+ return "current";
149
+ }
128
150
  }
129
151
 
130
152
  /**
@@ -22,6 +22,7 @@ import {
22
22
  extractMulchRecordIds,
23
23
  generateAgentName,
24
24
  getCurrentBranch,
25
+ getSharedWritableDirs,
25
26
  inferDomainsFromFiles,
26
27
  isRunningAsRoot,
27
28
  parentHasScouts,
@@ -343,6 +344,17 @@ describe("shouldShowScoutWarning", () => {
343
344
  });
344
345
  });
345
346
 
347
+ describe("getSharedWritableDirs", () => {
348
+ test("returns only .overstory for non-lead agents", () => {
349
+ expect(getSharedWritableDirs("/repo", "builder")).toEqual(["/repo/.overstory"]);
350
+ expect(getSharedWritableDirs("/repo", "scout")).toEqual(["/repo/.overstory"]);
351
+ });
352
+
353
+ test("includes canonical .git for lead agents", () => {
354
+ expect(getSharedWritableDirs("/repo", "lead")).toEqual(["/repo/.overstory", "/repo/.git"]);
355
+ });
356
+ });
357
+
346
358
  describe("generateAgentName", () => {
347
359
  test("returns capability-taskId when no collision", () => {
348
360
  expect(generateAgentName("builder", "overstory-2f10", [])).toBe("builder-overstory-2f10");
@@ -1005,6 +1017,27 @@ describe("sling provider env injection building blocks", () => {
1005
1017
  expect(combined.OVERSTORY_WORKTREE_PATH).toBe("/tmp/wt");
1006
1018
  });
1007
1019
 
1020
+ test("env dict from resolveModel can be spread with OVERSTORY_TASK_ID", () => {
1021
+ const config = makeConfig(
1022
+ { builder: "openrouter/anthropic/claude-3-5-sonnet" },
1023
+ { openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" } },
1024
+ );
1025
+ const manifest = makeManifest();
1026
+
1027
+ const { env } = resolveModel(config, manifest, "builder", "sonnet");
1028
+ // Simulates the spread in slingCommand: { ...env, OVERSTORY_AGENT_NAME: name, OVERSTORY_WORKTREE_PATH: wt, OVERSTORY_TASK_ID: taskId }
1029
+ const combined: Record<string, string> = {
1030
+ ...(env ?? {}),
1031
+ OVERSTORY_AGENT_NAME: "test-builder",
1032
+ OVERSTORY_WORKTREE_PATH: "/tmp/wt",
1033
+ OVERSTORY_TASK_ID: "overstory-1234",
1034
+ };
1035
+
1036
+ expect(combined.OVERSTORY_AGENT_NAME).toBe("test-builder");
1037
+ expect(combined.OVERSTORY_WORKTREE_PATH).toBe("/tmp/wt");
1038
+ expect(combined.OVERSTORY_TASK_ID).toBe("overstory-1234");
1039
+ });
1040
+
1008
1041
  test("resolveModel returns no env for native anthropic provider", () => {
1009
1042
  const config = makeConfig({ builder: "sonnet" }, { anthropic: { type: "native" } });
1010
1043
  const manifest = makeManifest();