@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
@@ -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,
@@ -48,6 +48,7 @@ import {
48
48
  ensureTmuxAvailable,
49
49
  isSessionAlive,
50
50
  killSession,
51
+ sanitizeTmuxName,
51
52
  sendKeys,
52
53
  waitForTuiReady,
53
54
  } from "../worktree/tmux.ts";
@@ -155,6 +156,21 @@ export interface SlingOptions {
155
156
  noScoutCheck?: boolean;
156
157
  baseBranch?: string;
157
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);
158
174
  }
159
175
 
160
176
  export interface AutoDispatchOptions {
@@ -464,6 +480,44 @@ export async function getCurrentBranch(repoRoot: string): Promise<string | null>
464
480
  return branch;
465
481
  }
466
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
+
467
521
  /**
468
522
  * Entry point for `ov sling <task-id> [flags]`.
469
523
  *
@@ -489,6 +543,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
489
543
  const forceHierarchy = opts.forceHierarchy ?? false;
490
544
  const skipScout = opts.skipScout ?? false;
491
545
  const skipTaskCheck = opts.skipTaskCheck ?? false;
546
+ const recover = opts.recover ?? false;
492
547
 
493
548
  if (Number.isNaN(depth) || depth < 0) {
494
549
  throw new ValidationError("--depth must be a non-negative integer", {
@@ -739,13 +794,17 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
739
794
  });
740
795
  }
741
796
 
742
- const workableStatuses = ["open", "in_progress"];
743
- if (!workableStatuses.includes(issue.status)) {
797
+ if (!isTaskWorkable(issue.status, recover)) {
744
798
  throw new ValidationError(
745
- `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.`,
746
800
  { field: "taskId", value: taskId },
747
801
  );
748
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
+ }
749
808
  }
750
809
 
751
810
  // 7. Create worktree
@@ -845,12 +904,20 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
845
904
  // 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
846
905
  const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
847
906
 
848
- // 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.
849
915
  await runtime.deployConfig(worktreePath, undefined, {
850
916
  agentName: name,
851
917
  capability,
852
918
  worktreePath,
853
919
  qualityGates: config.project.qualityGates,
920
+ isHeadless: useHeadless,
854
921
  });
855
922
 
856
923
  // 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
@@ -918,41 +985,48 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
918
985
  }
919
986
 
920
987
  // 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
921
- if (runtime.headless === true && runtime.buildDirectSpawn) {
922
- const directEnv = {
923
- ...runtime.buildEnv(resolvedModel),
924
- OVERSTORY_AGENT_NAME: name,
925
- OVERSTORY_WORKTREE_PATH: worktreePath,
926
- OVERSTORY_TASK_ID: taskId,
927
- };
928
- const argv = runtime.buildDirectSpawn({
929
- cwd: worktreePath,
930
- env: directEnv,
931
- ...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
932
- instructionPath: runtime.instructionPath,
933
- });
934
-
935
- // Create a timestamped log dir for this headless agent session.
936
- // Always redirect stdout to a file. This prevents SIGPIPE death:
937
- // ov sling exits after spawning, closing the pipe's read end.
938
- // If stdout is a pipe, the agent dies on the next write (SIGPIPE).
939
- // File writes have no such limit, and the agent survives the CLI exit.
940
- //
941
- // Note: RPC connection wiring is intentionally omitted here. The RPC pipe
942
- // is only useful when the spawner stays alive to consume it. ov sling is
943
- // a short-lived CLI — any connection created here dies with the process.
944
- const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
945
- const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
946
- mkdirSync(agentLogDir, { recursive: true });
947
-
948
- const headlessProc = await spawnHeadlessAgent(argv, {
949
- cwd: worktreePath,
950
- env: { ...(process.env as Record<string, string>), ...directEnv },
951
- stdoutFile: join(agentLogDir, "stdout.log"),
952
- stderrFile: join(agentLogDir, "stderr.log"),
953
- });
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
+ }
954
1025
 
955
- // 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).
956
1030
  const session: AgentSession = {
957
1031
  id: `session-${Date.now()}-${name}`,
958
1032
  agentName: name,
@@ -962,7 +1036,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
962
1036
  taskId: taskId,
963
1037
  tmuxSession: "",
964
1038
  state: "booting",
965
- pid: headlessProc.pid,
1039
+ pid: null,
966
1040
  parentAgent: parentAgent,
967
1041
  depth,
968
1042
  runId,
@@ -971,15 +1045,28 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
971
1045
  escalationLevel: 0,
972
1046
  stalledSince: null,
973
1047
  transcriptPath: null,
1048
+ ...(priorClaudeSessionId !== null ? { claudeSessionId: priorClaudeSessionId } : {}),
974
1049
  };
975
1050
  store.upsert(session);
976
1051
 
977
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
978
- try {
979
- runStore.incrementAgentCount(runId);
980
- } finally {
981
- runStore.close();
982
- }
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
+ });
983
1070
 
984
1071
  // 14. Output result (headless)
985
1072
  if (opts.json ?? false) {
@@ -990,21 +1077,26 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
990
1077
  branch: branchName,
991
1078
  worktree: worktreePath,
992
1079
  tmuxSession: "",
993
- pid: headlessProc.pid,
1080
+ pid: null,
1081
+ initialTurnFinalState: turnResult.finalState,
1082
+ claudeSessionId: turnResult.newSessionId,
994
1083
  });
995
1084
  } else {
996
- printSuccess("Agent launched (headless)", name);
997
- process.stdout.write(` Task: ${taskId}\n`);
998
- process.stdout.write(` Branch: ${branchName}\n`);
999
- process.stdout.write(` Worktree: ${worktreePath}\n`);
1000
- 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
+ }
1001
1093
  }
1002
1094
  } else {
1003
1095
  // 11c. Preflight: verify tmux is available before attempting session creation
1004
1096
  await ensureTmuxAvailable();
1005
1097
 
1006
1098
  // 12. Create tmux session running claude in interactive mode
1007
- const tmuxSessionName = `overstory-${config.project.name}-${name}`;
1099
+ const tmuxSessionName = `overstory-${sanitizeTmuxName(config.project.name)}-${name}`;
1008
1100
  const spawnCmd = runtime.buildSpawnCommand({
1009
1101
  model: resolvedModel.model,
1010
1102
  permissionMode: "bypass",
@@ -1015,6 +1107,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1015
1107
  OVERSTORY_AGENT_NAME: name,
1016
1108
  OVERSTORY_WORKTREE_PATH: worktreePath,
1017
1109
  OVERSTORY_TASK_ID: taskId,
1110
+ OVERSTORY_PROJECT_ROOT: config.project.root,
1018
1111
  },
1019
1112
  });
1020
1113
  const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
@@ -1022,6 +1115,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1022
1115
  OVERSTORY_AGENT_NAME: name,
1023
1116
  OVERSTORY_WORKTREE_PATH: worktreePath,
1024
1117
  OVERSTORY_TASK_ID: taskId,
1118
+ OVERSTORY_PROJECT_ROOT: config.project.root,
1025
1119
  });
1026
1120
 
1027
1121
  // 13. Record session BEFORE sending the beacon so that hook-triggered
@@ -1050,14 +1144,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1050
1144
 
1051
1145
  store.upsert(session);
1052
1146
 
1053
- // Increment agent count for the run
1054
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
1055
- try {
1056
- runStore.incrementAgentCount(runId);
1057
- } finally {
1058
- runStore.close();
1059
- }
1060
-
1061
1147
  // 13b. Give slow shells time to finish initializing before polling for TUI readiness.
1062
1148
  const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
1063
1149
  if (shellDelay > 0) {
@@ -1072,7 +1158,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1072
1158
  );
1073
1159
  if (!tuiReady) {
1074
1160
  const alive = await isSessionAlive(tmuxSessionName);
1075
- 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");
1076
1165
 
1077
1166
  if (alive) {
1078
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", () => {