@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
@@ -18,12 +18,13 @@
18
18
  * 14. Return AgentSession
19
19
  */
20
20
 
21
- import { mkdirSync } from "node:fs";
22
21
  import { mkdir } from "node:fs/promises";
23
22
  import { join, resolve } from "node:path";
23
+ import { buildInitialHeadlessPrompt, formatMailSection } from "../agents/headless-prompt.ts";
24
24
  import { createIdentity, loadIdentity } from "../agents/identity.ts";
25
25
  import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
26
26
  import { writeOverlay } from "../agents/overlay.ts";
27
+ import { runTurn } from "../agents/turn-runner.ts";
27
28
  import { createCanopyClient } from "../canopy/client.ts";
28
29
  import { loadConfig } from "../config.ts";
29
30
  import { AgentError, HierarchyError, ValidationError } from "../errors.ts";
@@ -38,9 +39,8 @@ import { openSessionStore } from "../sessions/compat.ts";
38
39
  import { createRunStore } from "../sessions/store.ts";
39
40
  import type { TrackerIssue } from "../tracker/factory.ts";
40
41
  import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
41
- import type { AgentSession, OverlayConfig } from "../types.ts";
42
+ import type { AgentSession, OverlayConfig, OverstoryConfig } from "../types.ts";
42
43
  import { createWorktree, rollbackWorktree } from "../worktree/manager.ts";
