@os-eco/overstory-cli 0.8.0 → 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.
package/README.md CHANGED
@@ -19,6 +19,7 @@ Requires [Bun](https://bun.sh) v1.0+, git, and tmux. At least one supported agen
19
19
  - [GitHub Copilot](https://github.com/features/copilot) (`copilot` CLI)
20
20
  - [Codex](https://github.com/openai/codex) (`codex` CLI)
21
21
  - [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` CLI)
22
+ - [Sapling](https://github.com/nichochar/sapling) (`sp` CLI)
22
23
 
23
24
  ```bash
24
25
  bun install -g @os-eco/overstory-cli
@@ -181,6 +182,7 @@ Overstory is runtime-agnostic. The `AgentRuntime` interface (`src/runtimes/types
181
182
  | Copilot | `copilot` | (none — `--allow-all-tools`) | Active development |
182
183
  | Codex | `codex` | OS-level sandbox (Seatbelt/Landlock) | Active development |
183
184
  | Gemini | `gemini` | `--sandbox` flag | Active development |
185
+ | Sapling | `sp` | `.sapling/guards.json` | Active development |
184
186
 
185
187
  ## How It Works
186
188
 
@@ -280,7 +282,7 @@ overstory/
280
282
  metrics/ SQLite metrics + pricing + transcript parsing
281
283
  doctor/ Health check modules (11 checks)
282
284
  insights/ Session insight analyzer for auto-expertise
283
- runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex, Gemini)
285
+ runtimes/ AgentRuntime abstraction (registry + adapters: Claude, Pi, Copilot, Codex, Gemini, Sapling)
284
286
  tracker/ Pluggable task tracker (beads + seeds backends)
285
287
  mulch/ mulch client (programmatic API + CLI wrapper)
286
288
  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.8.0",
3
+ "version": "0.8.2",
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",
@@ -409,6 +409,92 @@ describe("renderAgentPanel", () => {
409
409
  // dimBox.vertical is a dimmed ANSI string — present in output
410
410
  expect(out).toContain(dimBox.vertical);
411
411
  });
412
+
413
+ test("renders Live column header (not Tmux)", () => {
414
+ const data = makeDashboardData({});
415
+ const out = renderAgentPanel(data, 100, 12, 3);
416
+ expect(out).toContain("Live");
417
+ expect(out).not.toContain("Tmux");
418
+ });
419
+
420
+ test("shows green dot for headless agent with alive PID", () => {
421
+ const alivePid = process.pid; // own PID — guaranteed alive
422
+ const data = {
423
+ ...makeDashboardData({}),
424
+ status: {
425
+ currentRunId: null,
426
+ agents: [
427
+ {
428
+ id: "sess-h1",
429
+ agentName: "headless-worker",
430
+ capability: "builder",
431
+ worktreePath: "/tmp/wt/headless",
432
+ branchName: "overstory/headless/task-1",
433
+ taskId: "task-h1",
434
+ tmuxSession: "", // headless
435
+ state: "working" as const,
436
+ pid: alivePid,
437
+ parentAgent: null,
438
+ depth: 0,
439
+ runId: null,
440
+ startedAt: new Date(Date.now() - 10_000).toISOString(),
441
+ lastActivity: new Date().toISOString(),
442
+ escalationLevel: 0,
443
+ stalledSince: null,
444
+ transcriptPath: null,
445
+ },
446
+ ],
447
+ worktrees: [],
448
+ tmuxSessions: [], // no tmux sessions
449
+ unreadMailCount: 0,
450
+ mergeQueueCount: 0,
451
+ recentMetricsCount: 0,
452
+ },
453
+ };
454
+ const out = renderAgentPanel(data, 100, 12, 3);
455
+ // Green ">" for alive headless agent
456
+ expect(out).toContain(">");
457
+ expect(out).toContain("headless-worker");
458
+ });
459
+
460
+ test("shows red dot for headless agent with dead PID", () => {
461
+ const deadPid = 2_147_483_647;
462
+ const data = {
463
+ ...makeDashboardData({}),
464
+ status: {
465
+ currentRunId: null,
466
+ agents: [
467
+ {
468
+ id: "sess-h2",
469
+ agentName: "dead-headless", // short enough to not be truncated
470
+ capability: "builder",
471
+ worktreePath: "/tmp/wt/dead-headless",
472
+ branchName: "overstory/dead-headless/task-2",
473
+ taskId: "task-h2",
474
+ tmuxSession: "", // headless
475
+ state: "working" as const,
476
+ pid: deadPid,
477
+ parentAgent: null,
478
+ depth: 0,
479
+ runId: null,
480
+ startedAt: new Date(Date.now() - 10_000).toISOString(),
481
+ lastActivity: new Date().toISOString(),
482
+ escalationLevel: 0,
483
+ stalledSince: null,
484
+ transcriptPath: null,
485
+ },
486
+ ],
487
+ worktrees: [],
488
+ tmuxSessions: [],
489
+ unreadMailCount: 0,
490
+ mergeQueueCount: 0,
491
+ recentMetricsCount: 0,
492
+ },
493
+ };
494
+ const out = renderAgentPanel(data, 100, 12, 3);
495
+ expect(out).toContain("x");
496
+ expect(out).toContain("dead-headless");
497
+ });
412
498
  });
413
499
 
414
500
  describe("openDashboardStores", () => {
@@ -42,6 +42,7 @@ import { createTrackerClient, resolveBackend } from "../tracker/factory.ts";
42
42
  import type { TrackerIssue } from "../tracker/types.ts";
43
43
  import type { EventStore, MailMessage, StoredEvent } from "../types.ts";
44
44
  import { evaluateHealth } from "../watchdog/health.ts";
45
+ import { isProcessAlive } from "../worktree/tmux.ts";
45
46
  import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
46
47
 
47
48
  const pkgPath = resolve(import.meta.dir, "../../package.json");
@@ -555,7 +556,7 @@ export function renderAgentPanel(
555
556
  output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
556
557
 
557
558
  // Column headers
558
- const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Tmux `;
559
+ const colStr = `${dimBox.vertical} St Name Capability State Task ID Duration Live `;
559
560
  const colPadding = " ".repeat(
560
561
  Math.max(0, leftWidth - visibleLength(colStr) - visibleLength(dimBox.vertical)),
561
562
  );
@@ -595,10 +596,13 @@ export function renderAgentPanel(
595
596
  : now;
596
597
  const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
597
598
  const durationPadded = pad(duration, 9);
598
- const tmuxAlive = data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
599
- const tmuxDot = tmuxAlive ? color.green(">") : color.red("x");
599
+ const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
600
+ const alive = isHeadless
601
+ ? agent.pid !== null && isProcessAlive(agent.pid)
602
+ : data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
603
+ const aliveDot = alive ? color.green(">") : color.red("x");
600
604
 
601
- const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${tmuxDot} `;
605
+ const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
602
606
  const linePadding = " ".repeat(
603
607
  Math.max(0, leftWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
604
608
  );
@@ -469,6 +469,10 @@ describe("feedCommand", () => {
469
469
  "spawn",
470
470
  "error",
471
471
  "custom",
472
+ "turn_start",
473
+ "turn_end",
474
+ "progress",
475
+ "result",
472
476
  ] as const;
473
477
  for (const eventType of eventTypes) {
474
478
  store.insert(
@@ -494,6 +498,10 @@ describe("feedCommand", () => {
494
498
  expect(out).toContain("SPAWN");
495
499
  expect(out).toContain("ERROR");
496
500
  expect(out).toContain("CUSTM");
501
+ expect(out).toContain("TURN+");
502
+ expect(out).toContain("TURN-");
503
+ expect(out).toContain("PROG ");
504
+ expect(out).toContain("RSULT");
497
505
  });
498
506
  });
499
507
 
@@ -18,7 +18,7 @@ import { createMetricsStore } from "../metrics/store.ts";
18
18
  import { createSessionStore } from "../sessions/store.ts";
19
19
  import { cleanupTempDir } from "../test-helpers.ts";
20
20
  import type { InsertEvent, SessionMetrics } from "../types.ts";
21
- import { gatherInspectData, inspectCommand } from "./inspect.ts";
21
+ import { gatherInspectData, inspectCommand, printInspectData } from "./inspect.ts";
22
22
 
23
23
  /** Helper to create an InsertEvent with sensible defaults. */
24
24
  function makeEvent(overrides: Partial<InsertEvent> = {}): InsertEvent {
@@ -565,6 +565,161 @@ describe("inspectCommand", () => {
565
565
  });
566
566
  });
567
567
 
568
+ // === Headless agent support ===
569
+
570
+ describe("headless agent support", () => {
571
+ test("gatherInspectData skips tmux capture for headless agents (empty tmuxSession)", async () => {
572
+ const overstoryDir = join(tempDir, ".overstory");
573
+ const sessionsDbPath = join(overstoryDir, "sessions.db");
574
+ const store = createSessionStore(sessionsDbPath);
575
+ store.upsert({
576
+ id: "sess-h1",
577
+ agentName: "headless-agent",
578
+ capability: "builder",
579
+ worktreePath: "/tmp/wt",
580
+ branchName: "overstory/headless/task-1",
581
+ taskId: "overstory-h01",
582
+ tmuxSession: "", // headless
583
+ state: "working",
584
+ pid: process.pid,
585
+ parentAgent: null,
586
+ depth: 0,
587
+ runId: null,
588
+ startedAt: new Date().toISOString(),
589
+ lastActivity: new Date().toISOString(),
590
+ escalationLevel: 0,
591
+ stalledSince: null,
592
+ transcriptPath: null,
593
+ });
594
+ store.close();
595
+
596
+ // noTmux=false but tmuxSession="" — should skip tmux capture without error
597
+ const data = await gatherInspectData(tempDir, "headless-agent", { noTmux: false });
598
+ // tmuxOutput is null (no tmux) and no events yet → no fallback either
599
+ expect(data.session.agentName).toBe("headless-agent");
600
+ expect(data.session.tmuxSession).toBe("");
601
+ // tmuxOutput may be null (no events) or a string (fallback) — must not throw
602
+ });
603
+
604
+ test("gatherInspectData provides event-based output for headless agents with tool calls", async () => {
605
+ const overstoryDir = join(tempDir, ".overstory");
606
+ const sessionsDbPath = join(overstoryDir, "sessions.db");
607
+ const eventsDbPath = join(overstoryDir, "events.db");
608
+
609
+ const store = createSessionStore(sessionsDbPath);
610
+ store.upsert({
611
+ id: "sess-h2",
612
+ agentName: "headless-events",
613
+ capability: "builder",
614
+ worktreePath: "/tmp/wt",
615
+ branchName: "overstory/headless/task-2",
616
+ taskId: "overstory-h02",
617
+ tmuxSession: "", // headless
618
+ state: "working",
619
+ pid: process.pid,
620
+ parentAgent: null,
621
+ depth: 0,
622
+ runId: null,
623
+ startedAt: new Date().toISOString(),
624
+ lastActivity: new Date().toISOString(),
625
+ escalationLevel: 0,
626
+ stalledSince: null,
627
+ transcriptPath: null,
628
+ });
629
+ store.close();
630
+
631
+ const eventStore = createEventStore(eventsDbPath);
632
+ eventStore.insert(
633
+ makeEvent({ agentName: "headless-events", toolName: "Read", toolDurationMs: 50 }),
634
+ );
635
+ eventStore.insert(
636
+ makeEvent({ agentName: "headless-events", toolName: "Edit", toolDurationMs: 100 }),
637
+ );
638
+ eventStore.close();
639
+
640
+ const data = await gatherInspectData(tempDir, "headless-events", { noTmux: false });
641
+
642
+ // Should have fallback output
643
+ expect(data.tmuxOutput).not.toBeNull();
644
+ expect(data.tmuxOutput).toContain("Headless agent");
645
+ expect(data.tmuxOutput).toContain("Read");
646
+ });
647
+
648
+ test("printInspectData shows PID instead of tmux session for headless agents", () => {
649
+ const data = {
650
+ session: {
651
+ id: "sess-h3",
652
+ agentName: "headless-display",
653
+ capability: "builder",
654
+ worktreePath: "/tmp/wt",
655
+ branchName: "overstory/headless/task-3",
656
+ taskId: "overstory-h03",
657
+ tmuxSession: "", // headless
658
+ state: "working" as const,
659
+ pid: 99999,
660
+ parentAgent: null,
661
+ depth: 0,
662
+ runId: null,
663
+ startedAt: new Date().toISOString(),
664
+ lastActivity: new Date().toISOString(),
665
+ escalationLevel: 0,
666
+ stalledSince: null,
667
+ transcriptPath: null,
668
+ },
669
+ timeSinceLastActivity: 5000,
670
+ recentToolCalls: [],
671
+ currentFile: null,
672
+ toolStats: [],
673
+ tokenUsage: null,
674
+ tmuxOutput: null,
675
+ };
676
+
677
+ printInspectData(data);
678
+
679
+ const out = output();
680
+ expect(out).toContain("Process: PID");
681
+ expect(out).toContain("99999");
682
+ expect(out).toContain("headless");
683
+ expect(out).not.toContain("Tmux:");
684
+ });
685
+
686
+ test("printInspectData shows Recent Activity header for headless agents with tmuxOutput", () => {
687
+ const data = {
688
+ session: {
689
+ id: "sess-h4",
690
+ agentName: "headless-activity",
691
+ capability: "builder",
692
+ worktreePath: "/tmp/wt",
693
+ branchName: "overstory/headless/task-4",
694
+ taskId: "overstory-h04",
695
+ tmuxSession: "", // headless
696
+ state: "working" as const,
697
+ pid: 99998,
698
+ parentAgent: null,
699
+ depth: 0,
700
+ runId: null,
701
+ startedAt: new Date().toISOString(),
702
+ lastActivity: new Date().toISOString(),
703
+ escalationLevel: 0,
704
+ stalledSince: null,
705
+ transcriptPath: null,
706
+ },
707
+ timeSinceLastActivity: 5000,
708
+ recentToolCalls: [],
709
+ currentFile: null,
710
+ toolStats: [],
711
+ tokenUsage: null,
712
+ tmuxOutput: "[Headless agent — showing recent tool events]",
713
+ };
714
+
715
+ printInspectData(data);
716
+
717
+ const out = output();
718
+ expect(out).toContain("Recent Activity (headless)");
719
+ expect(out).not.toContain("Live Tmux Output");
720
+ });
721
+ });
722
+
568
723
  // === Human-readable output ===
569
724
 
570
725
  describe("human-readable output", () => {
@@ -193,13 +193,24 @@ export async function gatherInspectData(
193
193
  }
194
194
  }
195
195
 
196
- // tmux capture
196
+ // tmux capture (skipped for headless agents where tmuxSession is empty)
197
197
  let tmuxOutput: string | null = null;
198
198
  if (!opts.noTmux && session.tmuxSession) {
199
199
  const lines = opts.tmuxLines ?? 30;
200
200
  tmuxOutput = await captureTmux(session.tmuxSession, lines);
201
201
  }
202
202
 
203
+ // Headless fallback: show recent events as live output when no tmux
204
+ if (!tmuxOutput && session.tmuxSession === "" && recentToolCalls.length > 0) {
205
+ const lines: string[] = ["[Headless agent — showing recent tool events]", ""];
206
+ for (const call of recentToolCalls.slice(0, 15)) {
207
+ const time = new Date(call.timestamp).toLocaleTimeString();
208
+ const dur = call.durationMs !== null ? `${call.durationMs}ms` : "pending";
209
+ lines.push(` [${time}] ${call.toolName.padEnd(15)} ${dur}`);
210
+ }
211
+ tmuxOutput = lines.join("\n");
212
+ }
213
+
203
214
  return {
204
215
  session,
205
216
  timeSinceLastActivity,
@@ -233,7 +244,11 @@ export function printInspectData(data: InspectData): void {
233
244
  w(`Parent: ${accent(session.parentAgent)} (depth: ${session.depth})\n`);
234
245
  }
235
246
  w(`Started: ${session.startedAt}\n`);
236
- w(`Tmux: ${accent(session.tmuxSession)}\n`);
247
+ if (session.tmuxSession) {
248
+ w(`Tmux: ${accent(session.tmuxSession)}\n`);
249
+ } else if (session.pid !== null) {
250
+ w(`Process: PID ${accent(String(session.pid))} (headless)\n`);
251
+ }
237
252
  w("\n");
238
253
 
239
254
  // Current file
@@ -287,9 +302,9 @@ export function printInspectData(data: InspectData): void {
287
302
  w("\n");
288
303
  }
289
304
 
290
- // tmux output
305
+ // tmux output (or headless fallback)
291
306
  if (data.tmuxOutput) {
292
- w("Live Tmux Output\n");
307
+ w(data.session.tmuxSession ? "Live Tmux Output\n" : "Recent Activity (headless)\n");
293
308
  w(`${separator()}\n`);
294
309
  w(`${data.tmuxOutput}\n`);
295
310
  w(`${separator()}\n`);
@@ -701,6 +701,10 @@ describe("replayCommand", () => {
701
701
  "spawn",
702
702
  "error",
703
703
  "custom",
704
+ "turn_start",
705
+ "turn_end",
706
+ "progress",
707
+ "result",
704
708
  ] as const;
705
709
  for (const eventType of eventTypes) {
706
710
  store.insert(
@@ -724,6 +728,10 @@ describe("replayCommand", () => {
724
728
  expect(out).toContain("SPAWN");
725
729
  expect(out).toContain("ERROR");
726
730
  expect(out).toContain("CUSTOM");
731
+ expect(out).toContain("TURN START");
732
+ expect(out).toContain("TURN END");
733
+ expect(out).toContain("PROGRESS");
734
+ expect(out).toContain("RESULT");
727
735
  });
728
736
 
729
737
  test("long data values are truncated", async () => {