@amenophis1er/foreman 0.1.7 → 0.1.9

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.
@@ -1,7 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import http from 'node:http';
4
- import { ServiceRegistry, parseServicePath, portOpen, proxyToService, servicePath } from './services.js';
4
+ import { ServiceRegistry, parseServicePath, portOpen, proxyToService, servicePath, servicesHandler } from './services.js';
5
+ import { requestAllowed } from './guard.js';
5
6
 
6
7
  test('service paths round-trip and reject junk', () => {
7
8
  assert.equal(servicePath('run-1', 8934), '/svc/run-1/8934/');
@@ -51,3 +52,37 @@ test('proxyToService streams a response and reports a dead port as 502', async (
51
52
  front.close();
52
53
  }
53
54
  });
55
+
56
+ test('the services port serves only /svc/ — everything else is a 404', async () => {
57
+ const registry = new ServiceRegistry();
58
+ // The guard checks the Host against the port it is given, and the port is
59
+ // only known after listen(), so it is read out of a box the listener fills.
60
+ let port = 0;
61
+ const server = http.createServer(servicesHandler({
62
+ registry,
63
+ allowed: (req) => requestAllowed(req, { port, tailnet: null }),
64
+ }));
65
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
66
+ port = (server.address() as { port: number }).port;
67
+ try {
68
+ const other = await fetch(`http://127.0.0.1:${port}/anything`);
69
+ assert.equal(other.status, 404);
70
+ assert.deepEqual(await other.json(), { error: 'not found' });
71
+
72
+ const undeclared = await fetch(`http://127.0.0.1:${port}/svc/run-1/3000/`);
73
+ assert.equal(undeclared.status, 404);
74
+ assert.deepEqual(await undeclared.json(), { error: 'no such service' });
75
+
76
+ // And the guard still runs first: a rebound Host never reaches the proxy.
77
+ // Raw http, not fetch — undici refuses to let a caller set Host.
78
+ const rebound = await new Promise<number>((resolve, reject) => {
79
+ const r = http.request({ host: '127.0.0.1', port, path: '/svc/run-1/3000/', headers: { host: 'evil.example' } },
80
+ (res2) => { res2.resume(); resolve(res2.statusCode ?? 0); });
81
+ r.on('error', reject);
82
+ r.end();
83
+ });
84
+ assert.equal(rebound, 421);
85
+ } finally {
86
+ server.close();
87
+ }
88
+ });
package/src/services.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Services the crew exposes: a dev server it started to test its work, made
3
- * reachable through Foreman's own address — so the human on the phone can
4
- * open it over the tailnet without the agent opening a port to the world.
3
+ * reachable through Foreman's machine — so the human on the phone can open it
4
+ * over the tailnet without the agent opening a port to the world.
5
+ *
6
+ * These pages are served on their own port (FOREMAN_SERVICES_PORT, PORT + 1 by
7
+ * default), never on the dashboard's. Whatever the crew wrote runs in a
8
+ * browser, and if it ran on Foreman's origin the same-origin policy would let
9
+ * it call every Foreman API as the operator. A different port is a different
10
+ * origin, so it cannot.
5
11
  *
6
12
  * The proxy is deliberately narrow. Only ports a run declared, only on
7
13
  * loopback, only under `/svc/<run>/<port>/`. Nothing is guessed: a request
@@ -10,11 +16,12 @@
10
16
  */
11
17
  import http from 'node:http';
12
18
  import net from 'node:net';
19
+ import type { GuardVerdict } from './guard.js';
13
20
 
14
21
  export interface ExposedService {
15
22
  port: number;
16
23
  label: string;
17
- /** `/svc/<runId>/<port>/` — the path under Foreman's origin. */
24
+ /** `/svc/<runId>/<port>/` — the path under the services origin, not Foreman's. */
18
25
  path: string;
19
26
  since: number;
20
27
  }
@@ -100,3 +107,53 @@ export function proxyToService(
100
107
  });
101
108
  req.pipe(up);
102
109
  }
