@os-eco/overstory-cli 0.7.6 → 0.7.8

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 CHANGED
@@ -17,6 +17,7 @@ Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agen
17
17
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` CLI)
18
18
  - [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) (`pi` CLI)
19
19
  - [GitHub Copilot](https://github.com/features/copilot) (`copilot` CLI)
20
+ - [Codex](https://github.com/openai/codex) (`codex` CLI)
20
21
 
21
22
  ```bash
22
23
  bun install -g @os-eco/overstory-cli
@@ -79,7 +80,7 @@ Every command supports `--json` where noted. Global flags: `-q`/`--quiet`, `--ti
79
80
  | Command | Description |
80
81
  |---------|-------------|
81
82
  | `ov init` | Initialize `.overstory/` and bootstrap os-eco tools (`--yes`, `--name`, `--tools`, `--skip-mulch`, `--skip-seeds`, `--skip-canopy`, `--skip-onboard`, `--json`) |
82
- | `ov sling <task-id>` | Spawn a worker agent (`--capability`, `--name`, `--spec`, `--files`, `--parent`, `--depth`, `--skip-scout`, `--skip-review`, `--max-agents`, `--dispatch-max-agents`, `--skip-task-check`, `--no-scout-check`, `--runtime`, `--json`) |
83
+ | `ov sling <task-id>` | Spawn a worker agent (`--capability`, `--name`, `--spec`, `--files`, `--parent`, `--depth`, `--skip-scout`, `--skip-review`, `--max-agents`, `--dispatch-max-agents`, `--skip-task-check`, `--no-scout-check`, `--runtime`, `--base-branch`, `--json`) |
83
84
  | `ov stop <agent-name>` | Terminate a running agent (`--clean-worktree`, `--json`) |
84
85
  | `ov prime` | Load context for orchestrator/agent (`--agent`, `--compact`) |
85
86
  | `ov spec write <task-id>` | Write a task specification (`--body`) |
@@ -173,6 +174,7 @@ Overstory is runtime-agnostic. The `AgentRuntime` interface (`src/runtimes/types
173
174
  | Claude Code | `claude` | `settings.local.json` hooks | Stable |
174
175
  | Pi | `pi` | `.pi/extensions/` guard extension | Active development |
175
176
  | Copilot | `copilot` | (none — `--allow-all-tools`) | Active development |
177
+ | Codex | `codex` | OS-level sandbox (Seatbelt/Landlock) | Active development |
176
178
 
177
179
  ## How It Works
178
180
 
@@ -269,7 +271,7 @@ overstory/
269
271
  metrics/ SQLite metrics + pricing + transcript parsing
270
272
  doctor/ Health check modules (11 checks)
271
273
  insights/ Session insight analyzer for auto-expertise
272
- runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot)
274
+ runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex)
273
275
  tracker/ Pluggable task tracker (beads + seeds backends)
274
276
  mulch/ mulch client (programmatic API + CLI wrapper)
275
277
  e2e/ End-to-end lifecycle tests
@@ -277,6 +279,106 @@ overstory/
277
279
  templates/ Templates for overlays and hooks
