@os-eco/overstory-cli 0.9.4 → 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 (110) hide show
  1. package/README.md +47 -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 +54 -0
  26. package/src/commands/coordinator.test.ts +127 -0
  27. package/src/commands/coordinator.ts +203 -5
  28. package/src/commands/dashboard.test.ts +188 -0
  29. package/src/commands/dashboard.ts +13 -3
  30. package/src/commands/doctor.ts +3 -1
  31. package/src/commands/group.test.ts +94 -0
  32. package/src/commands/group.ts +49 -20
  33. package/src/commands/init.test.ts +8 -0
  34. package/src/commands/init.ts +8 -1
  35. package/src/commands/log.test.ts +56 -11
  36. package/src/commands/log.ts +134 -69
  37. package/src/commands/mail.test.ts +162 -0
  38. package/src/commands/mail.ts +64 -9
  39. package/src/commands/merge.test.ts +112 -1
  40. package/src/commands/merge.ts +17 -4
  41. package/src/commands/nudge.test.ts +351 -4
  42. package/src/commands/nudge.ts +356 -34
  43. package/src/commands/run.test.ts +43 -7
  44. package/src/commands/serve/build.test.ts +202 -0
  45. package/src/commands/serve/build.ts +206 -0
  46. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  47. package/src/commands/serve/coordinator-actions.ts +408 -0
  48. package/src/commands/serve/dev.test.ts +168 -0
  49. package/src/commands/serve/dev.ts +117 -0
  50. package/src/commands/serve/mail-actions.test.ts +312 -0
  51. package/src/commands/serve/mail-actions.ts +167 -0
  52. package/src/commands/serve/rest.test.ts +1323 -0
  53. package/src/commands/serve/rest.ts +708 -0
  54. package/src/commands/serve/static.ts +51 -0
  55. package/src/commands/serve/ws.test.ts +361 -0
  56. package/src/commands/serve/ws.ts +332 -0
  57. package/src/commands/serve.test.ts +459 -0
  58. package/src/commands/serve.ts +565 -0
  59. package/src/commands/sling.test.ts +73 -1
  60. package/src/commands/sling.ts +149 -64
  61. package/src/commands/status.test.ts +9 -0
  62. package/src/commands/status.ts +12 -4
  63. package/src/commands/stop.test.ts +174 -1
  64. package/src/commands/stop.ts +107 -8
  65. package/src/commands/watch.test.ts +43 -0
  66. package/src/commands/watch.ts +153 -28
  67. package/src/config.ts +23 -0
  68. package/src/doctor/consistency.test.ts +106 -0
  69. package/src/doctor/consistency.ts +48 -1
  70. package/src/doctor/serve.test.ts +95 -0
  71. package/src/doctor/serve.ts +86 -0
  72. package/src/doctor/types.ts +2 -1
  73. package/src/doctor/watchdog.ts +57 -1
  74. package/src/events/tailer.test.ts +234 -1
  75. package/src/events/tailer.ts +90 -0
  76. package/src/index.ts +53 -6
  77. package/src/json.ts +29 -0
  78. package/src/mail/client.ts +15 -2
  79. package/src/mail/store.test.ts +82 -0
  80. package/src/mail/store.ts +41 -4
  81. package/src/merge/lock.test.ts +149 -0
  82. package/src/merge/lock.ts +140 -0
  83. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  84. package/src/runtimes/claude.test.ts +791 -1
  85. package/src/runtimes/claude.ts +323 -1
  86. package/src/runtimes/connections.test.ts +141 -1
  87. package/src/runtimes/connections.ts +73 -4
  88. package/src/runtimes/headless-connection.test.ts +264 -0
  89. package/src/runtimes/headless-connection.ts +158 -0
  90. package/src/runtimes/types.ts +10 -0
  91. package/src/schema-consistency.test.ts +1 -0
  92. package/src/sessions/store.test.ts +390 -24
  93. package/src/sessions/store.ts +184 -19
  94. package/src/test-setup.test.ts +31 -0
  95. package/src/test-setup.ts +28 -0
  96. package/src/types.ts +56 -1
  97. package/src/utils/pid.test.ts +85 -1
  98. package/src/utils/pid.ts +86 -1
  99. package/src/utils/process-scan.test.ts +53 -0
  100. package/src/utils/process-scan.ts +76 -0
  101. package/src/watchdog/daemon.test.ts +1520 -411
  102. package/src/watchdog/daemon.ts +442 -83
  103. package/src/watchdog/health.test.ts +157 -0
  104. package/src/watchdog/health.ts +92 -25
  105. package/src/worktree/process.test.ts +71 -0
  106. package/src/worktree/process.ts +25 -5
  107. package/src/worktree/tmux.test.ts +3 -0
  108. package/src/worktree/tmux.ts +10 -3
  109. package/templates/CLAUDE.md.tmpl +19 -8
  110. package/templates/overlay.md.tmpl +3 -2
