@os-eco/overstory-cli 0.7.6 → 0.7.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 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
@@ -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
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.7",
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",
@@ -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 = {
@@ -24,6 +24,7 @@ import { createEventStore } from "../events/store.ts";
24
24
  import { accent, brand, color, visibleLength } from "../logging/color.ts";
25
25
  import {
26
26
  buildAgentColorMap,
27
+ extendAgentColorMap,
27
28
  formatDuration,
28
29
  formatEventLine,
29
30
  formatRelativeTime,
@@ -128,10 +129,10 @@ export { pad, truncate, horizontalLine };
128
129
 
129
130
  /**
130
131
  * Compute agent panel height from screen height and agent count.
131
- * min 8 rows, max floor(height * 0.5), grows with agent count (+4 for chrome).
132
+ * min 8 rows, max floor(height * 0.35), grows with agent count (+4 for chrome).
132
133
  */
133
134
  export function computeAgentPanelHeight(height: number, agentCount: number): number {
134
- return Math.max(8, Math.min(Math.floor(height * 0.5), agentCount + 4));
135
+ return Math.max(8, Math.min(Math.floor(height * 0.35), agentCount + 4));
135
136
  }
136
137
 
137
138
  /**
@@ -241,6 +242,49 @@ export function closeDashboardStores(stores: DashboardStores): void {
241
242
  }
242
243
  }
243
244
 
245
+ /**
246
+ * Rolling event buffer with incremental dedup by lastSeenId.
247
+ * Maintains a fixed-size window of the most recent events.
248
+ */
249
+ export class EventBuffer {
250
+ private events: StoredEvent[] = [];
251
+ private lastSeenId = 0;
252
+ private colorMap: Map<string, (s: string) => string> = new Map();
253
+ private readonly maxSize: number;
254
+
255
+ constructor(maxSize = 100) {
256
+ this.maxSize = maxSize;
257
+ }
258
+
259
+ poll(eventStore: EventStore): void {
260
+ const since = new Date(Date.now() - 60 * 1000).toISOString();
261
+ const allEvents = eventStore.getTimeline({ since, limit: 1000 });
262
+ const newEvents = allEvents.filter((e) => e.id > this.lastSeenId);
263
+
264
+ if (newEvents.length === 0) return;
265
+
266
+ extendAgentColorMap(this.colorMap, newEvents);
267
+ this.events = [...this.events, ...newEvents].slice(-this.maxSize);
268
+
269
+ const lastEvent = newEvents[newEvents.length - 1];
270
+ if (lastEvent) {
271
+ this.lastSeenId = lastEvent.id;
272
+ }
273
+ }
274
+
275
+ getEvents(): StoredEvent[] {
276
+ return this.events;
277
+ }
278
+
279
+ getColorMap(): Map<string, (s: string) => string> {
280
+ return this.colorMap;
281
+ }
282
+
283
+ get size(): number {
284
+ return this.events.length;
285
+ }
286
+ }
287
+
244
288
  /** Tracker data cached between dashboard ticks (10s TTL). */
245
289
  interface TrackerCache {
246
290
  tasks: TrackerIssue[];
@@ -263,6 +307,7 @@ interface DashboardData {
263
307
  };
264
308
  tasks: TrackerIssue[];
265
309
  recentEvents: StoredEvent[];
310
+ feedColorMap: Map<string, (s: string) => string>;
266
311
  }
267
312
 
268
313
  /**
@@ -289,6 +334,7 @@ async function loadDashboardData(
289
334
  stores: DashboardStores,
290
335
  runId?: string | null,
291
336
  thresholds?: { staleMs: number; zombieMs: number },
337
+ eventBuffer?: EventBuffer,
292
338
  ): Promise<DashboardData> {
293
339
  // Get all sessions from the pre-opened session store
294
340
  const allSessions = stores.sessionStore.getAll();
@@ -450,13 +496,14 @@ async function loadDashboardData(
450
496
  tasks = trackerCache.tasks;
451
497
  }
452
498
 
453
- // Load recent events from event store
499
+ // Load recent events via incremental buffer (or fallback to empty)
454
500
  let recentEvents: StoredEvent[] = [];
455
- if (stores.eventStore) {
501
+ let feedColorMap: Map<string, (s: string) => string> = new Map();
502
+ if (eventBuffer && stores.eventStore) {
456
503
  try {
457
- const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
458
- const events = stores.eventStore.getTimeline({ since: fiveMinAgo, limit: 20 });
459
- recentEvents = [...events].reverse(); // most recent first
504
+ eventBuffer.poll(stores.eventStore);
505
+ recentEvents = [...eventBuffer.getEvents()].reverse();
506
+ feedColorMap = eventBuffer.getColorMap();
460
507
  } catch {
461
508
  /* best effort */
462
509
  }
@@ -470,6 +517,7 @@ async function loadDashboardData(
470
517
  metrics: { totalSessions, avgDuration, byCapability },
471
518
  tasks,
472
519
  recentEvents,
520
+ feedColorMap,
473
521
  };
474
522
  }
475
523
 
