@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
@@ -11,19 +11,75 @@
11
11
 
12
12
  import { join } from "node:path";
13
13
  import { Command } from "commander";
14
+ import { encodeUserTurn } from "../agents/headless-prompt.ts";
15
+ import { createManifestLoader } from "../agents/manifest.ts";
16
+ import { type RunTurnOpts, runTurn, type TurnResult } from "../agents/turn-runner.ts";
17
+ import { buildRunTurnOptsFactory, isSpawnPerTurnAgent } from "../agents/turn-runner-dispatch.ts";
18
+ import { loadConfig } from "../config.ts";
14
19
  import { AgentError } from "../errors.ts";
15
20
  import { createEventStore } from "../events/store.ts";
16
21
  import { jsonOutput } from "../json.ts";
17
22
  import { printSuccess } from "../logging/color.ts";
23
+ import { getConnection } from "../runtimes/connections.ts";
24
+ import { hasNudge } from "../runtimes/headless-connection.ts";
18
25
  import { openSessionStore } from "../sessions/compat.ts";
19
- import type { EventStore } from "../types.ts";
20
- import { isSessionAlive, sendKeys } from "../worktree/tmux.ts";
26
+ import type { AgentSession, EventStore } from "../types.ts";
27
+ import { capturePaneContent, isSessionAlive, sendKeys } from "../worktree/tmux.ts";
21
28
 
22
29
  const DEFAULT_MESSAGE = "Check your mail inbox for new messages.";
23
30
  const MAX_RETRIES = 3;
24
31
  const RETRY_DELAY_MS = 500;
25
32
  const DEBOUNCE_MS = 500;
26
33
 
34
+ /**
35
+ * Maximum total time (ms) to wait for a busy pane to become idle before
36
+ * giving up and reporting the nudge as deferred. Sized to ride out short
37
+ * tool calls without blocking long-running thinks.
38
+ */
39
+ const IDLE_WAIT_MS = 3000;
40
+ const IDLE_POLL_INTERVAL_MS = 250;
41
+
42
+ /**
43
+ * Heuristic: does the captured pane content indicate the agent is mid-think?
44
+ *
45
+ * Claude Code's TUI shows "esc to interrupt" alongside the streaming token
46
+ * counter while a turn is in flight. The phrase is absent in idle state, when
47
+ * tool output is rendered, and on the trust dialog — so its presence is a
48
+ * reliable busy signal. Returns true when the agent appears busy and a nudge
49
+ * sent via tmux send-keys would be queued into the in-flight prompt instead
50
+ * of starting a fresh user turn. (overstory-8ff4)
51
+ */
52
+ export function paneAppearsBusy(paneContent: string): boolean {
53
+ return paneContent.includes("esc to interrupt");
54
+ }
55
+
56
+ /**
57
+ * Wait briefly for a tmux pane to leave the mid-think state.
58
+ *
59
+ * Polls capture-pane until the busy heuristic clears or the deadline elapses.
60
+ * Returns true if the pane became idle, false if it remained busy or pane
61
+ * capture failed throughout. Capture failures count as "not idle" so the
62
+ * caller defers the nudge rather than blasting send-keys into an unknown
63
+ * state. (overstory-8ff4)
64
+ */
65
+ async function waitForPaneIdle(
66
+ tmuxSession: string,
67
+ maxWaitMs: number = IDLE_WAIT_MS,
68
+ pollIntervalMs: number = IDLE_POLL_INTERVAL_MS,
69
+ ): Promise<boolean> {
70
+ const deadline = Date.now() + maxWaitMs;
71
+ while (true) {
72
+ const content = await capturePaneContent(tmuxSession, 20);
73
+ if (content !== null && !paneAppearsBusy(content)) {
74
+ return true;
75
+ }
76
+ if (Date.now() >= deadline) {
77
+ return false;
78
+ }
79
+ await Bun.sleep(pollIntervalMs);
80
+ }
81
+ }
82
+
27
83
  /**
28
84
  * Load the orchestrator's registered tmux session name.
29
85
  *
@@ -52,17 +108,35 @@ async function loadOrchestratorTmuxSession(projectRoot: string): Promise<string
52
108
  * For regular agents, looks up the SessionStore.
53
109
  * For "orchestrator", falls back to the orchestrator-tmux.json registration
54
110
  * file written by `overstory prime`.
111
+ *
112
+ * Returns the tmux session name on success, or a structured `null` result that
113
+ * captures the terminal-state diagnosis so callers can surface a helpful
114
+ * recovery hint instead of a generic "no active session" error (overstory-629f).
55
115
  */
