@os-eco/overstory-cli 0.9.3 → 0.10.3

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 (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -13,8 +13,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
+ import { createSessionStore, type SessionStore } from "../sessions/store.ts";
16
17
  import { cleanupTempDir } from "../test-helpers.ts";
17
- import type { EventStore } from "../types.ts";
18
+ import type { AgentSession, EventStore } from "../types.ts";
18
19
  import { createEventStore } from "./store.ts";
19
20
  import type { TailerHandle, TailerOptions } from "./tailer.ts";
20
21
  import { findLatestStdoutLog, startEventTailer } from "./tailer.ts";
@@ -484,3 +485,235 @@ describe("daemon tailer integration", () => {
484
485
  await cleanupTempDir(tmpDir);
485
486
  });
486
487
  });
488
+
489
+ // === session_id capture (overstory-7b8c Phase 1) ===
490
+
491
+ describe("startEventTailer session_id capture", () => {
492
+ let tmpDir: string;
493
+ let eventStore: EventStore;
494
+ let eventsDbPath: string;
495
+ let sessionStore: SessionStore;
496
+ let sessionsDbPath: string;
497
+
498
+ function makeSession(agentName: string): AgentSession {
499
+ const now = new Date().toISOString();
500
+ return {
501
+ id: `id-${agentName}`,
502
+ agentName,
503
+ capability: "builder",
504
+ worktreePath: "/tmp/wt",
505
+ branchName: "test-branch",
506
+ taskId: "task-1",
507
+ tmuxSession: "",
508
+ state: "working",
509
+ pid: 12345,
510
+ parentAgent: null,
511
+ depth: 0,
512
+ runId: null,
513
+ startedAt: now,
514
+ lastActivity: now,
515
+ escalationLevel: 0,
516
+ stalledSince: null,
517
+ transcriptPath: null,
518
+ };
519
+ }
520
+
521
+ beforeEach(async () => {
522
+ tmpDir = await createTempDir();
523
+ eventsDbPath = join(tmpDir, "events.db");
524
+ eventStore = createEventStore(eventsDbPath);
525
+ sessionsDbPath = join(tmpDir, "sessions.db");
526
+ sessionStore = createSessionStore(sessionsDbPath);
527
+ });
528
+
529
+ afterEach(async () => {
530
+ eventStore.close();
531
+ sessionStore.close();
532
+ await cleanupTempDir(tmpDir);
533
+ });
534
+
535
+ test("parses system event session_id and calls updateClaudeSessionId once", async () => {
536
+ const agentName = "agent-sid-1";
537
+ sessionStore.upsert(makeSession(agentName));
538
+ const logPath = await createAgentLogDir(tmpDir, agentName);
539
+
540
+ const sysLine = JSON.stringify({
541
+ type: "system",
542
+ subtype: "init",
543
+ session_id: "sess-first-pin",
544
+ timestamp: new Date().toISOString(),
545
+ });
546
+ await writeFile(logPath, `${sysLine}\n`);
547
+
548
+ const handle = startEventTailer({
549
+ stdoutLogPath: logPath,
550
+ agentName,
551
+ runId: null,
552
+ eventsDbPath,
553
+ pollIntervalMs: 50,
554
+ _eventStore: eventStore,
555
+ _sessionStore: sessionStore,
556
+ });
557
+
558
+ try {
559
+ await waitFor(() => sessionStore.getByName(agentName)?.claudeSessionId === "sess-first-pin");
560
+ expect(sessionStore.getByName(agentName)?.claudeSessionId).toBe("sess-first-pin");
561
+ } finally {
562
+ handle.stop();
563
+ }
564
+ });
565
+
566
+ test("ignores subsequent system events with the same session_id (single-fire)", async () => {
567
+ const agentName = "agent-sid-2";
568
+ sessionStore.upsert(makeSession(agentName));
569
+ const logPath = await createAgentLogDir(tmpDir, agentName);
570
+
571
+ // Three system events all carrying the same session_id.
572
+ const lines = [
573
+ JSON.stringify({
574
+ type: "system",
575
+ subtype: "init",
576
+ session_id: "sess-stable",
577
+ timestamp: new Date().toISOString(),
578
+ }),
579
+ JSON.stringify({
580
+ type: "system",
581
+ subtype: "ping",
582
+ session_id: "sess-stable",
583
+ timestamp: new Date().toISOString(),
584
+ }),
585
+ JSON.stringify({
586
+ type: "system",
587
+ subtype: "ping",
588
+ session_id: "sess-stable",
589
+ timestamp: new Date().toISOString(),
590
+ }),
591
+ ].join("\n");
592
+ await writeFile(logPath, `${lines}\n`);
593
+
594
+ // Wrap the SessionStore so we can count update calls without altering behaviour.
595
+ let updateCalls = 0;
596
+ const proxy: SessionStore = {
597
+ ...sessionStore,
598
+ upsert: (s) => sessionStore.upsert(s),
599
+ getByName: (n) => sessionStore.getByName(n),
600
+ getActive: () => sessionStore.getActive(),
601
+ getAll: () => sessionStore.getAll(),
602
+ count: () => sessionStore.count(),
603
+ getByRun: (r) => sessionStore.getByRun(r),
604
+ updateState: (n, s) => sessionStore.updateState(n, s),
605
+ updateLastActivity: (n) => sessionStore.updateLastActivity(n),
606
+ updateEscalation: (n, l, s) => sessionStore.updateEscalation(n, l, s),
607
+ updateTranscriptPath: (n, p) => sessionStore.updateTranscriptPath(n, p),
608
+ updateClaudeSessionId: (n, s) => {
609
+ updateCalls++;
610
+ sessionStore.updateClaudeSessionId(n, s);
611
+ },
612
+ remove: (n) => sessionStore.remove(n),
613
+ purge: (o) => sessionStore.purge(o),
614
+ close: () => {
615
+ /* owned by outer test */
616
+ },
617
+ };
618
+
619
+ const handle = startEventTailer({
620
+ stdoutLogPath: logPath,
621
+ agentName,
622
+ runId: null,
623
+ eventsDbPath,
624
+ pollIntervalMs: 50,
625
+ _eventStore: eventStore,
626
+ _sessionStore: proxy,
627
+ });
628
+
629
+ try {
630
+ // Wait until events.db has all three lines processed.
631
+ await waitFor(() => eventStore.getByAgent(agentName).length >= 3);
632
+ // Allow extra poll cycles to confirm no late updates sneak in.
633
+ await new Promise((resolve) => setTimeout(resolve, 150));
634
+ expect(updateCalls).toBe(1);
635
+ expect(sessionStore.getByName(agentName)?.claudeSessionId).toBe("sess-stable");
636
+ } finally {
637
+ handle.stop();
638
+ }
639
+ });
640
+
641
+ test("detects resume mismatch and invokes _onResumeMismatch DI hook (observed wins)", async () => {
642
+ const agentName = "agent-sid-3";
643
+ const session = makeSession(agentName);
644
+ session.claudeSessionId = "sess-requested-OLD";
645
+ sessionStore.upsert(session);
646
+ const logPath = await createAgentLogDir(tmpDir, agentName);
647
+
648
+ const sysLine = JSON.stringify({
649
+ type: "system",
650
+ subtype: "init",
651
+ session_id: "sess-observed-NEW",
652
+ timestamp: new Date().toISOString(),
653
+ });
654
+ await writeFile(logPath, `${sysLine}\n`);
655
+
656
+ const mismatches: Array<{ agent: string; requested: string; observed: string }> = [];
657
+ const handle = startEventTailer({
658
+ stdoutLogPath: logPath,
659
+ agentName,
660
+ runId: null,
661
+ eventsDbPath,
662
+ pollIntervalMs: 50,
663
+ _eventStore: eventStore,
664
+ _sessionStore: sessionStore,
665
+ _onResumeMismatch: (agent, requested, observed) =>
666
+ mismatches.push({ agent, requested, observed }),
667
+ });
668
+
669
+ try {
670
+ await waitFor(
671
+ () => sessionStore.getByName(agentName)?.claudeSessionId === "sess-observed-NEW",
672
+ );
673
+ expect(mismatches).toHaveLength(1);
674
+ expect(mismatches[0]).toEqual({
675
+ agent: agentName,
676
+ requested: "sess-requested-OLD",
677
+ observed: "sess-observed-NEW",
678
+ });
679
+ // observed wins — SessionStore is overwritten with the new id.
680
+ expect(sessionStore.getByName(agentName)?.claudeSessionId).toBe("sess-observed-NEW");
681
+ } finally {
682
+ handle.stop();
683
+ }
684
+ });
685
+
686
+ test("backward compat: tailer with no sessionsDbPath performs no SessionStore writes", async () => {
687
+ const agentName = "agent-sid-4";
688
+ sessionStore.upsert(makeSession(agentName));
689
+ const logPath = await createAgentLogDir(tmpDir, agentName);
690
+
691
+ const sysLine = JSON.stringify({
692
+ type: "system",
693
+ subtype: "init",
694
+ session_id: "sess-should-not-pin",
695
+ timestamp: new Date().toISOString(),
696
+ });
697
+ await writeFile(logPath, `${sysLine}\n`);
698
+
699
+ // No sessionsDbPath, no _sessionStore — tailer must still process events.
700
+ const handle = startEventTailer({
701
+ stdoutLogPath: logPath,
702
+ agentName,
703
+ runId: null,
704
+ eventsDbPath,
705
+ pollIntervalMs: 50,
706
+ _eventStore: eventStore,
707
+ });
708
+
709
+ try {
710
+ await waitFor(() => eventStore.getByAgent(agentName).length >= 1);
711
+ // Give the tailer extra time to confirm no late writes occur.
712
+ await new Promise((resolve) => setTimeout(resolve, 150));
713
+ // SessionStore must remain untouched.
714
+ expect(sessionStore.getByName(agentName)?.claudeSessionId ?? null).toBeNull();
715
+ } finally {
716
+ handle.stop();
717
+ }
718
+ });
719
+ });
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { readdir } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
+ import { createSessionStore, type SessionStore } from "../sessions/store.ts";
17
18
  import type { EventStore, EventType } from "../types.ts";
