@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
@@ -37,6 +37,7 @@ import {
37
37
  killProcessTree,
38
38
  killSession,
39
39
  listSessions,
40
+ sanitizeTmuxName,
40
41
  } from "../worktree/tmux.ts";
41
42
 
42
43
  export interface CleanOptions {
@@ -123,6 +124,7 @@ async function logSyntheticSessionEndEvents(overstoryDir: string): Promise<numbe
123
124
  interface CleanResult {
124
125
  sessionEndEventsLogged: number;
125
126
  tmuxKilled: number;
127
+ orphanPidsReaped: number;
126
128
  worktreesCleaned: number;
127
129
  branchesDeleted: number;
128
130
  mailWiped: boolean;
@@ -155,7 +157,7 @@ interface CleanResult {
155
157
  */
156
158
  async function killAllTmuxSessions(overstoryDir: string, projectName: string): Promise<number> {
157
159
  let killed = 0;
158
- const projectPrefix = `overstory-${projectName}-`;
160
+ const projectPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
159
161
  try {
160
162
  const tmuxSessions = await listSessions();
161
163
  const overStorySessions = tmuxSessions.filter((s) => s.name.startsWith(projectPrefix));
@@ -216,6 +218,45 @@ function loadRegisteredTmuxNames(overstoryDir: string): Set<string> | null {
216
218
  }
217
219
  }
218
220
 
221
+ /**
222
+ * Reap any spawn PIDs in sessions.db that survived tmux teardown.
223
+ *
224
+ * `killAllTmuxSessions` walks descendants of the live tmux pane PID and is
225
+ * sufficient for sessions whose tmux container is still up. This handles the
226
+ * leftover case: a stored pid that is still alive but its tmux session is
227
+ * gone (claude was reparented to init when its bash wrapper got SIGHUP) or
228
+ * the session is in a terminal state but the spawn never exited. Best-effort.
229
+ * (overstory-505d)
230
+ */
231
+ async function reapOrphanedPids(overstoryDir: string): Promise<number> {
232
+ let reaped = 0;
233
+ try {
234
+ const dbPath = join(overstoryDir, "sessions.db");
235
+ const jsonPath = join(overstoryDir, "sessions.json");
236
+ if (!existsSync(dbPath) && !existsSync(jsonPath)) {
237
+ return 0;
238
+ }
239
+ const { store } = openSessionStore(overstoryDir);
240
+ try {
241
+ for (const session of store.getAll()) {
242
+ if (session.pid === null) continue;
243
+ if (!isProcessAlive(session.pid)) continue;
244
+ try {
245
+ await killProcessTree(session.pid);
246
+ reaped++;
247
+ } catch {
248
+ // Best effort
249
+ }
250
+ }
251
+ } finally {
252
+ store.close();
253
+ }
254
+ } catch {
255
+ // Best effort
256
+ }
257
+ return reaped;
258
+ }
259
+
219
260
  /**
220
261
  * Remove all overstory worktrees (force remove with branch deletion).
221
262
  */
@@ -567,6 +608,7 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
567
608
  const result: CleanResult = {
568
609
  sessionEndEventsLogged: 0,
569
610
  tmuxKilled: 0,
611
+ orphanPidsReaped: 0,
570
612
  worktreesCleaned: 0,
571
613
  branchesDeleted: 0,
572
614
  mailWiped: false,
@@ -608,6 +650,14 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
608
650
  result.tmuxKilled = await killAllTmuxSessions(overstoryDir, config.project.name);
609
651
  }
610
652
 
653
+ // 2b. Reap any orphaned spawn PIDs that survived tmux teardown.
654
+ // Must run after killAllTmuxSessions (which collects descendants of live
655
+ // panes) but before sessions.db is wiped (we need pid records to find
656
+ // orphans). (overstory-505d)
657
+ if (doWorktrees || all) {
658
+ result.orphanPidsReaped = await reapOrphanedPids(overstoryDir);
659
+ }
660
+
611
661
  // 3. Remove worktrees
612
662
  if (doWorktrees) {
613
663
  result.worktreesCleaned = await cleanAllWorktrees(root);
@@ -669,6 +719,11 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
669
719
  if (result.tmuxKilled > 0) {
670
720
  lines.push(`Killed ${result.tmuxKilled} tmux session${result.tmuxKilled === 1 ? "" : "s"}`);
671
721
  }
722
+ if (result.orphanPidsReaped > 0) {
723
+ lines.push(
724
+ `Reaped ${result.orphanPidsReaped} orphaned spawn process${result.orphanPidsReaped === 1 ? "" : "es"}`,
725
+ );
726
+ }
672
727
  if (result.worktreesCleaned > 0) {
673
728
  lines.push(
674
729
  `Removed ${result.worktreesCleaned} worktree${result.worktreesCleaned === 1 ? "" : "s"}`,
@@ -12,7 +12,10 @@ import {
12
12
  } from "./completions.ts";
13
13
 
14
14
  afterEach(() => {
15
- process.exitCode = undefined;
15
+ // Use 0 not undefined — Bun doesn't reliably clear a nonzero exitCode when
16
+ // reassigned to undefined (see prior fix f3fde1a). If the 1 from completion
17
+ // tests leaks to bun test's shutdown, the suite exits 1 with 0 test failures.
18
+ process.exitCode = 0;
16
19
  });
17
20
 
18
21
  describe("COMMANDS array", () => {
@@ -28,6 +28,7 @@ import {
28
28
  coordinatorCommand,
29
29
  createCoordinatorCommand,
30
30
  resolveAttach,
31
+ startCoordinatorSession,
31
32
  } from "./coordinator.ts";
32
33
  import {
33
34
  buildOrchestratorBeacon,
@@ -2665,3 +2666,129 @@ describe("checkComplete", () => {
2665
2666
  expect(subcommandNames).toContain("check-complete");
2666
2667
  });
2667
2668
  });
2669
+
2670
+ describe("startCoordinatorSession headless", () => {
2671
+ test("with headless: true, calls spawnHeadlessAgent and skips tmux", async () => {
2672
+ const { tmux, calls: tmuxCalls } = makeFakeTmux();
2673
+ const { watchdog } = makeFakeWatchdog();
2674
+ const { monitor } = makeFakeMonitor();
2675
+
2676
+ const spawnCalls: Array<{
2677
+ argv: string[];
2678
+ cwd: string;
2679
+ agentName?: string;
2680
+ }> = [];
2681
+ const writes: string[] = [];
2682
+
2683
+ const fakeSpawn = async (
2684
+ argv: string[],
2685
+ opts: { cwd: string; env: Record<string, string>; agentName?: string },
2686
+ ): Promise<{
2687
+ pid: number;
2688
+ stdin: { write(data: string | Uint8Array): number | Promise<number> };
2689
+ stdout: ReadableStream<Uint8Array> | null;
2690
+ }> => {
2691
+ spawnCalls.push({ argv, cwd: opts.cwd, agentName: opts.agentName });
2692
+ return {
2693
+ pid: 55555,
2694
+ stdin: {
2695
+ write(data: string | Uint8Array): number {
2696
+ writes.push(typeof data === "string" ? data : new TextDecoder().decode(data));
2697
+ return 0;
2698
+ },
2699
+ },
2700
+ stdout: null,
2701
+ };
2702
+ };
2703
+
2704
+ const deps: CoordinatorDeps = {
2705
+ _tmux: tmux,
2706
+ _watchdog: watchdog,
2707
+ _monitor: monitor,
2708
+ _spawnHeadless: fakeSpawn,
2709
+ };
2710
+
2711
+ await captureStdout(async () => {
2712
+ await startCoordinatorSession(
2713
+ {
2714
+ json: true,
2715
+ attach: false,
2716
+ watchdog: false,
2717
+ monitor: false,
2718
+ headless: true,
2719
+ },
2720
+ deps,
2721
+ );
2722
+ });
2723
+
2724
+ // spawnHeadlessAgent was called exactly once with agentName: "coordinator"
2725
+ expect(spawnCalls.length).toBe(1);
2726
+ expect(spawnCalls[0]?.agentName).toBe("coordinator");
2727
+ expect(spawnCalls[0]?.cwd).toBe(tempDir);
2728
+
2729
+ // initial stdin prompt was written
2730
+ expect(writes.length).toBeGreaterThanOrEqual(1);
2731
+
2732
+ // tmux helpers were never called for the headless path
2733
+ expect(tmuxCalls.createSession.length).toBe(0);
2734
+ expect(tmuxCalls.sendKeys.length).toBe(0);
2735
+ expect(tmuxCalls.waitForTuiReady.length).toBe(0);
2736
+ expect(tmuxCalls.ensureTmuxAvailable).toBe(0);
2737
+
2738
+ // Session row records empty tmuxSession + the headless spawn pid
2739
+ const sessions = loadSessionsFromDb();
2740
+ expect(sessions.length).toBe(1);
2741
+ expect(sessions[0]?.agentName).toBe("coordinator");
2742
+ expect(sessions[0]?.tmuxSession).toBe("");
2743
+ expect(sessions[0]?.pid).toBe(55555);
2744
+ expect(sessions[0]?.state).toBe("booting");
2745
+
2746
+ // current-run.txt was written for downstream consumers
2747
+ const runFile = Bun.file(join(overstoryDir, "current-run.txt"));
2748
+ expect(await runFile.exists()).toBe(true);
2749
+ });
2750
+
2751
+ test("rejects when runtime has no buildDirectSpawn", async () => {
2752
+ // Override config to route the coordinator capability to a runtime that
2753
+ // lacks buildDirectSpawn (e.g. cursor). The headless path must reject.
2754
+ await Bun.write(
2755
+ join(overstoryDir, "config.yaml"),
2756
+ [
2757
+ "project:",
2758
+ " name: test-project",
2759
+ ` root: ${tempDir}`,
2760
+ " canonicalBranch: main",
2761
+ "watchdog:",
2762
+ " tier2Enabled: true",
2763
+ "runtime:",
2764
+ " capabilities:",
2765
+ " coordinator: cursor",
2766
+ ].join("\n"),
2767
+ );
2768
+
2769
+ const { tmux } = makeFakeTmux();
2770
+ const { watchdog } = makeFakeWatchdog();
2771
+ const { monitor } = makeFakeMonitor();
2772
+ const deps: CoordinatorDeps = {
2773
+ _tmux: tmux,
2774
+ _watchdog: watchdog,
2775
+ _monitor: monitor,
2776
+ _spawnHeadless: async () => {
2777
+ throw new Error("should not be called");
2778
+ },
2779
+ };
2780
+
2781
+ await expect(
2782
+ startCoordinatorSession(
2783
+ {
2784
+ json: true,
2785
+ attach: false,
2786
+ watchdog: false,
2787
+ monitor: false,
2788
+ headless: true,
2789
+ },
2790
+ deps,
2791
+ ),
2792
+ ).rejects.toThrow(ValidationError);
2793
+ });
2794
+ });
@@ -15,6 +15,7 @@
15
15
  import { mkdir, unlink } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
17
  import { Command } from "commander";
18
+ import { buildInitialHeadlessPrompt, formatMailSection } from "../agents/headless-prompt.ts";
18
19
  import { createIdentity, loadIdentity } from "../agents/identity.ts";
19
20
  import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
20
21
  import { loadConfig } from "../config.ts";
@@ -29,6 +30,8 @@ import { createRunStore, createSessionStore } from "../sessions/store.ts";
29
30
  import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
30
31
  import type { AgentSession } from "../types.ts";
31
32
  import { isProcessRunning } from "../watchdog/health.ts";
33
+ import type { SpawnHeadlessOptions } from "../worktree/process.ts";
34
+ import { spawnHeadlessAgent } from "../worktree/process.ts";
32
35
  import type { SessionState } from "../worktree/tmux.ts";
33
36
  import {
34
37
  capturePaneContent,
@@ -37,6 +40,7 @@ import {
37
40
  ensureTmuxAvailable,
38
41
  isSessionAlive,
39
42
  killSession,
43
+ sanitizeTmuxName,
40
44
  sendKeys,
41
45
  TMUX_SOCKET,
42
46
  waitForTuiReady,
@@ -45,7 +49,7 @@ import { nudgeAgent } from "./nudge.ts";
45
49
  import { isRunningAsRoot } from "./sling.ts";
46
50
 
47
51
  /** Default coordinator agent name. */
48
- const COORDINATOR_NAME = "coordinator";
52
+ export const COORDINATOR_NAME = "coordinator";
49
53
 
50
54
  export interface PersistentAgentSpec {
51
55
  commandName: string;
@@ -76,7 +80,7 @@ const ASK_DEFAULT_TIMEOUT_S = 120;
76
80
  * Includes the project name to prevent cross-project collisions (overstory-pcef).
77
81
  */
78
82
  function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
79
- return `overstory-${projectName}-${name}`;
83
+ return `overstory-${sanitizeTmuxName(projectName)}-${name}`;
80
84
  }
81
85
 
82
86
  /** Dependency injection for testing. Uses real implementations when omitted. */
@@ -119,6 +123,15 @@ export interface CoordinatorDeps {
119
123
  _capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
120
124
  /** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
121
125
  _pollIntervalMs?: number;
126
+ /** Override headless spawn (used by tests to avoid forking real subprocesses). */
127
+ _spawnHeadless?: (
128
+ argv: string[],
129
+ opts: SpawnHeadlessOptions,
130
+ ) => Promise<{
131
+ pid: number;
132
+ stdin: { write(data: string | Uint8Array): number | Promise<number> };
133
+ stdout: ReadableStream<Uint8Array> | null;
134
+ }>;
122
135
  }
123
136
 
124
137
  /**
@@ -331,6 +344,13 @@ export interface CoordinatorSessionOptions {
331
344
  displayName?: string;
332
345
  /** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
333
346
  beaconBuilder?: (trackerCli: string) => string;
347
+ /**
348
+ * When true, spawn the coordinator headless (no tmux pane). The runtime must
349
+ * implement buildDirectSpawn(). The CLI command `ov coordinator start` does
350
+ * not yet pass this flag — it is consumed by the headless start path used by
351
+ * the web UI's POST /api/coordinator/start endpoint.
352
+ */
353
+ headless?: boolean;
334
354
  }
335
355
 
336
356
  /**
@@ -364,6 +384,7 @@ export async function startCoordinatorSession(
364
384
  agentDefFile: agentDefFileOpt,
365
385
  displayName: displayNameOpt,
366
386
  beaconBuilder: beaconBuilderOpt,
387
+ headless: headlessFlag,
367
388
  } = opts;
368
389
 
369
390
  const coordinatorName = agentNameOpt ?? coordinatorNameOpt ?? COORDINATOR_NAME;
@@ -458,6 +479,157 @@ export async function startCoordinatorSession(
458
479
  });
459
480
  }
460
481
 
482
+ // Headless start path: bypass tmux entirely and spawn the coordinator
483
+ // process directly via runtime.buildDirectSpawn(). Same hooks, identity,
484
+ // and run-tracking as the tmux path — only the spawn mechanism differs.
485
+ if (headlessFlag === true) {
486
+ if (!runtime.buildDirectSpawn) {
487
+ throw new ValidationError(
488
+ `Headless coordinator start requires a runtime with buildDirectSpawn (got: ${runtime.id})`,
489
+ { field: "runtime", value: runtime.id },
490
+ );
491
+ }
492
+
493
+ const spawnHeadless = deps._spawnHeadless ?? spawnHeadlessAgent;
494
+ const directEnv: Record<string, string> = {
495
+ ...runtime.buildEnv(resolvedModel),
496
+ OVERSTORY_AGENT_NAME: coordinatorName,
497
+ OVERSTORY_PROJECT_ROOT: projectRoot,
498
+ ...(profileFlag ? { OVERSTORY_PROFILE: profileFlag } : {}),
499
+ };
500
+ const argv = runtime.buildDirectSpawn({
501
+ cwd: projectRoot,
502
+ env: directEnv,
503
+ ...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
504
+ instructionPath: runtime.instructionPath,
505
+ });
506
+
507
+ // Per-session log dir mirrors sling.ts headless path.
508
+ const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
509
+ const headlessLogDir = join(overstoryDir, "logs", "coordinator", logTimestamp);
510
+ await mkdir(headlessLogDir, { recursive: true });
511
+
512
+ const headlessProc = await spawnHeadless(argv, {
513
+ cwd: projectRoot,
514
+ env: { ...(process.env as Record<string, string>), ...directEnv },
515
+ stdoutFile: join(headlessLogDir, "stdout.log"),
516
+ stderrFile: join(headlessLogDir, "stderr.log"),
517
+ agentName: coordinatorName,
518
+ });
519
+
520
+ // Build the initial stdin prompt from agent definition + pending dispatch
521
+ // mail + activation beacon. Replaces SessionStart hooks (no-op headless).
522
+ const agentDefPath = join(projectRoot, ".overstory", "agent-defs", agentDefFile);
523
+ const agentDefHandle = Bun.file(agentDefPath);
524
+ const primeContext = (await agentDefHandle.exists()) ? await agentDefHandle.text() : "";
525
+
526
+ const mailDbPath = join(overstoryDir, "mail.db");
527
+ const pendingMailStore = createMailStore(mailDbPath);
528
+ let mailSection = "";
529
+ try {
530
+ const pendingMailClient = createMailClient(pendingMailStore);
531
+ const pendingMessages = pendingMailClient.check(coordinatorName);
532
+ mailSection = formatMailSection(pendingMessages);
533
+ } finally {
534
+ pendingMailStore.close();
535
+ }
536
+
537
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
538
+ const trackerCli = trackerCliName(resolvedBackend);
539
+ const beacon = beaconBuilder(trackerCli);
540
+ const initialPrompt = buildInitialHeadlessPrompt(
541
+ primeContext || undefined,
542
+ mailSection || undefined,
543
+ beacon,
544
+ );
545
+ await headlessProc.stdin.write(initialPrompt);
546
+
547
+ // Create run record + current-run.txt + session row.
548
+ const sessionId = `session-${Date.now()}-${coordinatorName}`;
549
+ const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
550
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
551
+ try {
552
+ runStore.createRun({
553
+ id: runId,
554
+ startedAt: new Date().toISOString(),
555
+ coordinatorSessionId: sessionId,
556
+ coordinatorName,
557
+ status: "active",
558
+ });
559
+ } finally {
560
+ runStore.close();
561
+ }
562
+ await Bun.write(join(overstoryDir, "current-run.txt"), runId);
563
+
564
+ const session: AgentSession = {
565
+ id: sessionId,
566
+ agentName: coordinatorName,
567
+ capability,
568
+ worktreePath: projectRoot,
569
+ branchName: config.project.canonicalBranch,
570
+ taskId: "",
571
+ tmuxSession: "", // headless: no tmux pane
572
+ state: "booting",
573
+ pid: headlessProc.pid,
574
+ parentAgent: null,
575
+ depth: 0,
576
+ runId,
577
+ startedAt: new Date().toISOString(),
578
+ lastActivity: new Date().toISOString(),
579
+ escalationLevel: 0,
580
+ stalledSince: null,
581
+ transcriptPath: null,
582
+ };
583
+ store.upsert(session);
584
+
585
+ // Auto-start watchdog / monitor (same as tmux path).
586
+ let watchdogPid: number | undefined;
587
+ if (watchdogFlag) {
588
+ const watchdogResult = await watchdog.start();
589
+ if (watchdogResult) {
590
+ watchdogPid = watchdogResult.pid;
591
+ if (!json) printHint("Watchdog started");
592
+ } else {
593
+ if (!json) printWarning("Watchdog failed to start");
594
+ }
595
+ }
596
+ let monitorPid: number | undefined;
597
+ if (monitorFlag) {
598
+ if (!config.watchdog.tier2Enabled) {
599
+ if (!json) printWarning("Monitor skipped", "watchdog.tier2Enabled is false");
600
+ } else {
601
+ const monitorResult = await monitor.start([]);
602
+ if (monitorResult) {
603
+ monitorPid = monitorResult.pid;
604
+ if (!json) printHint("Monitor started");
605
+ } else {
606
+ if (!json) printWarning("Monitor failed to start");
607
+ }
608
+ }
609
+ }
610
+
611
+ const output = {
612
+ agentName: coordinatorName,
613
+ capability,
614
+ tmuxSession: "",
615
+ projectRoot,
616
+ pid: headlessProc.pid,
617
+ headless: true,
618
+ watchdog: watchdogFlag ? watchdogPid !== undefined : false,
619
+ monitor: monitorFlag ? monitorPid !== undefined : false,
620
+ };
621
+
622
+ if (json) {
623
+ jsonOutput(`${capability} start`, output);
624
+ } else {
625
+ printSuccess(`${displayName} started (headless)`);
626
+ process.stdout.write(` Root: ${projectRoot}\n`);
627
+ process.stdout.write(` PID: ${headlessProc.pid}\n`);
628
+ process.stdout.write(` Logs: ${headlessLogDir}\n`);
629
+ }
630
+ return;
631
+ }
632
+
461
633
  // Preflight: verify tmux is installed before attempting to spawn.
462
634
  // Without this check, a missing tmux leads to cryptic errors later.
463
635
  await tmux.ensureTmuxAvailable();
@@ -628,6 +800,7 @@ export async function startCoordinatorSession(
628
800
  process.stdout.write(` Tmux: ${tmuxSession}\n`);
629
801
  process.stdout.write(` Root: ${projectRoot}\n`);
630
802
  process.stdout.write(` PID: ${pid}\n`);
803
+ printHint("Open the UI: `ov serve` then http://localhost:7321 — primary operator surface");
631
804
  }
632
805
 
633
806
  if (shouldAttach) {
@@ -678,6 +851,18 @@ function isActivePersistentAgentSession(
678
851
  * 3. Mark session as completed in SessionStore
679
852
  * 4. Auto-complete the active run (if current-run.txt exists)
680
853
  */
854
+ /**
855
+ * Stop the default coordinator. Handles both tmux and headless sessions.
856
+ * Exposed for callers outside the CLI command surface (e.g. the web-UI POST
857
+ * /api/coordinator/stop endpoint, which lives in coordinator-actions.ts).
858
+ */
859
+ export async function stopCoordinatorSession(
860
+ opts: { json: boolean },
861
+ deps: CoordinatorDeps = {},
862
+ ): Promise<void> {
863
+ await stopPersistentAgent(COORDINATOR_SPEC, opts, deps);
864
+ }
865
+
681
866
  async function stopPersistentAgent(
682
867
  spec: PersistentAgentSpec,
683
868
  opts: { json: boolean },
@@ -711,10 +896,24 @@ async function stopPersistentAgent(
711
896
  });
712
897
  }
713
898
 
714
- // Kill tmux session with process tree cleanup
715
- const alive = await tmux.isSessionAlive(session.tmuxSession);
716
- if (alive) {
717
- await tmux.killSession(session.tmuxSession);
899
+ // Headless sessions have no tmux pane (tmuxSession === ""). Tear down via
900
+ // the connection registry (SIGTERM-with-SIGKILL-escalation) and skip tmux.
901
+ if (session.tmuxSession === "") {
902
+ const { removeConnection } = await import("../runtimes/connections.ts");
903
+ removeConnection(spec.agentName);
904
+ if (session.pid !== null && isProcessRunning(session.pid)) {
905
+ try {
906
+ process.kill(session.pid, "SIGTERM");
907
+ } catch {
908
+ // process may have exited between the check and the signal
909
+ }
910
+ }
911
+ } else {
912
+ // Kill tmux session with process tree cleanup
913
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
914
+ if (alive) {
915
+ await tmux.killSession(session.tmuxSession);
916
+ }
718
917
  }
719
918
 
720
919
  // Always attempt to stop watchdog