@@ -496,7 +544,7 @@ export function renderAgentPanel(
496
544
  panelHeight: number,
497
545
  startRow: number,
498
546
  ): string {
499
- const leftWidth = Math.floor(fullWidth * 0.6);
547
+ const leftWidth = fullWidth;
500
548
  let output = "";
501
549
 
502
550
  // Panel header
@@ -650,7 +698,7 @@ export function renderFeedPanel(
650
698
  let output = "";
651
699
 
652
700
  // Header
653
- const headerLine = `${dimBox.vertical} ${brand.bold("Feed")} (last 5 min)`;
701
+ const headerLine = `${dimBox.vertical} ${brand.bold("Feed")} (live)`;
654
702
  const headerPadding = " ".repeat(
655
703
  Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
656
704
  );
@@ -676,7 +724,8 @@ export function renderFeedPanel(
676
724
  return output;
677
725
  }
678
726
 
679
- const colorMap = buildAgentColorMap(data.recentEvents);
727
+ const colorMap =
728
+ data.feedColorMap.size > 0 ? data.feedColorMap : buildAgentColorMap(data.recentEvents);
680
729
  const visibleEvents = data.recentEvents.slice(0, maxRows);
681
730
 
682
731
  for (let i = 0; i < visibleEvents.length; i++) {
@@ -733,12 +782,10 @@ export function renderFeedPanel(
733
782
  */
734
783
  function renderMailPanel(
735
784
  data: DashboardData,
736
- width: number,
737
- height: number,
785
+ panelWidth: number,
786
+ panelHeight: number,
738
787
  startRow: number,
739
788
  ): string {
740
- const panelHeight = Math.floor(height * 0.3);
741
- const panelWidth = Math.floor(width * 0.5);
742
789
  let output = "";
743
790
 
744
791
  const unreadCount = data.status.unreadMailCount;
@@ -787,13 +834,11 @@ function renderMailPanel(
787
834
  */
788
835
  function renderMergeQueuePanel(
789
836
  data: DashboardData,
790
- width: number,
791
- height: number,
837
+ panelWidth: number,
838
+ panelHeight: number,
792
839
  startRow: number,
793
840
  startCol: number,
794
841
  ): string {
795
- const panelHeight = Math.floor(height * 0.3);
796
- const panelWidth = width - startCol + 1;
797
842
  let output = "";
798
843
 
799
844
  const headerLine = `${dimBox.vertical} ${brand.bold("Merge Queue")} (${data.mergeQueue.length})`;
@@ -847,26 +892,20 @@ function renderMetricsPanel(
847
892
  const separator = dimHorizontalLine(width, dimBox.tee, dimBox.teeRight);
848
893
  output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
849
894
 
850
- const headerLine = `${dimBox.vertical} ${brand.bold("Metrics")}`;
851
- const headerPadding = " ".repeat(
852
- Math.max(0, width - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
853
- );
854
- output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
855
-
856
895
  const totalSessions = data.metrics.totalSessions;
857
896
  const avgDur = formatDuration(data.metrics.avgDuration);
858
897
  const byCapability = Object.entries(data.metrics.byCapability)
859
898
  .map(([cap, count]) => `${cap}:${count}`)
860
899
  .join(", ");
861
900
 
862
- const metricsLine = `${dimBox.vertical} Total sessions: ${totalSessions} | Avg duration: ${avgDur} | By capability: ${byCapability}`;
901
+ const metricsLine = `${dimBox.vertical} ${brand.bold("Metrics")} Total: ${totalSessions} | Avg: ${avgDur} | ${byCapability}`;
863
902
  const metricsPadding = " ".repeat(
864
903
  Math.max(0, width - visibleLength(metricsLine) - visibleLength(dimBox.vertical)),
865
904
  );
866
- output += `${CURSOR.cursorTo(startRow + 2, 1)}${metricsLine}${metricsPadding}${dimBox.vertical}\n`;
905
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${metricsLine}${metricsPadding}${dimBox.vertical}\n`;
867
906
 
868
907
  const bottomBorder = dimHorizontalLine(width, dimBox.bottomLeft, dimBox.bottomRight);
869
- output += `${CURSOR.cursorTo(startRow + 3, 1)}${bottomBorder}\n`;
908
+ output += `${CURSOR.cursorTo(startRow + 2, 1)}${bottomBorder}\n`;
870
909
 
871
910
  return output;
872
911
  }
@@ -883,50 +922,42 @@ function renderDashboard(data: DashboardData, interval: number): void {
883
922
  // Header (rows 1-2)
884
923
  output += renderHeader(width, interval, data.currentRunId);
885
924
 
886
- // Agent panel start row
925
+ // Agent panel: full width, capped at 35% of height
887
926
  const agentPanelStart = 3;
888
-
889
- // Dynamic agent panel height
890
927
  const agentCount = data.status.agents.length;
891
928
  const agentPanelHeight = computeAgentPanelHeight(height, agentCount);
929
+ output += renderAgentPanel(data, width, agentPanelHeight, agentPanelStart);
892
930
 
893
- // Column widths
894
- const leftWidth = Math.floor(width * 0.6);
895
- const rightWidth = width - leftWidth;
896
- const rightStartCol = leftWidth + 1;
931
+ // Middle zone: Feed (left 60%) | Tasks (right 40%)
932
+ const middleStart = agentPanelStart + agentPanelHeight + 1;
933
+ const compactPanelHeight = 5; // fixed for mail/merge panels
934
+ const metricsHeight = 3; // separator + data + border
935
+ const middleHeight = Math.max(6, height - middleStart - compactPanelHeight - metricsHeight);
897
936
 
898
- // Right column split (Tasks upper / Feed lower)
899
- const rightHalf = Math.floor(agentPanelHeight / 2);
900
- const feedHeight = agentPanelHeight - rightHalf;
937
+ const feedWidth = Math.floor(width * 0.6);
938
+ output += renderFeedPanel(data, 1, feedWidth, middleHeight, middleStart);
901
939
 
902
- // Render left: agents (60% wide, dynamic height)
903
- output += renderAgentPanel(data, width, agentPanelHeight, agentPanelStart);
940
+ const taskWidth = width - feedWidth;
941
+ const taskStartCol = feedWidth + 1;
942
+ output += renderTasksPanel(data, taskStartCol, taskWidth, middleHeight, middleStart);
904
943
 
905
- // Render right-upper: tasks
906
- output += renderTasksPanel(data, rightStartCol, rightWidth, rightHalf, agentPanelStart);
944
+ // Compact panels: Mail (left 50%) | Merge Queue (right 50%) — fixed 5 rows
945
+ const compactStart = middleStart + middleHeight;
946
+ const mailWidth = Math.floor(width * 0.5);
947
+ output += renderMailPanel(data, mailWidth, compactPanelHeight, compactStart);
907
948
 
908
- // Render right-lower: feed
909
- output += renderFeedPanel(
949
+ const mergeStartCol = mailWidth + 1;
950
+ const mergeWidth = width - mailWidth;
951
+ output += renderMergeQueuePanel(
910
952
  data,
911
- rightStartCol,
912
- rightWidth,
913
- feedHeight,
914
- agentPanelStart + rightHalf,
953
+ mergeWidth,
954
+ compactPanelHeight,
955
+ compactStart,
956
+ mergeStartCol,
915
957
  );
916
958
 
917
- // Middle panels (mail/merge) start after agent block
918
- const middlePanelStart = agentPanelStart + agentPanelHeight + 1;
919
-
920
- // Mail panel (left 50%)
921
- output += renderMailPanel(data, width, height, middlePanelStart);
922
-
923
- // Merge queue panel (right 50%)
924
- const mergeQueueCol = Math.floor(width * 0.5) + 1;
925
- output += renderMergeQueuePanel(data, width, height, middlePanelStart, mergeQueueCol);
926
-
927
- // Metrics panel (bottom strip)
928
- const middlePanelHeight = Math.floor(height * 0.3);
929
- const metricsStart = middlePanelStart + middlePanelHeight + 1;
959
+ // Metrics footer
960
+ const metricsStart = compactStart + compactPanelHeight;
930
961
  output += renderMetricsPanel(data, width, height, metricsStart);
931
962
 
932
963
  process.stdout.write(output);
@@ -963,6 +994,9 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
963
994
  // Open stores once for the entire poll loop lifetime
964
995
  const stores = openDashboardStores(root);
965
996
 
997
+ // Create rolling event buffer (persisted across poll ticks)
998
+ const eventBuffer = new EventBuffer(100);
999
+
966
1000
  // Compute health thresholds once from config (reused across poll ticks)
967
1001
  const thresholds = {
968
1002
  staleMs: config.watchdog.staleThresholdMs,
@@ -984,7 +1018,7 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
984
1018
 
985
1019
  // Poll loop
986
1020
  while (running) {
987
- const data = await loadDashboardData(root, stores, runId, thresholds);
1021
+ const data = await loadDashboardData(root, stores, runId, thresholds, eventBuffer);
988
1022
  renderDashboard(data, interval);
989
1023
  await Bun.sleep(interval);
990
1024
  }
package/src/index.ts CHANGED
@@ -45,7 +45,7 @@ import { OverstoryError, WorktreeError } from "./errors.ts";
45
45
  import { jsonError } from "./json.ts";
46
46
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
47
47
 
48
- export const VERSION = "0.7.6";
48
+ export const VERSION = "0.7.7";
49
49
 
50
50
  const rawArgs = process.argv.slice(2);
51
51
 
@@ -651,7 +651,7 @@ describe("ClaudeRuntime integration: registry resolves 'claude' as default", ()
651
651
 
652
652
  test("getRuntime rejects unknown runtimes", async () => {
653
653
  const { getRuntime } = await import("./registry.ts");
654
- expect(() => getRuntime("codex")).toThrow('Unknown runtime: "codex"');
655
654
  expect(() => getRuntime("opencode")).toThrow('Unknown runtime: "opencode"');
655
+ expect(() => getRuntime("aider")).toThrow('Unknown runtime: "aider"');
656
656
  });
657
657
  });