@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
@@ -410,6 +410,112 @@ describe("checkConsistency", () => {
410
410
  expect(checks.find((c) => c.name === "missing-tmux")?.status).toBe("pass");
411
411
  });
412
412
 
413
+ test("orphan-spawns: terminal state with live pid is flagged", async () => {
414
+ const dbPath = join(overstoryDir, "sessions.db");
415
+ const store = createSessionStore(dbPath);
416
+
417
+ store.upsert({
418
+ id: "session-1",
419
+ agentName: "orphaned-agent",
420
+ capability: "builder",
421
+ worktreePath: join(overstoryDir, "worktrees", "orphaned-agent"),
422
+ branchName: "overstory/orphaned-agent/test-123",
423
+ taskId: "test-123",
424
+ tmuxSession: "",
425
+ state: "completed",
426
+ pid: 4242,
427
+ parentAgent: null,
428
+ depth: 0,
429
+ runId: null,
430
+ startedAt: new Date().toISOString(),
431
+ lastActivity: new Date().toISOString(),
432
+ escalationLevel: 0,
433
+ stalledSince: null,
434
+ transcriptPath: null,
435
+ });
436
+ store.close();
437
+
438
+ mockIsProcessAlive.mockReturnValue(true);
439
+ mockListSessions.mockResolvedValue([]);
440
+
441
+ const checks = await checkConsistency(config, overstoryDir, mockDeps);
442
+
443
+ const orphanCheck = checks.find((c) => c.name === "orphan-spawns");
444
+ expect(orphanCheck).toBeDefined();
445
+ expect(orphanCheck?.status).toBe("warn");
446
+ expect(orphanCheck?.message).toContain("1 orphaned spawn");
447
+ expect(orphanCheck?.details?.[0]).toContain("orphaned-agent");
448
+ expect(orphanCheck?.fixable).toBe(true);
449
+ });
450
+
451
+ test("orphan-spawns: tmux dead but pid alive is flagged", async () => {
452
+ const dbPath = join(overstoryDir, "sessions.db");
453
+ const store = createSessionStore(dbPath);
454
+
455
+ store.upsert({
456
+ id: "session-1",
457
+ agentName: "tmux-dead-agent",
458
+ capability: "builder",
459
+ worktreePath: join(overstoryDir, "worktrees", "tmux-dead-agent"),
460
+ branchName: "overstory/tmux-dead-agent/test-123",
461
+ taskId: "test-123",
462
+ tmuxSession: "overstory-testproject-tmux-dead-agent",
463
+ state: "working",
464
+ pid: 4242,
465
+ parentAgent: null,
466
+ depth: 0,
467
+ runId: null,
468
+ startedAt: new Date().toISOString(),
469
+ lastActivity: new Date().toISOString(),
470
+ escalationLevel: 0,
471
+ stalledSince: null,
472
+ transcriptPath: null,
473
+ });
474
+ store.close();
475
+
476
+ mockIsProcessAlive.mockReturnValue(true);
477
+ // tmux server reports no matching session
478
+ mockListSessions.mockResolvedValue([]);
479
+
480
+ const checks = await checkConsistency(config, overstoryDir, mockDeps);
481
+
482
+ const orphanCheck = checks.find((c) => c.name === "orphan-spawns");
483
+ expect(orphanCheck?.status).toBe("warn");
484
+ expect(orphanCheck?.details?.[0]).toContain("tmux session");
485
+ });
486
+
487
+ test("orphan-spawns: passes when terminal-state pid is dead", async () => {
488
+ const dbPath = join(overstoryDir, "sessions.db");
489
+ const store = createSessionStore(dbPath);
490
+
491
+ store.upsert({
492
+ id: "session-1",
493
+ agentName: "clean-completed",
494
+ capability: "builder",
495
+ worktreePath: join(overstoryDir, "worktrees", "clean-completed"),
496
+ branchName: "overstory/clean-completed/test-123",
497
+ taskId: "test-123",
498
+ tmuxSession: "",
499
+ state: "completed",
500
+ pid: 4242,
501
+ parentAgent: null,
502
+ depth: 0,
503
+ runId: null,
504
+ startedAt: new Date().toISOString(),
505
+ lastActivity: new Date().toISOString(),
506
+ escalationLevel: 0,
507
+ stalledSince: null,
508
+ transcriptPath: null,
509
+ });
510
+ store.close();
511
+
512
+ mockIsProcessAlive.mockReturnValue(false);
513
+
514
+ const checks = await checkConsistency(config, overstoryDir, mockDeps);
515
+
516
+ expect(checks.find((c) => c.name === "orphan-spawns")?.status).toBe("pass");
517
+ });
518
+
413
519
  test("handles tmux not installed gracefully", async () => {
414
520
  // Mock tmux listing to throw an error
415
521
  mockListSessions.mockRejectedValue(new Error("tmux: command not found"));
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { openSessionStore } from "../sessions/compat.ts";
4
4
  import type { AgentSession, OverstoryConfig } from "../types.ts";
5
5
  import { listWorktrees } from "../worktree/manager.ts";
6
- import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
6
+ import { isProcessAlive, listSessions, sanitizeTmuxName } from "../worktree/tmux.ts";
7
7
  import type { DoctorCheck } from "./types.ts";
8
8
 
9
9
  /**
@@ -134,7 +134,7 @@ export async function checkConsistency(
134
134
 
135
135
  // 5. Check for orphaned tmux sessions (tmux session exists but no SessionStore entry)
136
136
  const projectName = config.project.name;
137
- const overstoryTmuxPrefix = `overstory-${projectName}-`;
137
+ const overstoryTmuxPrefix = `overstory-${sanitizeTmuxName(projectName)}-`;
138
138
  const overstoryTmuxSessions = tmuxSessions.filter((s) => s.name.startsWith(overstoryTmuxPrefix));
139
139
  const storeTmuxNames = new Set(storeSessions.map((s) => s.tmuxSession));
140
140
 
@@ -212,7 +212,9 @@ export async function checkConsistency(
212
212
 
213
213
  // 8. Check for SessionStore entries with missing tmux sessions
214
214
  const existingTmuxNames = new Set(tmuxSessions.map((s) => s.name));
215
- const missingTmux = liveSessions.filter((s) => !existingTmuxNames.has(s.tmuxSession));
215
+ const missingTmux = liveSessions.filter(
216
+ (s) => s.tmuxSession.length > 0 && !existingTmuxNames.has(s.tmuxSession),
217
+ );
216
218
 
217
219
  if (missingTmux.length > 0) {
218
220
  checks.push({
@@ -232,6 +234,51 @@ export async function checkConsistency(
232
234
  });
233
235
  }
234
236
 
237
+ // 8b. Check for orphaned claude spawn PIDs (overstory-505d).
238
+ //
239
+ // An orphan is a session whose pid is still alive but should not be:
240
+ // - the session reached a terminal state (completed/zombie) yet the
241
+ // spawn didn't exit, or
242
+ // - the tmux container is gone but the claude child survived (was
243
+ // reparented to init when its bash wrapper got SIGHUP).
244
+ // Run `ov clean --all` to reap. Distinct from `dead-pids` (the inverse:
245
+ // session is live but its pid already died).
246
+ const orphanedSpawns: Array<{ session: AgentSession; reason: string }> = [];
247
+ for (const s of storeSessions) {
248
+ if (s.pid === null || !isProcessAliveFn(s.pid)) continue;
249
+ if (s.state === "completed" || s.state === "zombie") {
250
+ orphanedSpawns.push({
251
+ session: s,
252
+ reason: `state=${s.state} but pid ${s.pid} still alive`,
253
+ });
254
+ continue;
255
+ }
256
+ if (s.tmuxSession.length > 0 && !existingTmuxNames.has(s.tmuxSession)) {
257
+ orphanedSpawns.push({
258
+ session: s,
259
+ reason: `tmux session "${s.tmuxSession}" missing but pid ${s.pid} alive`,
260
+ });
261
+ }
262
+ }
263
+
264
+ if (orphanedSpawns.length > 0) {
265
+ checks.push({
266
+ name: "orphan-spawns",
267
+ category: "consistency",
268
+ status: "warn",
269
+ message: `Found ${orphanedSpawns.length} orphaned spawn process(es) — run "ov clean --all" to reap`,
270
+ details: orphanedSpawns.map(({ session, reason }) => `${session.agentName}: ${reason}`),
271
+ fixable: true,
272
+ });
273
+ } else {
274
+ checks.push({
275
+ name: "orphan-spawns",
276
+ category: "consistency",
277
+ status: "pass",
278
+ message: "No orphaned spawn processes detected",
279
+ });
280
+ }
281
+
235
282
  // 9. Check reviewer-to-builder ratio per lead
236
283
  const parentGroups = new Map<string, { builders: number; reviewers: number }>();
237
284
  for (const session of storeSessions) {
@@ -0,0 +1,95 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { OverstoryConfig } from "../types.ts";
6
+ import { checkServe } from "./serve.ts";
7
+
8
+ describe("checkServe", () => {
9
+ let tempDir: string;
10
+ let mockConfig: OverstoryConfig;
11
+
12
+ beforeEach(() => {
13
+ tempDir = mkdtempSync(join(tmpdir(), "overstory-serve-doctor-test-"));
14
+ mockConfig = {
15
+ project: { name: "test", root: tempDir, canonicalBranch: "main" },
16
+ agents: {
17
+ manifestPath: "",
18
+ baseDir: "",
19
+ maxConcurrent: 5,
20
+ staggerDelayMs: 100,
21
+ maxDepth: 2,
22
+ maxSessionsPerRun: 0,
23
+ maxAgentsPerLead: 5,
24
+ },
25
+ worktrees: { baseDir: "" },
26
+ taskTracker: { backend: "auto", enabled: true },
27
+ mulch: { enabled: true, domains: [], primeFormat: "markdown" },
28
+ merge: { aiResolveEnabled: false, reimagineEnabled: false },
29
+ providers: {
30
+ anthropic: { type: "native" },
31
+ },
32
+ watchdog: {
33
+ tier0Enabled: false,
34
+ tier0IntervalMs: 30000,
35
+ tier1Enabled: false,
36
+ tier2Enabled: false,
37
+ staleThresholdMs: 300000,
38
+ zombieThresholdMs: 600000,
39
+ nudgeIntervalMs: 60000,
40
+ },
41
+ models: {},
42
+ logging: { verbose: false, redactSecrets: true },
43
+ };
44
+ });
45
+
46
+ afterEach(() => {
47
+ rmSync(tempDir, { recursive: true, force: true });
48
+ });
49
+
50
+ test("ui/dist missing — returns warn about missing build", async () => {
51
+ const checks = await checkServe(mockConfig, tempDir);
52
+ const distCheck = checks.find((c) => c.name === "serve ui/dist");
53
+
54
+ expect(distCheck).toBeDefined();
55
+ expect(distCheck?.status).toBe("warn");
56
+ expect(distCheck?.message).toContain("ui/dist not found");
57
+ expect(distCheck?.details?.some((d) => d.includes("ui/dist"))).toBe(true);
58
+ });
59
+
60
+ test("ui/dist exists but index.html missing — returns warn about incomplete build", async () => {
61
+ mkdirSync(join(tempDir, "ui", "dist"), { recursive: true });
62
+ const checks = await checkServe(mockConfig, tempDir);
63
+ const distCheck = checks.find((c) => c.name === "serve ui/dist");
64
+
65
+ expect(distCheck).toBeDefined();
66
+ expect(distCheck?.status).toBe("warn");
67
+ expect(distCheck?.message).toContain("index.html is missing");
68
+ });
69
+
70
+ test("ui/dist with index.html — returns pass", async () => {
71
+ mkdirSync(join(tempDir, "ui", "dist"), { recursive: true });
72
+ writeFileSync(join(tempDir, "ui", "dist", "index.html"), "<html></html>");
73
+ const checks = await checkServe(mockConfig, tempDir);
74
+ const distCheck = checks.find((c) => c.name === "serve ui/dist");
75
+
76
+ expect(distCheck).toBeDefined();
77
+ expect(distCheck?.status).toBe("pass");
78
+ expect(distCheck?.message).toContain("index.html");
79
+ });
80
+
81
+ test("port check included in results", async () => {
82
+ const checks = await checkServe(mockConfig, tempDir);
83
+ const portCheck = checks.find((c) => c.name === "serve port");
84
+
85
+ expect(portCheck).toBeDefined();
86
+ // Server not running — should warn (or pass if something happens to be on the default port)
87
+ expect(portCheck?.status === "warn" || portCheck?.status === "pass").toBe(true);
88
+ });
89
+
90
+ test("returns exactly 2 checks (ui/dist + port)", async () => {
91
+ const checks = await checkServe(mockConfig, tempDir);
92
+ expect(checks).toHaveLength(2);
93
+ expect(checks.map((c) => c.category).every((cat) => cat === "serve")).toBe(true);
94
+ });
95
+ });
@@ -0,0 +1,86 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DEFAULT_SERVE_PORT } from "../commands/serve.ts";
4
+ import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
5
+
6
+ /**
7
+ * ov serve subsystem health checks.
8
+ * Validates ui/dist build output and port reachability.
9
+ */
10
+ export const checkServe: DoctorCheckFn = async (config, _overstoryDir): Promise<DoctorCheck[]> => {
11
+ const checks: DoctorCheck[] = [];
12
+
13
+ // Check 1: ui/dist directory exists (only relevant if a UI has been built)
14
+ const uiDistPath = join(config.project.root, "ui", "dist");
15
+ const uiDistExists = existsSync(uiDistPath);
16
+ const indexHtmlExists = uiDistExists && existsSync(join(uiDistPath, "index.html"));
17
+
18
+ if (!uiDistExists) {
19
+ checks.push({
20
+ name: "serve ui/dist",
21
+ category: "serve",
22
+ status: "warn",
23
+ message: "ui/dist not found — run the UI build before starting ov serve",
24
+ details: [`Expected: ${uiDistPath}`],
25
+ });
26
+ } else if (!indexHtmlExists) {
27
+ checks.push({
28
+ name: "serve ui/dist",
29
+ category: "serve",
30
+ status: "warn",
31
+ message: "ui/dist exists but index.html is missing — UI build may be incomplete",
32
+ details: [`Expected: ${join(uiDistPath, "index.html")}`],
33
+ });
34
+ } else {
35
+ checks.push({
36
+ name: "serve ui/dist",
37
+ category: "serve",
38
+ status: "pass",
39
+ message: "ui/dist is present with index.html",
40
+ });
41
+ }
42
+
43
+ // Check 2: default port reachability (non-blocking probe)
44
+ const port = DEFAULT_SERVE_PORT;
45
+ const host = "127.0.0.1";
46
+ const reachable = await probePort(host, port);
47
+ if (reachable) {
48
+ checks.push({
49
+ name: "serve port",
50
+ category: "serve",
51
+ status: "pass",
52
+ message: `ov serve is reachable on ${host}:${port}`,
53
+ });
54
+ } else {
55
+ checks.push({
56
+ name: "serve port",
57
+ category: "serve",
58
+ status: "warn",
59
+ message: `ov serve is not running on ${host}:${port}`,
60
+ details: [`Start with: ov serve --port ${port}`],
61
+ });
62
+ }
63
+
64
+ return checks;
65
+ };
66
+
67
+ /**
68
+ * Probe whether a TCP port is open by attempting an HTTP connection.
69
+ * Returns true if the server responds, false on any error.
70
+ */
71
+ async function probePort(host: string, port: number): Promise<boolean> {
72
+ try {
73
+ const controller = new AbortController();
74
+ const timeout = setTimeout(() => controller.abort(), 1000);
75
+ try {
76
+ const res = await fetch(`http://${host}:${port}/healthz`, {
77
+ signal: controller.signal,
78
+ });
79
+ return res.ok || res.status < 500;
80
+ } finally {
81
+ clearTimeout(timeout);
82
+ }
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
@@ -15,7 +15,8 @@ export type DoctorCategory =
15
15
  | "version"
16
16
  | "ecosystem"
17
17
  | "providers"
18
- | "watchdog";
18
+ | "watchdog"
19
+ | "serve";
19
20
 
20
21
  /** Result of a single doctor health check. */
21
22
  export interface DoctorCheck {
@@ -3,6 +3,7 @@ import { stat, unlink } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { getRuntime } from "../runtimes/registry.ts";
5
5
  import { openSessionStore } from "../sessions/compat.ts";
6
+ import { findRunningWatchdogProcesses } from "../utils/process-scan.ts";
6
7
  import { isProcessRunning } from "../watchdog/health.ts";
7
8
  import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
8
9
 
@@ -134,7 +135,62 @@ export const checkWatchdog: DoctorCheckFn = async (
134
135
  }
135
136
  }
136
137
 
137
- // Check 6: Tier 1 triage available if tier1Enabled
138
+ // Check 6: multi-daemon detection (overstory-8ef6).
139
+ // Earlier releases had no exclusion lock, so multiple `ov watch` daemons
140
+ // could run simultaneously. We scan the process table for `ov watch`
141
+ // processes and flag any case with more than one. This is observational —
142
+ // even with the lock now in place, a corrupted/missing PID file could
143
+ // still let a foreign daemon slip past, and we want doctor to catch it.
144
+ try {
145
+ const watchProcs = await findRunningWatchdogProcesses();
146
+ if (watchProcs.length > 1) {
147
+ const lockOwner = existsSync(pidFilePath)
148
+ ? Number.parseInt((await Bun.file(pidFilePath).text()).trim(), 10)
149
+ : Number.NaN;
150
+ const lockOwnerLabel = Number.isFinite(lockOwner) ? `${lockOwner}` : "(none)";
151
+ const pidList = watchProcs.map((p) => p.pid).join(", ");
152
+ checks.push({
153
+ name: "watchdog multi-daemon",
154
+ category: "watchdog",
155
+ status: "fail",
156
+ message: `${watchProcs.length} 'ov watch' daemons running concurrently — only one should be live`,
157
+ details: [
158
+ `Live PIDs: ${pidList}`,
159
+ `PID-file owner: ${lockOwnerLabel}`,
160
+ "Run 'ov watch --kill-others' to terminate the foreign daemons.",
161
+ ],
162
+ fixable: true,
163
+ fix: async () => {
164
+ const ownerPid = Number.isFinite(lockOwner) ? lockOwner : null;
165
+ const messages: string[] = [];
166
+ for (const proc of watchProcs) {
167
+ if (proc.pid === ownerPid) continue;
168
+ try {
169
+ process.kill(proc.pid, "SIGTERM");
170
+ messages.push(`Killed foreign watchdog PID ${proc.pid}`);
171
+ } catch {
172
+ messages.push(`PID ${proc.pid} already gone`);
173
+ }
174
+ }
175
+ if (messages.length === 0) {
176
+ messages.push("No foreign watchdogs to kill — fix is a no-op");
177
+ }
178
+ return messages;
179
+ },
180
+ });
181
+ }
182
+ } catch {
183
+ // Process scan failure is non-fatal — leave a soft warning instead of
184
+ // failing the whole doctor run.
185
+ checks.push({
186
+ name: "watchdog multi-daemon",
187
+ category: "watchdog",
188
+ status: "warn",
189
+ message: "Could not scan process table for foreign 'ov watch' daemons",
190
+ });
191
+ }
192
+
193
+ // Check 7: Tier 1 triage available if tier1Enabled
138
194
  if (config.watchdog.tier1Enabled) {
139
195
  try {
140
196
  getRuntime(config?.runtime?.printCommand ?? config?.runtime?.default, config);