@amenophis1er/foreman 0.1.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.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
|
@@ -0,0 +1,2325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MissionRun — one director-led mission: planning, delegation, verification.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Spawn the director session with its charter and in-process MCP tools
|
|
6
|
+
* (spawn_worker / check_workers / wait_for_worker / message_worker /
|
|
7
|
+
* ask_human).
|
|
8
|
+
* - Run workers as separate, resumable SDK sessions, concurrently with the
|
|
9
|
+
* director's own turn: spawn_worker returns as soon as the worker exists,
|
|
10
|
+
* and the director reads progress and results back through check_workers
|
|
11
|
+
* and wait_for_worker. Nothing holds the director's turn open longer than
|
|
12
|
+
* a bounded wait, so it can run several workers at once and notice one
|
|
13
|
+
* while another is still working.
|
|
14
|
+
* - Give each worker one Foreman tool of its own, report_progress, so the
|
|
15
|
+
* director's view of a worker is not only inferred from its tool calls
|
|
16
|
+
* but includes what the worker itself says it has done and is stuck on.
|
|
17
|
+
* - Enforce the per-run budget before any new worker work starts.
|
|
18
|
+
* - Emit every observable event through the injected {@link Emitter}, which
|
|
19
|
+
* both broadcasts to live clients and persists to the run's event log.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import {
|
|
26
|
+
query,
|
|
27
|
+
tool,
|
|
28
|
+
createSdkMcpServer,
|
|
29
|
+
type McpServerConfig,
|
|
30
|
+
type Query,
|
|
31
|
+
type PermissionResult,
|
|
32
|
+
type SDKMessage,
|
|
33
|
+
type SDKUserMessage,
|
|
34
|
+
} from '@anthropic-ai/claude-agent-sdk';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A pushable async iterable of user messages — the director's streaming
|
|
38
|
+
* prompt. Steering pushes additional messages; `close()` ends the
|
|
39
|
+
* conversation once the mission's final turn has completed.
|
|
40
|
+
*/
|
|
41
|
+
class MessageStream implements AsyncIterable<SDKUserMessage> {
|
|
42
|
+
private queue: SDKUserMessage[] = [];
|
|
43
|
+
private waiter: ((r: IteratorResult<SDKUserMessage>) => void) | null = null;
|
|
44
|
+
private closed = false;
|
|
45
|
+
|
|
46
|
+
push(text: string): boolean {
|
|
47
|
+
if (this.closed) return false;
|
|
48
|
+
const msg: SDKUserMessage = {
|
|
49
|
+
type: 'user',
|
|
50
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
51
|
+
parent_tool_use_id: null,
|
|
52
|
+
session_id: '',
|
|
53
|
+
};
|
|
54
|
+
if (this.waiter) {
|
|
55
|
+
const w = this.waiter;
|
|
56
|
+
this.waiter = null;
|
|
57
|
+
w({ value: msg, done: false });
|
|
58
|
+
} else {
|
|
59
|
+
this.queue.push(msg);
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close(): void {
|
|
65
|
+
this.closed = true;
|
|
66
|
+
if (this.waiter) {
|
|
67
|
+
const w = this.waiter;
|
|
68
|
+
this.waiter = null;
|
|
69
|
+
w({ value: undefined, done: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get isClosed(): boolean {
|
|
74
|
+
return this.closed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Messages pushed but not yet consumed by the SDK's input pump. */
|
|
78
|
+
get pending(): number {
|
|
79
|
+
return this.queue.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
[Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
|
|
83
|
+
return {
|
|
84
|
+
next: (): Promise<IteratorResult<SDKUserMessage>> => {
|
|
85
|
+
if (this.queue.length) return Promise.resolve({ value: this.queue.shift()!, done: false });
|
|
86
|
+
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
|
87
|
+
return new Promise((resolve) => { this.waiter = resolve; });
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
import { WORK_DIR, makePolicy, type PendingPermission } from './policy.js';
|
|
93
|
+
import type { AgentEnv } from './provider.js';
|
|
94
|
+
import { generateRunTitle } from './title.js';
|
|
95
|
+
import { combineBasis, costBasisOf, isPriced, type CostBasis } from './types.js';
|
|
96
|
+
import { priceUsage, type ModelPrice } from './prices.js';
|
|
97
|
+
import { captureBaseline } from './deck.js';
|
|
98
|
+
import type { RunMeta, TokenUsage, WorkerMeta, WorkerProgress } from './types.js';
|
|
99
|
+
|
|
100
|
+
/** A run's usage before its first `result` message. */
|
|
101
|
+
function emptyUsage(): TokenUsage {
|
|
102
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Folds one SDK `result` message's `usage` object into a running total.
|
|
107
|
+
*
|
|
108
|
+
* Exported (pure, no `this`) so the accumulation is testable without
|
|
109
|
+
* spinning up the Agent SDK: the shape it defends against is a message that
|
|
110
|
+
* omits `usage` entirely, or reports it with some fields missing — both
|
|
111
|
+
* observed across providers a gateway sits in front of — never a thrown
|
|
112
|
+
* error or a poisoned NaN that a partial sum would otherwise carry forever.
|
|
113
|
+
*/
|
|
114
|
+
export function accumulateUsage(current: TokenUsage, raw: unknown): TokenUsage {
|
|
115
|
+
const d = normalizeUsage(raw);
|
|
116
|
+
return {
|
|
117
|
+
inputTokens: current.inputTokens + d.inputTokens,
|
|
118
|
+
outputTokens: current.outputTokens + d.outputTokens,
|
|
119
|
+
cacheReadTokens: current.cacheReadTokens + d.cacheReadTokens,
|
|
120
|
+
cacheWriteTokens: current.cacheWriteTokens + d.cacheWriteTokens,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One SDK `usage` object as token counts — the delta a single result message
|
|
126
|
+
* reports, before it is folded into the run total.
|
|
127
|
+
*
|
|
128
|
+
* Separate from accumulateUsage() because pricing needs the delta on its own:
|
|
129
|
+
* charging a per-token rate against the running total would bill every turn
|
|
130
|
+
* for every turn before it.
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeUsage(raw: unknown): TokenUsage {
|
|
133
|
+
const u = (raw ?? {}) as Record<string, unknown>;
|
|
134
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
135
|
+
return {
|
|
136
|
+
inputTokens: num(u.input_tokens),
|
|
137
|
+
outputTokens: num(u.output_tokens),
|
|
138
|
+
cacheReadTokens: num(u.cache_read_input_tokens),
|
|
139
|
+
cacheWriteTokens: num(u.cache_creation_input_tokens),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What Foreman keeps out of git inside a mission folder's .claude/.
|
|
145
|
+
*
|
|
146
|
+
* `settings.local.json` is the file an "always allow" grant creates. The
|
|
147
|
+
* `.gitignore` line covers this file itself, so adding it introduces nothing
|
|
148
|
+
* new to `git status` — without that line the fix would trade one untracked
|
|
149
|
+
* file for another. Everything else in .claude/ (settings.json, agents,
|
|
150
|
+
* commands) stays visible, since a project may legitimately track those.
|
|
151
|
+
*/
|
|
152
|
+
const LOCAL_IGNORE_LINES = ['settings.local.json', '.gitignore'];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The sanctioned scratch space inside a mission folder — see the definition
|
|
156
|
+
* in policy.ts for why it exists and why it lives there. Re-exported because
|
|
157
|
+
* this module is where callers already look for it.
|
|
158
|
+
*/
|
|
159
|
+
export { WORK_DIR };
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How long an approval card or a director question waits for the human
|
|
163
|
+
* before it is answered with its unattended default.
|
|
164
|
+
*
|
|
165
|
+
* An orchestrator must survive its human being away. A planned mission once
|
|
166
|
+
* sat most of an hour on a scratch-file write, and had the human been asleep
|
|
167
|
+
* it would have died on a question whose answer was knowable in advance. Ten
|
|
168
|
+
* minutes is long enough that an attended human — one who is watching the
|
|
169
|
+
* run, or has the badge in a tab — will have answered, and short enough that
|
|
170
|
+
* an unattended run loses a fraction of its wall clock to the wait rather than
|
|
171
|
+
* all of it. The default on expiry is the SAFE answer, never the permissive
|
|
172
|
+
* one: a permission is denied with a redirect to the workspace, a question is
|
|
173
|
+
* answered "decide yourself and record it". Per-run override: `askTimeoutMs`
|
|
174
|
+
* (0 disables, for a run someone intends to babysit).
|
|
175
|
+
*/
|
|
176
|
+
export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60_000;
|
|
177
|
+
|
|
178
|
+
// The ask timer lives in ask.ts now, shared with the planner's ask_user: the
|
|
179
|
+
// rule — fire once, never after cancel, `0` means never — must be one
|
|
180
|
+
// implementation, not two that drift. Re-exported so callers and tests that
|
|
181
|
+
// learned it here keep working.
|
|
182
|
+
import { armAskTimeout } from './ask.js';
|
|
183
|
+
export { armAskTimeout };
|
|
184
|
+
|
|
185
|
+
function unattendedMinutes(ms: number): number {
|
|
186
|
+
return Math.max(1, Math.round(ms / 60_000));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What a tool gets back when its approval card expired unanswered. Denies,
|
|
191
|
+
* and says where to redo the work — the same redirect the temp-dir rule
|
|
192
|
+
* gives, because the answer to "may I write outside?" from an absent human
|
|
193
|
+
* is the answer the charter already gave.
|
|
194
|
+
*/
|
|
195
|
+
export function unattendedDenyMessage(afterMs: number): string {
|
|
196
|
+
return `Auto-denied after ${unattendedMinutes(afterMs)} minutes unattended — Foreman does not ` +
|
|
197
|
+
`block a mission on a human who is away. Redo this inside the workspace (${WORK_DIR}/) or ` +
|
|
198
|
+
'record in MISSION.md why the outside is needed.';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* What the director gets back when its `ask_human` expired unanswered. Not a
|
|
203
|
+
* refusal — the mission continues — but a decision handed back with the two
|
|
204
|
+
* conditions that make an unsupervised decision acceptable: write it down,
|
|
205
|
+
* and do not ask the same thing again.
|
|
206
|
+
*/
|
|
207
|
+
export function unattendedAnswer(afterMs: number): string {
|
|
208
|
+
return `No answer after ${unattendedMinutes(afterMs)} minutes — the human is away. Decide ` +
|
|
209
|
+
'yourself, record the decision and its reasoning in MISSION.md, and continue; do not ask ' +
|
|
210
|
+
'again unless the mission cannot proceed at all.';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Ensures every rule in `lines` appears in the gitignore at `file`, appending
|
|
215
|
+
* only the ones missing.
|
|
216
|
+
*
|
|
217
|
+
* Shared by the `.claude/` and `.foreman/` ignore files, which need the same
|
|
218
|
+
* care: an existing file is appended to, never replaced (a project may have
|
|
219
|
+
* its own rules there); a file without a trailing newline gets one before the
|
|
220
|
+
* new rules so the last existing rule is not fused with the first new one; a
|
|
221
|
+
* missing file is created. Each list ends with `.gitignore` itself so the file
|
|
222
|
+
* introduces nothing new to `git status` — without that line the fix would
|
|
223
|
+
* trade one untracked file for another. Write failures are swallowed: this is
|
|
224
|
+
* hygiene, and a read-only folder must not fail the run over it.
|
|
225
|
+
*/
|
|
226
|
+
export async function ensureIgnoreLines(file: string, lines: readonly string[]): Promise<void> {
|
|
227
|
+
const existing = await readFile(file, 'utf8').catch(() => null);
|
|
228
|
+
const present = existing === null ? [] : existing.split('\n');
|
|
229
|
+
const missing = lines.filter((rule) => !present.some((line) => line.trim() === rule));
|
|
230
|
+
if (missing.length === 0) return;
|
|
231
|
+
|
|
232
|
+
// An empty file is also "nothing to separate from": without the `!existing`
|
|
233
|
+
// case it would gain a leading blank line.
|
|
234
|
+
const prefix = !existing || existing.endsWith('\n') ? (existing ?? '') : `${existing}\n`;
|
|
235
|
+
await writeFile(file, `${prefix}${missing.join('\n')}\n`).catch(() => {});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Caps that hold whatever the provider is. A dollar budget is meaningless on a
|
|
240
|
+
* local model and actively wrong through a gateway (the SDK prices foreign
|
|
241
|
+
* tokens with Anthropic's table), but "too many turns" and "too long" are true
|
|
242
|
+
* everywhere — and a runaway agent is what a cap exists to stop, not a bill.
|
|
243
|
+
*/
|
|
244
|
+
// Sized to bound a runaway without shortening a legitimate mission. The
|
|
245
|
+
// director's own SDK limit is 150 turns, so anything tighter here would be a
|
|
246
|
+
// silent regression on runs that used to be governed by budget alone — these
|
|
247
|
+
// caps exist to give an UNMETERED run something that binds, not to second-guess
|
|
248
|
+
// a metered one that dollars already stop.
|
|
249
|
+
const DEFAULT_MAX_TURNS = 150;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* How long a worker may say nothing at all before it is treated as stalled.
|
|
253
|
+
*
|
|
254
|
+
* The SDK emits a message for every tool call, every result, every retry — so
|
|
255
|
+
* total silence is not a worker thinking hard, it is a worker that is not
|
|
256
|
+
* coming back. Observed in the wild: a worker sat silent for eight minutes,
|
|
257
|
+
* failed with `error: unknown`, retried, and sat silent again, while the
|
|
258
|
+
* director waited inside spawn_worker and the run looked alive from every
|
|
259
|
+
* angle. Nothing stopped it, because nothing was watching.
|
|
260
|
+
*
|
|
261
|
+
* Set well above a slow first token — a large prompt to a local model can
|
|
262
|
+
* legitimately take minutes — because the cost of being wrong in each
|
|
263
|
+
* direction is not symmetric. Killing a slow-but-working worker throws away
|
|
264
|
+
* real progress; waiting too long only costs time on a worker that was never
|
|
265
|
+
* going to answer.
|
|
266
|
+
*/
|
|
267
|
+
const DEFAULT_WORKER_SILENCE_MS = 8 * 60_000;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Grace between the wall-clock cap and the watchdog that enforces it anyway.
|
|
271
|
+
*
|
|
272
|
+
* The graceful path — capReached() at a turn boundary, one last turn to tick
|
|
273
|
+
* MISSION.md and summarise — is much better than being killed, so it gets
|
|
274
|
+
* first refusal. But it only runs at a turn boundary, and a director blocked
|
|
275
|
+
* inside a tool call never reaches one: the caps that are supposed to bound
|
|
276
|
+
* every run were, in that state, bounding nothing at all.
|
|
277
|
+
*/
|
|
278
|
+
const CAP_WATCHDOG_GRACE_MS = 5 * 60_000;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Watches for silence, and says so once.
|
|
282
|
+
*
|
|
283
|
+
* Extracted rather than inlined because it is the part with the actual rule
|
|
284
|
+
* in it — reset on any sign of life, fire exactly once, never fire after it
|
|
285
|
+
* is stopped — and a watchdog nobody has watched fire is not a watchdog.
|
|
286
|
+
*
|
|
287
|
+
* The poll interval scales with the threshold so a short one can be tested in
|
|
288
|
+
* milliseconds while a production eight-minute threshold still costs one
|
|
289
|
+
* wakeup every thirty seconds.
|
|
290
|
+
*/
|
|
291
|
+
export function watchSilence(
|
|
292
|
+
silenceMs: number,
|
|
293
|
+
onStall: (quietForMs: number) => void,
|
|
294
|
+
): { touch(): void; stop(): void } {
|
|
295
|
+
let last = Date.now();
|
|
296
|
+
let fired = false;
|
|
297
|
+
const every = Math.max(10, Math.min(30_000, Math.floor(silenceMs / 4)));
|
|
298
|
+
const timer = setInterval(() => {
|
|
299
|
+
if (fired) return;
|
|
300
|
+
const quiet = Date.now() - last;
|
|
301
|
+
if (quiet < silenceMs) return;
|
|
302
|
+
fired = true;
|
|
303
|
+
clearInterval(timer);
|
|
304
|
+
onStall(quiet);
|
|
305
|
+
}, every);
|
|
306
|
+
timer.unref?.();
|
|
307
|
+
return {
|
|
308
|
+
touch() { if (!fired) last = Date.now(); },
|
|
309
|
+
stop() { fired = true; clearInterval(timer); },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What the director is told when a worker went quiet.
|
|
315
|
+
*
|
|
316
|
+
* Deliberately not just "failed". A stall usually means the endpoint stopped
|
|
317
|
+
* answering rather than that the task was hard, and the one response that is
|
|
318
|
+
* certainly wrong — respawning the identical brief — is exactly what "worker
|
|
319
|
+
* failed" invites.
|
|
320
|
+
*/
|
|
321
|
+
export function stalledWorkerReport(
|
|
322
|
+
workerId: string, silenceMs: number, partial?: string,
|
|
323
|
+
): string {
|
|
324
|
+
const mins = Math.max(1, Math.round(silenceMs / 60_000));
|
|
325
|
+
return (
|
|
326
|
+
`WORKER STALLED: ${workerId} produced no output at all for ${mins} minute(s) and was ` +
|
|
327
|
+
`stopped. It did not fail a task — it never reported anything, which usually means the ` +
|
|
328
|
+
`model endpoint stopped responding rather than that the work was hard.\n\n` +
|
|
329
|
+
`Do NOT immediately respawn an identical worker; if the endpoint is the problem, the ` +
|
|
330
|
+
`next one stalls the same way and the mission spends its budget on silence. Consider ` +
|
|
331
|
+
`instead: doing this piece yourself, splitting it into a smaller brief, or — if workers ` +
|
|
332
|
+
`keep stalling — telling the human via mcp__foreman__ask_human that the worker model ` +
|
|
333
|
+
`appears unreachable.` +
|
|
334
|
+
(partial ? `\n\nPartial output before it went quiet:\n${partial}` : '')
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* How many identical tool calls in a row count as a loop.
|
|
340
|
+
*
|
|
341
|
+
* Five, not three: a legitimate retry-with-backoff — a flaky test, a port
|
|
342
|
+
* still bound, a file another process is writing — is two or three attempts,
|
|
343
|
+
* and cutting those off would turn ordinary robustness into a stall report.
|
|
344
|
+
* But the fifth identical call with identical input has no new information
|
|
345
|
+
* in it: nothing the agent controls has changed between attempts, so nothing
|
|
346
|
+
* about the answer will either. Past that point every call is spend with no
|
|
347
|
+
* expected return, and each one resets the silence watchdog, so this is the
|
|
348
|
+
* stall that watchdog can never see.
|
|
349
|
+
*/
|
|
350
|
+
export const DEFAULT_REPEAT_LIMIT = 5;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Tools whose identical repetition is supervision, not a loop.
|
|
354
|
+
*
|
|
355
|
+
* `check_workers` takes no arguments and answers differently each time a
|
|
356
|
+
* worker moves; calling it five times in a row while a worker builds is a
|
|
357
|
+
* director doing its job. The detector fired on exactly that, twenty-seven
|
|
358
|
+
* seconds into the first mixed-provider run — and a second streak would have
|
|
359
|
+
* interrupted a healthy mission for the crime of watching its crew. A status
|
|
360
|
+
* read carries its information in *when* it is made, so sameness of input
|
|
361
|
+
* says nothing; these are exempt, and the charter steers polling toward
|
|
362
|
+
* `wait_for_worker` instead, which blocks until something has changed.
|
|
363
|
+
*/
|
|
364
|
+
export const REPEAT_EXEMPT: ReadonlySet<string> = new Set([
|
|
365
|
+
'mcp__foreman__check_workers',
|
|
366
|
+
'mcp__foreman__wait_for_worker',
|
|
367
|
+
'mcp__foreman__report_progress',
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
/** Feed a tool use to a repeat watcher, unless it is one whose repetition is the point. */
|
|
371
|
+
export function observeToolUse(
|
|
372
|
+
repeats: { observe(toolName: string, input: unknown): void }, toolName: string, input: unknown,
|
|
373
|
+
): void {
|
|
374
|
+
if (REPEAT_EXEMPT.has(toolName)) return;
|
|
375
|
+
repeats.observe(toolName, input);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* JSON with keys sorted at every depth, so two inputs that differ only in
|
|
380
|
+
* key order compare equal. Models emit the same arguments in a different
|
|
381
|
+
* order from one turn to the next often enough that plain JSON.stringify
|
|
382
|
+
* would let a loop hide behind it.
|
|
383
|
+
*/
|
|
384
|
+
function stableStringify(v: unknown): string {
|
|
385
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'undefined';
|
|
386
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
|
|
387
|
+
const o = v as Record<string, unknown>;
|
|
388
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(',')}}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Watches for the stall the silence watchdog cannot see: an agent that is
|
|
393
|
+
* busy but looping — the same failing command forty times, the same file
|
|
394
|
+
* re-read forever. It looks like work, burns tokens like work, and keeps the
|
|
395
|
+
* silence clock ticking over like work.
|
|
396
|
+
*
|
|
397
|
+
* Same shape as {@link watchSilence} for the same reason: the rule lives in
|
|
398
|
+
* one small exported function that can be tested without an SDK. The rule is
|
|
399
|
+
* consecutive identity — same tool, deep-equal input — with a different call
|
|
400
|
+
* breaking the streak and re-arming detection. Fires exactly once per streak:
|
|
401
|
+
* the caller decides what to do about it, and a loop that continues past the
|
|
402
|
+
* report is the same loop, not a new finding.
|
|
403
|
+
*/
|
|
404
|
+
export function watchRepeats(
|
|
405
|
+
limit: number,
|
|
406
|
+
onLoop: (info: { toolName: string; count: number; input: unknown }) => void,
|
|
407
|
+
): { observe(toolName: string, input: unknown): void; reset(): void } {
|
|
408
|
+
let key: string | null = null;
|
|
409
|
+
let count = 0;
|
|
410
|
+
let fired = false;
|
|
411
|
+
return {
|
|
412
|
+
observe(toolName, input) {
|
|
413
|
+
const k = `${toolName}\u0000${stableStringify(input)}`;
|
|
414
|
+
if (k === key) {
|
|
415
|
+
count++;
|
|
416
|
+
} else {
|
|
417
|
+
key = k; count = 1; fired = false;
|
|
418
|
+
}
|
|
419
|
+
if (fired || count < limit) return;
|
|
420
|
+
fired = true;
|
|
421
|
+
onLoop({ toolName, count, input });
|
|
422
|
+
},
|
|
423
|
+
reset() { key = null; count = 0; fired = false; },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* What the director is told when a worker was stopped for looping.
|
|
429
|
+
*
|
|
430
|
+
* Parallel to {@link stalledWorkerReport}, and for the same reason: framed as
|
|
431
|
+
* "failed", the natural response is to send the same brief again, and the
|
|
432
|
+
* same brief walks into the same wall. The repeated call is included because
|
|
433
|
+
* it is the one piece of evidence the director cannot otherwise see — it
|
|
434
|
+
* shows where the wall is.
|
|
435
|
+
*/
|
|
436
|
+
export function loopingWorkerReport(
|
|
437
|
+
workerId: string, toolName: string, count: number, partial?: string,
|
|
438
|
+
): string {
|
|
439
|
+
return (
|
|
440
|
+
`WORKER LOOPING: ${workerId} issued the identical ${toolName} call ${count} times in a row ` +
|
|
441
|
+
`with identical input and was stopped. This is a worker stuck, not a task that failed: it ` +
|
|
442
|
+
`hit something it could not see past and kept trying the one move it had.\n\n` +
|
|
443
|
+
`Do NOT respawn the identical brief; a fresh worker with the same instructions finds the ` +
|
|
444
|
+
`same wall. Look at what it was repeating (${toolName}) and either do that step yourself, ` +
|
|
445
|
+
`change the approach or the brief so the step is not needed, or — if the obstacle is ` +
|
|
446
|
+
`outside the mission's control — ask the human via mcp__foreman__ask_human.` +
|
|
447
|
+
(partial ? `\n\nOutput before it was stopped:\n${partial}` : '')
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The tool_use blocks on an SDK assistant message; empty for anything else. */
|
|
452
|
+
function toolUsesOf(m: Record<string, unknown>): Array<{ name: string; input: unknown }> {
|
|
453
|
+
if (m.type !== 'assistant') return [];
|
|
454
|
+
const content = (m.message as { content?: unknown } | undefined)?.content;
|
|
455
|
+
if (!Array.isArray(content)) return [];
|
|
456
|
+
return content.filter(
|
|
457
|
+
(b): b is { type: 'tool_use'; name: string; input: unknown } =>
|
|
458
|
+
Boolean(b) && (b as { type?: unknown }).type === 'tool_use' && typeof (b as { name?: unknown }).name === 'string',
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const DEFAULT_MAX_SECONDS = 4 * 60 * 60;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* How long wait_for_worker may hold the director's turn.
|
|
465
|
+
*
|
|
466
|
+
* The default is long enough that a director waiting on the one thing it
|
|
467
|
+
* needs is not woken for nothing every few seconds; the hard maximum exists
|
|
468
|
+
* so that no argument the director passes can recreate the blocked-forever
|
|
469
|
+
* turn that synchronous spawning was. A worker that outlives the wait is
|
|
470
|
+
* still running — the director is told so and asked again, and every such
|
|
471
|
+
* return is a turn boundary at which the caps get to bind.
|
|
472
|
+
*/
|
|
473
|
+
export const DEFAULT_WAIT_SECONDS = 300;
|
|
474
|
+
export const MAX_WAIT_SECONDS = 600;
|
|
475
|
+
|
|
476
|
+
/** How many activity lines a worker record keeps. Enough to see a pattern. */
|
|
477
|
+
export const RECENT_LINES = 8;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Longest `status` report_progress keeps. One line in a status block that is
|
|
481
|
+
* printed for every worker on every check_workers; a worker that wants to
|
|
482
|
+
* say more has `done`, `next` and `blocked` for it.
|
|
483
|
+
*/
|
|
484
|
+
export const PROGRESS_STATUS_MAX = 200;
|
|
485
|
+
|
|
486
|
+
/** The worker-side tool's full name, as the policy and the activity window see it. */
|
|
487
|
+
const REPORT_PROGRESS_TOOL = 'mcp__foreman__report_progress';
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* One line of a worker's activity, as the director will see it.
|
|
491
|
+
*
|
|
492
|
+
* A tool name alone says "Bash"; the director needs "Bash npm test" to tell
|
|
493
|
+
* verification from a loop. The hint is the argument that identifies what the
|
|
494
|
+
* call was about — the same handful of keys across the harness's tools — or,
|
|
495
|
+
* failing that, the first string in the input. Kept short because eight of
|
|
496
|
+
* these sit in every check_workers block for every worker.
|
|
497
|
+
*/
|
|
498
|
+
export function activityHint(toolName: string, input: unknown): string {
|
|
499
|
+
const o = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
|
|
500
|
+
const keys = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'prompt', 'description'];
|
|
501
|
+
let arg = keys.map((k) => o[k]).find((v): v is string => typeof v === 'string' && v.length > 0);
|
|
502
|
+
if (arg === undefined) arg = Object.values(o).find((v): v is string => typeof v === 'string' && v.length > 0);
|
|
503
|
+
const hint = arg ? oneLine(arg, 60) : '';
|
|
504
|
+
return hint ? `${toolName} ${hint}` : toolName;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The head of a piece of text, flattened to one line, cut to `max` chars. */
|
|
508
|
+
function oneLine(text: string, max: number): string {
|
|
509
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
510
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** "2m14s" for a duration; what a person reads at a glance. */
|
|
514
|
+
function fmtAge(ms: number): string {
|
|
515
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
516
|
+
if (s < 60) return `${s}s`;
|
|
517
|
+
const m = Math.floor(s / 60);
|
|
518
|
+
if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`;
|
|
519
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The compact status block check_workers shows for one worker.
|
|
524
|
+
*
|
|
525
|
+
* Exported for the same reason as the watchdogs: it is the director's only
|
|
526
|
+
* view of a running worker, and what it shows — age, time since the last
|
|
527
|
+
* message, call count, the worker's own progress report, the recent lines,
|
|
528
|
+
* the report once there is one — is a rule worth pinning without an SDK
|
|
529
|
+
* behind it. `withReport` is false when the caller is about to print the
|
|
530
|
+
* report itself.
|
|
531
|
+
*
|
|
532
|
+
* Progress sits above `recent` because it is the better signal: the recent
|
|
533
|
+
* lines are the harness guessing from tool names, the progress block is the
|
|
534
|
+
* worker saying where it is, and a director skimming several blocks should
|
|
535
|
+
* meet the worker's account first.
|
|
536
|
+
*/
|
|
537
|
+
export function workerStatusBlock(w: WorkerMeta, now = Date.now(), withReport = true): string {
|
|
538
|
+
const head = [`${w.id} ${w.status}`];
|
|
539
|
+
if (w.startedAt) head.push(`age ${fmtAge((w.endedAt ?? now) - w.startedAt)}`);
|
|
540
|
+
if (w.status === 'running' && w.lastActivityAt) head.push(`last activity ${fmtAge(now - w.lastActivityAt)} ago`);
|
|
541
|
+
head.push(`${w.toolCalls ?? 0} tool calls`);
|
|
542
|
+
const lines = [head.join(' ')];
|
|
543
|
+
if (w.progress) {
|
|
544
|
+
const p = w.progress;
|
|
545
|
+
lines.push(` progress (${fmtAge(now - p.at)} ago): ${p.status}`);
|
|
546
|
+
if (p.done?.length) lines.push(` done: ${p.done.join(', ')}`);
|
|
547
|
+
if (p.next) lines.push(` next: ${p.next}`);
|
|
548
|
+
if (p.blocked) lines.push(` blocked: ${p.blocked}`);
|
|
549
|
+
}
|
|
550
|
+
if (w.recent?.length) {
|
|
551
|
+
lines.push(' recent:');
|
|
552
|
+
for (const r of w.recent) lines.push(` ${r}`);
|
|
553
|
+
}
|
|
554
|
+
if (withReport && w.status !== 'running') {
|
|
555
|
+
lines.push(` report${w.isError ? ' (FAILED)' : ''}:`);
|
|
556
|
+
lines.push(` ${(w.report || '(no report)').split('\n').join('\n ')}`);
|
|
557
|
+
}
|
|
558
|
+
return lines.join('\n');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Which half of the crew spent something. Roles can be on different providers. */
|
|
562
|
+
type AgentRole = 'director' | 'worker';
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* How a worker may be launched other than as the run configured it. Used by
|
|
566
|
+
* exactly one path — a retry on the director's provider after a stall, at the
|
|
567
|
+
* human's request — so it is deliberately narrow: an environment, a model,
|
|
568
|
+
* and which role's rates price the tokens.
|
|
569
|
+
*/
|
|
570
|
+
interface WorkerOverrides {
|
|
571
|
+
env?: AgentEnv;
|
|
572
|
+
model?: string;
|
|
573
|
+
priceRole?: AgentRole;
|
|
574
|
+
/** For the transcript and the report: why this worker exists. */
|
|
575
|
+
reason?: string;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Broadcasts to SSE clients and appends to the run's event log. */
|
|
579
|
+
export type Emitter = (event: string, data: unknown) => void;
|
|
580
|
+
|
|
581
|
+
/** Persists updated run metadata (fire-and-forget from the run's viewpoint). */
|
|
582
|
+
export type MetaSink = (meta: RunMeta) => void;
|
|
583
|
+
|
|
584
|
+
export const DIRECTOR_CHARTER = `
|
|
585
|
+
You are the FOREMAN DIRECTOR. You run a mission autonomously inside one folder by
|
|
586
|
+
directing worker agents. Non-negotiable rules, in priority order:
|
|
587
|
+
|
|
588
|
+
1. PLAN FIRST. Before anything else, write .foreman/MISSION.md in the working
|
|
589
|
+
directory with: the mission (one line), DONE WHEN (verifiable criteria), a
|
|
590
|
+
plan as a checklist of verifiable milestones, a Log section, and a Decisions
|
|
591
|
+
section. TICK THE BOXES AS YOU GO: the moment a milestone or a DONE WHEN
|
|
592
|
+
criterion is actually verified, change its "- [ ]" to "- [x]" in the same
|
|
593
|
+
turn. A log line saying something is done is not a substitute for ticking
|
|
594
|
+
it. The doc is the mission's source of truth, not your context window, and
|
|
595
|
+
it is what a resumed director reads to work out what is already finished —
|
|
596
|
+
an unticked box costs the run the budget of proving that work again.
|
|
597
|
+
SCALE THE DOC TO THE MISSION: a trivial task deserves a three-line doc
|
|
598
|
+
(mission, one DONE WHEN, one milestone);
|
|
599
|
+
never pad small missions with ceremony. The doc's existence is mandatory;
|
|
600
|
+
its length is not.
|
|
601
|
+
2. DELEGATE IMPLEMENTATION. Use mcp__foreman__spawn_worker to have a worker do
|
|
602
|
+
the building/editing. Give each worker one well-scoped, self-contained task
|
|
603
|
+
with full context (paths, constraints, expected result). spawn_worker
|
|
604
|
+
RETURNS IMMEDIATELY; the worker runs in the background. Spawn independent
|
|
605
|
+
tasks together so they run in parallel, and never spawn two workers on the
|
|
606
|
+
same files. Supervise with mcp__foreman__check_workers — it shows each
|
|
607
|
+
worker's age, recent activity and finished report — rather than waiting
|
|
608
|
+
blind. Do not poll check_workers in a tight loop — each call is a turn you
|
|
609
|
+
pay for and it will not change faster than the worker does; to wait for a
|
|
610
|
+
result, call mcp__foreman__wait_for_worker, which blocks until something
|
|
611
|
+
has changed (it is bounded, and tells you if the worker is still going).
|
|
612
|
+
Use mcp__foreman__message_worker to send follow-ups or corrections to a
|
|
613
|
+
finished worker. You may read files and run verification commands yourself,
|
|
614
|
+
but implementation edits belong to workers. A worker's report is a claim to
|
|
615
|
+
verify, not a fact.
|
|
616
|
+
A WORKER THAT STALLS IS NOT A WORKER THAT FAILED. If a worker's report says
|
|
617
|
+
it produced nothing and was stopped, the endpoint serving it is
|
|
618
|
+
the likely cause, not the task. Respawning the same brief is then the one
|
|
619
|
+
response guaranteed to waste the same minutes again. Do the piece yourself,
|
|
620
|
+
or cut it into a smaller brief, and if a second worker stalls the same way
|
|
621
|
+
say so to the human via mcp__foreman__ask_human rather than continuing to
|
|
622
|
+
spend the run on silence. Record it in the log either way: a mission that
|
|
623
|
+
quietly lost half an hour to a dead endpoint should say so.
|
|
624
|
+
A WORKER THAT LOOPS IS THE SAME CASE. If a worker's report says it
|
|
625
|
+
repeated one call many times and was stopped, it hit a wall it could not
|
|
626
|
+
see. Do not send the same brief back; look at what it was repeating, and
|
|
627
|
+
change the approach or the brief.
|
|
628
|
+
3. VERIFY INDEPENDENTLY. Never trust a worker's "done". Read the files and run
|
|
629
|
+
the checks yourself before ticking a milestone. Artifacts you produce
|
|
630
|
+
(screenshots, reports, exports) must depict the FINAL state: if any file
|
|
631
|
+
changes after you captured them, RE-CAPTURE before ticking that milestone.
|
|
632
|
+
An artifact older than the code it documents is a false report.
|
|
633
|
+
OPEN WHAT YOU CAPTURED. A screenshot is evidence only once you have looked
|
|
634
|
+
at it. Never write "no defects observed" about an image you did not read
|
|
635
|
+
back — saying it makes the report false even when the page is fine.
|
|
636
|
+
SCROLL-REVEALED CONTENT IS THE COMMON TRAP. Modern pages start sections at
|
|
637
|
+
"opacity: 0" and fade them in when they scroll into view, so a full-page
|
|
638
|
+
capture taken without scrolling records blank space where the content is.
|
|
639
|
+
Before a full-page screenshot: emulate "prefers-reduced-motion: reduce" if
|
|
640
|
+
the page honours it, or scroll the whole page to the bottom, wait for the
|
|
641
|
+
animations to settle, and scroll back. Then open the file and confirm the
|
|
642
|
+
sections you expect are actually visible in it. A mostly-black screenshot is
|
|
643
|
+
a failed capture, not a finished milestone.
|
|
644
|
+
WORK INSIDE THE WORKSPACE. Everything you or a worker creates —
|
|
645
|
+
verification scripts, screenshots, scratch tooling, node_modules for a
|
|
646
|
+
helper, temp output — goes under ${WORK_DIR}/ inside the mission folder,
|
|
647
|
+
never /tmp or anywhere outside it. It is gitignored, so it keeps the
|
|
648
|
+
project root clean without leaving the project. A write to /tmp or any
|
|
649
|
+
other temp directory is DENIED outright, no question asked — the denial
|
|
650
|
+
names ${WORK_DIR}/ and you redo it there. Any other path outside the folder
|
|
651
|
+
asks the human, and an ask nobody answers within ${DEFAULT_ASK_TIMEOUT_MS / 60_000}
|
|
652
|
+
minutes is denied the same way; a mission that needs the outside should
|
|
653
|
+
say so in MISSION.md and ask via mcp__foreman__ask_human, not discover it
|
|
654
|
+
mid-run. If you are prompted for a path outside the folder, the answer is
|
|
655
|
+
almost always to redo it under ${WORK_DIR}/, not to wait for approval.
|
|
656
|
+
SHOW, DON'T DESCRIBE: when you have a server running that shows the work
|
|
657
|
+
(a dev server, a static preview), call mcp__foreman__expose_service with
|
|
658
|
+
its port and put the URL it returns in your report — the human can open
|
|
659
|
+
it from their phone. Keep that server up until the mission ends.
|
|
660
|
+
4. REPORT WHAT YOU SEE. Judge the work as a competent professional would, not
|
|
661
|
+
only against the letter of the acceptance criteria. If you observe a defect
|
|
662
|
+
the criteria did not name — tap targets too small to use, unreadable
|
|
663
|
+
contrast, a broken layout, a hazard, an obviously wrong result — fix it
|
|
664
|
+
when it is clearly in scope, and otherwise say so plainly in your final
|
|
665
|
+
summary and in MISSION.md. Staying silent about a problem you could see is
|
|
666
|
+
a failed mission even when every listed box is ticked.
|
|
667
|
+
5. DECIDE AND RECORD, DON'T ASK. You are running unattended more often than
|
|
668
|
+
not. Make the reasonable call, write it and the reasoning into MISSION.md,
|
|
669
|
+
and continue. Reserve mcp__foreman__ask_human for decisions that are
|
|
670
|
+
irreversible or that spend money the mission was not given — those you ask
|
|
671
|
+
and wait for. A question left unanswered for ${DEFAULT_ASK_TIMEOUT_MS / 60_000}
|
|
672
|
+
minutes is auto-answered "decide yourself"; treat that answer as the
|
|
673
|
+
human's, record what you decided, and do not ask it again.
|
|
674
|
+
6. NEVER modify Foreman itself, its server, or any oversight tooling. Tooling
|
|
675
|
+
failure is an escalation, never a self-repair.
|
|
676
|
+
7. When DONE WHEN is verified, update MISSION.md (all boxes ticked, final log
|
|
677
|
+
entry) and end with a short summary of what was built and how you verified it.
|
|
678
|
+
`;
|
|
679
|
+
|
|
680
|
+
export const WORKER_CHARTER = `
|
|
681
|
+
You are a FOREMAN WORKER. Complete exactly the task you were given, inside the
|
|
682
|
+
working directory. Never ask interactive questions — if you are blocked on a
|
|
683
|
+
decision you cannot make, print "BLOCKED: <your question>" and end your turn.
|
|
684
|
+
REPORT PROGRESS. Call mcp__foreman__report_progress when you finish a distinct
|
|
685
|
+
sub-step, when you change approach, and immediately when you are blocked. The
|
|
686
|
+
director supervises several workers and can only help with what it can see; a
|
|
687
|
+
worker that goes quiet for minutes looks stalled and may be stopped. Reporting
|
|
688
|
+
is never a substitute for finishing.
|
|
689
|
+
WORK INSIDE THE WORKSPACE. Anything you create that is not part of the task's
|
|
690
|
+
deliverable — scripts, screenshots, helper installs, temp output — goes under
|
|
691
|
+
${WORK_DIR}/ inside the working directory, never /tmp or anywhere outside it.
|
|
692
|
+
A write to /tmp or another temp directory is denied outright; any other path
|
|
693
|
+
outside the folder asks the human and is denied if nobody answers within
|
|
694
|
+
${DEFAULT_ASK_TIMEOUT_MS / 60_000} minutes. Either way, redo it under ${WORK_DIR}/
|
|
695
|
+
instead of waiting.
|
|
696
|
+
When finished, end with a concise report of what you did and how you checked it.
|
|
697
|
+
`;
|
|
698
|
+
|
|
699
|
+
/** Foreman's bundled Playwright MCP server, resolved from this repo. */
|
|
700
|
+
const PLAYWRIGHT_MCP_CLI = fileURLToPath(
|
|
701
|
+
new URL('../node_modules/@playwright/mcp/cli.js', import.meta.url),
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
/** Claude subscription/quota exhaustion — an external pause, not a failure. */
|
|
705
|
+
const USAGE_LIMIT_RE = /out of usage credits|usage limit reached|upgrade to increase your usage/i;
|
|
706
|
+
|
|
707
|
+
/** One worker's outcome, as runWorker hands it back. */
|
|
708
|
+
interface WorkerOutcome { report: string; isError: boolean }
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* A worker record plus the in-process handles that must never be persisted:
|
|
712
|
+
* the live query (for interrupts), the run promise (so nothing is left
|
|
713
|
+
* floating), and the `done` promise wait_for_worker races against a timer.
|
|
714
|
+
* Everything here is stripped by syncWorkersMeta().
|
|
715
|
+
*/
|
|
716
|
+
interface WorkerRuntime extends WorkerMeta {
|
|
717
|
+
q?: Query;
|
|
718
|
+
promise?: Promise<WorkerOutcome>;
|
|
719
|
+
done?: Promise<void>;
|
|
720
|
+
settle?: () => void;
|
|
721
|
+
/** The report has been returned to the director at least once. Never deletes it. */
|
|
722
|
+
reportShown?: boolean;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const RUNTIME_ONLY: ReadonlyArray<keyof WorkerRuntime> = ['q', 'promise', 'done', 'settle', 'reportShown'];
|
|
726
|
+
|
|
727
|
+
export class MissionRun {
|
|
728
|
+
readonly meta: RunMeta;
|
|
729
|
+
private directorQ?: Query;
|
|
730
|
+
private readonly workers = new Map<string, WorkerRuntime>();
|
|
731
|
+
private workerSeq = 0;
|
|
732
|
+
private readonly runAllowed = new Set<string>();
|
|
733
|
+
/**
|
|
734
|
+
* Directories the human opened with "always" on a folder-boundary card.
|
|
735
|
+
* Consulted by the policy on every call, so a grant covers the very next
|
|
736
|
+
* sibling command — which is the loop it exists to break.
|
|
737
|
+
*/
|
|
738
|
+
private readonly allowedRoots = new Set<string>();
|
|
739
|
+
/** Set once the cap is passed; the wind-down turn is allowed, then the loop ends. */
|
|
740
|
+
private budgetStopped = false;
|
|
741
|
+
private readonly pendingPermissions = new Map<string, PendingPermission & { agent: string }>();
|
|
742
|
+
private readonly pendingQuestions = new Map<string, (answer: string) => void>();
|
|
743
|
+
/**
|
|
744
|
+
* What each pending ask *is*, for surfaces that answer it away from the
|
|
745
|
+
* transcript — the fleet board, a phone. The resolvers above know only how
|
|
746
|
+
* to settle an ask; this is the text, the options and when it was raised.
|
|
747
|
+
*/
|
|
748
|
+
private readonly askMeta = new Map<string, {
|
|
749
|
+
kind: 'permission' | 'question'; text: string; options?: string[]; toolName?: string; since: number;
|
|
750
|
+
}>();
|
|
751
|
+
/**
|
|
752
|
+
* The unattended-default timer per pending ask (permission or question),
|
|
753
|
+
* keyed by the ask's id. Cancelled on any normal resolution and swept in
|
|
754
|
+
* the run's `finally`, so a timer never fires into a run that is over.
|
|
755
|
+
*/
|
|
756
|
+
private readonly askTimers = new Map<string, { cancel(): void }>();
|
|
757
|
+
/** The director's streaming prompt; steering pushes into it. */
|
|
758
|
+
private directorInput?: MessageStream;
|
|
759
|
+
/** Director cost is cumulative per query; track the last figure for deltas. */
|
|
760
|
+
private directorCostSeen = 0;
|
|
761
|
+
private wasInterrupted = false;
|
|
762
|
+
private usageLimited = false;
|
|
763
|
+
/** Director turns taken, for the cap that applies to every provider. */
|
|
764
|
+
private turns = 0;
|
|
765
|
+
/** Gateway tokens seen since the last confirmed result. Never persisted. */
|
|
766
|
+
private interimUsage: TokenUsage = emptyUsage();
|
|
767
|
+
/** The ledger reading the interim is measured against. */
|
|
768
|
+
private ledgerBaseline: (TokenUsage & { calls: number; costUsd?: number }) | null = null;
|
|
769
|
+
/** The three piles `meta.costUsd` is the sum of — see RunMeta.costParts. */
|
|
770
|
+
private costParts: { native: number; rated: number; ledger: number };
|
|
771
|
+
/** Ledger cost persisted by earlier attempts; this attempt's ledger starts at zero. */
|
|
772
|
+
private ledgerBefore = 0;
|
|
773
|
+
/** Once an upstream has stated a cost, its figure outranks the rated one for gateway tokens. */
|
|
774
|
+
private ledgerReportsCost = false;
|
|
775
|
+
private rebaseline = false;
|
|
776
|
+
private ledgerTimer?: ReturnType<typeof setInterval>;
|
|
777
|
+
private capTimer?: ReturnType<typeof setInterval>;
|
|
778
|
+
private hardStopped = false;
|
|
779
|
+
private startedAt = Date.now();
|
|
780
|
+
|
|
781
|
+
constructor(
|
|
782
|
+
meta: RunMeta,
|
|
783
|
+
private readonly emit: Emitter,
|
|
784
|
+
private readonly saveMeta: MetaSink,
|
|
785
|
+
/**
|
|
786
|
+
* Credential + wire per role, resolved once at dispatch.
|
|
787
|
+
*
|
|
788
|
+
* Two, not one, because a run may put its director on a capable provider
|
|
789
|
+
* and its workers on a cheap or local one — that is the point of the
|
|
790
|
+
* provider model, and it is decided by which model each role was given.
|
|
791
|
+
* Passed in rather than derived here so exactly one module decides what an
|
|
792
|
+
* agent can authenticate as; see provider.ts.
|
|
793
|
+
*/
|
|
794
|
+
private readonly agentEnv: { director: AgentEnv; worker: AgentEnv },
|
|
795
|
+
/**
|
|
796
|
+
* Per-token rates per role, where the endpoint that will send the bill
|
|
797
|
+
* published them (see prices.ts). Absent for an Anthropic-native role —
|
|
798
|
+
* the SDK already reports its real cost — and absent for any endpoint that
|
|
799
|
+
* publishes nothing, which is what keeps that run honestly `unpriced`
|
|
800
|
+
* rather than priced from a table Foreman made up.
|
|
801
|
+
*/
|
|
802
|
+
private readonly prices: { director?: ModelPrice; worker?: ModelPrice } = {},
|
|
803
|
+
/**
|
|
804
|
+
* Live token counts from the gateway, for the long stretches where the
|
|
805
|
+
* SDK has nothing to say.
|
|
806
|
+
*
|
|
807
|
+
* An OpenAI-compatible endpoint reports usage only on the final chunk of
|
|
808
|
+
* a stream, and the SDK only surfaces it on the `result` message that
|
|
809
|
+
* ends a turn — so a director working through one long turn shows zero
|
|
810
|
+
* tokens for minutes while really having spent a hundred thousand. The
|
|
811
|
+
* gateway sees every response, so it can answer in the meantime.
|
|
812
|
+
*
|
|
813
|
+
* Strictly an INTERIM figure: it is emitted, never persisted, and it is
|
|
814
|
+
* reset to nothing every time a `result` message arrives with the real
|
|
815
|
+
* number. The SDK stays the authority on what this run actually spent.
|
|
816
|
+
*/
|
|
817
|
+
private readonly ledger?: {
|
|
818
|
+
key: string;
|
|
819
|
+
roles: { director: boolean; worker: boolean };
|
|
820
|
+
read: (key: string) => Promise<TokenUsage & { calls: number; costUsd?: number } | null>;
|
|
821
|
+
},
|
|
822
|
+
/**
|
|
823
|
+
* Each role's cost basis, so a mid-run change of provider — a worker
|
|
824
|
+
* retried on the director's — can re-derive the run's basis and say so
|
|
825
|
+
* before another token is spent. Absent means "unknown", which leaves the
|
|
826
|
+
* run's basis alone.
|
|
827
|
+
*/
|
|
828
|
+
private readonly roleBasis?: { director: CostBasis; worker: CostBasis },
|
|
829
|
+
/**
|
|
830
|
+
* What the host lends the run beyond the model: today, putting a dev
|
|
831
|
+
* server the crew started behind Foreman's own address so the human can
|
|
832
|
+
* open it from the phone. Optional — a run without a host hook simply
|
|
833
|
+
* has no `expose_service` to offer.
|
|
834
|
+
*/
|
|
835
|
+
private readonly host: {
|
|
836
|
+
exposeService?: (runId: string, port: number, label: string) => Promise<{ ok: true; url: string; path: string } | { ok: false; reason: string }>;
|
|
837
|
+
} = {},
|
|
838
|
+
) {
|
|
839
|
+
this.meta = meta;
|
|
840
|
+
// Cost kept as parts, so a resume adds to the right pile and an upstream
|
|
841
|
+
// that reports its own figure can outrank the rated one for the same
|
|
842
|
+
// tokens. A run recorded before parts existed has its whole total treated
|
|
843
|
+
// as native, which changes nothing about what it shows.
|
|
844
|
+
this.costParts = meta.costParts ?? { native: meta.costUsd ?? 0, rated: 0, ledger: 0 };
|
|
845
|
+
this.ledgerBefore = this.costParts.ledger;
|
|
846
|
+
// Usage and turns are counted from zero on a fresh run, but a resumed one
|
|
847
|
+
// must keep the running total rather than quietly under-reporting
|
|
848
|
+
// everything before the restart — same reasoning as the workers map below.
|
|
849
|
+
this.meta.usage = meta.usage ?? emptyUsage();
|
|
850
|
+
this.turns = meta.turns ?? 0;
|
|
851
|
+
// Rehydrate orchestrator state from persisted metadata so a resumed run
|
|
852
|
+
// behaves like the original process: message_worker can reach prior
|
|
853
|
+
// workers, new worker ids never collide with old ones, and "always
|
|
854
|
+
// allow" grants survive.
|
|
855
|
+
for (const w of meta.workers) {
|
|
856
|
+
const r: WorkerRuntime = { ...w };
|
|
857
|
+
// A worker persisted as 'running' has no process behind it any more: the
|
|
858
|
+
// one that was driving it died with the previous Foreman. Left as
|
|
859
|
+
// 'running', wait_for_worker would have nothing to wait on and
|
|
860
|
+
// check_workers would show a worker that is never going to finish.
|
|
861
|
+
// Its session may still be resumable, and the record says so.
|
|
862
|
+
if (r.status === 'running') {
|
|
863
|
+
r.status = 'error';
|
|
864
|
+
r.isError = true;
|
|
865
|
+
r.endedAt = r.endedAt ?? Date.now();
|
|
866
|
+
r.report = r.report ?? 'Foreman restarted while this worker was running; its result was lost. ' +
|
|
867
|
+
'Verify what it left on disk, then message_worker it to continue or spawn a fresh one.';
|
|
868
|
+
}
|
|
869
|
+
this.workers.set(w.id, r);
|
|
870
|
+
const n = Number(w.id.match(/^worker-(\d+)$/)?.[1] ?? 0);
|
|
871
|
+
if (n > this.workerSeq) this.workerSeq = n;
|
|
872
|
+
}
|
|
873
|
+
for (const t of meta.allowedTools ?? []) this.runAllowed.add(t);
|
|
874
|
+
for (const r of meta.allowedRoots ?? []) this.allowedRoots.add(r);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Writes both grant sets back to meta and persists — one place, so they cannot drift. */
|
|
878
|
+
private syncAllowed(): void {
|
|
879
|
+
this.meta.allowedTools = [...this.runAllowed];
|
|
880
|
+
this.meta.allowedRoots = [...this.allowedRoots];
|
|
881
|
+
this.saveMeta(this.meta);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// -- unattended defaults ----------------------------------------------------
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Arms the unattended-default timer for one ask. `onTimeout` runs only if
|
|
888
|
+
* the ask is still pending when the clock expires; it receives the wait so
|
|
889
|
+
* the message and the event can say how long the human was given.
|
|
890
|
+
*/
|
|
891
|
+
private armAsk(id: string, onTimeout: (afterMs: number) => void): void {
|
|
892
|
+
const ms = this.meta.askTimeoutMs ?? DEFAULT_ASK_TIMEOUT_MS;
|
|
893
|
+
this.askTimers.set(id, armAskTimeout(ms, () => {
|
|
894
|
+
this.askTimers.delete(id);
|
|
895
|
+
onTimeout(ms);
|
|
896
|
+
}));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private cancelAsk(id: string): void {
|
|
900
|
+
this.askTimers.get(id)?.cancel();
|
|
901
|
+
this.askTimers.delete(id);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private cancelAllAsks(): void {
|
|
905
|
+
for (const t of this.askTimers.values()) t.cancel();
|
|
906
|
+
this.askTimers.clear();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// -- public control surface (called by the HTTP layer) --------------------
|
|
910
|
+
|
|
911
|
+
get pendingPermissionIds(): string[] {
|
|
912
|
+
return [...this.pendingPermissions.keys()];
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
get pendingQuestionIds(): string[] {
|
|
916
|
+
return [...this.pendingQuestions.keys()];
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Every ask still open, with the text and options a remote surface needs to answer it. */
|
|
920
|
+
pendingAsks(): Array<{ id: string; kind: 'permission' | 'question'; text: string; options?: string[]; toolName?: string; since: number }> {
|
|
921
|
+
return [...this.askMeta].map(([id, m]) => ({ id, ...m }));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
resolvePermission(id: string, decision: 'allow' | 'allow_always' | 'deny', message?: string): boolean {
|
|
925
|
+
this.askMeta.delete(id);
|
|
926
|
+
const pending = this.pendingPermissions.get(id);
|
|
927
|
+
if (!pending) return false;
|
|
928
|
+
this.pendingPermissions.delete(id);
|
|
929
|
+
this.cancelAsk(id);
|
|
930
|
+
let result: PermissionResult;
|
|
931
|
+
if (decision === 'allow_always' && pending.escapedPath) {
|
|
932
|
+
// The card was about a PATH, so that is what "always" grants — see the
|
|
933
|
+
// contract on `PendingPermission.escapedPath`. Adding the tool instead
|
|
934
|
+
// would be ignored by the boundary (by design) and the next sibling
|
|
935
|
+
// command would prompt again: "Always" looked broken while doing
|
|
936
|
+
// exactly what it said. Not `updatedPermissions`: the SDK's grant is a
|
|
937
|
+
// tool rule, and there is none to give here.
|
|
938
|
+
this.allowedRoots.add(pending.escapedPath);
|
|
939
|
+
this.syncAllowed();
|
|
940
|
+
this.emit('root_allowed', { path: pending.escapedPath, agent: pending.agent, toolName: pending.toolName });
|
|
941
|
+
result = { behavior: 'allow' };
|
|
942
|
+
} else if (decision === 'allow_always') {
|
|
943
|
+
this.runAllowed.add(pending.toolName);
|
|
944
|
+
this.syncAllowed();
|
|
945
|
+
result = { behavior: 'allow', updatedPermissions: pending.suggestions };
|
|
946
|
+
} else if (decision === 'allow') {
|
|
947
|
+
result = { behavior: 'allow' };
|
|
948
|
+
} else {
|
|
949
|
+
result = { behavior: 'deny', message: message || 'Denied by user.' };
|
|
950
|
+
}
|
|
951
|
+
pending.resolve(result);
|
|
952
|
+
this.emit('permission_resolved', { id, behavior: decision });
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
answerQuestion(id: string, text: string): boolean {
|
|
957
|
+
this.askMeta.delete(id);
|
|
958
|
+
const resolve = this.pendingQuestions.get(id);
|
|
959
|
+
if (!resolve) return false;
|
|
960
|
+
this.pendingQuestions.delete(id);
|
|
961
|
+
this.cancelAsk(id);
|
|
962
|
+
resolve(text);
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Unsolicited operator guidance to the director, delivered at its next
|
|
968
|
+
* turn boundary. Returns false once the mission is finishing.
|
|
969
|
+
*/
|
|
970
|
+
steer(text: string): boolean {
|
|
971
|
+
if (!this.directorInput || this.directorInput.isClosed) return false;
|
|
972
|
+
this.emit('steer', { to: 'director', text, timing: 'next' });
|
|
973
|
+
return this.directorInput.push(
|
|
974
|
+
'[OPERATOR STEER — mid-mission note from the human overseer]\n' +
|
|
975
|
+
`${text}\n\n` +
|
|
976
|
+
'Acknowledge briefly, update .foreman/MISSION.md if this changes the plan ' +
|
|
977
|
+
'or DONE WHEN, and continue the mission with this guidance applied.',
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Change a running mission's settings without restarting it.
|
|
983
|
+
*
|
|
984
|
+
* Both fields genuinely bind mid-run, which is why these two and not a
|
|
985
|
+
* general settings patch:
|
|
986
|
+
*
|
|
987
|
+
* - **`browserTools`** is read by browserServers() at every worker spawn,
|
|
988
|
+
* so turning it on reaches every worker started from now on. The
|
|
989
|
+
* director keeps whatever tool set its own query() was opened with and
|
|
990
|
+
* gains the browser on its next resume — said plainly in the returned
|
|
991
|
+
* note, because a half-applied change the human believes is fully
|
|
992
|
+
* applied is worse than one they know the shape of.
|
|
993
|
+
* - **`budgetUsd`** is re-read by enforceBudget() on every cost update, so
|
|
994
|
+
* a raised cap takes effect on the next token. Raising it also clears the
|
|
995
|
+
* alerts already sent: a notice that fired against the old figure has
|
|
996
|
+
* been superseded, and leaving the flags set would mean a genuine
|
|
997
|
+
* overrun of the NEW budget passed in silence.
|
|
998
|
+
*
|
|
999
|
+
* Returns what actually changed, for the transcript. A governance layer has
|
|
1000
|
+
* to record who changed the rules mid-mission, so this is emitted into the
|
|
1001
|
+
* run's own event log rather than only mutating state.
|
|
1002
|
+
*/
|
|
1003
|
+
applySettings(patch: { browserTools?: boolean; budgetUsd?: number }): string[] {
|
|
1004
|
+
const changed: string[] = [];
|
|
1005
|
+
|
|
1006
|
+
if (typeof patch.browserTools === 'boolean' && patch.browserTools !== Boolean(this.meta.browserTools)) {
|
|
1007
|
+
this.meta.browserTools = patch.browserTools || undefined;
|
|
1008
|
+
changed.push(patch.browserTools
|
|
1009
|
+
? 'browser tools ON — workers spawned from now on get a headless browser; ' +
|
|
1010
|
+
'the director gains it when the run is resumed'
|
|
1011
|
+
: 'browser tools OFF — no worker spawned from now on gets a browser');
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (typeof patch.budgetUsd === 'number' && Number.isFinite(patch.budgetUsd)
|
|
1015
|
+
&& patch.budgetUsd >= 0 && patch.budgetUsd !== this.meta.budgetUsd) {
|
|
1016
|
+
const raised = patch.budgetUsd > this.meta.budgetUsd;
|
|
1017
|
+
changed.push(`budget $${this.meta.budgetUsd.toFixed(2)} → $${patch.budgetUsd.toFixed(2)}`);
|
|
1018
|
+
this.meta.budgetUsd = patch.budgetUsd;
|
|
1019
|
+
if (raised) {
|
|
1020
|
+
// The wind-down order already delivered cannot be unsaid — the
|
|
1021
|
+
// director read it — but the flags must not keep a later, real
|
|
1022
|
+
// overrun quiet.
|
|
1023
|
+
this.budgetNoticeSent = false;
|
|
1024
|
+
this.budgetKillSent = false;
|
|
1025
|
+
this.budgetStopped = false;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (changed.length) {
|
|
1030
|
+
this.saveMeta(this.meta);
|
|
1031
|
+
this.emit('settings_changed', { changes: changed, browserTools: Boolean(this.meta.browserTools), budgetUsd: this.meta.budgetUsd });
|
|
1032
|
+
this.emitEconomics();
|
|
1033
|
+
}
|
|
1034
|
+
return changed;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async interrupt(): Promise<void> {
|
|
1038
|
+
this.wasInterrupted = true;
|
|
1039
|
+
// Pending asks are settled by the SDK's abort (permissions) or die with
|
|
1040
|
+
// the query (questions); a timer firing after that would auto-deny into
|
|
1041
|
+
// a run that is already stopping and stamp the transcript with a wait
|
|
1042
|
+
// that was never going to be answered.
|
|
1043
|
+
this.cancelAllAsks();
|
|
1044
|
+
this.directorInput?.close(); // no further turns; let the session wind down
|
|
1045
|
+
for (const w of this.workers.values()) {
|
|
1046
|
+
if (w.q) await w.q.interrupt().catch(() => {});
|
|
1047
|
+
}
|
|
1048
|
+
await this.directorQ?.interrupt().catch(() => {});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// -- lifecycle ------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Runs the mission to completion. Resolves when the director ends.
|
|
1055
|
+
*
|
|
1056
|
+
* Passing `resume` marks this as a continuation: the director is told to
|
|
1057
|
+
* re-verify state against MISSION.md rather than start the mission over.
|
|
1058
|
+
* `resume.sessionId` restores its prior context when available — it is
|
|
1059
|
+
* absent when the director's model changed, since a session cannot switch
|
|
1060
|
+
* models. Resuming WITHOUT a session is still a resume: the mission doc
|
|
1061
|
+
* and the working directory carry the state across.
|
|
1062
|
+
*/
|
|
1063
|
+
async start(resume?: { sessionId?: string }): Promise<void> {
|
|
1064
|
+
const resumeSessionId = resume?.sessionId;
|
|
1065
|
+
const isResume = resume !== undefined;
|
|
1066
|
+
// Only a run with a gatewayed role has anything to poll for. Three
|
|
1067
|
+
// seconds is chosen against what it is for — a person watching a turn
|
|
1068
|
+
// that has been silent for minutes — not against how fast tokens move.
|
|
1069
|
+
if (this.ledger && (this.ledger.roles.director || this.ledger.roles.worker)) {
|
|
1070
|
+
this.ledgerTimer = setInterval(() => void this.pollLedger(), 3000);
|
|
1071
|
+
this.ledgerTimer.unref?.();
|
|
1072
|
+
}
|
|
1073
|
+
// The backstop for the caps the director cannot check while it is blocked
|
|
1074
|
+
// inside a tool call. Deliberately later than the graceful wind-down, so
|
|
1075
|
+
// a responsive director always gets to finish its own way.
|
|
1076
|
+
this.capTimer = setInterval(() => this.enforceCapsFromOutside(), 60_000);
|
|
1077
|
+
this.capTimer.unref?.();
|
|
1078
|
+
this.emit(isResume ? 'run_resumed' : 'run_started', {
|
|
1079
|
+
runId: this.meta.id,
|
|
1080
|
+
folder: this.meta.folder,
|
|
1081
|
+
mission: this.meta.mission,
|
|
1082
|
+
budgetUsd: this.meta.budgetUsd,
|
|
1083
|
+
costUsd: this.meta.costUsd,
|
|
1084
|
+
// Carried here, not only on `cost`, because a run that spends no
|
|
1085
|
+
// priceable dollars may never emit a cost event at all — and the UI
|
|
1086
|
+
// would then keep replaying an older run's cost basis forever.
|
|
1087
|
+
costBasis: costBasisOf(this.meta),
|
|
1088
|
+
metered: isPriced(this.meta),
|
|
1089
|
+
usage: this.meta.usage,
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
// Name the mission in parallel with running it: the title is display-only,
|
|
1093
|
+
// so nothing waits on it, and a resumed run that predates titling picks
|
|
1094
|
+
// one up here.
|
|
1095
|
+
if (!this.meta.title) void this.titleMission();
|
|
1096
|
+
|
|
1097
|
+
const prompt = isResume
|
|
1098
|
+
? `MISSION (unchanged): ${this.meta.mission}\n\n` +
|
|
1099
|
+
'This mission was interrupted (process restart, crash, usage limit, or an ' +
|
|
1100
|
+
'operator stop) and is now being resumed. DO NOT START OVER. ' +
|
|
1101
|
+
(resumeSessionId
|
|
1102
|
+
? 'Do not trust your memory of progress: '
|
|
1103
|
+
: 'You are a NEW session with no memory of this mission at all — everything ' +
|
|
1104
|
+
'you know must come from disk. ') +
|
|
1105
|
+
're-read .foreman/MISSION.md if it exists ' +
|
|
1106
|
+
'(write it first if it does not), inspect the working directory to see which ' +
|
|
1107
|
+
'files already exist and what state they are in, and verify which milestones are ' +
|
|
1108
|
+
'actually complete. Keep completed work; do not rewrite files that already ' +
|
|
1109
|
+
'satisfy their milestone. Update the doc to match reality, then continue ' +
|
|
1110
|
+
'the mission to DONE WHEN. ' +
|
|
1111
|
+
this.budgetNote()
|
|
1112
|
+
: `MISSION: ${this.meta.mission}\n\n${this.budgetLine()} ` +
|
|
1113
|
+
`Working directory: ${this.meta.folder}. Begin by writing .foreman/MISSION.md, then execute the plan.`;
|
|
1114
|
+
|
|
1115
|
+
try {
|
|
1116
|
+
// The mission doc directory ignores itself wholesale (`*`, which also
|
|
1117
|
+
// covers the scratch space) so missions never pollute `git status` in
|
|
1118
|
+
// real repositories — MISSION.md is Foreman's record, not the project's.
|
|
1119
|
+
// Ensured rather than overwritten, so a rule someone added by hand
|
|
1120
|
+
// survives. The scratch space exists before the director's first turn,
|
|
1121
|
+
// fresh start and resume alike (a resumed folder may predate WORK_DIR).
|
|
1122
|
+
// The first mkdir is inside the try so a bad folder surfaces as a failed
|
|
1123
|
+
// run, never an unhandled rejection; the scratch dir swallows its own
|
|
1124
|
+
// errors because a missing outlet should not stop a mission, only make
|
|
1125
|
+
// it ask more.
|
|
1126
|
+
const foremanDir = path.join(this.meta.folder, '.foreman');
|
|
1127
|
+
await mkdir(foremanDir, { recursive: true });
|
|
1128
|
+
await ensureIgnoreLines(path.join(foremanDir, '.gitignore'), ['*']);
|
|
1129
|
+
// What the folder looks like before the crew touches it, so the deck
|
|
1130
|
+
// can later say what this run changed. Fresh starts only: a resume
|
|
1131
|
+
// continues the same run and must keep the same baseline. Never on the
|
|
1132
|
+
// critical path — a missing baseline degrades to "cannot attribute",
|
|
1133
|
+
// not to a failed run.
|
|
1134
|
+
if (!isResume) {
|
|
1135
|
+
void captureBaseline(this.meta.folder, this.meta.id).catch(() => {});
|
|
1136
|
+
}
|
|
1137
|
+
await mkdir(path.join(this.meta.folder, WORK_DIR), { recursive: true }).catch(() => {});
|
|
1138
|
+
// Covers a .claude/ left by an earlier run; the one this run creates is
|
|
1139
|
+
// handled again on the way out.
|
|
1140
|
+
await this.ignoreLocalSettings();
|
|
1141
|
+
|
|
1142
|
+
// Streaming prompt: the mission goes in first; steering pushes more
|
|
1143
|
+
// user messages, each starting a new director turn.
|
|
1144
|
+
const input = new MessageStream();
|
|
1145
|
+
this.directorInput = input;
|
|
1146
|
+
input.push(prompt);
|
|
1147
|
+
|
|
1148
|
+
const q = query({
|
|
1149
|
+
prompt: input,
|
|
1150
|
+
options: {
|
|
1151
|
+
cwd: this.meta.folder,
|
|
1152
|
+
permissionMode: 'default',
|
|
1153
|
+
resume: resumeSessionId,
|
|
1154
|
+
model: this.meta.directorModel,
|
|
1155
|
+
maxTurns: 150,
|
|
1156
|
+
systemPrompt: { type: 'preset', preset: 'claude_code', append: DIRECTOR_CHARTER },
|
|
1157
|
+
...this.agentEnv.director,
|
|
1158
|
+
mcpServers: { foreman: this.makeTools(), ...this.browserServers() },
|
|
1159
|
+
canUseTool: this.policyFor('director'),
|
|
1160
|
+
},
|
|
1161
|
+
});
|
|
1162
|
+
this.directorQ = q;
|
|
1163
|
+
|
|
1164
|
+
// The loop watchdog for the director. Not a kill: the director has the
|
|
1165
|
+
// mission context and is the only agent that can change course, so the
|
|
1166
|
+
// first streak gets an in-band notice. A second streak of the same call
|
|
1167
|
+
// after being told, in so many words, that it is looping is a director
|
|
1168
|
+
// that is not going to recover — and every further turn is spend. Built
|
|
1169
|
+
// fresh per query() call, so a resumed session starts with a clean slate
|
|
1170
|
+
// rather than inheriting a streak from a context it no longer has.
|
|
1171
|
+
let loopNoticed: string | null = null;
|
|
1172
|
+
const repeats = watchRepeats(DEFAULT_REPEAT_LIMIT, ({ toolName, count, input }) => {
|
|
1173
|
+
const streak = `${toolName}\u0000${stableStringify(input)}`;
|
|
1174
|
+
this.emit('director_looping', { toolName, count });
|
|
1175
|
+
if (loopNoticed === streak) {
|
|
1176
|
+
this.emit('run_error', {
|
|
1177
|
+
error: `Director ignored a loop notice and issued the identical ${toolName} call ` +
|
|
1178
|
+
`${count} more times in a row — run interrupted.`,
|
|
1179
|
+
});
|
|
1180
|
+
void this.interrupt();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
loopNoticed = streak;
|
|
1184
|
+
repeats.reset();
|
|
1185
|
+
this.directorInput?.push(
|
|
1186
|
+
'[LOOP DETECTED — automated notice]\n' +
|
|
1187
|
+
`You have issued the identical ${toolName} call ${count} times in a row with identical ` +
|
|
1188
|
+
'input. Repeating it again will not produce a different result. Stop, state in one ' +
|
|
1189
|
+
'sentence what you expected to change and why it did not, and either take a different ' +
|
|
1190
|
+
'approach or ask the human via mcp__foreman__ask_human.',
|
|
1191
|
+
);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
let lastTurnFailed = false;
|
|
1195
|
+
for await (const msg of this.directorQ as AsyncIterable<SDKMessage>) {
|
|
1196
|
+
const m = msg as Record<string, unknown>;
|
|
1197
|
+
if (typeof m.session_id === 'string') this.meta.directorSessionId = m.session_id;
|
|
1198
|
+
for (const t of toolUsesOf(m)) observeToolUse(repeats, t.name, t.input);
|
|
1199
|
+
if (m.type === 'result') {
|
|
1200
|
+
// Cumulative per query() call — record only the delta per turn.
|
|
1201
|
+
const total = m.total_cost_usd as number | undefined;
|
|
1202
|
+
if (typeof total === 'number') {
|
|
1203
|
+
// Usage first: addCost emits the `cost` event carrying it, so
|
|
1204
|
+
// folding it in afterwards ships a snapshot one turn stale — the
|
|
1205
|
+
// meter showed `0 tok` on a run that had just spent 123k of them.
|
|
1206
|
+
this.turns++;
|
|
1207
|
+
this.addUsage(m.usage);
|
|
1208
|
+
this.addCost(total - this.directorCostSeen);
|
|
1209
|
+
this.directorCostSeen = total;
|
|
1210
|
+
} else {
|
|
1211
|
+
this.turns++;
|
|
1212
|
+
this.addUsage(m.usage);
|
|
1213
|
+
}
|
|
1214
|
+
lastTurnFailed = Boolean(m.is_error);
|
|
1215
|
+
this.noteUsageLimit(String(m.result ?? ''));
|
|
1216
|
+
|
|
1217
|
+
// Enforce the cap against the director's own spend, at the only
|
|
1218
|
+
// point its cost is known. One wind-down turn, then stop — checked
|
|
1219
|
+
// before the pending-input test so a queued steer cannot extend a
|
|
1220
|
+
// run that has already been told this was its last turn.
|
|
1221
|
+
const windDown = this.budgetWindDown();
|
|
1222
|
+
if (windDown) {
|
|
1223
|
+
input.push(windDown);
|
|
1224
|
+
} else if (this.budgetStopped) {
|
|
1225
|
+
input.close();
|
|
1226
|
+
this.emit('message', { agent: 'director', msg });
|
|
1227
|
+
break;
|
|
1228
|
+
}
|
|
1229
|
+
// A result means the CLI is idle with all delivered input
|
|
1230
|
+
// processed (a steer consumed mid-turn is folded into that
|
|
1231
|
+
// turn's result). Only a steer still waiting in our queue
|
|
1232
|
+
// guarantees another turn; otherwise the mission is over.
|
|
1233
|
+
// Closing the input stream alone does not end the CLI
|
|
1234
|
+
// session, so break and dispose the query explicitly.
|
|
1235
|
+
if (input.pending === 0) {
|
|
1236
|
+
input.close();
|
|
1237
|
+
this.emit('message', { agent: 'director', msg });
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
this.emit('message', { agent: 'director', msg });
|
|
1242
|
+
}
|
|
1243
|
+
input.close();
|
|
1244
|
+
await (q as AsyncGenerator<SDKMessage>).return?.(undefined as never).catch(() => {});
|
|
1245
|
+
// A run cut short by the cap is resumable, not complete — labelling it
|
|
1246
|
+
// 'done' would claim a mission finished that the budget ended.
|
|
1247
|
+
this.meta.status = this.wasInterrupted || this.usageLimited || this.budgetStopped ? 'interrupted'
|
|
1248
|
+
: lastTurnFailed ? 'error' : 'done';
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
this.meta.status = 'error';
|
|
1251
|
+
this.emit('run_error', { error: String(err) });
|
|
1252
|
+
} finally {
|
|
1253
|
+
// Stop polling before anything else: the run is over, every result is
|
|
1254
|
+
// in, and an interim estimate outliving the real figure would be the
|
|
1255
|
+
// one number here that is not backed by anything.
|
|
1256
|
+
if (this.ledgerTimer) clearInterval(this.ledgerTimer);
|
|
1257
|
+
if (this.capTimer) clearInterval(this.capTimer);
|
|
1258
|
+
this.cancelAllAsks();
|
|
1259
|
+
this.interimUsage = emptyUsage();
|
|
1260
|
+
// "Always allow" makes the SDK write .claude/settings.local.json into the
|
|
1261
|
+
// mission folder, which appears only once a grant happens — so this runs
|
|
1262
|
+
// after the work, not just before it.
|
|
1263
|
+
await this.ignoreLocalSettings();
|
|
1264
|
+
if (this.meta.status === 'running') this.meta.status = 'interrupted';
|
|
1265
|
+
|
|
1266
|
+
// A director's exit is not proof its mission succeeded. The charter makes
|
|
1267
|
+
// it write DONE WHEN criteria and tick each one the moment it is actually
|
|
1268
|
+
// verified, so criteria still unticked at exit are the director's own
|
|
1269
|
+
// record that the work is unfinished — and reporting that as 'done' is
|
|
1270
|
+
// the one lie a mission runner cannot afford. Downgrading to
|
|
1271
|
+
// 'interrupted' is also the useful answer: it is what makes the run
|
|
1272
|
+
// resumable rather than closed.
|
|
1273
|
+
if (this.meta.status === 'done') {
|
|
1274
|
+
const unmet = await this.unmetCriteria();
|
|
1275
|
+
if (unmet?.length) {
|
|
1276
|
+
this.meta.status = 'interrupted';
|
|
1277
|
+
this.emit('mission_incomplete', {
|
|
1278
|
+
unmet,
|
|
1279
|
+
text: `The director ended with ${unmet.length} DONE WHEN criteri` +
|
|
1280
|
+
`${unmet.length === 1 ? 'on' : 'a'} still unticked, so this run is not done. ` +
|
|
1281
|
+
'Resume to continue it.',
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
this.meta.endedAt = Date.now();
|
|
1286
|
+
this.saveMeta(this.meta);
|
|
1287
|
+
this.emit('run_finished', {
|
|
1288
|
+
status: this.meta.status,
|
|
1289
|
+
costUsd: this.meta.costUsd,
|
|
1290
|
+
directorSessionId: this.meta.directorSessionId,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// -- internals ------------------------------------------------------------
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Asks the cheapest model for a short name for this mission, once.
|
|
1299
|
+
*
|
|
1300
|
+
* Fire-and-forget by design: it must not delay the director's first turn,
|
|
1301
|
+
* and a run whose title never arrives is displayed by its brief instead.
|
|
1302
|
+
* The fraction of a cent it costs is folded into the run's ledger rather
|
|
1303
|
+
* than spent invisibly.
|
|
1304
|
+
*/
|
|
1305
|
+
private async titleMission(): Promise<void> {
|
|
1306
|
+
// Titling rides with the director: same provider, same bill.
|
|
1307
|
+
const named = await generateRunTitle(this.meta.mission, this.agentEnv.director);
|
|
1308
|
+
if (!named || this.meta.title) return;
|
|
1309
|
+
this.meta.title = named.title;
|
|
1310
|
+
this.emit('run_titled', { title: named.title });
|
|
1311
|
+
// addCost persists meta, so the title lands on disk with its own cost.
|
|
1312
|
+
if (named.costUsd > 0) this.addCost(named.costUsd);
|
|
1313
|
+
else this.saveMeta(this.meta);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* DONE WHEN criteria the director never ticked.
|
|
1318
|
+
*
|
|
1319
|
+
* Reads the mission doc rather than trusting the transcript, because the doc
|
|
1320
|
+
* is the mission's contract and the thing a resumed director reads back.
|
|
1321
|
+
* Only the DONE WHEN section counts: Plan milestones describe the route, and
|
|
1322
|
+
* a route can legitimately change, but the criteria are what "finished"
|
|
1323
|
+
* means for this mission.
|
|
1324
|
+
*
|
|
1325
|
+
* Null when there is nothing to judge by — no doc, or a doc with no criteria
|
|
1326
|
+
* — because absence of evidence is not evidence of failure, and a mission
|
|
1327
|
+
* whose director never wrote a doc has already failed more visibly.
|
|
1328
|
+
*/
|
|
1329
|
+
private async unmetCriteria(): Promise<string[] | null> {
|
|
1330
|
+
const doc = await readFile(path.join(this.meta.folder, '.foreman', 'MISSION.md'), 'utf8')
|
|
1331
|
+
.catch(() => null);
|
|
1332
|
+
if (!doc) return null;
|
|
1333
|
+
|
|
1334
|
+
const lines = doc.split('\n');
|
|
1335
|
+
const start = lines.findIndex((l) => /^#{1,6}\s*DONE\s*WHEN/i.test(l.trim()));
|
|
1336
|
+
if (start === -1) return null;
|
|
1337
|
+
|
|
1338
|
+
const unmet: string[] = [];
|
|
1339
|
+
let sawAny = false;
|
|
1340
|
+
for (const line of lines.slice(start + 1)) {
|
|
1341
|
+
// The section ends at the next heading; checkboxes below it are the plan.
|
|
1342
|
+
if (/^#{1,6}\s/.test(line)) break;
|
|
1343
|
+
const box = line.match(/^\s*[-*]\s*\[( |x|X)\]\s*(.*)$/);
|
|
1344
|
+
if (!box) continue;
|
|
1345
|
+
sawAny = true;
|
|
1346
|
+
if (box[1] === ' ') unmet.push(box[2].trim());
|
|
1347
|
+
}
|
|
1348
|
+
return sawAny ? unmet : null;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* A headless Playwright browser (its own profile — never the user's
|
|
1353
|
+
* Chrome), granted when the mission enabled browser tools.
|
|
1354
|
+
*/
|
|
1355
|
+
private browserServers(): Record<string, McpServerConfig> {
|
|
1356
|
+
if (!this.meta.browserTools) return {};
|
|
1357
|
+
return {
|
|
1358
|
+
playwright: {
|
|
1359
|
+
type: 'stdio',
|
|
1360
|
+
// Absolute path: the server runs with the mission folder as cwd,
|
|
1361
|
+
// where npx cannot resolve Foreman's own dependency.
|
|
1362
|
+
command: process.execPath,
|
|
1363
|
+
// Chrome channel by default (the machine's own Chrome, no download);
|
|
1364
|
+
// FOREMAN_BROWSER picks another channel or Playwright's Chromium.
|
|
1365
|
+
args: [PLAYWRIGHT_MCP_CLI, '--headless', '--isolated', '--browser', process.env.FOREMAN_BROWSER || 'chrome'],
|
|
1366
|
+
},
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
private policyFor(agent: string) {
|
|
1371
|
+
return makePolicy(agent, this.meta.folder, this.runAllowed, this.allowedRoots, {
|
|
1372
|
+
onAutoAllow: (a, toolName, reason) => this.emit('auto_allowed', { agent: a, toolName, reason }),
|
|
1373
|
+
onAutoDeny: (a, toolName, reason) => this.emit('auto_denied', { agent: a, toolName, reason }),
|
|
1374
|
+
onAsk: (a, id, req) => {
|
|
1375
|
+
this.askMeta.set(id, {
|
|
1376
|
+
kind: 'permission', toolName: req.toolName, since: Date.now(),
|
|
1377
|
+
text: `${a} wants ${req.toolName}${req.description ? ` — ${req.description}` : ''}`,
|
|
1378
|
+
});
|
|
1379
|
+
this.emit('permission_request', { id, agent: a, ...req });
|
|
1380
|
+
},
|
|
1381
|
+
register: (id, pending) => {
|
|
1382
|
+
// `pending` carries `escapedPath` through untouched: resolvePermission
|
|
1383
|
+
// reads it to decide whether "always" grants the path or the tool.
|
|
1384
|
+
this.pendingPermissions.set(id, { ...pending, agent });
|
|
1385
|
+
// The unattended default. Resolved here rather than through
|
|
1386
|
+
// resolvePermission so the transcript gets `permission_timeout`, not
|
|
1387
|
+
// a `permission_resolved` that looks like a human clicked Deny.
|
|
1388
|
+
this.armAsk(id, (afterMs) => {
|
|
1389
|
+
if (!this.pendingPermissions.delete(id)) return;
|
|
1390
|
+
pending.resolve({ behavior: 'deny', message: unattendedDenyMessage(afterMs) });
|
|
1391
|
+
this.emit('permission_timeout', { id, agent, toolName: pending.toolName, afterMs });
|
|
1392
|
+
});
|
|
1393
|
+
},
|
|
1394
|
+
unregister: (id) => {
|
|
1395
|
+
this.askMeta.delete(id);
|
|
1396
|
+
const existed = this.pendingPermissions.delete(id);
|
|
1397
|
+
this.cancelAsk(id);
|
|
1398
|
+
if (existed) this.emit('permission_resolved', { id, behavior: 'aborted' });
|
|
1399
|
+
return existed;
|
|
1400
|
+
},
|
|
1401
|
+
}, { toolPolicy: this.meta.toolPolicy, autoAllowReadOnly: this.meta.autoAllowReadOnly });
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Detects Claude quota exhaustion in a result. This is not a mission
|
|
1406
|
+
* failure: no more work is possible until credits refresh, so the run
|
|
1407
|
+
* is marked interrupted (resumable) and the human is told plainly.
|
|
1408
|
+
*/
|
|
1409
|
+
private noteUsageLimit(text: string): void {
|
|
1410
|
+
if (this.usageLimited || !USAGE_LIMIT_RE.test(text)) return;
|
|
1411
|
+
this.usageLimited = true;
|
|
1412
|
+
this.directorInput?.close(); // further turns would only burn retries
|
|
1413
|
+
this.emit('usage_limit', {
|
|
1414
|
+
text: 'Paused: your Claude usage credits are exhausted, so no agent can ' +
|
|
1415
|
+
'make progress right now. This is not a mission failure — the work so ' +
|
|
1416
|
+
'far is saved. Resume once credits refresh (or switch to a cheaper ' +
|
|
1417
|
+
'model in Settings, which resume will pick up).',
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
private budgetNoticeSent = false;
|
|
1422
|
+
private budgetKillSent = false;
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Keeps an "always allow" grant out of `git status`.
|
|
1426
|
+
*
|
|
1427
|
+
* Granting a tool for the run makes the SDK persist it to
|
|
1428
|
+
* `.claude/settings.local.json` in the mission folder — a file the operator
|
|
1429
|
+
* never asked for and, in a real repository, one they could commit by
|
|
1430
|
+
* accident. `.foreman/` solves this by ignoring itself wholesale; `.claude/`
|
|
1431
|
+
* cannot, because a project may legitimately track its own agents, commands
|
|
1432
|
+
* and shared settings.json there. So only the local-settings file is ignored,
|
|
1433
|
+
* an existing .gitignore is appended to rather than replaced, and the
|
|
1434
|
+
* directory is never created here — a mission that grants nothing leaves the
|
|
1435
|
+
* folder exactly as it found it.
|
|
1436
|
+
*/
|
|
1437
|
+
private async ignoreLocalSettings(): Promise<void> {
|
|
1438
|
+
const dir = path.join(this.meta.folder, '.claude');
|
|
1439
|
+
if (!(await stat(dir).then((st) => st.isDirectory(), () => false))) return;
|
|
1440
|
+
await ensureIgnoreLines(path.join(dir, '.gitignore'), LOCAL_IGNORE_LINES);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Fold in the SDK's own dollar figure for a role.
|
|
1446
|
+
*
|
|
1447
|
+
* Ignored outright where Foreman holds that role's real rates: the SDK
|
|
1448
|
+
* prices every response with Anthropic's table, so on a gateway role its
|
|
1449
|
+
* number is fiction — and adding fiction to a figure computed from the
|
|
1450
|
+
* endpoint's own published rates would corrupt the one honest total.
|
|
1451
|
+
*/
|
|
1452
|
+
/**
|
|
1453
|
+
* `costUsd` from its parts. The upstream's own figure, once it has given
|
|
1454
|
+
* one, replaces the rated figure for gateway tokens rather than adding to
|
|
1455
|
+
* it — they price the same tokens, and the party that sends the bill wins.
|
|
1456
|
+
*/
|
|
1457
|
+
private recomputeCost(): void {
|
|
1458
|
+
const p = this.costParts;
|
|
1459
|
+
this.meta.costUsd = p.native + (this.ledgerReportsCost ? p.ledger : p.rated);
|
|
1460
|
+
this.meta.costParts = { ...p };
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
private addCost(usd: number | undefined, role: AgentRole = 'director'): void {
|
|
1464
|
+
if (typeof usd !== 'number') return;
|
|
1465
|
+
// Two cases where the SDK's dollar figure is not a fact about this run:
|
|
1466
|
+
// a role Foreman prices itself, and a role behind a gateway at all. The
|
|
1467
|
+
// second is the one that leaked — Anthropic's table applied to 5.8M
|
|
1468
|
+
// Ollama tokens produced "$30.14" on a fleet card for a run that cost
|
|
1469
|
+
// nothing measurable. Stopped at the source, not hidden at the display.
|
|
1470
|
+
if (this.prices[role] || this.ledger?.roles[role]) return;
|
|
1471
|
+
this.costParts.native += usd;
|
|
1472
|
+
this.recomputeCost();
|
|
1473
|
+
this.saveMeta(this.meta);
|
|
1474
|
+
this.emitEconomics();
|
|
1475
|
+
this.enforceBudget();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Folds one result message's token usage into the run total and persists
|
|
1480
|
+
* it. Called alongside addCost() from the same two call sites (director
|
|
1481
|
+
* loop, runWorker) so usage and cost are always in step — the honest
|
|
1482
|
+
* counterpart to a dollar figure that is not honest on every provider.
|
|
1483
|
+
*/
|
|
1484
|
+
/**
|
|
1485
|
+
* The one event that carries a run's economics. Emitted whenever either half
|
|
1486
|
+
* changes — dollars OR tokens — because through a gateway the SDK often
|
|
1487
|
+
* reports no cost at all, and a UI told only about dollars would never learn
|
|
1488
|
+
* that this run has none to report.
|
|
1489
|
+
*/
|
|
1490
|
+
private emitEconomics(): void {
|
|
1491
|
+
this.emit('cost', {
|
|
1492
|
+
costUsd: this.meta.costUsd,
|
|
1493
|
+
budgetUsd: this.meta.budgetUsd,
|
|
1494
|
+
usage: this.liveUsage(),
|
|
1495
|
+
costBasis: costBasisOf(this.meta),
|
|
1496
|
+
metered: isPriced(this.meta),
|
|
1497
|
+
turns: this.turns,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Read the gateway's running total and republish the economics.
|
|
1503
|
+
*
|
|
1504
|
+
* The baseline is what the ledger held at the last confirmed result, so
|
|
1505
|
+
* what is shown is only the growth since — added to a persisted total that
|
|
1506
|
+
* already accounts for everything before it. When a result lands, the
|
|
1507
|
+
* interim is dropped to nothing rather than carried, because briefly
|
|
1508
|
+
* under-reporting an estimate is safer than briefly double-counting one.
|
|
1509
|
+
*/
|
|
1510
|
+
private async pollLedger(): Promise<void> {
|
|
1511
|
+
if (!this.ledger) return;
|
|
1512
|
+
const now = await this.ledger.read(this.ledger.key).catch(() => null);
|
|
1513
|
+
if (!now) return;
|
|
1514
|
+
// An upstream that states its own cost per response (OpenRouter does) is
|
|
1515
|
+
// the most authoritative figure there is for those tokens: it replaces
|
|
1516
|
+
// the rated figure rather than adding to it. A run that was `unpriced`
|
|
1517
|
+
// becomes `priced` the moment a real bill shows up — and says so, because
|
|
1518
|
+
// the dollar cap arms with it.
|
|
1519
|
+
if (typeof now.costUsd === 'number' && Number.isFinite(now.costUsd)) {
|
|
1520
|
+
const before = this.meta.costUsd;
|
|
1521
|
+
this.ledgerReportsCost = true;
|
|
1522
|
+
this.costParts.ledger = this.ledgerBefore + now.costUsd;
|
|
1523
|
+
if (!isPriced(this.meta)) {
|
|
1524
|
+
this.meta.costBasis = 'priced';
|
|
1525
|
+
this.meta.metered = true;
|
|
1526
|
+
this.emit('settings_changed', {
|
|
1527
|
+
changes: ['the upstream reports its own cost per response — this run is now priced and the dollar cap is live'],
|
|
1528
|
+
browserTools: Boolean(this.meta.browserTools), budgetUsd: this.meta.budgetUsd,
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
this.recomputeCost();
|
|
1532
|
+
if (this.meta.costUsd !== before) { this.saveMeta(this.meta); this.enforceBudget(); }
|
|
1533
|
+
}
|
|
1534
|
+
if (!this.ledgerBaseline || this.rebaseline) {
|
|
1535
|
+
this.ledgerBaseline = now;
|
|
1536
|
+
this.rebaseline = false;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const b = this.ledgerBaseline;
|
|
1540
|
+
const grew = {
|
|
1541
|
+
inputTokens: Math.max(0, now.inputTokens - b.inputTokens),
|
|
1542
|
+
outputTokens: Math.max(0, now.outputTokens - b.outputTokens),
|
|
1543
|
+
cacheReadTokens: Math.max(0, now.cacheReadTokens - b.cacheReadTokens),
|
|
1544
|
+
cacheWriteTokens: Math.max(0, now.cacheWriteTokens - b.cacheWriteTokens),
|
|
1545
|
+
};
|
|
1546
|
+
const changed = Object.keys(grew).some(
|
|
1547
|
+
(k) => grew[k as keyof TokenUsage] !== this.interimUsage[k as keyof TokenUsage],
|
|
1548
|
+
);
|
|
1549
|
+
this.interimUsage = grew;
|
|
1550
|
+
if (changed) this.emitEconomics();
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Confirmed usage plus whatever the gateway has seen since. */
|
|
1554
|
+
private liveUsage(): TokenUsage {
|
|
1555
|
+
const c = this.meta.usage ?? emptyUsage();
|
|
1556
|
+
const i = this.interimUsage;
|
|
1557
|
+
return {
|
|
1558
|
+
inputTokens: c.inputTokens + i.inputTokens,
|
|
1559
|
+
outputTokens: c.outputTokens + i.outputTokens,
|
|
1560
|
+
cacheReadTokens: c.cacheReadTokens + i.cacheReadTokens,
|
|
1561
|
+
cacheWriteTokens: c.cacheWriteTokens + i.cacheWriteTokens,
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
private addUsage(raw: unknown, role: AgentRole = 'director'): void {
|
|
1566
|
+
// The real number for this turn just arrived; the estimate standing in
|
|
1567
|
+
// for it is now redundant, and the ledger must be re-baselined so the
|
|
1568
|
+
// same tokens are not offered again as growth.
|
|
1569
|
+
this.interimUsage = emptyUsage();
|
|
1570
|
+
this.rebaseline = true;
|
|
1571
|
+
const delta = normalizeUsage(raw);
|
|
1572
|
+
this.meta.usage = accumulateUsage(this.meta.usage ?? emptyUsage(), raw);
|
|
1573
|
+
this.meta.turns = this.turns;
|
|
1574
|
+
// Where the endpoint published rates, this is the run's real cost: its
|
|
1575
|
+
// own tokens at its own prices, accumulated per role so a mixed run bills
|
|
1576
|
+
// each half correctly instead of applying one table to both.
|
|
1577
|
+
const price = this.prices[role];
|
|
1578
|
+
if (price) { this.costParts.rated += priceUsage(price, delta); this.recomputeCost(); }
|
|
1579
|
+
this.saveMeta(this.meta);
|
|
1580
|
+
this.emitEconomics();
|
|
1581
|
+
if (price) this.enforceBudget();
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* The cap is a real fence, not just a gate on new workers:
|
|
1586
|
+
* - at 100% the director gets an in-band wind-down order (delivered into
|
|
1587
|
+
* its live turn via the streaming input);
|
|
1588
|
+
* - at 125% the run is interrupted outright.
|
|
1589
|
+
*/
|
|
1590
|
+
private enforceBudget(): void {
|
|
1591
|
+
const { costUsd, budgetUsd } = this.meta;
|
|
1592
|
+
if (budgetUsd <= 0) return;
|
|
1593
|
+
// The hard 125% kill is the one that ends a run outright, so it must never
|
|
1594
|
+
// fire on a figure that is not real money. A run that is not `priced` is
|
|
1595
|
+
// bounded by the turn and time caps in capReached() instead.
|
|
1596
|
+
if (!isPriced(this.meta)) return;
|
|
1597
|
+
if (!this.budgetKillSent && costUsd >= budgetUsd * 1.25) {
|
|
1598
|
+
this.budgetKillSent = true;
|
|
1599
|
+
this.budgetNoticeSent = true; // the kill supersedes the wind-down notice
|
|
1600
|
+
this.emit('budget_alert', {
|
|
1601
|
+
level: 'exceeded', costUsd, budgetUsd,
|
|
1602
|
+
text: `Budget overrun past 125% ($${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}) — run interrupted.`,
|
|
1603
|
+
});
|
|
1604
|
+
void this.interrupt();
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
if (!this.budgetNoticeSent && costUsd >= budgetUsd) {
|
|
1608
|
+
this.budgetNoticeSent = true;
|
|
1609
|
+
this.emit('budget_alert', {
|
|
1610
|
+
level: 'reached', costUsd, budgetUsd,
|
|
1611
|
+
text: `Budget cap reached ($${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}) — director ordered to wind down.`,
|
|
1612
|
+
});
|
|
1613
|
+
this.directorInput?.push(
|
|
1614
|
+
'[BUDGET ENFORCEMENT — automated notice]\n' +
|
|
1615
|
+
`The run has reached its budget cap: $${costUsd.toFixed(2)} spent of ` +
|
|
1616
|
+
`$${budgetUsd.toFixed(2)}. Stop starting new work now. Update .foreman/MISSION.md ` +
|
|
1617
|
+
'with the true state, summarize what is done and what is not, and end your turn. ' +
|
|
1618
|
+
'If finishing is essential, ask the human for a budget increase via ' +
|
|
1619
|
+
'mcp__foreman__ask_human. The run will be force-interrupted at 125% of budget.',
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* What the director is told about its budget, in the units that are true.
|
|
1626
|
+
*
|
|
1627
|
+
* Quoting dollars on an unmetered run is not a cosmetic slip: the figure is
|
|
1628
|
+
* Anthropic pricing applied to somebody else's tokens, and a director told
|
|
1629
|
+
* it has overspent behaves accordingly — it stops delegating and asks for
|
|
1630
|
+
* authorisation it does not need. That is exactly what happened on the first
|
|
1631
|
+
* mixed-provider run, and a resumed session carries the belief in its
|
|
1632
|
+
* restored context long after the cap itself is gone.
|
|
1633
|
+
*/
|
|
1634
|
+
private budgetLine(): string {
|
|
1635
|
+
const turns = this.meta.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
1636
|
+
switch (costBasisOf(this.meta)) {
|
|
1637
|
+
case 'free':
|
|
1638
|
+
return `This run costs nothing per token — it is served by hardware the ` +
|
|
1639
|
+
`operator already owns — so there is no spend cap. It is bounded by ` +
|
|
1640
|
+
`${turns} director turns.`;
|
|
1641
|
+
case 'unpriced':
|
|
1642
|
+
// Deliberately still "no spend cap", and deliberately not silent about
|
|
1643
|
+
// the spend. A director told only that money is being spent, with no
|
|
1644
|
+
// figure and no cap, invents a limit and winds itself down early.
|
|
1645
|
+
return `This run does draw on a paid account, but Foreman cannot price it, ` +
|
|
1646
|
+
`so there is no dollar cap and no figure to reason about — do not ration ` +
|
|
1647
|
+
`yourself against one. It is bounded by ${turns} director turns.`;
|
|
1648
|
+
case 'priced':
|
|
1649
|
+
return `Budget: $${this.meta.budgetUsd.toFixed(2)} total for this run.`;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
private budgetNote(): string {
|
|
1654
|
+
if (!isPriced(this.meta)) {
|
|
1655
|
+
return 'Budget note: this run has no dollar cap — any earlier message ' +
|
|
1656
|
+
'about a spend cap no longer applies, and you do not need authorisation ' +
|
|
1657
|
+
'to continue. Carry on to DONE WHEN.';
|
|
1658
|
+
}
|
|
1659
|
+
return `Budget note: $${this.meta.costUsd.toFixed(2)} of ` +
|
|
1660
|
+
`$${this.meta.budgetUsd.toFixed(2)} is already spent.`;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/** Appended to worker reports so the director can see the true burn rate
|
|
1664
|
+
* (its own turn costs are invisible to it otherwise). */
|
|
1665
|
+
private costFooter(): string {
|
|
1666
|
+
if (!isPriced(this.meta)) {
|
|
1667
|
+
const u = this.meta.usage;
|
|
1668
|
+
const tokens = u ? u.inputTokens + u.outputTokens : 0;
|
|
1669
|
+
const basis = costBasisOf(this.meta) === 'free'
|
|
1670
|
+
? 'not billed per token' : 'spend not tracked';
|
|
1671
|
+
return `\n\n[Run so far: ${this.turns} director turns` +
|
|
1672
|
+
`${tokens ? `, ${Math.round(tokens / 1000)}k tokens` : ''} — ${basis}]`;
|
|
1673
|
+
}
|
|
1674
|
+
return `\n\n[Run cost so far: $${this.meta.costUsd.toFixed(2)} of ` +
|
|
1675
|
+
`$${this.meta.budgetUsd.toFixed(2)} budget — includes director turns]`;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Enforce the wall clock on a run that has stopped checking it itself.
|
|
1680
|
+
*
|
|
1681
|
+
* Runs on a timer rather than at a turn boundary, because the situation it
|
|
1682
|
+
* exists for is precisely one where turn boundaries have stopped happening:
|
|
1683
|
+
* a director waiting inside a tool call — message_worker, ask_human — on
|
|
1684
|
+
* something that will never answer. Only time is enforced here — turns and budget can only advance
|
|
1685
|
+
* *at* a turn boundary, so if none are happening neither can move.
|
|
1686
|
+
*/
|
|
1687
|
+
private enforceCapsFromOutside(): void {
|
|
1688
|
+
if (this.hardStopped || this.wasInterrupted) return;
|
|
1689
|
+
const elapsed = (Date.now() - this.startedAt) / 1000;
|
|
1690
|
+
const maxSeconds = this.meta.maxSeconds ?? DEFAULT_MAX_SECONDS;
|
|
1691
|
+
if (elapsed < maxSeconds + CAP_WATCHDOG_GRACE_MS / 1000) return;
|
|
1692
|
+
this.hardStopped = true;
|
|
1693
|
+
this.emit('budget_stop', {
|
|
1694
|
+
costUsd: this.meta.costUsd,
|
|
1695
|
+
budgetUsd: this.meta.budgetUsd,
|
|
1696
|
+
costBasis: costBasisOf(this.meta),
|
|
1697
|
+
metered: isPriced(this.meta),
|
|
1698
|
+
reason:
|
|
1699
|
+
`TIME CAP ENFORCED: ${Math.round(elapsed / 60)} minutes, past the ` +
|
|
1700
|
+
`${Math.round(maxSeconds / 60)}-minute cap and its grace period. The director did not ` +
|
|
1701
|
+
`wind down on its own, which usually means it was blocked waiting on something.`,
|
|
1702
|
+
});
|
|
1703
|
+
void this.interrupt();
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** How far past its limits the run is, as a wind-down reason — or null. */
|
|
1707
|
+
private capReached(): string | null {
|
|
1708
|
+
if (this.turns >= (this.meta.maxTurns ?? DEFAULT_MAX_TURNS)) {
|
|
1709
|
+
return `TURN CAP REACHED: ${this.turns} director turns.`;
|
|
1710
|
+
}
|
|
1711
|
+
const elapsed = (Date.now() - this.startedAt) / 1000;
|
|
1712
|
+
const maxSeconds = this.meta.maxSeconds ?? DEFAULT_MAX_SECONDS;
|
|
1713
|
+
if (elapsed >= maxSeconds) {
|
|
1714
|
+
return `TIME CAP REACHED: ${Math.round(elapsed / 60)} minutes.`;
|
|
1715
|
+
}
|
|
1716
|
+
// Money only binds where the figure is real. Enforcing it through a
|
|
1717
|
+
// gateway ends working runs over spend that never happened.
|
|
1718
|
+
if (!isPriced(this.meta)) return null;
|
|
1719
|
+
if (this.meta.costUsd < this.meta.budgetUsd) return null;
|
|
1720
|
+
return `BUDGET CAP REACHED: $${this.meta.costUsd.toFixed(2)} of $${this.meta.budgetUsd.toFixed(2)}.`;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
private overBudget(): string | null {
|
|
1724
|
+
const cap = this.capReached();
|
|
1725
|
+
if (!cap) return null;
|
|
1726
|
+
if (!isPriced(this.meta)) {
|
|
1727
|
+
return `${cap} Do not start new work. Update MISSION.md, summarize the state, and stop.`;
|
|
1728
|
+
}
|
|
1729
|
+
return (
|
|
1730
|
+
`BUDGET EXHAUSTED: $${this.meta.costUsd.toFixed(2)} spent of ` +
|
|
1731
|
+
`$${this.meta.budgetUsd.toFixed(2)} cap. Do not start new work. Update MISSION.md, ` +
|
|
1732
|
+
`summarize the state, and stop (or ask the human for a budget increase via ask_human).`
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* The cap applied to the director's own turns, not just to delegation.
|
|
1738
|
+
*
|
|
1739
|
+
* overBudget() only reaches the director through spawn_worker/message_worker,
|
|
1740
|
+
* so a director that stops delegating and keeps working never sees it — the
|
|
1741
|
+
* run overshoots by however much its remaining turns cost. Checked here at
|
|
1742
|
+
* every turn boundary instead.
|
|
1743
|
+
*
|
|
1744
|
+
* Returns the wind-down instruction on the first turn past the cap, and null
|
|
1745
|
+
* afterwards: exactly one bounded turn to tick MISSION.md and summarise, then
|
|
1746
|
+
* {@link budgetExhausted} ends the loop. Killing the director outright would
|
|
1747
|
+
* be a harder stop but would strand the mission doc mid-flight, which is the
|
|
1748
|
+
* state a resume can least afford.
|
|
1749
|
+
*/
|
|
1750
|
+
private budgetWindDown(): string | null {
|
|
1751
|
+
const cap = this.capReached();
|
|
1752
|
+
if (!cap || this.budgetStopped) return null;
|
|
1753
|
+
this.budgetStopped = true;
|
|
1754
|
+
this.emit('budget_stop', {
|
|
1755
|
+
costUsd: this.meta.costUsd,
|
|
1756
|
+
budgetUsd: this.meta.budgetUsd,
|
|
1757
|
+
costBasis: costBasisOf(this.meta),
|
|
1758
|
+
metered: isPriced(this.meta),
|
|
1759
|
+
reason: cap,
|
|
1760
|
+
});
|
|
1761
|
+
return (
|
|
1762
|
+
`${cap} ` +
|
|
1763
|
+
'This is your LAST turn — the run ends when it does. Do not start new work, do not ' +
|
|
1764
|
+
'spawn or message workers, and do not begin any verification you have not already ' +
|
|
1765
|
+
'finished. Use this turn only to: tick every MISSION.md box you have genuinely ' +
|
|
1766
|
+
'verified, add a final log line naming what is left undone, and reply with a short ' +
|
|
1767
|
+
'summary of where the mission stands so it can be resumed with a larger budget.'
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Starts a worker and returns at once; the session runs in the background.
|
|
1773
|
+
*
|
|
1774
|
+
* The split between this and {@link runWorker} is the asynchronous design in
|
|
1775
|
+
* one place: everything the director can observe about a worker — the record
|
|
1776
|
+
* in the map, `worker_started`, the `done` promise wait_for_worker races, the
|
|
1777
|
+
* stored report and `worker_finished` at the end — is settled here, around a
|
|
1778
|
+
* runWorker that only drives the SDK session. The promise is kept on the
|
|
1779
|
+
* record rather than dropped, so a worker is never a floating promise, and
|
|
1780
|
+
* the outcome is written onto the record rather than returned once, because
|
|
1781
|
+
* the director now reads it back whenever it asks.
|
|
1782
|
+
*
|
|
1783
|
+
* Synchronous up to the point runWorker takes over: by the time this returns
|
|
1784
|
+
* the record exists and `worker_started` has been emitted, which is exactly
|
|
1785
|
+
* the guarantee spawn_worker's immediate reply relies on.
|
|
1786
|
+
*/
|
|
1787
|
+
/**
|
|
1788
|
+
* Item 6, decided as "ask": a worker on a gateway provider has stalled or
|
|
1789
|
+
* looped. Rather than letting the director cope alone or retrying somewhere
|
|
1790
|
+
* else on its own, the harness asks the human — in the tab and on their
|
|
1791
|
+
* phone — with the retry as a button: *continue (the director decides)*,
|
|
1792
|
+
* or *retry once on the director's provider*. Nobody answers in ten
|
|
1793
|
+
* minutes → continue. The fallback is therefore never automatic and never
|
|
1794
|
+
* more than one tap away, which is what reconciled "ask" with "fallback".
|
|
1795
|
+
*
|
|
1796
|
+
* A retry on the director's provider changes what the run's dollars mean —
|
|
1797
|
+
* a free or unpriced worker becomes a priced one — so the cost basis is
|
|
1798
|
+
* re-derived and announced before that worker spends a token. Only asked
|
|
1799
|
+
* when a retry target actually differs; a worker already on the director's
|
|
1800
|
+
* provider has nowhere else to go.
|
|
1801
|
+
*/
|
|
1802
|
+
private async askFallback(
|
|
1803
|
+
workerId: string, prompt: string, outcome: WorkerOutcome, why: 'stalled' | 'looping',
|
|
1804
|
+
): Promise<WorkerOutcome> {
|
|
1805
|
+
const rb = this.roleBasis;
|
|
1806
|
+
const sameTarget = !rb
|
|
1807
|
+
|| (this.agentEnv.director === this.agentEnv.worker
|
|
1808
|
+
&& (this.meta.directorModel || '') === (this.meta.workerModel || ''));
|
|
1809
|
+
if (sameTarget) return outcome;
|
|
1810
|
+
|
|
1811
|
+
const directorLabel = this.meta.directorModel || 'the director\'s model';
|
|
1812
|
+
const options = [
|
|
1813
|
+
'Continue — the director decides what to do next',
|
|
1814
|
+
`Retry once on the director's provider (${directorLabel})`,
|
|
1815
|
+
];
|
|
1816
|
+
const id = `q-${Date.now()}-fallback-${workerId}`;
|
|
1817
|
+
const question =
|
|
1818
|
+
`${workerId} ${why} on ${this.meta.workerModel || 'the worker model'}. ` +
|
|
1819
|
+
`Continue and let the director decide, or retry the same brief once on ${directorLabel}?`;
|
|
1820
|
+
this.askMeta.set(id, { kind: 'question', text: question, options, since: Date.now() });
|
|
1821
|
+
this.emit('question', { id, question, options, harness: true });
|
|
1822
|
+
const answer = await new Promise<string>((resolve) => {
|
|
1823
|
+
this.pendingQuestions.set(id, resolve);
|
|
1824
|
+
this.armAsk(id, (afterMs) => {
|
|
1825
|
+
if (!this.pendingQuestions.delete(id)) return;
|
|
1826
|
+
this.askMeta.delete(id);
|
|
1827
|
+
this.emit('question_timeout', { id, afterMs });
|
|
1828
|
+
resolve(options[0]);
|
|
1829
|
+
});
|
|
1830
|
+
});
|
|
1831
|
+
if (!answer.toLowerCase().startsWith('retry')) {
|
|
1832
|
+
this.emit('question_answered', { id });
|
|
1833
|
+
return outcome;
|
|
1834
|
+
}
|
|
1835
|
+
this.emit('question_answered', { id });
|
|
1836
|
+
|
|
1837
|
+
// The human chose to spend on the director's provider: say what that
|
|
1838
|
+
// does to the money before it happens.
|
|
1839
|
+
const nb = combineBasis(costBasisOf(this.meta), rb.director);
|
|
1840
|
+
if (nb !== costBasisOf(this.meta)) {
|
|
1841
|
+
this.meta.costBasis = nb;
|
|
1842
|
+
this.meta.metered = nb === 'priced';
|
|
1843
|
+
this.emit('settings_changed', {
|
|
1844
|
+
changes: [`retrying ${workerId} on the director's provider — this run is now ${nb}` +
|
|
1845
|
+
(nb === 'priced' ? ' and the dollar cap is live' : '')],
|
|
1846
|
+
browserTools: Boolean(this.meta.browserTools), budgetUsd: this.meta.budgetUsd,
|
|
1847
|
+
});
|
|
1848
|
+
this.saveMeta(this.meta);
|
|
1849
|
+
this.emitEconomics();
|
|
1850
|
+
}
|
|
1851
|
+
const nextId = `worker-${++this.workerSeq}`;
|
|
1852
|
+
this.launchWorker(nextId, prompt, undefined, {
|
|
1853
|
+
env: this.agentEnv.director, model: this.meta.directorModel || undefined, priceRole: 'director',
|
|
1854
|
+
reason: `retry of ${workerId} on the director's provider, at the human's request`,
|
|
1855
|
+
});
|
|
1856
|
+
return {
|
|
1857
|
+
isError: true,
|
|
1858
|
+
report: `${outcome.report}\n\nTHE HUMAN CHOSE TO RETRY: the same brief is now running as ${nextId} on ` +
|
|
1859
|
+
`${directorLabel}. Supervise ${nextId} with check_workers / wait_for_worker; do not respawn this brief yourself.`,
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
private launchWorker(workerId: string, prompt: string, resumeSessionId?: string, overrides?: WorkerOverrides): WorkerRuntime {
|
|
1864
|
+
const existing = this.workers.get(workerId);
|
|
1865
|
+
const w: WorkerRuntime = existing ?? {
|
|
1866
|
+
id: workerId, status: 'running', costUsd: 0, task: prompt.slice(0, 500),
|
|
1867
|
+
};
|
|
1868
|
+
const now = Date.now();
|
|
1869
|
+
// A resumed worker is a new episode: fresh clock, fresh report. Its call
|
|
1870
|
+
// count and recent lines carry over, since they are the history the
|
|
1871
|
+
// director may be following.
|
|
1872
|
+
w.status = 'running';
|
|
1873
|
+
w.startedAt = now;
|
|
1874
|
+
w.lastActivityAt = now;
|
|
1875
|
+
w.endedAt = undefined;
|
|
1876
|
+
w.report = undefined;
|
|
1877
|
+
w.isError = undefined;
|
|
1878
|
+
w.reportShown = false;
|
|
1879
|
+
w.toolCalls ??= 0;
|
|
1880
|
+
w.recent ??= [];
|
|
1881
|
+
w.done = new Promise<void>((resolve) => { w.settle = resolve; });
|
|
1882
|
+
this.workers.set(workerId, w);
|
|
1883
|
+
this.syncWorkersMeta();
|
|
1884
|
+
this.emit('worker_started', {
|
|
1885
|
+
id: workerId, task: prompt.slice(0, 200), resumed: Boolean(resumeSessionId),
|
|
1886
|
+
...(overrides?.reason ? { reason: overrides.reason } : {}),
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
w.promise = this.runWorker(workerId, prompt, resumeSessionId, overrides)
|
|
1890
|
+
// runWorker catches its own failures; this is the belt for anything it
|
|
1891
|
+
// could not, because an unsettled `done` would hang a wait_for_worker.
|
|
1892
|
+
.catch((err): WorkerOutcome => ({ report: `Worker crashed: ${String(err)}`, isError: true }))
|
|
1893
|
+
.then((out) => {
|
|
1894
|
+
w.q = undefined;
|
|
1895
|
+
w.report = out.report;
|
|
1896
|
+
w.isError = out.isError;
|
|
1897
|
+
w.status = out.isError ? 'error' : 'done';
|
|
1898
|
+
w.endedAt = Date.now();
|
|
1899
|
+
this.syncWorkersMeta();
|
|
1900
|
+
this.emit('worker_finished', {
|
|
1901
|
+
id: workerId, status: w.status, sessionId: w.sessionId, report: out.report,
|
|
1902
|
+
});
|
|
1903
|
+
w.settle?.();
|
|
1904
|
+
return out;
|
|
1905
|
+
});
|
|
1906
|
+
return w;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/**
|
|
1910
|
+
* Drives one worker session to its end and hands back the outcome. Does not
|
|
1911
|
+
* touch the record's status or report — that is {@link launchWorker}'s
|
|
1912
|
+
* job, once, for every path this can exit by — but it does keep the
|
|
1913
|
+
* record's live activity fields current, because those are what
|
|
1914
|
+
* check_workers shows while this is still running.
|
|
1915
|
+
*/
|
|
1916
|
+
private async runWorker(workerId: string, prompt: string, resumeSessionId?: string, overrides?: WorkerOverrides):
|
|
1917
|
+
Promise<WorkerOutcome> {
|
|
1918
|
+
const w = this.workers.get(workerId);
|
|
1919
|
+
if (!w) throw new Error(`runWorker: no record for ${workerId}; launchWorker creates it`);
|
|
1920
|
+
|
|
1921
|
+
const q = query({
|
|
1922
|
+
prompt,
|
|
1923
|
+
options: {
|
|
1924
|
+
cwd: this.meta.folder,
|
|
1925
|
+
permissionMode: 'default',
|
|
1926
|
+
resume: resumeSessionId,
|
|
1927
|
+
model: overrides?.model || this.meta.workerModel,
|
|
1928
|
+
maxTurns: 60,
|
|
1929
|
+
systemPrompt: { type: 'preset', preset: 'claude_code', append: WORKER_CHARTER },
|
|
1930
|
+
...(overrides?.env ?? this.agentEnv.worker),
|
|
1931
|
+
// Built per worker: the report_progress handler closes over this id,
|
|
1932
|
+
// which is how a report lands on the right record without the worker
|
|
1933
|
+
// having to know its own name.
|
|
1934
|
+
mcpServers: { foreman: this.workerTools(workerId), ...this.browserServers() },
|
|
1935
|
+
canUseTool: this.policyFor(workerId),
|
|
1936
|
+
},
|
|
1937
|
+
});
|
|
1938
|
+
w.q = q;
|
|
1939
|
+
|
|
1940
|
+
let report = '';
|
|
1941
|
+
let isError = false;
|
|
1942
|
+
|
|
1943
|
+
// The stall watchdog. A silent worker no longer blocks the director, but
|
|
1944
|
+
// it still occupies a slot the director believes is working, still counts
|
|
1945
|
+
// toward the run's wall clock, and still ends up in a wait_for_worker
|
|
1946
|
+
// sooner or later — so it is stopped and reported rather than left to the
|
|
1947
|
+
// run's time cap hours later. Every message resets it; only silence trips it.
|
|
1948
|
+
const silenceMs = this.meta.workerSilenceMs ?? DEFAULT_WORKER_SILENCE_MS;
|
|
1949
|
+
let stalled = false;
|
|
1950
|
+
const watchdog = watchSilence(silenceMs, (quietFor) => {
|
|
1951
|
+
stalled = true;
|
|
1952
|
+
this.emit('worker_stalled', {
|
|
1953
|
+
id: workerId, quietForMs: quietFor,
|
|
1954
|
+
text: `${workerId} has produced nothing for ${Math.round(quietFor / 60_000)} minute(s) — stopping it.`,
|
|
1955
|
+
});
|
|
1956
|
+
// Interrupting ends the for-await below, which is what lets the
|
|
1957
|
+
// director be told rather than left waiting.
|
|
1958
|
+
void w.q?.interrupt().catch(() => {});
|
|
1959
|
+
});
|
|
1960
|
+
// The other stall: busy but looping. Each identical call touches the
|
|
1961
|
+
// silence watchdog above, so without this one a worker re-running the
|
|
1962
|
+
// same failing command is indistinguishable from one making progress —
|
|
1963
|
+
// until the budget says otherwise. A worker, unlike the director, has no
|
|
1964
|
+
// wider context to recover with, so it is stopped and the director told.
|
|
1965
|
+
// Created per runWorker call, so a new or resumed worker starts clean.
|
|
1966
|
+
let looping: { toolName: string; count: number } | null = null;
|
|
1967
|
+
const repeats = watchRepeats(DEFAULT_REPEAT_LIMIT, ({ toolName, count }) => {
|
|
1968
|
+
looping = { toolName, count };
|
|
1969
|
+
this.emit('worker_looping', {
|
|
1970
|
+
id: workerId, toolName, count,
|
|
1971
|
+
text: `${workerId} issued the identical ${toolName} call ${count} times in a row — stopping it.`,
|
|
1972
|
+
});
|
|
1973
|
+
void w.q?.interrupt().catch(() => {});
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
try {
|
|
1977
|
+
for await (const msg of q as AsyncIterable<SDKMessage>) {
|
|
1978
|
+
watchdog.touch();
|
|
1979
|
+
const m = msg as Record<string, unknown>;
|
|
1980
|
+
if (typeof m.session_id === 'string') w.sessionId = m.session_id;
|
|
1981
|
+
this.noteActivity(w, m);
|
|
1982
|
+
for (const t of toolUsesOf(m)) observeToolUse(repeats, t.name, t.input);
|
|
1983
|
+
if (m.type === 'result') {
|
|
1984
|
+
report = String(m.result ?? '');
|
|
1985
|
+
isError = Boolean(m.is_error);
|
|
1986
|
+
this.noteUsageLimit(report);
|
|
1987
|
+
// Same ordering rule as the director loop: the cost event carries
|
|
1988
|
+
// usage, so usage has to be current before it is emitted.
|
|
1989
|
+
// A worker retried on the director's provider is priced with the
|
|
1990
|
+
// director's rates: the tokens went through that gateway.
|
|
1991
|
+
this.addUsage(m.usage, overrides?.priceRole ?? 'worker');
|
|
1992
|
+
this.addCost(m.total_cost_usd as number | undefined, overrides?.priceRole ?? 'worker');
|
|
1993
|
+
w.costUsd += (m.total_cost_usd as number | undefined) ?? 0;
|
|
1994
|
+
}
|
|
1995
|
+
this.emit('message', { agent: workerId, msg });
|
|
1996
|
+
}
|
|
1997
|
+
} catch (err) {
|
|
1998
|
+
isError = true;
|
|
1999
|
+
report = report || `Worker crashed: ${String(err)}`;
|
|
2000
|
+
} finally {
|
|
2001
|
+
watchdog.stop();
|
|
2002
|
+
w.q = undefined;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// Told to the director as a fact plus its options, not as an order: it is
|
|
2006
|
+
// the agent with the context to know whether this needs a different
|
|
2007
|
+
// approach, a different worker, or a human.
|
|
2008
|
+
// A retry that itself stalls is not offered another retry: the fallback
|
|
2009
|
+
// is one tap, once, not a ladder.
|
|
2010
|
+
if (stalled) {
|
|
2011
|
+
const out: WorkerOutcome = { isError: true, report: stalledWorkerReport(workerId, silenceMs, report) };
|
|
2012
|
+
return overrides?.env ? out : this.askFallback(workerId, prompt, out, 'stalled');
|
|
2013
|
+
}
|
|
2014
|
+
if (looping) {
|
|
2015
|
+
const { toolName, count } = looping as { toolName: string; count: number };
|
|
2016
|
+
const out: WorkerOutcome = { isError: true, report: loopingWorkerReport(workerId, toolName, count, report) };
|
|
2017
|
+
return overrides?.env ? out : this.askFallback(workerId, prompt, out, 'looping');
|
|
2018
|
+
}
|
|
2019
|
+
return { report, isError };
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Folds one SDK message into the record's live view: the activity clock,
|
|
2024
|
+
* the call count, and the rolling `recent` window. Tool calls get a line
|
|
2025
|
+
* each; an assistant message with only text gets its opening words, which
|
|
2026
|
+
* is usually the worker saying what it is about to do.
|
|
2027
|
+
*/
|
|
2028
|
+
private noteActivity(w: WorkerRuntime, m: Record<string, unknown>): void {
|
|
2029
|
+
w.lastActivityAt = Date.now();
|
|
2030
|
+
const uses = toolUsesOf(m);
|
|
2031
|
+
// A report_progress call is counted but not hinted: its handler writes
|
|
2032
|
+
// the fuller `progress:` line into the window itself, and the same status
|
|
2033
|
+
// twice in eight lines would push out a line that said something.
|
|
2034
|
+
const lines = uses.filter((t) => t.name !== REPORT_PROGRESS_TOOL).map((t) => activityHint(t.name, t.input));
|
|
2035
|
+
if (uses.length) w.toolCalls = (w.toolCalls ?? 0) + uses.length;
|
|
2036
|
+
else if (m.type === 'assistant') {
|
|
2037
|
+
const content = (m.message as { content?: unknown } | undefined)?.content;
|
|
2038
|
+
const textBlock = Array.isArray(content)
|
|
2039
|
+
? content.find((b) => b && (b as { type?: unknown }).type === 'text') as { text?: unknown } | undefined
|
|
2040
|
+
: undefined;
|
|
2041
|
+
if (typeof textBlock?.text === 'string' && textBlock.text.trim()) lines.push(`"${oneLine(textBlock.text, 80)}"`);
|
|
2042
|
+
}
|
|
2043
|
+
this.pushRecent(w, lines);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/** Appends to the rolling window, dropping the oldest past RECENT_LINES. */
|
|
2047
|
+
private pushRecent(w: WorkerRuntime, lines: string[]): void {
|
|
2048
|
+
if (!lines.length) return;
|
|
2049
|
+
w.recent = [...(w.recent ?? []), ...lines].slice(-RECENT_LINES);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
private syncWorkersMeta(): void {
|
|
2053
|
+
this.meta.workers = [...this.workers.values()].map((w) => {
|
|
2054
|
+
const copy: Partial<WorkerRuntime> = { ...w };
|
|
2055
|
+
for (const k of RUNTIME_ONLY) delete copy[k];
|
|
2056
|
+
return copy as WorkerMeta;
|
|
2057
|
+
});
|
|
2058
|
+
this.saveMeta(this.meta);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// -- worker tools -----------------------------------------------------------
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* report_progress, the one Foreman tool a worker has. Stores the report on
|
|
2065
|
+
* the record, drops a `progress:` line into `recent` so the timeline shows
|
|
2066
|
+
* when the worker said it, and emits it for the UI. The status is capped
|
|
2067
|
+
* rather than rejected: a worker that wrote a paragraph still reported, and
|
|
2068
|
+
* the director would rather have its first 200 chars than an error.
|
|
2069
|
+
*
|
|
2070
|
+
* An unknown id is a no-op, not a throw: the only way to reach it is a
|
|
2071
|
+
* worker whose record was dropped under it, and failing its tool call would
|
|
2072
|
+
* make that worker's turn stranger, not the record come back.
|
|
2073
|
+
*/
|
|
2074
|
+
private reportProgressTool(
|
|
2075
|
+
workerId: string,
|
|
2076
|
+
{ status, done, next, blocked }: { status: string; done?: string[]; next?: string; blocked?: string },
|
|
2077
|
+
): string {
|
|
2078
|
+
const w = this.workers.get(workerId);
|
|
2079
|
+
if (!w) return `No record for ${workerId}; progress not stored.`;
|
|
2080
|
+
const clean = (s: string | undefined) => (s && s.trim() ? oneLine(s, PROGRESS_STATUS_MAX) : undefined);
|
|
2081
|
+
const progress: WorkerProgress = {
|
|
2082
|
+
status: clean(status) ?? '(no status)',
|
|
2083
|
+
at: Date.now(),
|
|
2084
|
+
};
|
|
2085
|
+
const steps = (done ?? []).map((d) => clean(d)).filter((d): d is string => d !== undefined);
|
|
2086
|
+
if (steps.length) progress.done = steps;
|
|
2087
|
+
const n = clean(next);
|
|
2088
|
+
if (n) progress.next = n;
|
|
2089
|
+
const b = clean(blocked);
|
|
2090
|
+
if (b) progress.blocked = b;
|
|
2091
|
+
w.progress = progress;
|
|
2092
|
+
this.pushRecent(w, [`progress: ${progress.status}${b ? ` — BLOCKED: ${b}` : ''}`]);
|
|
2093
|
+
this.syncWorkersMeta();
|
|
2094
|
+
this.emit('worker_progress', {
|
|
2095
|
+
id: workerId, status: progress.status, done: progress.done, next: progress.next, blocked: progress.blocked,
|
|
2096
|
+
});
|
|
2097
|
+
return b
|
|
2098
|
+
? 'Noted, including the blocker — the director can see it. Continue with whatever is not blocked, ' +
|
|
2099
|
+
'or end your turn with "BLOCKED: <question>" if nothing is.'
|
|
2100
|
+
: 'Noted. Continue.';
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/** The per-worker `foreman` MCP server; see {@link reportProgressTool}. */
|
|
2104
|
+
private workerTools(workerId: string) {
|
|
2105
|
+
const text = (t: string) => ({ content: [{ type: 'text' as const, text: t }] });
|
|
2106
|
+
const reportProgress = tool(
|
|
2107
|
+
'report_progress',
|
|
2108
|
+
'Tell the director where you are. Call it when you finish a distinct sub-step, when you ' +
|
|
2109
|
+
'change approach, and immediately when you are blocked. One line of status; optionally the ' +
|
|
2110
|
+
'sub-steps done so far, what you are about to do, and what you cannot get past.',
|
|
2111
|
+
{
|
|
2112
|
+
status: z.string().describe(`One-line summary of where you are (max ${PROGRESS_STATUS_MAX} chars)`),
|
|
2113
|
+
done: z.array(z.string()).optional().describe('Sub-steps completed so far'),
|
|
2114
|
+
next: z.string().optional().describe('What you are about to do'),
|
|
2115
|
+
blocked: z.string().optional().describe('Anything you cannot get past, stated so someone else could act on it'),
|
|
2116
|
+
},
|
|
2117
|
+
async (args) => text(this.reportProgressTool(workerId, args)),
|
|
2118
|
+
);
|
|
2119
|
+
return createSdkMcpServer({ name: 'foreman', tools: [reportProgress] });
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// -- director tools ---------------------------------------------------------
|
|
2123
|
+
//
|
|
2124
|
+
// Handler bodies live on the class and return plain text; makeTools() only
|
|
2125
|
+
// wraps them in MCP definitions. That keeps the director's tool surface
|
|
2126
|
+
// testable without an MCP server — the tests stub runWorker and drive these
|
|
2127
|
+
// directly — and keeps the rule (what the director is told, when) apart from
|
|
2128
|
+
// the plumbing (how it gets there).
|
|
2129
|
+
|
|
2130
|
+
/** `[worker-3 finished] <report>` — the shape every result reaches the director in. */
|
|
2131
|
+
private reportLine(w: WorkerRuntime): string {
|
|
2132
|
+
w.reportShown = true;
|
|
2133
|
+
return `[${w.id}${w.isError ? ' FAILED' : ' finished'}] ${w.report || '(no report)'}`;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
private spawnWorkerTool({ task }: { task: string }): string {
|
|
2137
|
+
const stop = this.overBudget();
|
|
2138
|
+
if (stop) return stop;
|
|
2139
|
+
const id = `worker-${++this.workerSeq}`;
|
|
2140
|
+
this.launchWorker(id, task);
|
|
2141
|
+
return `[${id} started] status: running. It works in the background — use check_workers ` +
|
|
2142
|
+
`to watch it, and wait_for_worker when you need its result.`;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
private async messageWorkerTool({ worker_id, message }: { worker_id: string; message: string }): Promise<string> {
|
|
2146
|
+
const stop = this.overBudget();
|
|
2147
|
+
if (stop) return stop;
|
|
2148
|
+
const w = this.workers.get(worker_id);
|
|
2149
|
+
if (!w?.sessionId) return `No resumable worker "${worker_id}".`;
|
|
2150
|
+
// Two sessions resuming the same id at once would interleave on one
|
|
2151
|
+
// transcript; the message waits for the worker, not the other way round.
|
|
2152
|
+
if (w.status === 'running') {
|
|
2153
|
+
return `${worker_id} is still running — wait_for_worker it first, then send the follow-up.`;
|
|
2154
|
+
}
|
|
2155
|
+
const run = this.launchWorker(worker_id, message, w.sessionId);
|
|
2156
|
+
await run.promise;
|
|
2157
|
+
return `${this.reportLine(run)}${this.costFooter()}`;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
private checkWorkersTool({ workerId }: { workerId?: string } = {}): string {
|
|
2161
|
+
if (workerId) {
|
|
2162
|
+
const w = this.workers.get(workerId);
|
|
2163
|
+
if (!w) return `No worker "${workerId}".${this.costFooter()}`;
|
|
2164
|
+
w.reportShown = w.reportShown || w.status !== 'running';
|
|
2165
|
+
return `${workerStatusBlock(w)}${this.costFooter()}`;
|
|
2166
|
+
}
|
|
2167
|
+
const all = [...this.workers.values()];
|
|
2168
|
+
if (!all.length) return `No workers have been spawned in this run.${this.costFooter()}`;
|
|
2169
|
+
const now = Date.now();
|
|
2170
|
+
for (const w of all) if (w.status !== 'running') w.reportShown = true;
|
|
2171
|
+
return `${all.map((w) => workerStatusBlock(w, now)).join('\n\n')}${this.costFooter()}`;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
private async waitForWorkerTool(
|
|
2175
|
+
{ workerId, timeoutSeconds }: { workerId?: string; timeoutSeconds?: number } = {},
|
|
2176
|
+
): Promise<string> {
|
|
2177
|
+
const secs = Math.min(MAX_WAIT_SECONDS, Math.max(0,
|
|
2178
|
+
typeof timeoutSeconds === 'number' && Number.isFinite(timeoutSeconds) ? timeoutSeconds : DEFAULT_WAIT_SECONDS));
|
|
2179
|
+
|
|
2180
|
+
let targets: WorkerRuntime[];
|
|
2181
|
+
if (workerId) {
|
|
2182
|
+
const w = this.workers.get(workerId);
|
|
2183
|
+
if (!w) return `No worker "${workerId}".${this.costFooter()}`;
|
|
2184
|
+
if (w.status !== 'running') return `${this.reportLine(w)}${this.costFooter()}`;
|
|
2185
|
+
targets = [w];
|
|
2186
|
+
} else {
|
|
2187
|
+
targets = [...this.workers.values()].filter((w) => w.status === 'running');
|
|
2188
|
+
if (!targets.length) {
|
|
2189
|
+
return `No worker is running. ${this.workers.size ? 'check_workers shows the finished ones.' : ''}`.trim() +
|
|
2190
|
+
this.costFooter();
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// Raced against a timer, and the timer is cleared on both exits: a stray
|
|
2195
|
+
// timeout outliving the run would keep the event loop, and with it the
|
|
2196
|
+
// process, alive for up to ten minutes after the last mission ended.
|
|
2197
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2198
|
+
const timeout = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), secs * 1000); });
|
|
2199
|
+
const first = await Promise.race<WorkerRuntime | null>([
|
|
2200
|
+
...targets.map((w) => (w.done ?? Promise.resolve()).then(() => w)),
|
|
2201
|
+
timeout,
|
|
2202
|
+
]).finally(() => clearTimeout(timer));
|
|
2203
|
+
|
|
2204
|
+
if (!first) {
|
|
2205
|
+
const now = Date.now();
|
|
2206
|
+
return `${targets.map((w) => workerStatusBlock(w, now)).join('\n\n')}\n\n` +
|
|
2207
|
+
`Still running after ${secs}s. Call wait_for_worker again to keep waiting, or ` +
|
|
2208
|
+
`check_workers to look without waiting.${this.costFooter()}`;
|
|
2209
|
+
}
|
|
2210
|
+
return `${this.reportLine(first)}${this.costFooter()}`;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
private makeTools() {
|
|
2214
|
+
const text = (t: string) => ({ content: [{ type: 'text' as const, text: t }] });
|
|
2215
|
+
|
|
2216
|
+
const spawnWorker = tool(
|
|
2217
|
+
'spawn_worker',
|
|
2218
|
+
'Start a worker agent on one self-contained implementation task in the working ' +
|
|
2219
|
+
'directory. Returns immediately with the worker id; the worker runs in the background. ' +
|
|
2220
|
+
'Spawn independent tasks together so they run in parallel. Follow with check_workers ' +
|
|
2221
|
+
'to supervise and wait_for_worker to collect the result. Include all context the ' +
|
|
2222
|
+
'worker needs: it does not see your conversation.',
|
|
2223
|
+
{ task: z.string().describe('Full task description with context, paths, and the expected result') },
|
|
2224
|
+
async (args) => text(this.spawnWorkerTool(args)),
|
|
2225
|
+
);
|
|
2226
|
+
|
|
2227
|
+
const checkWorkers = tool(
|
|
2228
|
+
'check_workers',
|
|
2229
|
+
'Show every worker (or one): status, age, seconds since its last activity, tool calls ' +
|
|
2230
|
+
'so far, its most recent actions, and — once finished — its full report. Does not wait.',
|
|
2231
|
+
{ workerId: z.string().optional().describe('One worker id, e.g. "worker-2"; omit for all') },
|
|
2232
|
+
async (args) => text(this.checkWorkersTool(args)),
|
|
2233
|
+
);
|
|
2234
|
+
|
|
2235
|
+
const waitForWorker = tool(
|
|
2236
|
+
'wait_for_worker',
|
|
2237
|
+
'Wait until a worker finishes (or, with no id, until any running worker finishes) and ' +
|
|
2238
|
+
`return its report. Bounded: default ${DEFAULT_WAIT_SECONDS}s, at most ${MAX_WAIT_SECONDS}s; ` +
|
|
2239
|
+
'on timeout it returns the current status and you may call it again.',
|
|
2240
|
+
{
|
|
2241
|
+
workerId: z.string().optional().describe('The worker to wait for; omit to wait for whichever finishes first'),
|
|
2242
|
+
timeoutSeconds: z.number().optional().describe(`Seconds to wait (default ${DEFAULT_WAIT_SECONDS}, max ${MAX_WAIT_SECONDS})`),
|
|
2243
|
+
},
|
|
2244
|
+
async (args) => text(await this.waitForWorkerTool(args)),
|
|
2245
|
+
);
|
|
2246
|
+
|
|
2247
|
+
const messageWorker = tool(
|
|
2248
|
+
'message_worker',
|
|
2249
|
+
'Send a follow-up message to a finished worker (correction, next step, answer to a ' +
|
|
2250
|
+
"BLOCKED question). Resumes that worker's session with its prior context intact. " +
|
|
2251
|
+
'Blocks until the worker finishes and returns its report.',
|
|
2252
|
+
{
|
|
2253
|
+
worker_id: z.string().describe('The worker id, e.g. "worker-1"'),
|
|
2254
|
+
message: z.string().describe('The follow-up instruction or answer'),
|
|
2255
|
+
},
|
|
2256
|
+
async (args) => text(await this.messageWorkerTool(args)),
|
|
2257
|
+
);
|
|
2258
|
+
|
|
2259
|
+
const askHuman = tool(
|
|
2260
|
+
'ask_human',
|
|
2261
|
+
'Ask the human overseer a question and wait for their answer. Use for anything ' +
|
|
2262
|
+
'irreversible, out of scope, over budget, or that only a human can decide.',
|
|
2263
|
+
{
|
|
2264
|
+
question: z.string().describe('The question, with enough context to answer it'),
|
|
2265
|
+
options: z.array(z.string()).min(2).max(6).optional().describe(
|
|
2266
|
+
'When the answer is a choice, the choices — recommended first. The human ' +
|
|
2267
|
+
'taps one (in the tab or on their phone) instead of typing; they can still type.'),
|
|
2268
|
+
},
|
|
2269
|
+
async ({ question, options }) => {
|
|
2270
|
+
const id = `q-${Date.now()}-${this.pendingQuestions.size}`;
|
|
2271
|
+
const opts = options?.map((o) => o.trim()).filter(Boolean).slice(0, 6);
|
|
2272
|
+
this.askMeta.set(id, { kind: 'question', text: question, options: opts?.length ? opts : undefined, since: Date.now() });
|
|
2273
|
+
this.emit('question', { id, question, ...(opts?.length ? { options: opts } : {}) });
|
|
2274
|
+
// Same unattended default as a permission card, with the opposite
|
|
2275
|
+
// polarity: a question is not refused, it is handed back. The
|
|
2276
|
+
// director keeps its context and is told to decide and record.
|
|
2277
|
+
let timedOut = false;
|
|
2278
|
+
const answer = await new Promise<string>((resolve) => {
|
|
2279
|
+
this.pendingQuestions.set(id, resolve);
|
|
2280
|
+
this.armAsk(id, (afterMs) => {
|
|
2281
|
+
if (!this.pendingQuestions.delete(id)) return;
|
|
2282
|
+
this.askMeta.delete(id);
|
|
2283
|
+
this.askMeta.delete(id);
|
|
2284
|
+
timedOut = true;
|
|
2285
|
+
this.emit('question_timeout', { id, afterMs });
|
|
2286
|
+
resolve(unattendedAnswer(afterMs));
|
|
2287
|
+
});
|
|
2288
|
+
});
|
|
2289
|
+
if (timedOut) return text(answer);
|
|
2290
|
+
this.emit('question_answered', { id });
|
|
2291
|
+
return text(`Human answered: ${answer}`);
|
|
2292
|
+
},
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
const exposeService = tool(
|
|
2296
|
+
'expose_service',
|
|
2297
|
+
'Make a local server the crew started (a dev server, a preview) reachable for the ' +
|
|
2298
|
+
'human through Foreman, including from their phone. Give the port it listens on at ' +
|
|
2299
|
+
'127.0.0.1; you get back the URL to put in your report. Only ports something is ' +
|
|
2300
|
+
'actually listening on; only for this run.',
|
|
2301
|
+
{
|
|
2302
|
+
port: z.number().int().min(1).max(65535).describe('The local port the service listens on'),
|
|
2303
|
+
label: z.string().max(60).optional().describe('What it is, in a few words — "preview", "storybook", "API docs"'),
|
|
2304
|
+
},
|
|
2305
|
+
async ({ port, label }) => {
|
|
2306
|
+
const fn = this.host.exposeService;
|
|
2307
|
+
if (!fn) return { content: [{ type: 'text' as const, text: 'Exposing services is not available in this run.' }] };
|
|
2308
|
+
const r = await fn(this.meta.id, port, (label ?? '').trim() || `port ${port}`);
|
|
2309
|
+
if (!r.ok) return { content: [{ type: 'text' as const, text: `Not exposed: ${r.reason}` }] };
|
|
2310
|
+
const entry = { port, label: (label ?? '').trim() || `port ${port}`, path: r.path, since: Date.now() };
|
|
2311
|
+
this.meta.services = [...(this.meta.services ?? []).filter((s) => s.port !== port), entry];
|
|
2312
|
+
this.saveMeta(this.meta);
|
|
2313
|
+
this.emit('service_exposed', { ...entry, url: r.url });
|
|
2314
|
+
return { content: [{ type: 'text' as const, text:
|
|
2315
|
+
`Exposed. The human can open it at ${r.url} (Foreman proxies it to 127.0.0.1:${port}; ` +
|
|
2316
|
+
'absolute links inside the page resolve while the page is open from that URL). Keep the server ' +
|
|
2317
|
+
'running while they may want to look, and put the URL in your report.' }] };
|
|
2318
|
+
},
|
|
2319
|
+
);
|
|
2320
|
+
return createSdkMcpServer({
|
|
2321
|
+
name: 'foreman',
|
|
2322
|
+
tools: [spawnWorker, checkWorkers, waitForWorker, messageWorker, askHuman, exposeService],
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
}
|