110
+
111
+ /**
112
+ * The whole of the services port: the guard, the proxy, and nothing else.
113
+ *
114
+ * It is a function of its dependencies rather than a closure over the server's
115
+ * module scope so a test can start one on an ephemeral port with its own
116
+ * registry — importing server.ts would start listening for real. `allowed` is
117
+ * passed in for the same reason: the guard has to be told the port it is
118
+ * defending, which the test only learns after listen().
119
+ */
120
+ export function servicesHandler(deps: {
121
+ registry: ServiceRegistry;
122
+ allowed: (req: http.IncomingMessage) => GuardVerdict;
123
+ }): http.RequestListener {
124
+ const jsonErr = (res: http.ServerResponse, status: number, error: string) => {
125
+ const body = JSON.stringify({ error });
126
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
127
+ res.end(body);
128
+ };
129
+ return (req, res) => {
130
+ // A DNS-rebound page must not reach the proxy either: it would be talking
131
+ // to the crew's dev server from an origin of the attacker's choosing.
132
+ const verdict = deps.allowed(req);
133
+ if (!verdict.ok) { jsonErr(res, verdict.status, verdict.error); return; }
134
+ const url = new URL(req.url ?? '/', `http://127.0.0.1`);
135
+ // Services the crew exposed: /svc/<run>/<port>/… goes to 127.0.0.1:<port>,
136
+ // but only for a pair a run declared. A page served this way asks for its
137
+ // absolute-path assets (`/app.js`) against this server's root; those arrive
138
+ // as sub-resource requests carrying the service page as Referer, and are
139
+ // routed to the same service. Documents never are — a typed URL is not.
140
+ const svc = parseServicePath(url.pathname);
141
+ if (svc) {
142
+ if (!deps.registry.has(svc.runId, svc.port)) { jsonErr(res, 404, 'no such service'); return; }
143
+ proxyToService(req, res, svc.port, svc.rest, url.search, servicePath(svc.runId, svc.port));
144
+ return;
145
+ }
146
+ const ref = req.headers.referer;
147
+ const dest = String(req.headers['sec-fetch-dest'] ?? '');
148
+ if (ref && dest && dest !== 'document' && dest !== 'empty' && !url.pathname.startsWith(SVC_PREFIX)) {
149
+ try {
150
+ const via = parseServicePath(new URL(ref).pathname);
151
+ if (via && deps.registry.has(via.runId, via.port)) {
152
+ proxyToService(req, res, via.port, url.pathname, url.search, servicePath(via.runId, via.port));
153
+ return;
154
+ }
155
+ } catch { /* not a URL we can read — fall through to the 404 */ }
156
+ }
157
+ jsonErr(res, 404, 'not found');
158
+ };
159
+ }
package/src/store.test.ts CHANGED
@@ -117,3 +117,21 @@ test('projects: concurrent adds do not lose writes', async () => {
117
117
  assert.equal((await store.listProjects()).length, 8);
118
118
  await rm(root, { recursive: true, force: true });
119
119
  });
120
+
121
+ test('archiveChat retires a conversation: gone from the project, kept on disk, and idempotent', async () => {
122
+ const { store, root } = await tmpStore();
123
+ await store.appendChat('p-abc', { ts: 1, event: 'chat_message', data: { text: 'hi' } });
124
+ await store.writeChatMeta({ projectId: 'p-abc', costUsd: 1, createdAt: 1, updatedAt: 1 });
125
+ assert.deepEqual(await store.listChatIds(), ['p-abc']);
126
+
127
+ await store.archiveChat('p-abc', '1700000000000-deadbeef');
128
+ assert.deepEqual(await store.listChatIds(), []);
129
+ assert.equal(await store.readChatMeta('p-abc'), null);
130
+ assert.deepEqual(await store.readChatEvents('p-abc'), []);
131
+ const archived = path.join(root, 'chats', '_archive', 'p-abc-1700000000000-deadbeef', 'events.jsonl');
132
+ assert.ok((await import('node:fs/promises')).readFile(archived, 'utf8'));
133
+
134
+ // Nothing to archive is not an error.
135
+ await store.archiveChat('p-abc', 'again');
136
+ await rm(root, { recursive: true, force: true });
137
+ });
package/src/store.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * previous process: it appends a synthetic `run_finished` event and marks
20
20
  * the run interrupted, so replaying a log always terminates cleanly.
21
21
  */