278
280
  ```
279
281
 
282
+ ## Configuration
283
+
284
+ ### Gateway Providers
285
+
286
+ Route agent API calls through custom gateway endpoints (z.ai, OpenRouter, self-hosted proxies). Configure providers in `.overstory/config.yaml`:
287
+
288
+ ```yaml
289
+ providers:
290
+ anthropic:
291
+ type: native
292
+ zai:
293
+ type: gateway
294
+ baseUrl: https://api.z.ai/v1
295
+ authTokenEnv: ZAI_API_KEY
296
+ openrouter:
297
+ type: gateway
298
+ baseUrl: https://openrouter.ai/api/v1
299
+ authTokenEnv: OPENROUTER_API_KEY
300
+ models:
301
+ builder: zai/claude-sonnet-4-6
302
+ scout: openrouter/openai/gpt-4o
303
+ ```
304
+
305
+ **How it works:** Model refs use `provider/model-id` format. Overstory sets `ANTHROPIC_BASE_URL` to the gateway `baseUrl`, `ANTHROPIC_AUTH_TOKEN` from the env var named in `authTokenEnv`, and `ANTHROPIC_API_KEY=""` to prevent direct Anthropic calls. The agent receives `"sonnet"` as a model alias and Claude Code routes via env vars.
306
+
307
+ **Environment variable notes:**
308
+ - `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_API_KEY` are mutually exclusive per-agent
309
+ - Gateway agents get `ANTHROPIC_API_KEY=""` and `ANTHROPIC_AUTH_TOKEN` from provider config
310
+ - Direct Anthropic API calls (merge resolver, watchdog triage) still need `ANTHROPIC_API_KEY` in the orchestrator env
311
+
312
+ **Validation:** `ov doctor --category providers` checks reachability, auth tokens, model-provider refs, and tool-use compatibility.
313
+
314
+ **`ProviderConfig` fields:**
315
+
316
+ | Field | Type | Required | Description |
317
+ |-------|------|----------|-------------|
318
+ | `type` | `native` or `gateway` | Yes | Provider type |
319
+ | `baseUrl` | string | Gateway only | API endpoint URL |
320
+ | `authTokenEnv` | string | Gateway only | Env var name holding auth token |
321
+
322
+ ## Troubleshooting
323
+
324
+ ### Coordinator died during startup
325
+
326
+ This error means the coordinator tmux session exited before the TUI became ready. The most common cause is slow shell initialization.
327
+
328
+ **Step 1: Measure shell startup time**
329
+
330
+ ```bash
331
+ time zsh -i -c exit # For zsh
332
+ time bash -i -c exit # For bash
333
+ ```
334
+
335
+ If startup takes more than 1 second, slow shell init is likely the cause.
336
+
337
+ **Step 2: Common slow-startup causes**
338
+
339
+ | Cause | Typical delay | Fix |
340
+ |-------|---------------|-----|
341
+ | oh-my-zsh with many plugins | 1-5s | Reduce plugins, switch to lighter framework (zinit with lazy loading) |
342
+ | nvm (Node Version Manager) | 1-3s | Use `--no-use` + lazy-load nvm, or switch to fnm/volta |
343
+ | pyenv init | 0.5-2s | Lazy-load pyenv |
344
+ | rbenv init | 0.5-1s | Lazy-load rbenv |
345
+ | starship prompt | 0.5-1s | Check starship timings |
346
+ | conda auto-activate | 1-3s | `auto_activate_base: false` in `.condarc` |
347
+ | Homebrew shellenv | 0.5-1s | Cache output instead of evaluating every shell start |
348
+
349
+ **Step 3: Configure `shellInitDelayMs`** in `.overstory/config.yaml`:
350
+
351
+ ```yaml
352
+ runtime:
353
+ shellInitDelayMs: 3000
354
+ ```
355
+
356
+ - Default: `0` (no delay)
357
+ - Typical values: `1000`–`5000` depending on shell startup time
358
+ - Values above `30000` (30s) trigger a warning
359
+ - Inserts a delay between tmux session creation and TUI readiness polling
360
+
361
+ **Step 4: Optimization examples**
362
+
363
+ Lazy-load nvm (add to `~/.zshrc` or `~/.bashrc`):
364
+
365
+ ```bash
366
+ # Lazy-load nvm — only activates when you first call nvm/node/npm
367
+ export NVM_DIR="$HOME/.nvm"
368
+ nvm() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; nvm "$@"; }
369
+ node() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; node "$@"; }
370
+ npm() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; npm "$@"; }
371
+ npx() { unset -f nvm node npm npx; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; npx "$@"; }
372
+ ```
373
+
374
+ Reduce oh-my-zsh plugins (edit `~/.zshrc`):
375
+
376
+ ```bash
377
+ # Before: plugins=(git zsh-autosuggestions zsh-syntax-highlighting node npm python ruby rbenv pyenv ...)
378
+ # After: keep only what you use regularly
379
+ plugins=(git)
380
+ ```
381
+
280
382
  ## Part of os-eco
281
383
 
282
384
  Overstory is part of the [os-eco](https://github.com/jayminwest/os-eco) AI agent tooling ecosystem.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@ interface TmuxCallTracker {
38
38
  env?: Record<string, string>;
39
39
  }>;
40
40
  isSessionAlive: Array<{ name: string; result: boolean }>;
41
+ checkSessionState: Array<{ name: string; result: "alive" | "dead" | "no_server" }>;
41
42
  killSession: Array<{ name: string }>;
42
43
  sendKeys: Array<{ name: string; keys: string }>;
43
44
  waitForTuiReady: Array<{ name: string }>;
@@ -68,6 +69,7 @@ function makeFakeTmux(
68
69
  options: {
69
70
  waitForTuiReadyResult?: boolean;
70
71
  ensureTmuxAvailableError?: Error;
72
+ checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
71
73
  } = {},
72
74
  ): {
73
75
  tmux: NonNullable<CoordinatorDeps["_tmux"]>;
@@ -76,6 +78,7 @@ function makeFakeTmux(
76
78
  const calls: TmuxCallTracker = {
77
79
  createSession: [],
78
80
  isSessionAlive: [],
81
+ checkSessionState: [],
79
82
  killSession: [],
80
83
  sendKeys: [],
81
84
  waitForTuiReady: [],
@@ -97,6 +100,13 @@ function makeFakeTmux(
97
100
  calls.isSessionAlive.push({ name, result: alive });
98
101
  return alive;
99
102
  },
103
+ checkSessionState: async (name: string): Promise<"alive" | "dead" | "no_server"> => {
104
+ const stateMap = options.checkSessionStateMap ?? {};
105
+ // Default: derive from sessionAliveMap for backwards compat
106
+ const state = stateMap[name] ?? (sessionAliveMap[name] ? "alive" : "dead");
107
+ calls.checkSessionState.push({ name, result: state });
108
+ return state;
109
+ },
100
110
  killSession: async (name: string): Promise<void> => {
101
111
  calls.killSession.push({ name });
102
112
  },
@@ -325,7 +335,11 @@ function makeDeps(
325
335
  sessionAliveMap: Record<string, boolean> = {},
326
336
  watchdogConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
327
337
  monitorConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
328
- tmuxOptions?: { waitForTuiReadyResult?: boolean; ensureTmuxAvailableError?: Error },
338
+ tmuxOptions?: {
339
+ waitForTuiReadyResult?: boolean;
340
+ ensureTmuxAvailableError?: Error;
341
+ checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
342
+ },
329
343
  ): {
330
344
  deps: CoordinatorDeps;
331
345
  calls: TmuxCallTracker;
@@ -606,7 +620,7 @@ describe("startCoordinator", () => {
606
620
 
607
621
  test("rejects duplicate when coordinator is already running", async () => {
608
622
  // Write an existing active coordinator session
609
- const existing = makeCoordinatorSession({ state: "working" });
623
+ const existing = makeCoordinatorSession({ state: "working", pid: process.pid });
610
624
  saveSessionsToDb([existing]);
611
625
 
612
626
  // Mock tmux as alive for the existing session
@@ -623,6 +637,29 @@ describe("startCoordinator", () => {
623
637
  }
624
638
  });
625
639
 
640
+ test("rejects duplicate when pid is null but tmux session is alive", async () => {
641
+ // Session has null pid (e.g. migrated from older schema) but tmux is alive.
642
+ // Cannot prove it's a zombie without a pid, so treat as active.
643
+ const existing = makeCoordinatorSession({ state: "working", pid: null });
644
+ saveSessionsToDb([existing]);
645
+
646
+ const { deps } = makeDeps(
647
+ { "overstory-test-project-coordinator": true },
648
+ undefined,
649
+ undefined,
650
+ { checkSessionStateMap: { "overstory-test-project-coordinator": "alive" } },
651
+ );
652
+
653
+ try {
654
+ await coordinatorCommand(["start"], deps);
655
+ expect(true).toBe(false); // Should have thrown
656
+ } catch (err) {
657
+ expect(err).toBeInstanceOf(AgentError);
658
+ const ae = err as AgentError;
659
+ expect(ae.message).toContain("already running");
660
+ }
661
+ });
662
+
626
663
  test("cleans up dead session and starts new one", async () => {
627
664
  // Write an existing session that claims to be working
628
665
  const deadSession = makeCoordinatorSession({
@@ -656,6 +693,98 @@ describe("startCoordinator", () => {
656
693
  expect(newSession?.id).not.toBe("session-dead-coordinator");
657
694
  });
658
695
 
696
+ test("cleans up zombie session when tmux alive but PID dead", async () => {
697
+ // Session is "working" in DB, tmux session exists, but the PID is dead
698
+ const zombieSession = makeCoordinatorSession({
699
+ id: "session-zombie-coordinator",
700
+ state: "working",
701
+ pid: 999999, // Non-existent PID
702
+ });
703
+ saveSessionsToDb([zombieSession]);
704
+
705
+ // Tmux session is alive (pane exists) but PID 999999 is not running
706
+ const { deps } = makeDeps(
707
+ { "overstory-test-project-coordinator": true },
708
+ undefined,
709
+ undefined,
710
+ { checkSessionStateMap: { "overstory-test-project-coordinator": "alive" } },
711
+ );
712
+
713
+ const originalSleep = Bun.sleep;
714
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
715
+
716
+ try {
717
+ await captureStdout(() => coordinatorCommand(["start"], deps));
718
+ } finally {
719
+ Bun.sleep = originalSleep;
720
+ }
721
+
722
+ // Zombie session should be cleaned up and new one created
723
+ const sessions = loadSessionsFromDb();
724
+ expect(sessions).toHaveLength(1);
725
+ const newSession = sessions[0];
726
+ expect(newSession?.state).toBe("booting");
727
+ expect(newSession?.id).not.toBe("session-zombie-coordinator");
728
+ });
729
+
730
+ test("cleans up stale session when tmux server is not running", async () => {
731
+ // Session is "booting" in DB but tmux server crashed
732
+ const staleSession = makeCoordinatorSession({
733
+ id: "session-stale-coordinator",
734
+ state: "booting",
735
+ });
736
+ saveSessionsToDb([staleSession]);
737
+
738
+ // checkSessionState returns no_server
739
+ const { deps } = makeDeps(
740
+ { "overstory-test-project-coordinator": false },
741
+ undefined,
742
+ undefined,
743
+ { checkSessionStateMap: { "overstory-test-project-coordinator": "no_server" } },
744
+ );
745
+
746
+ const originalSleep = Bun.sleep;
747
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
748
+
749
+ try {
750
+ await captureStdout(() => coordinatorCommand(["start"], deps));
751
+ } finally {
752
+ Bun.sleep = originalSleep;
753
+ }
754
+
755
+ // Stale session cleaned up, new one created
756
+ const sessions = loadSessionsFromDb();
757
+ expect(sessions).toHaveLength(1);
758
+ const newSession = sessions[0];
759
+ expect(newSession?.state).toBe("booting");
760
+ expect(newSession?.id).not.toBe("session-stale-coordinator");
761
+ });
762
+
763
+ test("respects shellInitDelayMs config before polling TUI readiness", async () => {
764
+ // Append shellInitDelayMs to existing config (preserve tier2Enabled etc.)
765
+ const configPath = join(tempDir, ".overstory", "config.yaml");
766
+ const existing = await Bun.file(configPath).text();
767
+ await Bun.write(configPath, `${existing}\nruntime:\n shellInitDelayMs: 500\n`);
768
+
769
+ const { deps } = makeDeps();
770
+
771
+ const sleepCalls: number[] = [];
772
+ const originalSleep = Bun.sleep;
773
+ Bun.sleep = ((ms: number | Date) => {
774
+ if (typeof ms === "number") sleepCalls.push(ms);
775
+ return Promise.resolve();
776
+ }) as typeof Bun.sleep;
777
+
778
+ try {
779
+ await captureStdout(() => coordinatorCommand(["start"], deps));
780
+ } finally {
781
+ Bun.sleep = originalSleep;
782
+ }
783
+
784
+ // The 500ms shell init delay should appear in the sleep calls
785
+ expect(sleepCalls).toContain(500);
786
+ });
787
+
659
788
  test("throws AgentError when tmux is not available", async () => {
660
789
  const { deps } = makeDeps({}, undefined, undefined, {
661
790
  ensureTmuxAvailableError: new AgentError(
@@ -27,7 +27,9 @@ import { createRunStore } from "../sessions/store.ts";
27
27
  import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
28
28
  import type { AgentSession } from "../types.ts";
29
29
  import { isProcessRunning } from "../watchdog/health.ts";
30
+ import type { SessionState } from "../worktree/tmux.ts";
30
31
  import {
32
+ checkSessionState,
31
33
  createSession,
32
34
  ensureTmuxAvailable,
33
35
  isSessionAlive,
@@ -58,6 +60,7 @@ export interface CoordinatorDeps {
58
60
  env?: Record<string, string>,
59
61
  ) => Promise<number>;
60
62
  isSessionAlive: (name: string) => Promise<boolean>;
63
+ checkSessionState: (name: string) => Promise<SessionState>;
61
64
  killSession: (name: string) => Promise<void>;
62
65
  sendKeys: (name: string, keys: string) => Promise<void>;
63
66
  waitForTuiReady: (
@@ -275,6 +278,7 @@ async function startCoordinator(
275
278
  const tmux = deps._tmux ?? {
276
279
  createSession,
277
280
  isSessionAlive,
281
+ checkSessionState,
278
282
  killSession,
279
283
  sendKeys,
280
284
  waitForTuiReady,
@@ -308,15 +312,29 @@ async function startCoordinator(
308
312
  existing.state !== "completed" &&
309
313
  existing.state !== "zombie"
310
314
  ) {
311
- const alive = await tmux.isSessionAlive(existing.tmuxSession);
312
- if (alive) {
313
- throw new AgentError(
314
- `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
315
- { agentName: COORDINATOR_NAME },
316
- );
315
+ const sessionState = await tmux.checkSessionState(existing.tmuxSession);
316
+
317
+ if (sessionState === "alive") {
318
+ // Tmux session exists -- but is the process inside still running?
319
+ // A crashed Claude Code leaves a zombie tmux pane that blocks retries.
320
+ if (existing.pid !== null && !isProcessRunning(existing.pid)) {
321
+ // Zombie: tmux pane exists but agent process has exited.
322
+ // Kill the empty session and reclaim the slot.
323
+ await tmux.killSession(existing.tmuxSession);
324
+ store.updateState(COORDINATOR_NAME, "completed");
325
+ } else {
326
+ // Either the process is genuinely running (pid alive), or pid is null
327
+ // (e.g. sessions migrated from an older schema). In both cases we
328
+ // cannot prove the session is a zombie, so treat it as active.
329
+ throw new AgentError(
330
+ `Coordinator is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
331
+ { agentName: COORDINATOR_NAME },
332
+ );
333
+ }
334
+ } else {
335
+ // Session is dead or tmux server is not running -- clean up stale DB entry.
336
+ store.updateState(COORDINATOR_NAME, "completed");
317
337
  }
318
- // Session recorded but tmux is dead — mark as completed and continue
319
- store.updateState(COORDINATOR_NAME, "completed");
320
338
  }
321
339
 
322
340
  // Resolve model and runtime early (needed for deployConfig and spawn)
@@ -413,6 +431,12 @@ async function startCoordinator(
413
431
 
414
432
  store.upsert(session);
415
433
 
434
+ // Give slow shells time to finish initializing before polling for TUI readiness.
435
+ const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
436
+ if (shellDelay > 0) {
437
+ await Bun.sleep(shellDelay);
438
+ }
439
+
416
440
  // Wait for Claude Code TUI to render before sending input
417
441
  const tuiReady = await tmux.waitForTuiReady(tmuxSession, (content) =>
418
442
  runtime.detectReady(content),
@@ -423,8 +447,13 @@ async function startCoordinator(
423
447
  if (!alive) {
424
448
  // Clean up the stale session record
425
449
  store.updateState(COORDINATOR_NAME, "completed");
450
+ const sessionState = await tmux.checkSessionState(tmuxSession);
451
+ const detail =
452
+ sessionState === "no_server"
453
+ ? "The tmux server is no longer running. It may have crashed or been killed externally."
454
+ : "The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.";
426
455
  throw new AgentError(
427
- `Coordinator tmux session "${tmuxSession}" died during startup. The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.`,
456
+ `Coordinator tmux session "${tmuxSession}" died during startup. ${detail}`,
428
457
  { agentName: COORDINATOR_NAME },
429
458
  );
430
459
  }
@@ -512,6 +541,7 @@ async function stopCoordinator(opts: { json: boolean }, deps: CoordinatorDeps =
512
541
  const tmux = deps._tmux ?? {
513
542
  createSession,
514
543
  isSessionAlive,
544
+ checkSessionState,
515
545
  killSession,
516
546
  sendKeys,
517
547
  waitForTuiReady,
@@ -626,6 +656,7 @@ async function statusCoordinator(
626
656
  const tmux = deps._tmux ?? {
627
657
  createSession,
628
658
  isSessionAlive,
659
+ checkSessionState,
629
660
  killSession,
630
661
  sendKeys,
631
662
  waitForTuiReady,
@@ -796,6 +796,7 @@ describe("costsCommand", () => {
796
796
  cacheCreationTokens: 100,
797
797
  estimatedCostUsd: 0.15,
798
798
  modelUsed: "claude-sonnet-4-5",
799
+ runId: null,
799
800
  createdAt: new Date().toISOString(),
800
801
  });
801
802
  metricsStore.close();
@@ -853,6 +854,7 @@ describe("costsCommand", () => {
853
854
  cacheCreationTokens: 100,
854
855
  estimatedCostUsd: 0.15,
855
856
  modelUsed: "claude-sonnet-4-5",
857
+ runId: null,
856
858
  createdAt: new Date().toISOString(),
857
859
  });
858
860
  metricsStore.close();
@@ -937,6 +939,7 @@ describe("costsCommand", () => {
937
939
  cacheCreationTokens: 0,
938
940
  estimatedCostUsd: 0.15,
939
941
  modelUsed: "claude-sonnet-4-5",
942
+ runId: null,
940
943
  createdAt: new Date().toISOString(),
941
944
  });
942
945
  metricsStore.recordSnapshot({
@@ -947,6 +950,7 @@ describe("costsCommand", () => {
947
950
  cacheCreationTokens: 0,
948
951
  estimatedCostUsd: 0.25,
949
952
  modelUsed: "claude-sonnet-4-5",
953
+ runId: null,
950
954
  createdAt: new Date().toISOString(),
951
955
  });
952
956
  metricsStore.close();
@@ -997,6 +1001,7 @@ describe("costsCommand", () => {
997
1001
  cacheCreationTokens: 0,
998
1002
  estimatedCostUsd: 0.3,
999
1003
  modelUsed: "claude-sonnet-4-5",
1004
+ runId: null,
1000
1005
  createdAt: new Date().toISOString(),
1001
1006
  });
1002
1007
  metricsStore.close();
@@ -367,7 +367,7 @@ async function executeCosts(opts: CostsOpts): Promise<void> {
367
367
  const { store: sessionStore } = openSessionStore(overstoryDir);
368
368
 
369
369
  try {
370
- const snapshots = metricsStore.getLatestSnapshots();
370
+ const snapshots = metricsStore.getLatestSnapshots(runId ?? undefined);
371
371
  if (snapshots.length === 0) {
372
372
  if (json) {
373
373
  jsonOutput("costs", {
@@ -21,6 +21,7 @@ import {
21
21
  computeAgentPanelHeight,
22
22
  dashboardCommand,
23
23
  dimBox,
24
+ EventBuffer,
24
25
  filterAgentsByRun,
25
26
  horizontalLine,
26
27
  openDashboardStores,
@@ -242,28 +243,28 @@ describe("dimBox", () => {
242
243
 
243
244
  describe("computeAgentPanelHeight", () => {
244
245
  test("0 agents: clamps to minimum 8", () => {
245
- // max(8, min(floor(30*0.5), 0+4)) = max(8, min(15,4)) = max(8,4) = 8
246
+ // max(8, min(floor(30*0.35)=10, 0+4)) = max(8, min(10,4)) = max(8,4) = 8
246
247
  expect(computeAgentPanelHeight(30, 0)).toBe(8);
247
248
  });
248
249
 
249
250
  test("4 agents: still clamps to minimum 8", () => {
250
- // max(8, min(15, 4+4)) = max(8, 8) = 8
251
+ // max(8, min(10, 4+4)) = max(8, 8) = 8
251
252
  expect(computeAgentPanelHeight(30, 4)).toBe(8);
252
253
  });
253
254
 
254
- test("20 agents with height 30: clamps to floor(height*0.5)", () => {
255
- // max(8, min(15, 24)) = max(8,15) = 15
256
- expect(computeAgentPanelHeight(30, 20)).toBe(15);
255
+ test("20 agents with height 30: clamps to floor(height*0.35)", () => {
256
+ // max(8, min(floor(30*0.35)=10, 24)) = max(8,10) = 10
257
+ expect(computeAgentPanelHeight(30, 20)).toBe(10);
257
258
  });
258
259
 
259
260
  test("10 agents with height 30: grows with agent count", () => {
260
- // max(8, min(15, 14)) = max(8,14) = 14
261
- expect(computeAgentPanelHeight(30, 10)).toBe(14);
261
+ // max(8, min(10, 14)) = max(8,10) = 10
262
+ expect(computeAgentPanelHeight(30, 10)).toBe(10);
262
263
  });
263
264
 
264
- test("small height: respects 50% cap", () => {
265
- // height=20: max(8, min(10, 20+4)) = max(8,10) = 10
266
- expect(computeAgentPanelHeight(20, 20)).toBe(10);
265
+ test("small height: respects 35% cap", () => {
266
+ // height=20: max(8, min(floor(20*0.35)=7, 24)) = max(8,7) = 8
267
+ expect(computeAgentPanelHeight(20, 20)).toBe(8);
267
268
  });
268
269
  });
269
270
 
@@ -302,6 +303,7 @@ function makeDashboardData(
302
303
  metrics: { totalSessions: 0, avgDuration: 0, byCapability: {} },
303
304
  tasks: overrides.tasks ?? [],
304
305
  recentEvents: (overrides.recentEvents as never[]) ?? [],
306
+ feedColorMap: new Map(),
305
307
  };
306
308
  }
307
309
 
@@ -366,6 +368,7 @@ describe("renderFeedPanel", () => {
366
368
  const data = makeDashboardData({ recentEvents: [] });
367
369
  const out = renderFeedPanel(data, 1, 80, 8, 1);
368
370
  expect(out).toContain("Feed");
371
+ expect(out).toContain("(live)");
369
372
  });
370
373
 
371
374
  test("renders event agent name when events are present", () => {
@@ -554,6 +557,94 @@ describe("closeDashboardStores", () => {
554
557
  });
555
558
  });
556
559
 
560
+ describe("EventBuffer", () => {
561
+ let tempDir: string;
562
+
563
+ beforeEach(async () => {
564
+ tempDir = await mkdtemp(join(tmpdir(), "event-buffer-test-"));
565
+ });
566
+
567
+ afterEach(async () => {
568
+ await cleanupTempDir(tempDir);
569
+ });
570
+
571
+ function makeEvent(agentName: string) {
572
+ return {
573
+ agentName,
574
+ eventType: "tool_end" as const,
575
+ level: "info" as const,
576
+ runId: null,
577
+ sessionId: null,
578
+ toolName: null,
579
+ toolArgs: null,
580
+ toolDurationMs: null,
581
+ data: null,
582
+ };
583
+ }
584
+
585
+ test("starts empty", () => {
586
+ const buf = new EventBuffer();
587
+ expect(buf.size).toBe(0);
588
+ expect(buf.getEvents()).toEqual([]);
589
+ });
590
+
591
+ test("poll adds events from event store", async () => {
592
+ const overstoryDir = join(tempDir, ".overstory");
593
+ await mkdir(overstoryDir, { recursive: true });
594
+ const store = createEventStore(join(overstoryDir, "events.db"));
595
+ store.insert(makeEvent("agent-a"));
596
+
597
+ const buf = new EventBuffer();
598
+ buf.poll(store);
599
+ expect(buf.size).toBe(1);
600
+ store.close();
601
+ });
602
+
603
+ test("deduplicates by lastSeenId (double poll returns same count)", async () => {
604
+ const overstoryDir = join(tempDir, ".overstory");
605
+ await mkdir(overstoryDir, { recursive: true });
606
+ const store = createEventStore(join(overstoryDir, "events.db"));
607
+ store.insert(makeEvent("agent-a"));
608
+
609
+ const buf = new EventBuffer();
610
+ buf.poll(store);
611
+ buf.poll(store); // second poll should not duplicate
612
+ expect(buf.size).toBe(1);
613
+ store.close();
614
+ });
615
+
616
+ test("trims to maxSize keeping most recent events", async () => {
617
+ const overstoryDir = join(tempDir, ".overstory");
618
+ await mkdir(overstoryDir, { recursive: true });
619
+ const store = createEventStore(join(overstoryDir, "events.db"));
620
+ for (let i = 0; i < 5; i++) {
621
+ store.insert(makeEvent(`agent-${i}`));
622
+ }
623
+
624
+ const buf = new EventBuffer(3);
625
+ buf.poll(store);
626
+ expect(buf.size).toBe(3);
627
+ store.close();
628
+ });
629
+
630
+ test("builds color map across polls", async () => {
631
+ const overstoryDir = join(tempDir, ".overstory");
632
+ await mkdir(overstoryDir, { recursive: true });
633
+ const store = createEventStore(join(overstoryDir, "events.db"));
634
+ store.insert(makeEvent("agent-x"));
635
+
636
+ const buf = new EventBuffer();
637
+ buf.poll(store);
638
+ expect(buf.getColorMap().has("agent-x")).toBe(true);
639
+
640
+ store.insert(makeEvent("agent-y"));
641
+ buf.poll(store);
642
+ expect(buf.getColorMap().has("agent-x")).toBe(true);
643
+ expect(buf.getColorMap().has("agent-y")).toBe(true);
644
+ store.close();
645
+ });
646
+ });
647
+
557
648
  // Type check: DashboardStores includes eventStore
558
649
  test("DashboardStores type includes eventStore field", () => {
559
650
  const stores: DashboardStores = {