@@ -124,6 +124,7 @@ async function logSyntheticSessionEndEvents(overstoryDir: string): Promise<numbe
124
124
  interface CleanResult {
125
125
  sessionEndEventsLogged: number;
126
126
  tmuxKilled: number;
127
+ orphanPidsReaped: number;
127
128
  worktreesCleaned: number;
128
129
  branchesDeleted: number;
129
130
  mailWiped: boolean;
@@ -217,6 +218,45 @@ function loadRegisteredTmuxNames(overstoryDir: string): Set<string> | null {
217
218
  }
218
219
  }
219
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
+
220
260
  /**
221
261
  * Remove all overstory worktrees (force remove with branch deletion).
222
262
  */
@@ -568,6 +608,7 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
568
608
  const result: CleanResult = {
569
609
  sessionEndEventsLogged: 0,
570
610
  tmuxKilled: 0,
611
+ orphanPidsReaped: 0,
571
612
  worktreesCleaned: 0,
572
613
  branchesDeleted: 0,
573
614
  mailWiped: false,
@@ -609,6 +650,14 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
609
650
  result.tmuxKilled = await killAllTmuxSessions(overstoryDir, config.project.name);
610
651
  }
611
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
+
612
661
  // 3. Remove worktrees
613
662
  if (doWorktrees) {
614
663
  result.worktreesCleaned = await cleanAllWorktrees(root);
@@ -670,6 +719,11 @@ export async function cleanCommand(opts: CleanOptions): Promise<void> {
670
719
  if (result.tmuxKilled > 0) {
671
720
  lines.push(`Killed ${result.tmuxKilled} tmux session${result.tmuxKilled === 1 ? "" : "s"}`);
672
721
  }
722
+ if (result.orphanPidsReaped > 0) {
723
+ lines.push(
724
+ `Reaped ${result.orphanPidsReaped} orphaned spawn process${result.orphanPidsReaped === 1 ? "" : "es"}`,
725
+ );
726
+ }
673
727
  if (result.worktreesCleaned > 0) {
674
728
  lines.push(
675
729
  `Removed ${result.worktreesCleaned} worktree${result.worktreesCleaned === 1 ? "" : "s"}`,
@@ -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,
@@ -46,7 +49,7 @@ import { nudgeAgent } from "./nudge.ts";
46
49
  import { isRunningAsRoot } from "./sling.ts";
47
50
 
48
51
  /** Default coordinator agent name. */
49
- const COORDINATOR_NAME = "coordinator";
52
+ export const COORDINATOR_NAME = "coordinator";
50
53
 
51
54
  export interface PersistentAgentSpec {
52
55
  commandName: string;
@@ -120,6 +123,15 @@ export interface CoordinatorDeps {
120
123
  _capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
121
124
  /** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
122
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
+ }>;
123
135
  }
124
136
 
125
137
  /**
@@ -332,6 +344,13 @@ export interface CoordinatorSessionOptions {
332
344
  displayName?: string;
333
345
  /** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
334
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;
335
354
  }
336
355
 
337
356
  /**
@@ -365,6 +384,7 @@ export async function startCoordinatorSession(
365
384
  agentDefFile: agentDefFileOpt,
366
385
  displayName: displayNameOpt,
367
386
  beaconBuilder: beaconBuilderOpt,
387
+ headless: headlessFlag,
368
388
  } = opts;
369
389
 
370
390
  const coordinatorName = agentNameOpt ?? coordinatorNameOpt ?? COORDINATOR_NAME;
@@ -459,6 +479,157 @@ export async function startCoordinatorSession(
459
479
  });
460
480
  }
461
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
+
462
633
  // Preflight: verify tmux is installed before attempting to spawn.
463
634
  // Without this check, a missing tmux leads to cryptic errors later.
464
635
  await tmux.ensureTmuxAvailable();
@@ -629,6 +800,7 @@ export async function startCoordinatorSession(
629
800
  process.stdout.write(` Tmux: ${tmuxSession}\n`);
630
801
  process.stdout.write(` Root: ${projectRoot}\n`);
631
802
  process.stdout.write(` PID: ${pid}\n`);
803
+ printHint("Open the UI: `ov serve` then http://localhost:7321 — primary operator surface");
632
804
  }
633
805
 
634
806
  if (shouldAttach) {
@@ -679,6 +851,18 @@ function isActivePersistentAgentSession(
679
851
  * 3. Mark session as completed in SessionStore
680
852
  * 4. Auto-complete the active run (if current-run.txt exists)
681
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
+
682
866
  async function stopPersistentAgent(
683
867
  spec: PersistentAgentSpec,
684
868
  opts: { json: boolean },
@@ -712,10 +896,24 @@ async function stopPersistentAgent(
712
896
  });
713
897
  }
714
898
 
715
- // Kill tmux session with process tree cleanup
716
- const alive = await tmux.isSessionAlive(session.tmuxSession);
717
- if (alive) {
718
- 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
+ }
719
917
  }
720
918
 
721
919
  // Always attempt to stop watchdog