18
19
  import { createEventStore } from "./store.ts";
19
20
 
@@ -66,10 +67,26 @@ export interface TailerOptions {
66
67
  runId: string | null;
67
68
  /** Absolute path to events.db. The tailer opens its own connection. */
68
69
  eventsDbPath: string;
70
+ /**
71
+ * Absolute path to sessions.db. When present and not equal to ":memory:",
72
+ * the tailer opens a dedicated SessionStore to persist the runtime-provided
73
+ * session_id (e.g. Claude stream-json `session_id`). Omit (or set to
74
+ * ":memory:") for tailers that should not write to SessionStore.
75
+ */
76
+ sessionsDbPath?: string;
69
77
  /** Poll interval in milliseconds (default: 500). */
70
78
  pollIntervalMs?: number;
71
79
  /** DI: injected EventStore for testing (overrides eventsDbPath). */
72
80
  _eventStore?: EventStore;
81
+ /** DI: injected SessionStore for testing (overrides sessionsDbPath). */
82
+ _sessionStore?: SessionStore;
83
+ /**
84
+ * DI: invoked exactly once per tailer when an observed session_id differs
85
+ * from the prior claudeSessionId stored in SessionStore. Receives the agent
86
+ * name, the prior (requested) id, and the newly observed id. Production
87
+ * code logs a warning to stderr instead.
88
+ */
89
+ _onResumeMismatch?: (agentName: string, requested: string, observed: string) => void;
73
90
  }