116
+ type ResolveTargetResult =
117
+ | { kind: "found"; tmuxSession: string }
118
+ | { kind: "missing" }
119
+ | { kind: "terminal"; state: "completed" | "zombie"; capability: string; taskId: string };
120
+
56
121
  async function resolveTargetSession(
57
122
  projectRoot: string,
58
123
  agentName: string,
59
- ): Promise<string | null> {
124
+ ): Promise<ResolveTargetResult> {
60
125
  const overstoryDir = join(projectRoot, ".overstory");
61
126
  const { store } = openSessionStore(overstoryDir);
127
+ let terminal: ResolveTargetResult | null = null;
62
128
  try {
63
129
  const session = store.getByName(agentName);
64
- if (session && session.state !== "zombie" && session.state !== "completed") {
65
- return session.tmuxSession;
130
+ if (session) {
131
+ if (session.state !== "zombie" && session.state !== "completed") {
132
+ return { kind: "found", tmuxSession: session.tmuxSession };
133
+ }
134
+ terminal = {
135
+ kind: "terminal",
136
+ state: session.state,
137
+ capability: session.capability,
138
+ taskId: session.taskId,
139
+ };
66
140
  }
67
141
  } finally {
68
142
  store.close();
@@ -70,10 +144,32 @@ async function resolveTargetSession(
70
144
 
71
145
  // Fallback for orchestrator: check orchestrator-tmux.json
72
146
  if (agentName === "orchestrator") {
73
- return await loadOrchestratorTmuxSession(projectRoot);
147
+ const orchestratorTmux = await loadOrchestratorTmuxSession(projectRoot);
148
+ if (orchestratorTmux !== null) {
149
+ return { kind: "found", tmuxSession: orchestratorTmux };
150
+ }
74
151
  }
75
152
 
76
- return null;
153
+ return terminal ?? { kind: "missing" };
154
+ }
155
+
156
+ /**
157
+ * Build the operator-facing failure reason when a nudge cannot find a live
158
+ * session. Terminal-state agents get a recovery hint pointing at
159
+ * `ov sling --recover`; missing agents keep the generic message. (overstory-629f)
160
+ */
161
+ export function buildMissingSessionReason(
162
+ agentName: string,
163
+ resolution: ResolveTargetResult,
164
+ ): string {
165
+ if (resolution.kind === "terminal") {
166
+ return (
167
+ `No active session for agent "${agentName}" (state: ${resolution.state}). ` +
168
+ `The agent has exited; re-dispatch with ` +
169
+ `'ov sling ${resolution.taskId} --capability ${resolution.capability} --recover'.`
170
+ );
171
+ }
172
+ return `No active session for agent "${agentName}"`;
77
173
  }
78
174
 
79
175
  /**
@@ -116,14 +212,32 @@ async function recordNudge(statePath: string, agentName: string): Promise<void>
116
212
  await Bun.write(statePath, `${JSON.stringify(state, null, "\t")}\n`);
117
213
  }
118
214
 
215
+ /** Outcome of a tmux nudge attempt. */
216
+ type SendNudgeResult =
217
+ | { kind: "delivered" }
218
+ | { kind: "deferred"; reason: string }
219
+ | { kind: "failed" };
220
+
119
221
  /**
120
222
  * Send a nudge to an agent's tmux session with retry logic.
121
223
  *
122
224
  * @param tmuxSession - The tmux session name
123
225
  * @param message - The text to send
124
- * @returns true if the nudge was delivered, false if all retries failed
226
+ * @returns delivered on success, deferred when the agent stays mid-think
227
+ * beyond the idle window, failed when send-keys exhausts retries.
125
228
  */
126
- async function sendNudgeWithRetry(tmuxSession: string, message: string): Promise<boolean> {
229
+ async function sendNudgeWithRetry(tmuxSession: string, message: string): Promise<SendNudgeResult> {
230
+ // Guard: never send-keys into a mid-think pane. Without this check, the
231
+ // nudge text is queued as input and corrupts the in-flight prompt.
232
+ // (overstory-8ff4)
233
+ const idle = await waitForPaneIdle(tmuxSession);
234
+ if (!idle) {
235
+ return {
236
+ kind: "deferred",
237
+ reason: "Agent is mid-think (esc-to-interrupt visible) — nudge deferred",
238
+ };
239
+ }
240
+
127
241
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
128
242
  try {
129
243
  await sendKeys(tmuxSession, message);
@@ -133,14 +247,14 @@ async function sendNudgeWithRetry(tmuxSession: string, message: string): Promise
133
247
  // Same workaround as sling.ts and coordinator.ts.
134
248
  await Bun.sleep(500);
135
249
  await sendKeys(tmuxSession, "");
136
- return true;
250
+ return { kind: "delivered" };
137
251
  } catch {
138
252
  if (attempt < MAX_RETRIES) {
139
253
  await Bun.sleep(RETRY_DELAY_MS);
140
254
  }
141
255
  }
142
256
  }
143
- return false;
257
+ return { kind: "failed" };
144
258
  }
145
259
 
146
260
  /**
@@ -196,39 +310,238 @@ function recordNudgeEvent(
196
310
  }
197
311
  }
198
312
 
313
+ /** Test-only injection point for the spawn-per-turn dispatch path. */
314
+ export interface NudgeAgentDeps {
315
+ _runTurnFn?: (opts: RunTurnOpts) => Promise<TurnResult>;
316
+ _loadConfig?: typeof loadConfig;
317
+ }
318
+
319
+ /**
320
+ * Look up the agent's session row. Returns null when missing or terminal.
321
+ * Terminal sessions are filtered here so the spawn-per-turn dispatch path
322
+ * never re-spawns a completed builder.
323
+ */
324
+ function loadActiveSessionForNudge(projectRoot: string, agentName: string): AgentSession | null {
325
+ const overstoryDir = join(projectRoot, ".overstory");
326
+ try {
327
+ const { store } = openSessionStore(overstoryDir);
328
+ try {
329
+ const session = store.getByName(agentName);
330
+ if (!session) return null;
331
+ if (session.state === "completed" || session.state === "zombie") return null;
332
+ return session;
333
+ } finally {
334
+ store.close();
335
+ }
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ /** Best-effort: insert a nudge event into events.db. Never throws. */
342
+ function recordNudgeEventBestEffort(
343
+ overstoryDir: string,
344
+ agentName: string,
345
+ message: string,
346
+ delivered: boolean,
347
+ ): void {
348
+ try {
349
+ const eventsDbPath = join(overstoryDir, "events.db");
350
+ const eventStore = createEventStore(eventsDbPath);
351
+ try {
352
+ void readCurrentRunId(overstoryDir).then((runId) => {
353
+ try {
354
+ recordNudgeEvent(eventStore, {
355
+ runId,
356
+ agentName,
357
+ from: "orchestrator",
358
+ message,
359
+ delivered,
360
+ });
361
+ } finally {
362
+ try {
363
+ eventStore.close();
364
+ } catch {
365
+ // already closed
366
+ }
367
+ }
368
+ });
369
+ } catch {
370
+ try {
371
+ eventStore.close();
372
+ } catch {
373
+ // already closed
374
+ }
375
+ }
376
+ } catch {
377
+ // non-fatal
378
+ }
379
+ }
380
+
381
+ interface TryNudgeViaTurnRunnerInput {
382
+ agentName: string;
383
+ message: string;
384
+ overstoryDir: string;
385
+ projectRoot: string;
386
+ statePath: string;
387
+ deps: NudgeAgentDeps;
388
+ }
389
+
390
+ /**
391
+ * If the target agent is a Phase 2 spawn-per-turn builder, deliver `message`
392
+ * as a single user turn through `runTurn` and return the delivery result.
393
+ *
394
+ * Returns `null` when the agent is not eligible (flag off, non-builder,
395
+ * terminal state, missing session, runtime cannot direct-spawn). The caller
396
+ * falls back to the legacy FIFO/connection/tmux paths.
397
+ *
398
+ * The runTurn call is awaited synchronously: that lets the in-process
399
+ * turn-lock serialize against the mail dispatcher running in `ov serve`.
400
+ * Failures throw — the caller treats them as a delivery error.
401
+ */
402
+ async function tryNudgeViaTurnRunner(
403
+ input: TryNudgeViaTurnRunnerInput,
404
+ ): Promise<{ delivered: boolean; queued?: boolean; reason?: string } | null> {
405
+ const session = loadActiveSessionForNudge(input.projectRoot, input.agentName);
406
+ if (!session) return null;
407
+
408
+ const _load = input.deps._loadConfig ?? loadConfig;
409
+ let config: Awaited<ReturnType<typeof loadConfig>>;
410
+ try {
411
+ config = await _load(input.projectRoot);
412
+ } catch {
413
+ return null;
414
+ }
415
+
416
+ const manifestLoader = createManifestLoader(
417
+ join(config.project.root, config.agents.manifestPath),
418
+ join(config.project.root, config.agents.baseDir),
419
+ );
420
+ let manifest: Awaited<ReturnType<typeof manifestLoader.load>>;
421
+ try {
422
+ manifest = await manifestLoader.load();
423
+ } catch {
424
+ return null;
425
+ }
426
+
427
+ let factory: ReturnType<typeof buildRunTurnOptsFactory>;
428
+ try {
429
+ factory = buildRunTurnOptsFactory({
430
+ session,
431
+ config,
432
+ manifest,
433
+ overstoryDir: input.overstoryDir,
434
+ });
435
+ } catch {
436
+ return null;
437
+ }
438
+
439
+ if (!isSpawnPerTurnAgent(session, config, factory.runtime)) return null;
440
+
441
+ const runTurnFn = input.deps._runTurnFn ?? runTurn;
442
+ const opts = factory.build(encodeUserTurn(input.message));
443
+
444
+ try {
445
+ const result = await runTurnFn(opts);
446
+ await recordNudge(input.statePath, input.agentName);
447
+ // Mirror the FIFO branch's queued semantics: the message has been
448
+ // consumed by claude inside this turn, but follow-up turns may still
449
+ // observe it as "queued" if the agent didn't act on it immediately.
450
+ return {
451
+ delivered: true,
452
+ queued: result.cleanResult !== true,
453
+ };
454
+ } catch (err) {
455
+ return {
456
+ delivered: false,
457
+ reason: `Spawn-per-turn dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
458
+ };
459
+ }
460
+ }
461
+
199
462
  /**
200
463
  * Core nudge function. Exported for use by mail send auto-nudge.
201
464
  *
465
+ * Routes through the registered RuntimeConnection when available (headless agents),
466
+ * or falls back to the tmux send-keys path (interactive agents).
467
+ *
468
+ * Headless nudges return queued=true because Claude Code does not reliably poll
469
+ * stdin while an API stream is in flight — the message sits in the pipe buffer
470
+ * until the current turn completes.
471
+ *
472
+ * For task-scoped headless Claude (Phase 3 spawn-per-turn), the nudge becomes
473
+ * a single user-turn delivered via `runTurn`. The call awaits the turn
474
+ * synchronously so the in-process turn-lock can serialize against concurrent
475
+ * mail dispatchers.
476
+ *
202
477
  * @param projectRoot - Absolute path to the project root
203
478
  * @param agentName - Name of the agent to nudge
204
479
  * @param message - Text to send (defaults to mail check prompt)
205
480
  * @param force - Skip debounce check
206
- * @returns Object with delivery status
481
+ * @returns Object with delivery status; queued=true when headless and buffered
207
482
  */
208
483
  export async function nudgeAgent(
209
484
  projectRoot: string,
210
485
  agentName: string,
211
486
  message: string = DEFAULT_MESSAGE,
212
487
  force = false,
213
- ): Promise<{ delivered: boolean; reason?: string }> {
214
- let result: { delivered: boolean; reason?: string };
488
+ deps: NudgeAgentDeps = {},
489
+ ): Promise<{ delivered: boolean; queued?: boolean; reason?: string }> {
490
+ let result: { delivered: boolean; queued?: boolean; reason?: string } | undefined;
215
491
 
216
- // Resolve tmux session (SessionStore for agents, orchestrator-tmux.json for orchestrator)
217
- const tmuxSessionName = await resolveTargetSession(projectRoot, agentName);
492
+ const statePath = join(projectRoot, ".overstory", "nudge-state.json");
218
493
 
219
- if (!tmuxSessionName) {
220
- result = { delivered: false, reason: `No active session for agent "${agentName}"` };
494
+ // Check debounce early — applies to both headless and tmux paths
495
+ if (!force) {
496
+ const debounced = await isDebounced(statePath, agentName);
497
+ if (debounced) {
498
+ return { delivered: false, reason: "Debounced: nudge sent too recently" };
499
+ }
500
+ }
501
+
502
+ const overstoryDir = join(projectRoot, ".overstory");
503
+
504
+ // Runtime-agnostic delivery preference (mx-17830a):
505
+ // 1. Live in-process RuntimeConnection (Sapling RPC) → conn.nudge()
506
+ // 2. Spawn-per-turn task-scoped Claude → runTurn() (no live connection)
507
+ // 3. Tmux interactive agent → tmux send-keys
508
+ const inProcConn = getConnection(agentName);
509
+ if (inProcConn !== undefined && hasNudge(inProcConn)) {
510
+ // In-process RPC path (Sapling and friends).
511
+ const nudgeResult = await inProcConn.nudge(message);
512
+ await recordNudge(statePath, agentName);
513
+ result = { delivered: true, queued: nudgeResult.status === "Queued" };
221
514
  } else {
222
- // Check debounce (unless forced)
223
- let debounced = false;
224
- if (!force) {
225
- const statePath = join(projectRoot, ".overstory", "nudge-state.json");
226
- debounced = await isDebounced(statePath, agentName);
515
+ // Spawn-per-turn dispatch for task-scoped headless Claude. When the
516
+ // agent is eligible, deliver the nudge as a user turn through `runTurn`.
517
+ // Returns null when ineligible (terminal state, persistent capability,
518
+ // flag off, etc.) and we fall through to the tmux path.
519
+ const spawnPerTurnResult = await tryNudgeViaTurnRunner({
520
+ agentName,
521
+ message,
522
+ overstoryDir,
523
+ projectRoot,
524
+ statePath,
525
+ deps,
526
+ });
527
+ if (spawnPerTurnResult !== null) {
528
+ recordNudgeEventBestEffort(overstoryDir, agentName, message, spawnPerTurnResult.delivered);
529
+ return spawnPerTurnResult;
227
530
  }
531
+ // No live connection AND no spawn-per-turn eligibility — try tmux.
532
+ }
228
533
 
229
- if (debounced) {
230
- result = { delivered: false, reason: "Debounced: nudge sent too recently" };
534
+ if (result === undefined) {
535
+ // Tmux path: resolve session name from SessionStore / orchestrator-tmux.json
536
+ const resolution = await resolveTargetSession(projectRoot, agentName);
537
+
538
+ if (resolution.kind !== "found") {
539
+ result = {
540
+ delivered: false,
541
+ reason: buildMissingSessionReason(agentName, resolution),
542
+ };
231
543
  } else {
544
+ const tmuxSessionName = resolution.tmuxSession;
232
545
  // Verify tmux session is alive
233
546
  const alive = await isSessionAlive(tmuxSessionName);
234
547
  if (!alive) {
@@ -237,14 +550,15 @@ export async function nudgeAgent(
237
550
  reason: `Tmux session "${tmuxSessionName}" is not alive`,
238
551
  };
239
552
  } else {
240
- // Send with retry
241
- const delivered = await sendNudgeWithRetry(tmuxSessionName, message);
242
-
243
- if (delivered) {
244
- // Record nudge for debounce tracking
245
- const statePath = join(projectRoot, ".overstory", "nudge-state.json");
553
+ // Send with retry — sendNudgeWithRetry waits for an idle pane
554
+ // before attempting send-keys (overstory-8ff4). It distinguishes
555
+ // "deferred" (agent mid-think) from "failed" (transient errors).
556
+ const sendResult = await sendNudgeWithRetry(tmuxSessionName, message);
557
+ if (sendResult.kind === "delivered") {
246
558
  await recordNudge(statePath, agentName);
247
559
  result = { delivered: true };
560
+ } else if (sendResult.kind === "deferred") {
561
+ result = { delivered: false, reason: sendResult.reason };
248
562
  } else {
249
563
  result = {
250
564
  delivered: false,
@@ -257,7 +571,6 @@ export async function nudgeAgent(
257
571
 
258
572
  // Record event to EventStore (fire-and-forget)
259
573
  try {
260
- const overstoryDir = join(projectRoot, ".overstory");
261
574
  const eventsDbPath = join(overstoryDir, "events.db");
262
575
  const eventStore = createEventStore(eventsDbPath);
263
576
  try {
@@ -311,9 +624,18 @@ export async function nudgeCommand(args: string[]): Promise<void> {
311
624
  const result = await nudgeAgent(projectRoot, agentName, message, opts.force ?? false);
312
625
 
313
626
  if (opts.json) {
314
- jsonOutput("nudge", { agentName, delivered: result.delivered, reason: result.reason });
627
+ jsonOutput("nudge", {
628
+ agentName,
629
+ delivered: result.delivered,
630
+ queued: result.queued,
631
+ reason: result.reason,
632
+ });
315
633
  } else if (result.delivered) {
316
- printSuccess("Nudge delivered", agentName);
634
+ if (result.queued) {
635
+ printSuccess("Nudge queued (headless — will process after current turn)", agentName);
636
+ } else {
637
+ printSuccess("Nudge delivered", agentName);
638
+ }
317
639
  } else {
318
640
  throw new AgentError(`Nudge failed: ${result.reason}`, { agentName });
319
641
  }
@@ -214,7 +214,7 @@ describe("complete run", () => {
214
214
  describe("show run details", () => {
215
215
  test("fetches run and its agents from stores", () => {
216
216
  const runId = "run-2026-02-13T10:00:00.000Z";
217
- runStore.createRun(makeRun({ agentCount: 2 }));
217
+ runStore.createRun(makeRun());
218
218
 
219
219
  sessionStore.upsert(
220
220
  makeSession({
@@ -237,6 +237,7 @@ describe("show run details", () => {
237
237
 
238
238
  const run = runStore.getRun(runId);
239
239
  expect(run).not.toBeNull();
240
+ // agentCount is derived from sessions in the same run.
240
241
  expect(run?.agentCount).toBe(2);
241
242
 
242
243
  const agents = sessionStore.getByRun(runId);
@@ -244,6 +245,41 @@ describe("show run details", () => {
244
245
  expect(agents.map((a) => a.agentName).sort()).toEqual(["builder-1", "scout-1"]);
245
246
  });
246
247
 
248
+ test("agent count includes coordinator session in the same run (overstory-8e69)", () => {
249
+ const runId = "run-2026-02-13T10:00:00.000Z";
250
+ runStore.createRun(makeRun());
251
+
252
+ // Coordinator + two children — all sharing the same run_id.
253
+ sessionStore.upsert(
254
+ makeSession({
255
+ agentName: "coordinator",
256
+ id: "s-coord",
257
+ runId,
258
+ capability: "coordinator",
259
+ state: "working",
260
+ }),
261
+ );
262
+ sessionStore.upsert(makeSession({ agentName: "lead-1", id: "s-1", runId, capability: "lead" }));
263
+ sessionStore.upsert(
264
+ makeSession({ agentName: "builder-1", id: "s-2", runId, capability: "builder" }),
265
+ );
266
+
267
+ const run = runStore.getRun(runId);
268
+ expect(run?.agentCount).toBe(3);
269
+ expect(sessionStore.getByRun(runId)).toHaveLength(3);
270
+ });
271
+
272
+ test("agent count drops when a session is removed", () => {
273
+ const runId = "run-2026-02-13T10:00:00.000Z";
274
+ runStore.createRun(makeRun());
275
+ sessionStore.upsert(makeSession({ agentName: "a", id: "s-1", runId }));
276
+ sessionStore.upsert(makeSession({ agentName: "b", id: "s-2", runId }));
277
+ expect(runStore.getRun(runId)?.agentCount).toBe(2);
278
+
279
+ sessionStore.remove("a");
280
+ expect(runStore.getRun(runId)?.agentCount).toBe(1);
281
+ });
282
+
247
283
  test("returns null for missing run", () => {
248
284
  const run = runStore.getRun("nonexistent-run");
249
285
  expect(run).toBeNull();
@@ -356,17 +392,17 @@ describe("duration formatting", () => {
356
392
 
357
393
  describe("multiple runs lifecycle", () => {
358
394
  test("create, use, complete multiple runs sequentially", async () => {
359
- // Run 1
395
+ // Run 1: two sessions, then completed.
360
396
  runStore.createRun(makeRun({ id: "run-1", startedAt: "2026-02-13T08:00:00.000Z" }));
361
397
  await writeCurrentRun("run-1");
362
- runStore.incrementAgentCount("run-1");
363
- runStore.incrementAgentCount("run-1");
398
+ sessionStore.upsert(makeSession({ agentName: "r1-a", id: "s-r1-1", runId: "run-1" }));
399
+ sessionStore.upsert(makeSession({ agentName: "r1-b", id: "s-r1-2", runId: "run-1" }));
364
400
  runStore.completeRun("run-1", "completed");
365
401
 
366
- // Run 2
402
+ // Run 2: one session, still active.
367
403
  runStore.createRun(makeRun({ id: "run-2", startedAt: "2026-02-13T12:00:00.000Z" }));
368
404
  await writeCurrentRun("run-2");
369
- runStore.incrementAgentCount("run-2");
405
+ sessionStore.upsert(makeSession({ agentName: "r2-a", id: "s-r2-1", runId: "run-2" }));
370
406
 
371
407
  // Verify state
372
408
  const currentRunId = await readCurrentRunFile();
@@ -394,7 +430,7 @@ describe("edge cases", () => {
394
430
 
395
431
  test("show run with agents from different capabilities", () => {
396
432
  const runId = "run-2026-02-13T10:00:00.000Z";
397
- runStore.createRun(makeRun({ agentCount: 3 }));
433
+ runStore.createRun(makeRun());
398
434
 
399
435
  const capabilities = ["builder", "scout", "reviewer"];
400
436
  for (let i = 0; i < capabilities.length; i++) {