@amenophis1er/foreman 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/orchestrator.test.ts +8 -1
- package/src/orchestrator.ts +12 -0
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +93 -9
- package/src/server.ts +19 -0
- package/src/types.ts +6 -0
- package/ui/dist/assets/{index-h0osI3Nn.js → index--dvfX3Mo.js} +36 -36
- package/ui/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -230,7 +230,7 @@ result.
|
|
|
230
230
|
```sh
|
|
231
231
|
npm ci && npm run setup # dependencies, then the dashboard build
|
|
232
232
|
npm start # serves http://localhost:4177
|
|
233
|
-
npm test #
|
|
233
|
+
npm test # 364 tests, node:test
|
|
234
234
|
npm run typecheck # server and dashboard
|
|
235
235
|
npm run dev # API + Vite together
|
|
236
236
|
scripts/dev-restart.sh # restarts the server only when nothing would be lost
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
stalledWorkerReport, workerStatusBlock,
|
|
10
10
|
watchRepeats, watchSilence, REPEAT_EXEMPT, observeToolUse,
|
|
11
11
|
DEFAULT_ASK_TIMEOUT_MS, armAskTimeout, unattendedAnswer, unattendedDenyMessage,
|
|
12
|
-
tokenCapLabel,
|
|
12
|
+
tokenCapLabel, stopReasonOf,
|
|
13
13
|
} from './orchestrator.js';
|
|
14
14
|
import { makePolicy, type PendingPermission } from './policy.js';
|
|
15
15
|
import type { AgentEnv } from './provider.js';
|
|
@@ -1249,3 +1249,10 @@ test('tokenCapLabel rounds to a figure a director can hold in mind', () => {
|
|
|
1249
1249
|
assert.equal(tokenCapLabel(250_000), '250k tokens');
|
|
1250
1250
|
assert.equal(tokenCapLabel(400), '400 tokens');
|
|
1251
1251
|
});
|
|
1252
|
+
|
|
1253
|
+
test('stopReasonOf names the cap behind a capReached sentence', () => {
|
|
1254
|
+
assert.equal(stopReasonOf('BUDGET CAP REACHED: $10.02 of $10.00.'), 'budget');
|
|
1255
|
+
assert.equal(stopReasonOf('TURN CAP REACHED: 150 director turns.'), 'turns');
|
|
1256
|
+
assert.equal(stopReasonOf('TIME CAP REACHED: 240 minutes.'), 'time');
|
|
1257
|
+
assert.equal(stopReasonOf('TOKEN CAP REACHED: 20.1M tokens.'), 'tokens');
|
|
1258
|
+
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -265,6 +265,14 @@ const DEFAULT_MAX_TURNS = 150;
|
|
|
265
265
|
*/
|
|
266
266
|
export const DEFAULT_MAX_TOKENS = 20_000_000;
|
|
267
267
|
|
|
268
|
+
/** The cap behind a capReached() sentence, as the run record stores it. */
|
|
269
|
+
export function stopReasonOf(cap: string): 'budget' | 'turns' | 'time' | 'tokens' {
|
|
270
|
+
if (cap.startsWith('TURN')) return 'turns';
|
|
271
|
+
if (cap.startsWith('TIME')) return 'time';
|
|
272
|
+
if (cap.startsWith('TOKEN')) return 'tokens';
|
|
273
|
+
return 'budget';
|
|
274
|
+
}
|
|
275
|
+
|
|
268
276
|
/**
|
|
269
277
|
* The token cap as a director should read it: "5M tokens", not "5000000".
|
|
270
278
|
* A budget is only useful if the agent it constrains can hold it in mind, and
|
|
@@ -1725,6 +1733,8 @@ export class MissionRun {
|
|
|
1725
1733
|
const maxSeconds = this.meta.maxSeconds ?? DEFAULT_MAX_SECONDS;
|
|
1726
1734
|
if (elapsed < maxSeconds + CAP_WATCHDOG_GRACE_MS / 1000) return;
|
|
1727
1735
|
this.hardStopped = true;
|
|
1736
|
+
this.meta.stopReason = 'time';
|
|
1737
|
+
this.saveMeta(this.meta);
|
|
1728
1738
|
this.emit('budget_stop', {
|
|
1729
1739
|
costUsd: this.meta.costUsd,
|
|
1730
1740
|
budgetUsd: this.meta.budgetUsd,
|
|
@@ -1797,6 +1807,8 @@ export class MissionRun {
|
|
|
1797
1807
|
const cap = this.capReached();
|
|
1798
1808
|
if (!cap || this.budgetStopped) return null;
|
|
1799
1809
|
this.budgetStopped = true;
|
|
1810
|
+
this.meta.stopReason = stopReasonOf(cap);
|
|
1811
|
+
this.saveMeta(this.meta);
|
|
1800
1812
|
this.emit('budget_stop', {
|
|
1801
1813
|
costUsd: this.meta.costUsd,
|
|
1802
1814
|
budgetUsd: this.meta.budgetUsd,
|
package/src/policy.test.ts
CHANGED
|
@@ -149,6 +149,46 @@ test('a file write under the root does not escape', () => {
|
|
|
149
149
|
assert.ok(bashEscapesFolder('echo hi > /tmp/out.txt', FOLDER, [ROOT]), 'outside the root still prompts');
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test('operators inside quotes are text: a perl program with => is not a redirect', () => {
|
|
153
|
+
// The exact shape that put an approval card in front of an in-folder edit:
|
|
154
|
+
// `=>/` inside the single-quoted program read as `>` + the path `/…`.
|
|
155
|
+
assert.equal(bashEscapesFolder(`perl -0pi -e 's/\\.filter\\(\\(\\[a, b\\]\\) =>/.filter(([a]) =>/' ${ROOT}/x.tsx`, FOLDER, [ROOT]), null);
|
|
156
|
+
assert.equal(bashEscapesFolder(`set -e; D=src/f; perl -0pi -e 's/ a,\\n b,/ a,/' $D/one.tsx; perl -0pi -e 's/x =>/y/' $D/two.tsx`, FOLDER, [ROOT]), null);
|
|
157
|
+
assert.equal(bashEscapesFolder('echo "a > b; c | d & e" && echo \'x>/etc/passwd\'', FOLDER, [ROOT]), null);
|
|
158
|
+
assert.equal(bashEscapesFolder(`git commit -m "fix: handle a > b" ${ROOT}`, FOLDER, [ROOT]), null);
|
|
159
|
+
// A real redirect next to a quoted one is still seen, with its real text.
|
|
160
|
+
assert.deepEqual(bashEscape('echo "x > y" > /tmp/out.txt', FOLDER), {
|
|
161
|
+
reason: 'echo "x > y" > /tmp/out.txt — redirects output outside the mission folder', path: '/tmp/out.txt', grant: '/tmp',
|
|
162
|
+
});
|
|
163
|
+
// Quoted paths still resolve: the mask touches operators, not text.
|
|
164
|
+
assert.ok(bashEscapesFolder('rm -rf "/opt/homebrew"', FOLDER, [ROOT]));
|
|
165
|
+
// A quoted path with spaces is one token now, so it resolves and is judged.
|
|
166
|
+
assert.equal(bashEscape(`echo hi > "/tmp/o u t.txt"`, FOLDER)!.path, '/tmp/o u t.txt');
|
|
167
|
+
assert.equal(bashEscapesFolder(`echo hi > "${ROOT}/o u t.txt"`, FOLDER, [ROOT]), null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('the shell keeps its cwd between commands: a cd in one call places the next', () => {
|
|
171
|
+
const shell = { cwd: FOLDER };
|
|
172
|
+
assert.equal(bashEscape('cd examples/demo', FOLDER, [], shell), null);
|
|
173
|
+
assert.equal(shell.cwd, path.join(FOLDER, 'examples/demo'));
|
|
174
|
+
// The exact shape from a real mission: a log two levels up is inside the folder.
|
|
175
|
+
assert.equal(bashEscape('(npx tsx server.ts > ../../.foreman/work/demo/run1.log 2>&1 &) ; sleep 1', FOLDER, [], shell), null);
|
|
176
|
+
// A cd inside a subshell does not move the shell.
|
|
177
|
+
assert.equal(bashEscape('(cd .. && ls) && echo ok', FOLDER, [], shell), null);
|
|
178
|
+
assert.equal(shell.cwd, path.join(FOLDER, 'examples/demo'));
|
|
179
|
+
// From there, three levels up is out, and says so.
|
|
180
|
+
assert.match(bashEscape('touch ../../../x', FOLDER, [], shell)!.reason, /outside the mission folder/);
|
|
181
|
+
// Without a shell, the analysis starts at the folder as before.
|
|
182
|
+
assert.match(bashEscape('echo hi > ../../.foreman/work/demo/run1.log', FOLDER)!.reason, /redirects output outside/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('a shell -c body is analysed as a command, so quoting cannot hide a cd', () => {
|
|
186
|
+
assert.ok(bashEscapesFolder(`sh -c 'cd /tmp && npm install'`, FOLDER, [ROOT]));
|
|
187
|
+
assert.match(bashEscapesFolder(`bash -c "mkdir -p /tmp/x/y"`, FOLDER, [ROOT])!, /creates a path outside/);
|
|
188
|
+
assert.equal(bashEscapesFolder(`sh -c 'npm test && echo ok'`, FOLDER, [ROOT]), null);
|
|
189
|
+
assert.equal(bashEscape(`bash -c 'cd /tmp/x'`, FOLDER)!.grant, '/tmp/x');
|
|
190
|
+
});
|
|
191
|
+
|
|
152
192
|
test('bashEscape carries the offending path and the directory to grant', () => {
|
|
153
193
|
// Directory-ish targets: the path itself is the grant.
|
|
154
194
|
assert.deepEqual(bashEscape('cd /tmp/x && ls', FOLDER), {
|
package/src/policy.ts
CHANGED
|
@@ -153,15 +153,52 @@ const WRITE_VERBS: Record<string, { mode: ArgMode; opts?: string[]; does: string
|
|
|
153
153
|
const FILE_OPTS = new Set(['-o', '--output', '-O', '--output-document']);
|
|
154
154
|
/** Prefixes that merely wrap the real verb (`sudo rm`, `env FOO=1 mkdir`). */
|
|
155
155
|
const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'builtin']);
|
|
156
|
+
/** Shells whose `-c` argument is a command in its own right. */
|
|
157
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']);
|
|
156
158
|
/** Pseudo-devices: writing to them touches nothing on disk. */
|
|
157
159
|
const DEV_SINKS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty']);
|
|
158
160
|
const URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
159
161
|
/** `> f`, `>> f`, `2> f`, `&> f` — but not `2>&1` / `>&2` (fd duplication). */
|
|
160
162
|
const REDIRECT_RE = /(?:&>>?|\d*>>?)(?!&)\s*(\S+)/g;
|
|
161
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Shell operators inside quotes are text, not operators. The redirect scan
|
|
166
|
+
* and the segment split below work on the raw string, so a perl program in
|
|
167
|
+
* single quotes — `perl -pi -e 's/x =>/y/' f` — read as `>` followed by the
|
|
168
|
+
* path `/y/`, which resolved to the filesystem root and put an approval card
|
|
169
|
+
* in front of an ordinary in-folder edit. Operators inside a quoted region
|
|
170
|
+
* are swapped for private-use characters before the scan and swapped back by
|
|
171
|
+
* `bare()`, so tokens still carry their real text. Spaces inside quotes are
|
|
172
|
+
* masked too, so a quoted string is one token: `sh -c '…'` bodies and paths
|
|
173
|
+
* with spaces both come through whole. Quoting rules followed:
|
|
174
|
+
* single quotes take everything literally; double quotes honour backslash;
|
|
175
|
+
* a backslash outside quotes escapes the next character.
|
|
176
|
+
*/
|
|
177
|
+
const MASK: Record<string, string> = { '>': '\uE001', '<': '\uE002', '|': '\uE003', ';': '\uE004', '&': '\uE005', '\n': '\uE006', ' ': '\uE007', '\t': '\uE008' };
|
|
178
|
+
const UNMASK_RE = /[\uE001-\uE008]/g;
|
|
179
|
+
const UNMASK: Record<string, string> = Object.fromEntries(Object.entries(MASK).map(([k, v]) => [v, k]));
|
|
180
|
+
function maskQuoted(command: string): string {
|
|
181
|
+
let out = '';
|
|
182
|
+
let quote: '"' | "'" | null = null;
|
|
183
|
+
for (let i = 0; i < command.length; i++) {
|
|
184
|
+
const c = command[i];
|
|
185
|
+
if (quote === null) {
|
|
186
|
+
if (c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
|
|
187
|
+
if (c === '"' || c === "'") quote = c;
|
|
188
|
+
out += c;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (quote === '"' && c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
|
|
192
|
+
if (c === quote) { quote = null; out += c; continue; }
|
|
193
|
+
out += MASK[c] ?? c;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
const unmask = (s: string): string => s.replace(UNMASK_RE, (ch) => UNMASK[ch] ?? ch);
|
|
198
|
+
|
|
162
199
|
/** Strips one layer of matching quotes and shell grouping punctuation. */
|
|
163
200
|
function bare(token: string): string {
|
|
164
|
-
let t = token.replace(/^[(\\]+|[)]+$/g, '');
|
|
201
|
+
let t = unmask(token).replace(/^[(\\]+|[)]+$/g, '');
|
|
165
202
|
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) t = t.slice(1, -1);
|
|
166
203
|
return t;
|
|
167
204
|
}
|
|
@@ -260,22 +297,31 @@ export interface BashEscape {
|
|
|
260
297
|
*/
|
|
261
298
|
export function bashEscape(
|
|
262
299
|
command: string, folder: string, extraRoots: Iterable<string> = [],
|
|
300
|
+
shell?: { cwd?: string },
|
|
263
301
|
): BashEscape | null {
|
|
264
302
|
const root = path.resolve(folder);
|
|
265
303
|
// Snapshot the grants once per call: the caller's set is mutable and may be
|
|
266
304
|
// appended to while we walk, and one command must see one consistent view.
|
|
267
305
|
const roots = [root, ...extraRoots];
|
|
268
306
|
const outside = (p: string): boolean => !DEV_SINKS.has(p) && !underAnyRoot(p, roots);
|
|
269
|
-
const label = (
|
|
307
|
+
const label = (raw: string): string => { const seg = unmask(raw); return seg.length > 80 ? seg.slice(0, 77) + '…' : seg; };
|
|
270
308
|
const hit = (segment: string, does: string, p: string, isDir: boolean): BashEscape => ({
|
|
271
309
|
reason: `${label(segment)} — ${does} outside the mission folder`,
|
|
272
310
|
path: p,
|
|
273
311
|
grant: isDir ? p : path.dirname(p),
|
|
274
312
|
});
|
|
275
313
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
314
|
+
// The virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly. It
|
|
315
|
+
// starts where the agent's shell actually is: Claude Code's Bash tool keeps
|
|
316
|
+
// its working directory between commands, so a director that ran
|
|
317
|
+
// `cd examples/demo` one call ago and now writes `../../.foreman/work/x.log`
|
|
318
|
+
// is writing inside the folder — read from the root that looked like two
|
|
319
|
+
// levels out, and put an approval card in front of an in-folder log.
|
|
320
|
+
let cwd = shell?.cwd ? path.resolve(shell.cwd) : root;
|
|
321
|
+
// `cd` inside a subshell does not outlive it; only top-level moves persist.
|
|
322
|
+
let depth = 0;
|
|
323
|
+
|
|
324
|
+
const segments = maskQuoted(command)
|
|
279
325
|
.split(/&&|\|\||;|\n|\|(?!\|)|(?<![&>\d])&(?![&>])/)
|
|
280
326
|
.map((s) => s.trim())
|
|
281
327
|
.filter(Boolean);
|
|
@@ -286,7 +332,13 @@ export function bashEscape(
|
|
|
286
332
|
const p = asPath(m[1], cwd);
|
|
287
333
|
if (p !== null && outside(p)) return hit(segment, 'redirects output', p, false);
|
|
288
334
|
}
|
|
335
|
+
const opens = (segment.match(/\(/g) ?? []).length;
|
|
336
|
+
const closes = (segment.match(/\)/g) ?? []).length;
|
|
289
337
|
const words = segment.replace(REDIRECT_RE, ' ').split(/\s+/).filter(Boolean).map(bare);
|
|
338
|
+
// Depth as seen at this segment's verb: an opening paren on the segment
|
|
339
|
+
// itself puts its `cd` inside the subshell.
|
|
340
|
+
const depthHere = depth + (segment.trimStart().startsWith('(') ? 1 : 0);
|
|
341
|
+
depth = Math.max(0, depth + opens - closes);
|
|
290
342
|
// Peel wrappers and leading VAR=value assignments to reach the real verb.
|
|
291
343
|
let i = 0;
|
|
292
344
|
while (i < words.length && (WRAPPERS.has(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))) i++;
|
|
@@ -295,6 +347,19 @@ export function bashEscape(
|
|
|
295
347
|
const args = words.slice(i + 1);
|
|
296
348
|
const flagless = args.filter((a) => !a.startsWith('-'));
|
|
297
349
|
|
|
350
|
+
// `sh -c '…'`: the quoted body is a command of its own, run from the
|
|
351
|
+
// current virtual cwd. Analysed as one, so quoting cannot hide a `cd /tmp`
|
|
352
|
+
// — masking made the body opaque to the split above, and this is where it
|
|
353
|
+
// is looked at instead.
|
|
354
|
+
if (SHELLS.has(verb)) {
|
|
355
|
+
const c = args.indexOf('-c');
|
|
356
|
+
if (c >= 0 && c + 1 < args.length) {
|
|
357
|
+
const inner = bashEscape(args[c + 1], cwd, roots);
|
|
358
|
+
if (inner) return { ...inner, reason: `${label(segment)} — ${inner.reason.replace(/^.*? — /, '')}` };
|
|
359
|
+
}
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
298
363
|
if (verb === 'cd' || verb === 'pushd') {
|
|
299
364
|
const target = flagless[0] ?? '~'; // bare `cd` goes home
|
|
300
365
|
const p = asPath(target, cwd) ?? path.resolve(cwd, target);
|
|
@@ -302,6 +367,7 @@ export function bashEscape(
|
|
|
302
367
|
// Enough on its own: whatever follows (relative writes, `npm install`,
|
|
303
368
|
// `git init`) lands there, so we need not look at later segments.
|
|
304
369
|
if (outside(p)) return hit(segment, 'writes after this land', p, true);
|
|
370
|
+
if (depthHere === 0 && shell) shell.cwd = p;
|
|
305
371
|
continue;
|
|
306
372
|
}
|
|
307
373
|
|
|
@@ -482,6 +548,10 @@ export function makePolicy(
|
|
|
482
548
|
const foremanDir = path.join(folder, '.foreman') + path.sep;
|
|
483
549
|
const toolPolicy = { ...DEFAULT_TOOL_POLICY, ...settings?.toolPolicy };
|
|
484
550
|
const autoReadOnly = settings?.autoAllowReadOnly !== false;
|
|
551
|
+
// Where this agent's shell is. One policy per agent session, and the SDK's
|
|
552
|
+
// Bash keeps its cwd across calls, so a top-level `cd` in an allowed
|
|
553
|
+
// command is where the next command starts. Reset only with the session.
|
|
554
|
+
const shell: { cwd?: string } = { cwd: folder };
|
|
485
555
|
|
|
486
556
|
return async (toolName, input, opts) => {
|
|
487
557
|
// An explicit grant (Settings policy or the human's "Always" click) is a
|
|
@@ -507,10 +577,15 @@ export function makePolicy(
|
|
|
507
577
|
// The shell is the other door out of the job site: a command that would
|
|
508
578
|
// write outside the folder prompts under the same rule, so a blanket
|
|
509
579
|
// `Bash: allow` (or an "Always" click) cannot quietly `cd /tmp && npm i`.
|
|
580
|
+
// Analysed from the shell's current directory; the walk records where a
|
|
581
|
+
// top-level `cd` leaves it. A command that escapes is not run unless the
|
|
582
|
+
// human allows it, so its `cd` is remembered only after an allow below.
|
|
583
|
+
const shellAfter = { cwd: shell.cwd };
|
|
510
584
|
const escape =
|
|
511
585
|
toolName === 'Bash' && typeof input.command === 'string'
|
|
512
|
-
? bashEscape(input.command, folder, allowedRoots)
|
|
586
|
+
? bashEscape(input.command, folder, allowedRoots, shellAfter)
|
|
513
587
|
: null;
|
|
588
|
+
const rememberCwd = () => { if (toolName === 'Bash') shell.cwd = shellAfter.cwd; };
|
|
514
589
|
const leavesFolder = outsideFolder || escape !== null;
|
|
515
590
|
// What "always" on this card grants — see `PendingPermission.escapedPath`.
|
|
516
591
|
// For a file tool the parent directory: the worker that wrote
|
|
@@ -540,6 +615,7 @@ export function makePolicy(
|
|
|
540
615
|
) {
|
|
541
616
|
// Carry the reason through so the log shows what was waved past.
|
|
542
617
|
hooks.onAutoAllow(agent, toolName, opts.decisionReason);
|
|
618
|
+
rememberCwd();
|
|
543
619
|
return { behavior: 'allow' };
|
|
544
620
|
}
|
|
545
621
|
|
|
@@ -582,13 +658,21 @@ export function makePolicy(
|
|
|
582
658
|
input,
|
|
583
659
|
title,
|
|
584
660
|
description,
|
|
585
|
-
|
|
586
|
-
|
|
661
|
+
// The card's one warning line. When the folder boundary is the reason,
|
|
662
|
+
// say that — the SDK's own heuristic ("contains shell syntax (&) that
|
|
663
|
+
// cannot be statically analyzed") is true of most real commands and
|
|
664
|
+
// tells the reader nothing about why THIS one stopped.
|
|
665
|
+
decisionReason: leavesFolder
|
|
666
|
+
? (escape?.reason ?? `${filePath} is outside the mission folder`)
|
|
667
|
+
: opts.decisionReason,
|
|
587
668
|
escapedPath,
|
|
588
669
|
});
|
|
589
670
|
|
|
590
671
|
return new Promise<PermissionResult>((resolve) => {
|
|
591
|
-
hooks.register(id, {
|
|
672
|
+
hooks.register(id, {
|
|
673
|
+
resolve: (r) => { if (r.behavior === 'allow') rememberCwd(); resolve(r); },
|
|
674
|
+
toolName, suggestions: opts.suggestions, escapedPath,
|
|
675
|
+
});
|
|
592
676
|
opts.signal.addEventListener('abort', () => {
|
|
593
677
|
if (hooks.unregister(id)) {
|
|
594
678
|
resolve({ behavior: 'deny', message: 'Run was interrupted.' });
|
package/src/server.ts
CHANGED
|
@@ -1724,6 +1724,8 @@ async function startRun(
|
|
|
1724
1724
|
*/
|
|
1725
1725
|
async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
1726
1726
|
directorModel?: string; directorProviderId?: string; workerModel?: string; workerProviderId?: string;
|
|
1727
|
+
/** A new cap for the resumed attempt — the answer to "stopped at its budget". */
|
|
1728
|
+
budgetUsd?: number;
|
|
1727
1729
|
} = {}): Promise<void> {
|
|
1728
1730
|
const sessionId = meta.directorSessionId;
|
|
1729
1731
|
// Resume re-reads Settings for tool policy and auto-allow, so a policy
|
|
@@ -1748,9 +1750,24 @@ async function resumeRun(projectId: string, meta: RunMeta, pick: {
|
|
|
1748
1750
|
if (workerChanged) { meta.workerModel = modelChoice(pick.workerModel); meta.workerProviderId = pick.workerProviderId; }
|
|
1749
1751
|
meta.toolPolicy = settings.toolPolicy;
|
|
1750
1752
|
meta.autoAllowReadOnly = settings.autoAllowReadOnly;
|
|
1753
|
+
// A raised cap rides the resume: the run that stopped at $10 continues
|
|
1754
|
+
// under $15, and the transcript says who moved it and from where. Written
|
|
1755
|
+
// to the log as a settings change, which is what it is.
|
|
1756
|
+
const budgetWas = meta.budgetUsd;
|
|
1757
|
+
if (typeof pick.budgetUsd === 'number' && Number.isFinite(pick.budgetUsd) && pick.budgetUsd > 0 && pick.budgetUsd !== meta.budgetUsd) {
|
|
1758
|
+
meta.budgetUsd = pick.budgetUsd;
|
|
1759
|
+
}
|
|
1760
|
+
const stoppedAt = meta.stopReason;
|
|
1761
|
+
meta.stopReason = undefined;
|
|
1751
1762
|
meta.status = 'running';
|
|
1752
1763
|
meta.endedAt = undefined;
|
|
1753
1764
|
meta.resumes = (meta.resumes ?? 0) + 1;
|
|
1765
|
+
if (meta.budgetUsd !== budgetWas) {
|
|
1766
|
+
makeEmitter(meta.id, projectId)('settings_changed', {
|
|
1767
|
+
budgetUsd: meta.budgetUsd,
|
|
1768
|
+
changes: [`Budget ${meta.budgetUsd > budgetWas ? 'raised' : 'lowered'} to $${meta.budgetUsd.toFixed(2)} (was $${budgetWas.toFixed(2)}) before resume${stoppedAt === 'budget' ? ' — the last attempt stopped at its cap' : ''}.`],
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1754
1771
|
if (meta.git) {
|
|
1755
1772
|
const back = await ensureMissionBranch(meta.folder, meta.git.branch);
|
|
1756
1773
|
if (back) makeEmitter(meta.id, projectId)('git_note', { text: `Could not return to ${meta.git.branch} (${back}); the resumed mission runs on whatever is checked out.` });
|
|
@@ -2740,6 +2757,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2740
2757
|
const overrides = {
|
|
2741
2758
|
directorModel: str(resumeBody.directorModel), directorProviderId: str(resumeBody.directorProviderId),
|
|
2742
2759
|
workerModel: str(resumeBody.workerModel), workerProviderId: str(resumeBody.workerProviderId),
|
|
2760
|
+
budgetUsd: typeof resumeBody.budgetUsd === 'number' && Number.isFinite(resumeBody.budgetUsd) && resumeBody.budgetUsd > 0
|
|
2761
|
+
? Math.round(resumeBody.budgetUsd * 100) / 100 : undefined,
|
|
2743
2762
|
};
|
|
2744
2763
|
void resumeRun(meta.projectId, meta, overrides);
|
|
2745
2764
|
json(res, 200, { ok: true });
|
package/src/types.ts
CHANGED
|
@@ -325,6 +325,12 @@ export interface RunMeta {
|
|
|
325
325
|
askTimeoutMs?: number;
|
|
326
326
|
/** Give agents a headless Playwright browser (navigate, click, screenshot). */
|
|
327
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';
|
|
328
334
|
folder: string;
|
|
329
335
|
mission: string;
|
|
330
336
|
budgetUsd: number;
|