22
- import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
22
+ import { appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
23
23
  import os from 'node:os';
24
24
  import path from 'node:path';
25
25
  import crypto from 'node:crypto';
@@ -327,6 +327,21 @@ export class RunStore {
327
327
  await rm(this.chatDir(projectId), { recursive: true, force: true });
328
328
  }
329
329
 
330
+ /**
331
+ * Retires a conversation that produced a mission: the log moves under
332
+ * `chats/_archive/<project>-<run>/`, where `listChatIds` does not look, and
333
+ * the project starts its next visit with a blank page. Kept rather than
334
+ * deleted because it is the story of how that mission came to exist; the
335
+ * run itself is the continuation, and "Plan the next step" forks from it.
336
+ */
337
+ async archiveChat(projectId: string, runId: string): Promise<void> {
338
+ const from = this.chatDir(projectId);
339
+ if (!(await stat(from).catch(() => null))) return;
340
+ const archive = path.join(this.chatsDir, '_archive');
341
+ await mkdir(archive, { recursive: true });
342
+ await rename(from, path.join(archive, `${projectId}-${runId.replace(/[^A-Za-z0-9_-]/g, '')}`));
343
+ }
344
+
330
345
  /**
331
346
  * Marks runs left in status "running" by a dead process as interrupted,
332
347
  * appending a synthetic `run_finished` so replayed logs terminate cleanly.
@@ -0,0 +1,46 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { budgetAnchor, modelRecords, projectRecord, recordLine } from './track-record.js';
4
+ import type { RunMeta } from './types.js';
5
+
6
+ const run = (o: Partial<RunMeta>): RunMeta => ({
7
+ id: 'r', folder: '/x', mission: 'm', budgetUsd: 5, status: 'done', costUsd: 1, createdAt: 0, endedAt: 10 * 60_000, workers: [], costBasis: 'priced', ...o,
8
+ } as RunMeta);
9
+
10
+ test('modelRecords: directors by outcome with median cost and minutes; workers by finish', () => {
11
+ const runs = [
12
+ run({ directorModel: 'sonnet', workerModel: 'gemma4:12b', costUsd: 0.78, endedAt: 18 * 60_000, workers: [{ id: 'w1', status: 'done', costUsd: 0, task: '' }, { id: 'w2', status: 'error', costUsd: 0, task: '' }] }),
13
+ run({ directorModel: 'sonnet', costUsd: 0.22, endedAt: 1 * 60_000 }),
14
+ run({ directorModel: 'sonnet', status: 'interrupted', costUsd: 4.96 }),
15
+ run({ directorModel: 'ornith-1.5:9b', workerModel: 'ornith-1.5:9b', status: 'interrupted', costBasis: 'free', costUsd: 0, workers: [{ id: 'w1', status: 'done', costUsd: 0, task: '' }, { id: 'w2', status: 'done', costUsd: 0, task: '' }, { id: 'w3', status: 'error', costUsd: 0, task: '' }] }),
16
+ run({ directorModel: 'opus', status: 'running' }),
17
+ ] as RunMeta[];
18
+ const m = modelRecords(runs);
19
+ const sonnet = m.get('sonnet')!;
20
+ assert.equal(sonnet.runs, 3); assert.equal(sonnet.done, 2); assert.equal(sonnet.interrupted, 1);
21
+ assert.equal(sonnet.medianCostUsd, 0.5); assert.equal(sonnet.medianMinutes, 10);
22
+ assert.equal(m.get('gemma4:12b')!.workers, 2); assert.equal(m.get('gemma4:12b')!.workersDone, 1);
23
+ const ornith = m.get('ornith-1.5:9b')!;
24
+ assert.equal(ornith.runs, 1); assert.equal(ornith.done, 0); assert.equal(ornith.workers, 3); assert.equal(ornith.workersFailed, 1);
25
+ assert.equal(ornith.medianCostUsd, undefined);
26
+ assert.equal(m.has('opus'), false, 'a running run is not a record yet');
27
+ });
28
+
29
+ test('recordLine says it in one line, or nothing', () => {
30
+ const m = modelRecords([run({ directorModel: 'sonnet', workerModel: 'gemma4:12b', costUsd: 0.78, endedAt: 18 * 60_000, workers: [{ id: 'w1', status: 'done', costUsd: 0, task: '' }, { id: 'w2', status: 'error', costUsd: 0, task: '' }] })] as RunMeta[]);
31
+ assert.equal(recordLine(m.get('sonnet')), 'here: director 1/1 done (~$0.78, ~18 min)');
32
+ assert.equal(recordLine(m.get('gemma4:12b')), 'here: workers 1/2 finished');
33
+ assert.equal(recordLine(undefined), undefined);
34
+ });
35
+
36
+ test('projectRecord and budgetAnchor: quartiles, cap hits, and silence when there is too little', () => {
37
+ const few = projectRecord([run({ costUsd: 2 })]);
38
+ assert.equal(budgetAnchor(few, few, 'P'), '');
39
+ const runs = [1, 2, 3, 4, 4.9].map((c) => run({ costUsd: c, budgetUsd: 5 }));
40
+ const p = projectRecord(runs);
41
+ assert.equal(p.done, 5); assert.equal(p.medianCostUsd, 3); assert.equal(p.costLowUsd, 2); assert.equal(p.costHighUsd, 4); assert.equal(p.capHits, 1);
42
+ const a = budgetAnchor(p, projectRecord([...runs, run({ costUsd: 10, budgetUsd: 20 })]), 'Tick');
43
+ assert.match(a, /In "Tick", finished missions cost \$2\.0–\$4\.0 \(median \$3\.0\), ~10 min, over 5 finished missions; 1 ended within 5% of the cap\./);
44
+ assert.match(a, /Across the fleet: /);
45
+ assert.match(a, /anchor above it, not on it/);
46
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * What the run ledger says, read back: how models have done here, and what
3
+ * missions in a project have cost. Foreman already keeps every run's model,
4
+ * outcome, spend and crew; nobody was reading it. This is the safe kind of
5
+ * learning — statistics, deterministic and explainable — and it feeds three
6
+ * places without any agent writing anything: the planner's budget anchor,
7
+ * the model picker's second line, and the front desk's reports.
8
+ */
9
+ import type { RunMeta } from './types.js';
10
+
11
+ /** A model's record in one role. */
12
+ export interface ModelRecord {
13
+ model: string;
14
+ /** As director: runs and how they ended. */
15
+ runs: number;
16
+ done: number;
17
+ interrupted: number;
18
+ error: number;
19
+ /** Median priced spend of done runs; undefined when nothing was priced. */
20
+ medianCostUsd?: number;
21
+ /** Median wall clock of done runs, in minutes. */
22
+ medianMinutes?: number;
23
+ /** As worker: workers spawned on it and how they ended. */
24
+ workers: number;
25
+ workersDone: number;
26
+ workersFailed: number;
27
+ }
28
+
29
+ /** A project's record. */
30
+ export interface ProjectRecord {
31
+ runs: number;
32
+ done: number;
33
+ /** Priced spend of done runs, low and high (interquartile-ish: 25th and 75th). */
34
+ costLowUsd?: number;
35
+ costHighUsd?: number;
36
+ medianCostUsd?: number;
37
+ medianMinutes?: number;
38
+ /** Done runs that ended within 5% of their cap. */
39
+ capHits: number;
40
+ }
41
+
42
+ const median = (xs: number[]): number | undefined => {
43
+ if (!xs.length) return undefined;
44
+ const s = [...xs].sort((a, b) => a - b);
45
+ const m = Math.floor(s.length / 2);
46
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
47
+ };
48
+ const quantile = (xs: number[], q: number): number | undefined => {
49
+ if (!xs.length) return undefined;
50
+ const s = [...xs].sort((a, b) => a - b);
51
+ return s[Math.min(s.length - 1, Math.max(0, Math.round((s.length - 1) * q)))];
52
+ };
53
+ const finished = (r: RunMeta) => r.status !== 'running';
54
+ const priced = (r: RunMeta) => r.costBasis === 'priced' || (r.costBasis === undefined && r.metered !== false);
55
+ const minutes = (r: RunMeta) => (typeof r.endedAt === 'number' && typeof r.createdAt === 'number' ? (r.endedAt - r.createdAt) / 60_000 : undefined);
56
+
57
+ /** Every model's record across the runs given, keyed by model id. */
58
+ export function modelRecords(runs: RunMeta[]): Map<string, ModelRecord> {
59
+ const out = new Map<string, ModelRecord>();
60
+ const get = (model: string) => {
61
+ let r = out.get(model);
62
+ if (!r) { r = { model, runs: 0, done: 0, interrupted: 0, error: 0, workers: 0, workersDone: 0, workersFailed: 0 }; out.set(model, r); }
63
+ return r;
64
+ };
65
+ const costs = new Map<string, number[]>();
66
+ const mins = new Map<string, number[]>();
67
+ for (const run of runs) {
68
+ if (!finished(run)) continue;
69
+ const d = run.directorModel ? String(run.directorModel) : undefined;
70
+ if (d) {
71
+ const r = get(d);
72
+ r.runs += 1;
73
+ if (run.status === 'done') {
74
+ r.done += 1;
75
+ if (priced(run)) costs.set(d, [...(costs.get(d) ?? []), run.costUsd]);
76
+ const m = minutes(run);
77
+ if (m !== undefined) mins.set(d, [...(mins.get(d) ?? []), m]);
78
+ } else if (run.status === 'interrupted') r.interrupted += 1;
79
+ else if (run.status === 'error') r.error += 1;
80
+ }
81
+ const w = run.workerModel ? String(run.workerModel) : undefined;
82
+ if (w) {
83
+ const r = get(w);
84
+ for (const wk of run.workers ?? []) {
85
+ if (wk.status === 'running') continue;
86
+ r.workers += 1;
87
+ if (wk.status === 'done' && !wk.isError) r.workersDone += 1; else r.workersFailed += 1;
88
+ }
89
+ }
90
+ }
91
+ for (const [model, r] of out) {
92
+ r.medianCostUsd = median(costs.get(model) ?? []);
93
+ const mm = median(mins.get(model) ?? []);
94
+ r.medianMinutes = mm === undefined ? undefined : Math.round(mm);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** One project's record from its runs. */
100
+ export function projectRecord(runs: RunMeta[]): ProjectRecord {
101
+ const fin = runs.filter(finished);
102
+ const done = fin.filter((r) => r.status === 'done');
103
+ const costs = done.filter(priced).map((r) => r.costUsd);
104
+ const mins = done.map(minutes).filter((m): m is number => m !== undefined);
105
+ const mm = median(mins);
106
+ return {
107
+ runs: fin.length, done: done.length,
108
+ costLowUsd: quantile(costs, 0.25), costHighUsd: quantile(costs, 0.75), medianCostUsd: median(costs),
109
+ medianMinutes: mm === undefined ? undefined : Math.round(mm),
110
+ capHits: done.filter((r) => priced(r) && r.budgetUsd > 0 && r.costUsd >= r.budgetUsd * 0.95).length,
111
+ };
112
+ }
113
+
114
+ const usd = (n: number) => `$${n < 1 ? n.toFixed(2) : n.toFixed(n < 10 ? 1 : 0)}`;
115
+
116
+ /** The picker's second line for a model, or undefined when there is nothing to say yet. */
117
+ export function recordLine(r: ModelRecord | undefined): string | undefined {
118
+ if (!r || (r.runs === 0 && r.workers === 0)) return undefined;
119
+ const bits: string[] = [];
120
+ if (r.runs > 0) {
121
+ let s = `director ${r.done}/${r.runs} done`;
122
+ const extras = [r.medianCostUsd !== undefined ? `~${usd(r.medianCostUsd)}` : null, r.medianMinutes !== undefined ? `~${r.medianMinutes} min` : null].filter(Boolean);
123
+ if (extras.length) s += ` (${extras.join(', ')})`;
124
+ bits.push(s);
125
+ }
126
+ if (r.workers > 0) bits.push(`workers ${r.workersDone}/${r.workers} finished`);
127
+ return `here: ${bits.join(' · ')}`;
128
+ }
129
+
130
+ /**
131
+ * The planner's budget anchor from history, replacing the generic ladder
132
+ * when a project (or the fleet) has enough finished missions to say
133
+ * something. Returns '' when there is nothing worth anchoring on.
134
+ */
135
+ export function budgetAnchor(project: ProjectRecord, fleet: ProjectRecord, projectName?: string): string {
136
+ const lines: string[] = [];
137
+ const fmt = (p: ProjectRecord) => {
138
+ if (p.medianCostUsd === undefined) return null;
139
+ const range = p.costLowUsd !== undefined && p.costHighUsd !== undefined && p.costHighUsd > p.costLowUsd
140
+ ? `${usd(p.costLowUsd)}–${usd(p.costHighUsd)} (median ${usd(p.medianCostUsd)})` : `about ${usd(p.medianCostUsd)}`;
141
+ return `${range}${p.medianMinutes !== undefined ? `, ~${p.medianMinutes} min` : ''}, over ${p.done} finished mission${p.done === 1 ? '' : 's'}${p.capHits ? `; ${p.capHits} ended within 5% of the cap` : ''}`;
142
+ };
143
+ const pj = project.done >= 2 ? fmt(project) : null;
144
+ const fl = fleet.done >= 3 ? fmt(fleet) : null;
145
+ if (pj) lines.push(`In ${projectName ? `"${projectName}"` : 'this project'}, finished missions cost ${pj}.`);
146
+ if (fl && (!pj || fleet.done > project.done)) lines.push(`Across the fleet: ${fl}.`);
147
+ if (!lines.length) return '';
148
+ return `\nWHAT MISSIONS HAVE COST (from Foreman's own records — use these to anchor the budget before the generic ladder):\n ${lines.join('\n ')}\n A cap that was hit is a mission that ran out, not one that fit; anchor above it, not on it.\n`;
149
+ }
package/src/types.ts CHANGED
@@ -298,6 +298,12 @@ export interface RunMeta {
298
298
  claudeInstance?: ClaudeInstanceRef;
299
299
  /** Number of times this run was resumed after an interruption. */
300
300
  resumes?: number;
301
+ /**
302
+ * The branch this mission runs on, when the folder is a repository and the
303
+ * project runs missions on branches of their own. Foreman made it from
304
+ * `base` at start and commits on it at the end; it never merges or pushes.
305
+ */
306
+ git?: { branch: string; base: string; baseHead: string | null; commits?: number; commit?: string; /** The pull request the human opened from this run, once they did. */ pr?: string; /** Its fate, once known to be final (merged or closed); open is re-asked. */ prState?: 'merged' | 'closed' };
301
307
  /** Tools the human granted "always allow" for this run (survives resume). */
302
308
  allowedTools?: string[];
303
309
  /**
@@ -319,6 +325,12 @@ export interface RunMeta {
319
325
  askTimeoutMs?: number;
320
326
  /** Give agents a headless Playwright browser (navigate, click, screenshot). */
321
327
  browserTools?: boolean;
328
+ /**
329
+ * Which cap ended the last attempt, when one did. The dashboard reads it
330
+ * to offer the one action that helps — raise the budget and resume — and
331
+ * a resume clears it. Absent when the run ended for any other reason.
332
+ */
333
+ stopReason?: 'budget' | 'turns' | 'time' | 'tokens';
322
334
  folder: string;
323
335
  mission: string;
324
336
  budgetUsd: number;
@@ -339,6 +351,17 @@ export interface RunMeta {
339
351
  maxTurns?: number;
340
352
  /** Wall-clock cap. Matters most exactly where dollars matter least. */
341
353
  maxSeconds?: number;
354
+ /**
355
+ * Total tokens — input, output and cache alike — before the run winds down.
356
+ *
357
+ * The third bound, and the one that was missing: a run that costs nothing
358
+ * per token, or that Foreman cannot price, has only turns and the clock to
359
+ * stop it. Neither notices a chatty director whose turns are cheap and
360
+ * enormous, re-reading a large context a hundred and fifty times inside the
361
+ * time cap. Tokens are the resource actually being consumed there, so they
362
+ * are what the cap should count.
363
+ */
364
+ maxTokens?: number;
342
365
  /**
343
366
  * How long a worker may produce nothing before it is treated as stalled and
344
367
  * stopped. Absent means the orchestrator's default. Raise it for slow local