@os-eco/overstory-cli 0.9.4 → 0.11.0

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 (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  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 +219 -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/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -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,75 @@ export interface SlingOptions {
156
156
  noScoutCheck?: boolean;
157
157
  baseBranch?: string;
158
158
  profile?: string;
159
+ headless?: boolean;
160
+ recover?: boolean;
161
+ /**
162
+ * Comma-separated list of sibling agent names dispatched in parallel that
163
+ * may share file scope with this agent (overstory-f76a). Plumbed through
164
+ * to `OverlayConfig.siblings` so the overlay renders rebase-before-merge_ready
165
+ * guidance.
166
+ */
167
+ siblings?: string;
168
+ }
169
+
170
+ /**
171
+ * Parse the `--siblings <names>` argument into a normalized string array.
172
+ * Trims whitespace, drops empty entries. Empty / undefined input → `[]`.
173
+ *
174
+ * Exported for unit-testing.
175
+ */
176
+ export function parseSiblings(raw: string | undefined): string[] {
177
+ if (!raw) return [];
178
+ return raw
179
+ .split(",")
180
+ .map((s) => s.trim())
181
+ .filter((s) => s.length > 0);
182
+ }
183
+
184
+ const WORKABLE_STATUSES = ["open", "in_progress"] as const;
185
+
186
+ /**
187
+ * Decide whether a task with the given tracker status can accept a fresh
188
+ * sling. Normal dispatch requires an `open` or `in_progress` task; passing
189
+ * `recover` accepts any status so a coordinator can re-dispatch against a
190
+ * task whose previous owner exited (e.g. closed by a dead lead). (overstory-629f)
191
+ */
192
+ export function isTaskWorkable(status: string, recover: boolean): boolean {
193
+ if (recover) return true;
194
+ return (WORKABLE_STATUSES as readonly string[]).includes(status);
195
+ }
196
+
197
+ /**
198
+ * Resolve the effective `parentAgent` for a sling invocation, preserving the
199
+ * prior session's link on a re-spawn (`--recover`) when `--parent` was not
200
+ * explicitly passed.
201
+ *
202
+ * Pre-fix, sling always read `opts.parent ?? null` and upserted that into the
203
+ * session row, overwriting the prior `parent_agent` with null whenever a
204
+ * coordinator/lead invoked `ov sling --recover --name <existing>` without
205
+ * threading `--parent`. The runner then read `parentAgent === null` and
206
+ * skipped its in-band `worker_died` notify on a resumed-turn parser stall —
207
+ * the lead waited forever on a signal that never came (overstory-de3c).
208
+ *
209
+ * Resolution rules:
210
+ * - **Explicit caller intent wins.** If `opts.parent` is defined (including
211
+ * an empty string), use it verbatim. The caller may legitimately want to
212
+ * change or clear the parent on re-spawn.
213
+ * - **Caller silence preserves linkage.** If `opts.parent` is undefined and
214
+ * a prior session row exists with a non-null `parentAgent`, fall back to
215
+ * the prior value. Otherwise return null.
216
+ *
217
+ * Pure function so the regression test in `sling.test.ts` can assert behavior
218
+ * without spinning up the full sling command pipeline.
219
+ */
220
+ export function resolveParentAgent(
221
+ optsParent: string | undefined,
222
+ existingSession: { parentAgent: string | null } | null,
223
+ ): string | null {
224
+ if (optsParent !== undefined) {
225
+ return optsParent;
226
+ }
227
+ return existingSession?.parentAgent ?? null;
159
228
  }
160
229
 
161
230
  export interface AutoDispatchOptions {
@@ -164,6 +233,12 @@ export interface AutoDispatchOptions {
164
233
  capability: string;
165
234
  specPath: string | null;
166
235
  parentAgent: string | null;
236
+ /**
237
+ * The agent who invoked `ov sling` (from `OVERSTORY_AGENT_NAME` env var);
238
+ * takes precedence over `parentAgent` for the mail `from` field, since
239
+ * `--parent` describes the new agent's hierarchical parent, not the slinger.
240
+ */
241
+ slingerName: string | null;
167
242
  instructionPath: string;
168
243
  }
169
244
 
@@ -180,7 +255,7 @@ export function buildAutoDispatch(opts: AutoDispatchOptions): {
180
255
  subject: string;
181
256
  body: string;
182
257
  } {
183
- const from = opts.parentAgent ?? "orchestrator";
258
+ const from = opts.slingerName ?? opts.parentAgent ?? "orchestrator";
184
259
  const specLine = opts.specPath
185
260
  ? `Spec file: ${opts.specPath}`
186
261
  : "No spec file provided. Check your overlay for task details.";
@@ -465,6 +540,44 @@ export async function getCurrentBranch(repoRoot: string): Promise<string | null>
465
540
  return branch;
466
541
  }
467
542
 
543
+ /**
544
+ * Resolve whether to use the headless spawn path for a given runtime + flags + config.
545
+ *
546
+ * Precedence (highest first):
547
+ * 1. runtime.headless === true (statically headless runtimes always use headless)
548
+ * 2. Explicit --headless / --no-headless flag (boolean | undefined from commander)
549
+ * 3. config.runtime.claudeHeadlessByDefault (only applies when runtime.id === "claude")
550
+ * 4. Default: false (tmux)
551
+ *
552
+ * Throws ValidationError when --headless is explicitly true but the runtime has no
553
+ * buildDirectSpawn implementation.
554
+ */
555
+ export function resolveUseHeadless(
556
+ runtime: { id: string; headless?: boolean; buildDirectSpawn?: unknown },
557
+ flag: boolean | undefined,
558
+ config: OverstoryConfig,
559
+ ): boolean {
560
+ if (runtime.headless === true) return true;
561
+
562
+ if (flag === true) {
563
+ if (typeof runtime.buildDirectSpawn !== "function") {
564
+ throw new ValidationError(
565
+ `--headless requires a runtime with headless support. Runtime "${runtime.id}" does not implement buildDirectSpawn.`,
566
+ { field: "headless", value: true },
567
+ );
568
+ }
569
+ return true;
570
+ }
571
+ if (flag === false) return false;
572
+
573
+ if (runtime.id === "claude" && config.runtime?.claudeHeadlessByDefault === true) {
574
+ if (typeof runtime.buildDirectSpawn !== "function") return false;
575
+ return true;
576
+ }
577
+
578
+ return false;
579
+ }
580
+
468
581
  /**
469
582
  * Entry point for `ov sling <task-id> [flags]`.
470
583
  *
@@ -484,12 +597,15 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
484
597
  let name = nameWasAutoGenerated ? `${capability}-${taskId}` : rawName;
485
598
  const specPath = opts.spec ?? null;
486
599
  const filesRaw = opts.files;
487
- const parentAgent = opts.parent ?? null;
600
+ // Reassigned later when re-spawning an existing agent to preserve the prior
601
+ // row's parentAgent — see overstory-de3c at the existingSession lookup below.
602
+ let parentAgent = opts.parent ?? null;
488
603
  const depthStr = opts.depth;
489
604
  const depth = depthStr !== undefined ? Number.parseInt(depthStr, 10) : 0;
490
605
  const forceHierarchy = opts.forceHierarchy ?? false;
491
606
  const skipScout = opts.skipScout ?? false;
492
607
  const skipTaskCheck = opts.skipTaskCheck ?? false;
608
+ const recover = opts.recover ?? false;
493
609
 
494
610
  if (Number.isNaN(depth) || depth < 0) {
495
611
  throw new ValidationError("--depth must be a non-negative integer", {
@@ -560,6 +676,8 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
560
676
  .filter((f) => f.length > 0)
561
677
  : [];
562
678
 
679
+ const siblings = parseSiblings(opts.siblings);
680
+
563
681
  // 1. Load config
564
682
  const cwd = process.cwd();
565
683
  const config = await loadConfig(cwd);
@@ -661,18 +779,35 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
661
779
  );
662
780
  }
663
781
 
782
+ // Track the prior session row when re-spawning against an existing agent
783
+ // name so downstream code can preserve linkage (parentAgent, claudeSessionId)
784
+ // that the upsert would otherwise erase. Auto-generated names are unique
785
+ // so there is never a prior row to preserve.
786
+ let existingSession: AgentSession | null = null;
664
787
  if (nameWasAutoGenerated) {
665
788
  const takenNames = activeSessions.map((s) => s.agentName);
666
789
  name = generateAgentName(capability, taskId, takenNames);
667
790
  } else {
668
- const existing = store.getByName(name);
669
- if (existing && existing.state !== "zombie" && existing.state !== "completed") {
670
- throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
671
- agentName: name,
672
- });
791
+ existingSession = store.getByName(name);
792
+ if (
793
+ existingSession &&
794
+ existingSession.state !== "zombie" &&
795
+ existingSession.state !== "completed"
796
+ ) {
797
+ throw new AgentError(
798
+ `Agent name "${name}" is already in use (state: ${existingSession.state})`,
799
+ {
800
+ agentName: name,
801
+ },
802
+ );
673
803
  }
674
804
  }
675
805
 
806
+ // Preserve the prior session's parentAgent on re-spawn when --parent was
807
+ // not explicitly passed (overstory-de3c). See `resolveParentAgent` for the
808
+ // full rationale and resolution rules.
809
+ parentAgent = resolveParentAgent(opts.parent, existingSession);
810
+
676
811
  // 5d. Task-level locking: prevent concurrent agents on the same task ID.
677
812
  // Exception: the parent agent may delegate its own task to a child.
678
813
  const lockHolder = checkTaskLock(activeSessions, taskId);
@@ -740,13 +875,17 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
740
875
  });
741
876
  }
742
877
 
743
- const workableStatuses = ["open", "in_progress"];
744
- if (!workableStatuses.includes(issue.status)) {
878
+ if (!isTaskWorkable(issue.status, recover)) {
745
879
  throw new ValidationError(
746
- `Task "${taskId}" is not workable (status: ${issue.status}). Only open or in_progress issues can be assigned.`,
880
+ `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
881
  { field: "taskId", value: taskId },
748
882
  );
749
883
  }
884
+ if (recover && !(WORKABLE_STATUSES as readonly string[]).includes(issue.status)) {
885
+ process.stderr.write(
886
+ `Warning: --recover dispatching against task "${taskId}" with status "${issue.status}". Previous owner may have exited unexpectedly.\n`,
887
+ );
888
+ }
750
889
  }
751
890
 
752
891
  // 7. Create worktree
@@ -839,6 +978,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
839
978
  trackerCli: trackerCliName(resolvedBackend),
840
979
  trackerName: resolvedBackend,
841
980
  instructionPath: runtime.instructionPath,
981
+ siblings,
842
982
  };
843
983
 
844
984
  await writeOverlay(worktreePath, overlayConfig, config.project.root, runtime.instructionPath);
@@ -846,22 +986,32 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
846
986
  // 9. Resolve runtime + model (needed for deployConfig, spawn, and beacon)
847
987
  const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
848
988
 
849
- // 9a. Deploy hooks config (capability-specific guards)
989
+ // 9a. Resolve headless mode before deployConfig so hooks can be skipped for headless agents.
990
+ // resolveUseHeadless is also used at 11c for spawn routing — hoisted here to share the value.
991
+ const useHeadless = resolveUseHeadless(runtime, opts.headless, config);
992
+
993
+ // 9b. Deploy hooks config (capability-specific guards). In headless mode we deploy
994
+ // a PreToolUse-only subset (security guards) — overstory-e24b. Headless Claude Code
995
+ // dispatches settings.local.json hooks, so dropping them would leave destructive
996
+ // commands unblocked.
850
997
  await runtime.deployConfig(worktreePath, undefined, {
851
998
  agentName: name,
852
999
  capability,
853
1000
  worktreePath,
854
1001
  qualityGates: config.project.qualityGates,
1002
+ isHeadless: useHeadless,
855
1003
  });
856
1004
 
857
1005
  // 9b. Send auto-dispatch mail so it exists when SessionStart hook fires.
858
1006
  // This eliminates the race where coordinator sends dispatch AFTER agent boots.
1007
+ const slingerName = process.env.OVERSTORY_AGENT_NAME?.trim() || null;
859
1008
  const dispatch = buildAutoDispatch({
860
1009
  agentName: name,
861
1010
  taskId,
862
1011
  capability,
863
1012
  specPath: absoluteSpecPath,
864
1013
  parentAgent,
1014
+ slingerName,
865
1015
  instructionPath: runtime.instructionPath,
866
1016
  });
867
1017
  const mailStore = createMailStore(join(overstoryDir, "mail.db"));
@@ -919,42 +1069,51 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
919
1069
  }
920
1070
 
921
1071
  // 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
- });
1072
+ // useHeadless was resolved at step 9a (hoisted so deployConfig can skip hooks for headless).
1073
+ if (useHeadless && runtime.buildDirectSpawn) {
1074
+ // Phase 3 spawn-per-turn: headless agents have NO long-lived process.
1075
+ // sling builds the initial prompt, upserts the session row in
1076
+ // "booting", then drives the first user turn synchronously through
1077
+ // `runTurn`. The runner spawns claude with `--resume` (when a prior
1078
+ // session id exists), writes the prompt to a real stdin pipe, drains
1079
+ // stream-json, captures session id, transitions state to "working"
1080
+ // (or "completed" if terminal mail observed), and exits. No persistent
1081
+ // process remains after this returns; subsequent turns are driven by
1082
+ // `ov serve` (mail) or `ov nudge`.
1083
+ // `existingSession` was captured during the name-collision check (above).
1084
+ // Re-using it here keeps re-spawn linkage (parentAgent + claudeSessionId)
1085
+ // resolved from the same row.
1086
+ const priorClaudeSessionId = existingSession?.claudeSessionId ?? null;
1087
+
1088
+ // Build the initial prompt (mulch expertise + pending mail + beacon)
1089
+ // as the first user turn.
1090
+ const pendingMailStore = createMailStore(join(overstoryDir, "mail.db"));
1091
+ let initialPrompt: string;
1092
+ try {
1093
+ const pendingMailClient = createMailClient(pendingMailStore);
1094
+ const pendingMessages = pendingMailClient.check(name);
1095
+ const mailSection = formatMailSection(pendingMessages);
1096
+ const beacon = buildBeacon({
1097
+ agentName: name,
1098
+ capability,
1099
+ taskId,
1100
+ parentAgent,
1101
+ depth,
1102
+ instructionPath: runtime.instructionPath,
1103
+ });
1104
+ initialPrompt = buildInitialHeadlessPrompt(
1105
+ mulchExpertise,
1106
+ mailSection || undefined,
1107
+ beacon,
1108
+ );
1109
+ } finally {
1110
+ pendingMailStore.close();
1111
+ }
956
1112
 
957
- // 13. Record session with empty tmuxSession (no tmux pane for headless agents).
1113
+ // 13. Record session BEFORE runTurn so the runner reads it under its
1114
+ // lock. pid is null — there is no persistent process; the runner
1115
+ // publishes a per-turn PID via .overstory/agents/<name>/turn.pid for
1116
+ // the duration of each turn. Carry priorClaudeSessionId (mx-5c5ae6).
958
1117
  const session: AgentSession = {
959
1118
  id: `session-${Date.now()}-${name}`,
960
1119
  agentName: name,
@@ -964,7 +1123,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
964
1123
  taskId: taskId,
965
1124
  tmuxSession: "",
966
1125
  state: "booting",
967
- pid: headlessProc.pid,
1126
+ pid: null,
968
1127
  parentAgent: parentAgent,
969
1128
  depth,
970
1129
  runId,
@@ -973,15 +1132,28 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
973
1132
  escalationLevel: 0,
974
1133
  stalledSince: null,
975
1134
  transcriptPath: null,
1135
+ ...(priorClaudeSessionId !== null ? { claudeSessionId: priorClaudeSessionId } : {}),
976
1136
  };
977
1137
  store.upsert(session);
978
1138
 
979
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
980
- try {
981
- runStore.incrementAgentCount(runId);
982
- } finally {
983
- runStore.close();
984
- }
1139
+ // Drive the first user turn synchronously. runTurn manages spawn,
1140
+ // stdin write+EOF, event drain, session_id capture, terminal-mail
1141
+ // detection, and state transition.
1142
+ const turnResult = await runTurn({
1143
+ agentName: name,
1144
+ capability,
1145
+ overstoryDir,
1146
+ worktreePath,
1147
+ projectRoot: config.project.root,
1148
+ taskId,
1149
+ userTurnNdjson: initialPrompt,
1150
+ runtime,
1151
+ resolvedModel,
1152
+ runId,
1153
+ mailDbPath: join(overstoryDir, "mail.db"),
1154
+ eventsDbPath: join(overstoryDir, "events.db"),
1155
+ sessionsDbPath: join(overstoryDir, "sessions.db"),
1156
+ });
985
1157
 
986
1158
  // 14. Output result (headless)
987
1159
  if (opts.json ?? false) {
@@ -992,14 +1164,19 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
992
1164
  branch: branchName,
993
1165
  worktree: worktreePath,
994
1166
  tmuxSession: "",
995
- pid: headlessProc.pid,
1167
+ pid: null,
1168
+ initialTurnFinalState: turnResult.finalState,
1169
+ claudeSessionId: turnResult.newSessionId,
996
1170
  });
997
1171
  } 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`);
1172
+ printSuccess("Agent launched (headless, spawn-per-turn)", name);
1173
+ process.stdout.write(` Task: ${taskId}\n`);
1174
+ process.stdout.write(` Branch: ${branchName}\n`);
1175
+ process.stdout.write(` Worktree: ${worktreePath}\n`);
1176
+ process.stdout.write(` First-turn state: ${turnResult.finalState}\n`);
1177
+ if (turnResult.newSessionId) {
1178
+ process.stdout.write(` Claude session id: ${turnResult.newSessionId}\n`);
1179
+ }
1003
1180
  }
1004
1181
  } else {
1005
1182
  // 11c. Preflight: verify tmux is available before attempting session creation
@@ -1054,14 +1231,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1054
1231
 
1055
1232
  store.upsert(session);
1056
1233
 
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
1234
  // 13b. Give slow shells time to finish initializing before polling for TUI readiness.
1066
1235
  const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
1067
1236
  if (shellDelay > 0) {
@@ -1076,7 +1245,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
1076
1245
  );
1077
1246
  if (!tuiReady) {
1078
1247
  const alive = await isSessionAlive(tmuxSessionName);
1079
- store.updateState(name, "completed");
1248
+ // Mark as zombie (not completed) so the watchdog detects this failed
1249
+ // startup. 'completed' is a terminal success state that the watchdog
1250
+ // skips entirely (overstory-c40e).
1251
+ store.updateState(name, "zombie");
1080
1252
 
1081
1253
  if (alive) {
1082
1254
  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`);