74
91
 
75
92
  /**
@@ -109,6 +126,28 @@ export function startEventTailer(opts: TailerOptions): TailerHandle {
109
126
  }
110
127
  }
111
128
 
129
+ // Open a dedicated SessionStore for this tailer's lifetime when a real
130
+ // sessionsDbPath is provided. Tailers that omit sessionsDbPath (or pass
131
+ // ":memory:") skip session_id persistence entirely — backward compat for
132
+ // callers that don't yet route through the watchdog wiring.
133
+ let sessionStore: SessionStore | null = opts._sessionStore ?? null;
134
+ let ownedSessionStore = false;
135
+ if (!sessionStore && opts.sessionsDbPath && opts.sessionsDbPath !== ":memory:") {
136
+ try {
137
+ sessionStore = createSessionStore(opts.sessionsDbPath);
138
+ ownedSessionStore = true;
139
+ } catch {
140
+ // SessionStore failure is non-fatal — events still flow.
141
+ sessionStore = null;
142
+ }
143
+ }
144
+
145
+ // Single-fire guard for session_id pinning. Mirrors claude.ts:312
146
+ // `sessionIdPinned` so that updateClaudeSessionId is called at most once
147
+ // per tailer lifetime, even if many system events stream by.
148
+ let sessionIdPinned = false;
149
+ const onResumeMismatch = opts._onResumeMismatch;
150
+
112
151
  let stopped = false;
113
152
  let byteOffset = 0;
114
153
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -154,6 +193,48 @@ export function startEventTailer(opts: TailerOptions): TailerHandle {
154
193
 
155
194
  const toolDurationMs = typeof event.duration_ms === "number" ? event.duration_ms : null;
156
195
 
196
+ // Extract session_id from stream-json system events (e.g. Claude Code
197
+ // emits `{type:"system", subtype:"init", session_id:"sess-..."}` on
198
+ // every spawn — including --resume spawns, which assign a fresh id).
199
+ // The "result" event also carries session_id; treat both as authoritative.
200
+ // Single-fire per tailer lifetime so we don't churn writes.
201
+ if (!sessionIdPinned && sessionStore !== null) {
202
+ const sid =
203
+ typeof event.session_id === "string" && event.session_id.length > 0
204
+ ? event.session_id
205
+ : null;
206
+ if (sid !== null && (type === "system" || type === "result")) {
207
+ sessionIdPinned = true;
208
+ let prior: string | null = null;
209
+ try {
210
+ prior = sessionStore.getByName(agentName)?.claudeSessionId ?? null;
211
+ } catch {
212
+ prior = null;
213
+ }
214
+ try {
215
+ sessionStore.updateClaudeSessionId(agentName, sid);
216
+ } catch {
217
+ // Non-fatal: SessionStore write failure must not break tailing.
218
+ }
219
+ // Resume mismatch: requested != observed. The observed id wins
220
+ // (claude assigns fresh ids on --resume), but operators need to
221
+ // know — log a warning, and call DI hook for tests.
222
+ if (prior !== null && prior !== sid) {
223
+ if (onResumeMismatch) {
224
+ try {
225
+ onResumeMismatch(agentName, prior, sid);
226
+ } catch {
227
+ // DI hook errors must not crash the tailer.
228
+ }
229
+ } else {
230
+ process.stderr.write(
231
+ `[tailer] resume mismatch for ${agentName}: requested=${prior} observed=${sid}\n`,
232
+ );
233
+ }
234
+ }
235
+ }
236
+ }
237
+
157
238
  try {
158
239
  eventStore?.insert({
159
240
  runId,
@@ -201,6 +282,15 @@ export function startEventTailer(opts: TailerOptions): TailerHandle {
201
282
  }
202
283
  eventStore = null;
203
284
  }
285
+ // Close only the SessionStore this tailer owns.
286
+ if (ownedSessionStore && sessionStore) {
287
+ try {
288
+ sessionStore.close();
289
+ } catch {
290
+ // Non-fatal.
291
+ }
292
+ sessionStore = null;
293
+ }
204
294
  },
205
295
  };
206
296
  }
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import { createOrchestratorCommand } from "./commands/orchestrator.ts";
36
36
  import { primeCommand } from "./commands/prime.ts";
37
37
  import { createReplayCommand } from "./commands/replay.ts";
38
38
  import { createRunCommand } from "./commands/run.ts";
39
+ import { createServeCommand } from "./commands/serve.ts";
39
40
  import { slingCommand } from "./commands/sling.ts";
40
41
  import { specWriteCommand } from "./commands/spec.ts";
41
42
  import { createStatusCommand } from "./commands/status.ts";
@@ -51,7 +52,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
51
52
  import { jsonError } from "./json.ts";
52
53
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
53
54
 
54
- export const VERSION = "0.9.3";
55
+ export const VERSION = "0.10.3";
55
56
 
56
57
  const rawArgs = process.argv.slice(2);
57
58
 
@@ -103,6 +104,7 @@ const COMMANDS = [
103
104
  "run",
104
105
  "costs",
105
106
  "metrics",
107
+ "serve",
106
108
  "update",
107
109
  "upgrade",
108
110
  "completions",
@@ -204,9 +206,12 @@ program
204
206
  },
205
207
  });
206
208
 
207
- // Apply global flags before any command action runs
208
- program.hook("preAction", (thisCmd) => {
209
- const opts = thisCmd.optsWithGlobals();
209
+ // Apply global flags before any command action runs.
210
+ // `actionCommand` is the deepest command whose action is about to run (e.g.
211
+ // `coordinator start`); reading `optsWithGlobals()` on it walks up through
212
+ // every parent so subcommand-level `--project` flags are also seen.
213
+ program.hook("preAction", (_thisCmd, actionCommand) => {
214
+ const opts = actionCommand.optsWithGlobals();
210
215
  if (opts.quiet) {
211
216
  setQuiet(true);
212
217
  }
@@ -225,8 +230,9 @@ program.hook("preAction", (thisCmd) => {
225
230
  timingStart = performance.now();
226
231
  }
227
232
  });
228
- program.hook("postAction", () => {
229
- if (program.opts().timing && timingStart !== undefined) {
233
+ program.hook("postAction", (_thisCmd, actionCommand) => {
234
+ const opts = actionCommand.optsWithGlobals();
235
+ if (opts.timing && timingStart !== undefined) {
230
236
  const elapsed = performance.now() - timingStart;
231
237
  const formatted =
232
238
  elapsed < 1000 ? `${Math.round(elapsed)}ms` : `${(elapsed / 1000).toFixed(2)}s`;
@@ -246,6 +252,7 @@ program.addCommand(createWorktreeCommand());
246
252
  program.addCommand(createLogCommand());
247
253
  program.addCommand(createWatchCommand());
248
254
  program.addCommand(createGroupCommand());
255
+ program.addCommand(createServeCommand());
249
256
  program.addCommand(createCompletionsCommand());
250
257
 
251
258
  // Unmigrated commands — passthrough pattern
@@ -292,6 +299,14 @@ program
292
299
  .option("--runtime <name>", "Runtime adapter (default: config or claude)")
293
300
  .option("--base-branch <branch>", "Base branch for worktree creation (default: current HEAD)")
294
301
  .option("--profile <name>", "Canopy profile to apply to agent overlay")
302
+ .option(
303
+ "--headless",
304
+ "Spawn through Bun.spawn (stream-json) instead of tmux. Requires runtime with buildDirectSpawn.",
305
+ )
306
+ .option(
307
+ "--recover",
308
+ "Allow dispatch against a task in any tracker status (e.g. closed). Use when a prior owner exited and the task needs a fresh agent.",
309
+ )
295
310
  .option("--json", "Output result as JSON")
296
311
  .action(async (taskId, opts) => {
297
312
  await slingCommand(taskId, opts);
@@ -359,6 +374,7 @@ program
359
374
  program
360
375
  .command("mail")
361
376
  .description("Mail system (send/check/list/read/reply)")
377
+ .helpOption(false)
362
378
  .allowUnknownOption()
363
379
  .allowExcessArguments()
364
380
  .action(async (_opts, cmd) => {
@@ -422,6 +438,37 @@ program.addCommand(createUpdateCommand());
422
438
 
423
439
  program.addCommand(createUpgradeCommand());
424
440
 
441
+ // Propagate root-level globals to every (sub)command so they can appear before
442
+ // or after the command name. With `enablePositionalOptions()`, options declared
443
+ // on the root program are not accepted after a subcommand name; copying them
444
+ // onto each command lets `ov status --project /path` work the same as
445
+ // `ov --project /path status`. Skips the delegated `mail`/`nudge`/`logs`/`trace`
446
+ // commands, which use `allowUnknownOption()` and forward args to an inner
447
+ // Commander parser. The preAction hook reads `actionCommand.optsWithGlobals()`,
448
+ // so it sees these regardless of which level they were parsed at.
449
+ const DELEGATED_COMMANDS = new Set(["mail", "nudge", "logs", "trace"]);
450
+ const PROPAGATED_GLOBALS: ReadonlyArray<readonly [string, string]> = [
451
+ ["--project <path>", "Target project root (overrides auto-detection)"],
452
+ ["-q, --quiet", "Suppress non-error output"],
453
+ ["--timing", "Print command execution time to stderr"],
454
+ ];
455
+ function propagateGlobalOptions(cmd: Command): void {
456
+ for (const sub of cmd.commands) {
457
+ if (sub === cmd) continue;
458
+ if (!DELEGATED_COMMANDS.has(sub.name())) {
459
+ for (const [flag, desc] of PROPAGATED_GLOBALS) {
460
+ const long = flag.split(/[\s,]+/).find((p) => p.startsWith("--"));
461
+ const alreadyDeclared = sub.options.some((o) => o.long === long);
462
+ if (!alreadyDeclared) {
463
+ sub.option(flag, desc);
464
+ }
465
+ }
466
+ }
467
+ propagateGlobalOptions(sub);
468
+ }
469
+ }
470
+ propagateGlobalOptions(program);
471
+
425
472
  // Handle unknown commands with Levenshtein fuzzy-match suggestions
426
473
  program.on("command:*", (operands) => {
427
474
  const unknown = operands[0] ?? "";
package/src/json.ts CHANGED
@@ -22,3 +22,32 @@ export function jsonOutput(command: string, data: Record<string, unknown>): void
22
22
  export function jsonError(command: string, error: string): void {
23
23
  process.stdout.write(`${JSON.stringify({ success: false, command, error })}\n`);
24
24
  }
25
+
26
+ /**
27
+ * Build a JSON success Response for HTTP API handlers.
28
+ * Envelope: { success: true, command: 'serve', data, nextCursor? }
29
+ */
30
+ export function apiJson(
31
+ data: unknown,
32
+ init?: { status?: number; nextCursor?: string | null },
33
+ ): Response {
34
+ const envelope: Record<string, unknown> = { success: true, command: "serve", data };
35
+ if (init?.nextCursor != null) {
36
+ envelope.nextCursor = init.nextCursor;
37
+ }
38
+ return new Response(JSON.stringify(envelope), {
39
+ status: init?.status ?? 200,
40
+ headers: { "Content-Type": "application/json" },
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Build a JSON error Response for HTTP API handlers.
46
+ * Envelope: { success: false, command: 'serve', error }
47
+ */
48
+ export function apiError(message: string, status: number): Response {
49
+ return new Response(JSON.stringify({ success: false, command: "serve", error: message }), {
50
+ status,
51
+ headers: { "Content-Type": "application/json" },
52
+ });
53
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { MailError } from "../errors.ts";
10
- import type { MailMessage, MailPayloadMap, MailProtocolType } from "../types.ts";
10
+ import type { MailMessage, MailMessageType, MailPayloadMap, MailProtocolType } from "../types.ts";
11
11
  import type { MailStore } from "./store.ts";
12
12
 
13
13
  export interface MailClient {
@@ -42,7 +42,12 @@ export interface MailClient {
42
42
  checkInject(agentName: string): string;
43
43
 
44
44
  /** List messages with optional filters. */
45
- list(filters?: { from?: string; to?: string; unread?: boolean }): MailMessage[];
45
+ list(filters?: {
46
+ from?: string;
47
+ to?: string;
48
+ unread?: boolean;
49
+ type?: MailMessageType;
50
+ }): MailMessage[];
46
51
 
47
52
  /** Mark a message as read by ID. Returns whether the message was already read. */
48
53
  markRead(id: string): { alreadyRead: boolean };
@@ -50,6 +55,9 @@ export interface MailClient {
50
55
  /** Reply to a message. Returns the new message ID. */
51
56
  reply(messageId: string, body: string, from: string): string;
52
57
 
58
+ /** Delete a single message by id. Returns true if a row was deleted. */
59
+ deleteById(id: string): boolean;
60
+
53
61
  /** Close the underlying store. */
54
62
  close(): void;
55
63
  }
@@ -75,6 +83,7 @@ export function parsePayload<T extends MailProtocolType>(
75
83
  /** Protocol types that represent structured coordination messages. */
76
84
  const PROTOCOL_TYPES = new Set<string>([
77
85
  "worker_done",
86
+ "worker_died",
78
87
  "merge_ready",
79
88
  "merged",
80
89
  "merge_failed",
@@ -187,6 +196,10 @@ export function createMailClient(store: MailStore): MailClient {
187
196
  return { alreadyRead: false };
188
197
  },
189
198
 
199
+ deleteById(id): boolean {
200
+ return store.deleteById(id);
201
+ },
202
+
190
203
  reply(messageId, body, from): string {
191
204
  const original = store.getById(messageId);
192
205
  if (!original) {
@@ -437,6 +437,88 @@ describe("createMailStore", () => {
437
437
  expect(filtered).toHaveLength(1);
438
438
  expect(filtered[0]?.subject).toBe("msg1");
439
439
  });
440
+
441
+ test("filters by type", () => {
442
+ store.insert({
443
+ id: "",
444
+ from: "lead-a",
445
+ to: "coordinator",
446
+ subject: "merge_ready: t1",
447
+ body: "ready",
448
+ type: "merge_ready",
449
+ priority: "normal",
450
+ threadId: null,
451
+ });
452
+ store.insert({
453
+ id: "",
454
+ from: "builder-a",
455
+ to: "lead-a",
456
+ subject: "Worker done",
457
+ body: "done",
458
+ type: "worker_done",
459
+ priority: "normal",
460
+ threadId: null,
461
+ });
462
+ store.insert({
463
+ id: "",
464
+ from: "lead-a",
465
+ to: "coordinator",
466
+ subject: "status",
467
+ body: "still going",
468
+ type: "status",
469
+ priority: "normal",
470
+ threadId: null,
471
+ });
472
+
473
+ const mr = store.getAll({ type: "merge_ready" });
474
+ expect(mr).toHaveLength(1);
475
+ expect(mr[0]?.subject).toBe("merge_ready: t1");
476
+
477
+ const wd = store.getAll({ type: "worker_done" });
478
+ expect(wd).toHaveLength(1);
479
+ expect(wd[0]?.subject).toBe("Worker done");
480
+ });
481
+
482
+ test("combines type with from filter", () => {
483
+ store.insert({
484
+ id: "",
485
+ from: "lead-a",
486
+ to: "coordinator",
487
+ subject: "merge_ready: t1",
488
+ body: "ready",
489
+ type: "merge_ready",
490
+ priority: "normal",
491
+ threadId: null,
492
+ });
493
+ store.insert({
494
+ id: "",
495
+ from: "lead-b",
496
+ to: "coordinator",
497
+ subject: "merge_ready: t2",
498
+ body: "ready",
499
+ type: "merge_ready",
500
+ priority: "normal",
501
+ threadId: null,
502
+ });
503
+
504
+ const mine = store.getAll({ from: "lead-a", type: "merge_ready" });
505
+ expect(mine).toHaveLength(1);
506
+ expect(mine[0]?.from).toBe("lead-a");
507
+ });
508
+
509
+ test("returns empty array when no rows match the type filter", () => {
510
+ store.insert({
511
+ id: "",
512
+ from: "agent-a",
513
+ to: "orchestrator",
514
+ subject: "msg",
515
+ body: "body",
516
+ type: "status",
517
+ priority: "normal",
518
+ threadId: null,
519
+ });
520
+ expect(store.getAll({ type: "merge_ready" })).toHaveLength(0);
521
+ });
440
522
  });
441
523
 
442
524
  describe("getByThread", () => {