@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.
- package/README.md +47 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +73 -1
- package/src/commands/sling.ts +149 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/commands/nudge.ts
CHANGED
|
@@ -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<
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
147
|
+
const orchestratorTmux = await loadOrchestratorTmuxSession(projectRoot);
|
|
148
|
+
if (orchestratorTmux !== null) {
|
|
149
|
+
return { kind: "found", tmuxSession: orchestratorTmux };
|
|
150
|
+
}
|
|
74
151
|
}
|
|
75
152
|
|
|
76
|
-
return
|
|
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
|
|
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<
|
|
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
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
488
|
+
deps: NudgeAgentDeps = {},
|
|
489
|
+
): Promise<{ delivered: boolean; queued?: boolean; reason?: string }> {
|
|
490
|
+
let result: { delivered: boolean; queued?: boolean; reason?: string } | undefined;
|
|
215
491
|
|
|
216
|
-
|
|
217
|
-
const tmuxSessionName = await resolveTargetSession(projectRoot, agentName);
|
|
492
|
+
const statePath = join(projectRoot, ".overstory", "nudge-state.json");
|
|
218
493
|
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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", {
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/run.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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(
|
|
433
|
+
runStore.createRun(makeRun());
|
|
398
434
|
|
|
399
435
|
const capabilities = ["builder", "scout", "reviewer"];
|
|
400
436
|
for (let i = 0; i < capabilities.length; i++) {
|