43
- import { spawnHeadlessAgent } from "../worktree/process.ts";
44
44
  import {
45
45
  capturePaneContent,
46
46
  checkSessionState,
@@ -156,6 +156,21 @@ export interface SlingOptions {
156
156
  noScoutCheck?: boolean;
157
157
  baseBranch?: string;
158
158
  profile?: string;
159
+ headless?: boolean;
160
+ recover?: boolean;
161
+ }
162
+
163
+ const WORKABLE_STATUSES = ["open", "in_progress"] as const;
164
+
165
+ /**
166
+ * Decide whether a task with the given tracker status can accept a fresh
167
+ * sling. Normal dispatch requires an `open` or `in_progress` task; passing
168
+ * `recover` accepts any status so a coordinator can re-dispatch against a
169
+ * task whose previous owner exited (e.g. closed by a dead lead). (overstory-629f)
170
+ */
171
+ export function isTaskWorkable(status: string, recover: boolean): boolean {
172
+ if (recover) return true;
173
+ return (WORKABLE_STATUSES as readonly string[]).includes(status);
159
174
  }
160
175
 
161
176
  export interface AutoDispatchOptions {
@@ -465,6 +480,44 @@ export async function getCurrentBranch(repoRoot: string): Promise<string | null>
465
480
  return branch;
466
481
  }
467
482
 
483
+ /**
484
+ * Resolve whether to use the headless spawn path for a given runtime + flags + config.
485
+ *
486
+ * Precedence (highest first):
487
+ * 1. runtime.headless === true (statically headless runtimes always use headless)
488
+ * 2. Explicit --headless / --no-headless flag (boolean | undefined from commander)
489
+ * 3. config.runtime.claudeHeadlessByDefault (only applies when runtime.id === "claude")
490
+ * 4. Default: false (tmux)
491
+ *
492
+ * Throws ValidationError when --headless is explicitly true but the runtime has no
493
+ * buildDirectSpawn implementation.
494
+ */
495
+ export function resolveUseHeadless(
496
+ runtime: { id: string; headless?: boolean; buildDirectSpawn?: unknown },
497
+ flag: boolean | undefined,
498
+ config: OverstoryConfig,
499
+ ): boolean {
500
+ if (runtime.headless === true) return true;
501
+
502
+ if (flag === true) {
503
+ if (typeof runtime.buildDirectSpawn !== "function") {
504
+ throw new ValidationError(
505
+ `--headless requires a runtime with headless support. Runtime "${runtime.id}" does not implement buildDirectSpawn.`,
506
+ { field: "headless", value: true },
507
+ );
508
+ }
509
+ return true;
510
+ }
511
+ if (flag === false) return false;
512
+
513
+ if (runtime.id === "claude" && config.runtime?.claudeHeadlessByDefault === true) {
514
+ if (typeof runtime.buildDirectSpawn !== "function") return false;
515
+ return true;
516
+ }
517
+
518
+ return false;
519
+ }
520
+
468
521
  /**
469
522
  * Entry point for `ov sling <task-id> [flags]`.
470
523
  *
@@ -490,6 +543,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
490
543
  const forceHierarchy = opts.forceHierarchy ?? false;
491
544
  const skipScout = opts.skipScout ?? false;
492
545
  const skipTaskCheck = opts.skipTaskCheck ?? false;
546
+ const recover = opts.recover ?? false;
493
547
 
494
548
  if (Number.isNaN(depth) || depth < 0) {
495
549
  throw new ValidationError("--depth must be a non-negative integer", {
@@ -740,13 +794,17 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
740
794
  });
741
795
  }
742
796
 
743
- const workableStatuses = ["open", "in_progress"];
744
- if (!workableStatuses.includes(issue.status)) {
797
+ if (!isTaskWorkable(issue.status, recover)) {
745
798
  throw new ValidationError(
746
- `Task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned.`,
799
+ `Task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned. Pass --recover to re-dispatch against a closed task.`,
747
800
  { field: "taskId", value: taskId },
748
801
  );
749
802
  }
803
+ if (recover && !(WORKABLE_STATUSES as readonly string[]).includes(issue.status)) {
804
+ process.stderr.write(
805
+ `Warning: --recover dispatching against task "${taskId}" with status "${issue.status}". Previous owner may have exited unexpectedly.\n`,
806
+ );
807
+ }
750
808
  }
751
809
 
752
810
  // 7. Create worktree
@@ -846,12 +904,20 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
846
904
  // 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
847
905
  const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
848
906
 
849
- // 9a. Deploy hooks config (capability-specific guards)
907
+ // 9a. Resolve headless mode before deployConfig so hooks can be skipped for headless agents.
908
+ // resolveUseHeadless is also used at 11c for spawn routing — hoisted here to share the value.
909
+ const useHeadless = resolveUseHeadless(runtime, opts.headless, config);
910
+
911
+ // 9b. Deploy hooks config (capability-specific guards). In headless mode we deploy
912
+ // a PreToolUse-only subset (security guards) — overstory-e24b. Headless Claude Code
913
+ // dispatches settings.local.json hooks, so dropping them would leave destructive
914
+ // commands unblocked.
850
915
  await runtime.deployConfig(worktreePath, undefined, {
851
916
  agentName: name,
852
917
  capability,
853
918
  worktreePath,
854
919
  qualityGates: config.project.qualityGates,
920
+ isHeadless: useHeadless,
855
921
  });
856
922
 
857
923
  // 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
@@ -919,42 +985,48 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
919
985
  }
920
986
 
921
987
  // 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
922
- if (runtime.headless === true && runtime.buildDirectSpawn) {
923
- const directEnv = {
924
- ...runtime.buildEnv(resolvedModel),
925
- OVERSTORY_AGENT_NAME: name,
926
- OVERSTORY_WORKTREE_PATH: worktreePath,
927
- OVERSTORY_TASK_ID: taskId,
928
- OVERSTORY_PROJECT_ROOT: config.project.root,
929
- };
930
- const argv = runtime.buildDirectSpawn({
931
- cwd: worktreePath,
932
- env: directEnv,
933
- ...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
934
- instructionPath: runtime.instructionPath,
935
- });
936
-
937
- // Create a timestamped log dir for this headless agent session.
938
- // Always redirect stdout to a file. This prevents SIGPIPE death:
939
- // ov sling exits after spawning, closing the pipe's read end.
940
- // If stdout is a pipe, the agent dies on the next write (SIGPIPE).
941
- // File writes have no such limit, and the agent survives the CLI exit.
942
- //
943
- // Note: RPC connection wiring is intentionally omitted here. The RPC pipe
944
- // is only useful when the spawner stays alive to consume it. ov sling is
945
- // a short-lived CLI — any connection created here dies with the process.
946
- const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
947
- const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
948
- mkdirSync(agentLogDir, { recursive: true });
949
-
950
- const headlessProc = await spawnHeadlessAgent(argv, {
951
- cwd: worktreePath,
952
- env: { ...(process.env as Record<string, string>), ...directEnv },
953
- stdoutFile: join(agentLogDir, "stdout.log"),
954
- stderrFile: join(agentLogDir, "stderr.log"),
955
- });
988
+ // useHeadless was resolved at step 9a (hoisted so deployConfig can skip hooks for headless).
989
+ if (useHeadless && runtime.buildDirectSpawn) {
990
+ // Phase 3 spawn-per-turn: headless agents have NO long-lived process.
991
+ // sling builds the initial prompt, upserts the session row in
992
+ // "booting", then drives the first user turn synchronously through
993
+ // `runTurn`. The runner spawns claude with `--resume` (when a prior
994
+ // session id exists), writes the prompt to a real stdin pipe, drains
995
+ // stream-json, captures session id, transitions state to "working"
996
+ // (or "completed" if terminal mail observed), and exits. No persistent
997
+ // process remains after this returns; subsequent turns are driven by
998
+ // `ov serve` (mail) or `ov nudge`.
999
+ const priorClaudeSessionId = store.getByName(name)?.claudeSessionId ?? null;
1000
+
1001
+ // Build the initial prompt (mulch expertise + pending mail + beacon)
1002
+ // as the first user turn.
1003
+ const pendingMailStore = createMailStore(join(overstoryDir, "mail.db"));
1004
+ let initialPrompt: string;
1005
+ try {
1006
+ const pendingMailClient = createMailClient(pendingMailStore);
1007
+ const pendingMessages = pendingMailClient.check(name);
1008
+ const mailSection = formatMailSection(pendingMessages);
1009
+ const beacon = buildBeacon({
1010
+ agentName: name,
1011
+ capability,
1012
+ taskId,
1013
+ parentAgent,
1014
+ depth,
1015
+ instructionPath: runtime.instructionPath,
1016
+ });
1017
+ initialPrompt = buildInitialHeadlessPrompt(
1018
+ mulchExpertise,
1019
+ mailSection || undefined,
1020
+ beacon,
1021
+ );
1022
+ } finally {
1023
+ pendingMailStore.close();
1024
+ }
956
1025
 
957
- // 13. Record session with empty tmuxSession (no tmux pane for headless agents).
1026
+ // 13. Record session BEFORE runTurn so the runner reads it under its
1027
+ // lock. pid is null — there is no persistent process; the runner
1028
+ // publishes a per-turn PID via .overstory/agents/<name>/turn.pid for
1029
+ // the duration of each turn. Carry priorClaudeSessionId (mx-5c5ae6).
958
1030
  const session: AgentSession = {
959
1031
  id: `session-${Date.now()}-${name}`,
960
1032
  agentName: name,
@@ -964,7 +1036,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
964
1036
  taskId: taskId,
965
1037
  tmuxSession: "",
966
1038
  state: "booting",
967
- pid: headlessProc.pid,
1039
+ pid: null,
968
1040
  parentAgent: parentAgent,
969
1041
  depth,
970
1042
  runId,
@@ -973,15 +1045,28 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
973
1045
  escalationLevel: 0,
974
1046
  stalledSince: null,
975
1047
  transcriptPath: null,
1048
+ ...(priorClaudeSessionId !== null ? { claudeSessionId: priorClaudeSessionId } : {}),
976
1049
  };
977
1050
  store.upsert(session);
978
1051
 
979
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
980
- try {
981
- runStore.incrementAgentCount(runId);
982
- } finally {
983
- runStore.close();
984
- }
1052
+ // Drive the first user turn synchronously. runTurn manages spawn,
1053
+ // stdin write+EOF, event drain, session_id capture, terminal-mail
1054
+ // detection, and state transition.
1055
+ const turnResult = await runTurn({
1056
+ agentName: name,
1057
+ capability,
1058
+ overstoryDir,
1059
+ worktreePath,
1060
+ projectRoot: config.project.root,
1061
+ taskId,
1062
+ userTurnNdjson: initialPrompt,
1063
+ runtime,
1064
+ resolvedModel,
1065
+ runId,
1066
+ mailDbPath: join(overstoryDir, "mail.db"),
1067
+ eventsDbPath: join(overstoryDir, "events.db"),
1068
+ sessionsDbPath: join(overstoryDir, "sessions.db"),
1069
+ });
985
1070
 
986
1071
  // 14. Output result (headless)
987
1072
  if (opts.json ?? false) {
@@ -992,14 +1077,19 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
992
1077
  branch: branchName,
993
1078
  worktree: worktreePath,
994
1079
  tmuxSession: "",
995
- pid: headlessProc.pid,
1080
+ pid: null,
1081
+ initialTurnFinalState: turnResult.finalState,
1082
+ claudeSessionId: turnResult.newSessionId,
996
1083
  });
997
1084
  } else {
998
- printSuccess("Agent launched (headless)", name);
999
- process.stdout.write(` Task: ${taskId}\n`);
1000
- process.stdout.write(` Branch: ${branchName}\n`);
1001
- process.stdout.write(` Worktree: ${worktreePath}\n`);
1002
- process.stdout.write(` PID: ${headlessProc.pid}\n`);
1085
+ printSuccess("Agent launched (headless, spawn-per-turn)", name);
1086
+ process.stdout.write(` Task: ${taskId}\n`);
1087
+ process.stdout.write(` Branch: ${branchName}\n`);
1088
+ process.stdout.write(` Worktree: ${worktreePath}\n`);
1089
+ process.stdout.write(` First-turn state: ${turnResult.finalState}\n`);
1090
+ if (turnResult.newSessionId) {
1091
+ process.stdout.write(` Claude session id: ${turnResult.newSessionId}\n`);
1092
+ }
1003
1093
  }
1004
1094
  } else {
1005
1095
  // 11c. Preflight: verify tmux is available before attempting session creation
@@ -1054,14 +1144,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1054
1144
 
1055
1145
  store.upsert(session);
1056
1146
 
1057
- // Increment agent count for the run
1058
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
1059
- try {
1060
- runStore.incrementAgentCount(runId);
1061
- } finally {
1062
- runStore.close();
1063
- }
1064
-
1065
1147
  // 13b. Give slow shells time to finish initializing before polling for TUI readiness.
1066
1148
  const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
1067
1149
  if (shellDelay > 0) {
@@ -1076,7 +1158,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1076
1158
  );
1077
1159
  if (!tuiReady) {
1078
1160
  const alive = await isSessionAlive(tmuxSessionName);
1079
- store.updateState(name, "completed");
1161
+ // Mark as zombie (not completed) so the watchdog detects this failed
1162
+ // startup. 'completed' is a terminal success state that the watchdog
1163
+ // skips entirely (overstory-c40e).
1164
+ store.updateState(name, "zombie");
1080
1165
 
1081
1166
  if (alive) {
1082
1167
  await killSession(tmuxSessionName);
@@ -51,6 +51,7 @@ function makeStatusData(overrides: Partial<StatusData> = {}): StatusData {
51
51
  worktrees: [],
52
52
  tmuxSessions: [{ name: "overstory-test-builder", pid: 12345 }],
53
53
  unreadMailCount: 0,
54
+ unreadMailScope: "orchestrator",
54
55
  mergeQueueCount: 0,
55
56
  recentMetricsCount: 0,
56
57
  ...overrides,
@@ -90,6 +91,12 @@ describe("printStatus", () => {
90
91
  expect(out).not.toContain("Mail sent:");
91
92
  });
92
93
 
94
+ test("Mail line names the scope agent so per-agent scope is unambiguous", () => {
95
+ const data = makeStatusData({ unreadMailCount: 3, unreadMailScope: "lead-1" });
96
+ printStatus(data);
97
+ expect(stripAnsi(output())).toContain("Mail: 3 unread (to lead-1)");
98
+ });
99
+
93
100
  test("verbose: shows worktree path, logs dir, and mail timestamps", () => {
94
101
  const detail: VerboseAgentDetail = {
95
102
  worktreePath: "/tmp/worktrees/test-builder",
@@ -208,6 +215,7 @@ describe("--verbose --json", () => {
208
215
  worktrees: [],
209
216
  tmuxSessions: [],
210
217
  unreadMailCount: 0,
218
+ unreadMailScope: "orchestrator",
211
219
  mergeQueueCount: 0,
212
220
  recentMetricsCount: 0,
213
221
  verboseDetails: { agent: detail },
@@ -226,6 +234,7 @@ describe("--verbose --json", () => {
226
234
  worktrees: [],
227
235
  tmuxSessions: [],
228
236
  unreadMailCount: 0,
237
+ unreadMailScope: "orchestrator",
229
238
  mergeQueueCount: 0,
230
239
  recentMetricsCount: 0,
231
240
  };
@@ -84,6 +84,7 @@ export interface StatusData {
84
84
  worktrees: Array<{ path: string; branch: string; head: string }>;
85
85
  tmuxSessions: Array<{ name: string; pid: number }>;
86
86
  unreadMailCount: number;
87
+ unreadMailScope: string;
87
88
  mergeQueueCount: number;
88
89
  recentMetricsCount: number;
89
90
  verboseDetails?: Record<string, VerboseAgentDetail>;
@@ -228,6 +229,7 @@ export async function gatherStatus(
228
229
  worktrees,
229
230
  tmuxSessions,
230
231
  unreadMailCount,
232
+ unreadMailScope: agentName,
231
233
  mergeQueueCount,
232
234
  recentMetricsCount,
233
235
  verboseDetails,
@@ -260,10 +262,14 @@ export function printStatus(data: StatusData): void {
260
262
  ? new Date(agent.lastActivity).getTime()
261
263
  : now;
262
264
  const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
265
+ // See dashboard.ts for the three-topology liveness rationale (overstory-7a34).
263
266
  const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
264
- const alive = isHeadless
265
- ? agent.pid !== null && isProcessAlive(agent.pid)
266
- : tmuxSessionNames.has(agent.tmuxSession);
267
+ const isSpawnPerTurn = agent.tmuxSession === "" && agent.pid === null;
268
+ const alive = isSpawnPerTurn
269
+ ? agent.state !== "zombie" && agent.state !== "completed"
270
+ : isHeadless
271
+ ? agent.pid !== null && isProcessAlive(agent.pid)
272
+ : tmuxSessionNames.has(agent.tmuxSession);
267
273
  const aliveMarker = alive ? color.green(">") : color.red("x");
268
274
  w(` ${aliveMarker} ${accent(agent.agentName)} [${agent.capability}] `);
269
275
  w(`${agent.state} | ${accent(agent.taskId)} | ${duration}\n`);
@@ -293,7 +299,9 @@ export function printStatus(data: StatusData): void {
293
299
  w("\n");
294
300
 
295
301
  // Mail
296
- w(`Mail: ${data.unreadMailCount} unread\n`);
302
+ // Scope is per-agent (the orchestrator by default). Differs from `ov mail list
303
+ // --unread` (system-wide) and `ov mail check` (per-agent, marks as read).
304
+ w(`Mail: ${data.unreadMailCount} unread (to ${data.unreadMailScope})\n`);
297
305
 
298
306
  // Merge queue
299
307
  w(`Merge queue: ${data.mergeQueueCount} pending\n`);
@@ -15,9 +15,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { mkdir, realpath } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
17
  import { AgentError, ValidationError } from "../errors.ts";
18
+ import { createMailClient } from "../mail/client.ts";
19
+ import { createMailStore } from "../mail/store.ts";
18
20
  import { openSessionStore } from "../sessions/compat.ts";
19
21
  import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
20
- import type { AgentSession } from "../types.ts";
22
+ import type { AgentSession, MergeReadyPayload } from "../types.ts";
21
23
  import { type StopDeps, stopCommand } from "./stop.ts";
22
24
 
23
25
  // --- Fake Git (for branch deletion) ---
@@ -472,6 +474,177 @@ describe("stopCommand stop behavior", () => {
472
474
  store.close();
473
475
  expect(updated?.state).toBe("completed");
474
476
  });
477
+
478
+ test("stopping a lead writes lead_completed pending-nudge for coordinator", async () => {
479
+ // Regression test for overstory-49a7:
480
+ // The lead_completed nudge now fires from `ov stop` (real completion signal),
481
+ // not from the per-turn Stop hook, which was spamming the coordinator.
482
+ const session = makeAgentSession({
483
+ agentName: "lead-alpha",
484
+ capability: "lead",
485
+ state: "working",
486
+ tmuxSession: "overstory-lead-alpha",
487
+ });
488
+ saveSessionsToDb([session]);
489
+
490
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
491
+ await stopCommand("lead-alpha", {}, deps);
492
+
493
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
494
+ const markerFile = Bun.file(markerPath);
495
+ expect(await markerFile.exists()).toBe(true);
496
+
497
+ const marker = JSON.parse(await markerFile.text());
498
+ expect(marker.from).toBe("lead-alpha");
499
+ expect(marker.reason).toBe("lead_completed");
500
+ expect(marker.subject).toContain("lead-alpha");
501
+ expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
502
+ expect(marker.createdAt).toBeDefined();
503
+ });
504
+
505
+ test("lead exiting without merge_ready gets 'no merge_ready sent' subject (overstory-41fe)", async () => {
506
+ const session = makeAgentSession({
507
+ agentName: "lead-beta",
508
+ capability: "lead",
509
+ state: "working",
510
+ tmuxSession: "overstory-lead-beta",
511
+ });
512
+ saveSessionsToDb([session]);
513
+
514
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
515
+ await stopCommand("lead-beta", {}, deps);
516
+
517
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
518
+ const marker = JSON.parse(await Bun.file(markerPath).text());
519
+ expect(marker.subject).toBe(
520
+ "Lead lead-beta exited — no merge_ready sent, needs coordinator follow-up",
521
+ );
522
+ });
523
+
524
+ test("lead with one merge_ready gets branch-specific subject (overstory-41fe)", async () => {
525
+ const session = makeAgentSession({
526
+ agentName: "lead-gamma",
527
+ capability: "lead",
528
+ state: "working",
529
+ tmuxSession: "overstory-lead-gamma",
530
+ });
531
+ saveSessionsToDb([session]);
532
+
533
+ // Seed mail.db with a merge_ready message from this lead
534
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
535
+ const mailClient = createMailClient(mailStore);
536
+ const payload: MergeReadyPayload = {
537
+ branch: "overstory/lead-gamma/bead-42",
538
+ taskId: "bead-42",
539
+ agentName: "lead-gamma",
540
+ filesModified: ["src/foo.ts"],
541
+ };
542
+ mailClient.sendProtocol({
543
+ from: "lead-gamma",
544
+ to: "coordinator",
545
+ subject: "merge_ready: bead-42",
546
+ body: "ready",
547
+ type: "merge_ready",
548
+ payload,
549
+ });
550
+ mailClient.close();
551
+
552
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
553
+ await stopCommand("lead-gamma", {}, deps);
554
+
555
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
556
+ const marker = JSON.parse(await Bun.file(markerPath).text());
557
+ expect(marker.subject).toBe(
558
+ "Lead lead-gamma sent merge_ready for branch overstory/lead-gamma/bead-42",
559
+ );
560
+ });
561
+
562
+ test("lead with multiple merge_ready messages lists all unique branches (overstory-41fe)", async () => {
563
+ const session = makeAgentSession({
564
+ agentName: "lead-delta",
565
+ capability: "lead",
566
+ state: "working",
567
+ tmuxSession: "overstory-lead-delta",
568
+ });
569
+ saveSessionsToDb([session]);
570
+
571
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
572
+ const mailClient = createMailClient(mailStore);
573
+ for (const branch of ["overstory/worker-a/t1", "overstory/worker-b/t2"]) {
574
+ mailClient.sendProtocol({
575
+ from: "lead-delta",
576
+ to: "coordinator",
577
+ subject: `merge_ready: ${branch}`,
578
+ body: "ready",
579
+ type: "merge_ready",
580
+ payload: {
581
+ branch,
582
+ taskId: branch.split("/")[2] ?? "unknown",
583
+ agentName: "lead-delta",
584
+ filesModified: [],
585
+ },
586
+ });
587
+ }
588
+ mailClient.close();
589
+
590
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
591
+ await stopCommand("lead-delta", {}, deps);
592
+
593
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
594
+ const marker = JSON.parse(await Bun.file(markerPath).text());
595
+ expect(marker.subject).toContain("Lead lead-delta sent 2 merge_ready");
596
+ expect(marker.subject).toContain("overstory/worker-a/t1");
597
+ expect(marker.subject).toContain("overstory/worker-b/t2");
598
+ });
599
+
600
+ test("merge_ready messages from other agents do not influence the subject (overstory-41fe)", async () => {
601
+ const session = makeAgentSession({
602
+ agentName: "lead-eps",
603
+ capability: "lead",
604
+ state: "working",
605
+ tmuxSession: "overstory-lead-eps",
606
+ });
607
+ saveSessionsToDb([session]);
608
+
609
+ // A *different* lead has merge_ready in the same mail.db — should be ignored
610
+ const mailStore = createMailStore(join(overstoryDir, "mail.db"));
611
+ const mailClient = createMailClient(mailStore);
612
+ mailClient.sendProtocol({
613
+ from: "some-other-lead",
614
+ to: "coordinator",
615
+ subject: "merge_ready: x",
616
+ body: "ready",
617
+ type: "merge_ready",
618
+ payload: {
619
+ branch: "overstory/other/x",
620
+ taskId: "x",
621
+ agentName: "some-other-lead",
622
+ filesModified: [],
623
+ },
624
+ });
625
+ mailClient.close();
626
+
627
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
628
+ await stopCommand("lead-eps", {}, deps);
629
+
630
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
631
+ const marker = JSON.parse(await Bun.file(markerPath).text());
632
+ expect(marker.subject).toBe(
633
+ "Lead lead-eps exited — no merge_ready sent, needs coordinator follow-up",
634
+ );
635
+ });
636
+
637
+ test("stopping a non-lead agent does NOT write lead_completed pending-nudge", async () => {
638
+ const session = makeAgentSession({ state: "working", capability: "builder" });
639
+ saveSessionsToDb([session]);
640
+
641
+ const { deps } = makeDeps({ [session.tmuxSession]: true });
642
+ await stopCommand("my-builder", {}, deps);
643
+
644
+ const markerPath = join(overstoryDir, "pending-nudges", "coordinator.json");
645
+ const markerFile = Bun.file(markerPath);
646
+ expect(await markerFile.exists()).toBe(false);
647
+ });
475
648
  });
476
649
 
477
650
  describe("stopCommand --json output", () => {