@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.
Files changed (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. 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
+ }