@amenophis1er/foreman 0.1.8 → 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.
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 # 360 tests, node:test
233
+ npm test # 363 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.8",
3
+ "version": "0.1.9",
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",
@@ -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
+ });
@@ -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,
@@ -149,6 +149,31 @@ 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('a shell -c body is analysed as a command, so quoting cannot hide a cd', () => {
171
+ assert.ok(bashEscapesFolder(`sh -c 'cd /tmp && npm install'`, FOLDER, [ROOT]));
172
+ assert.match(bashEscapesFolder(`bash -c "mkdir -p /tmp/x/y"`, FOLDER, [ROOT])!, /creates a path outside/);
173
+ assert.equal(bashEscapesFolder(`sh -c 'npm test && echo ok'`, FOLDER, [ROOT]), null);
174
+ assert.equal(bashEscape(`bash -c 'cd /tmp/x'`, FOLDER)!.grant, '/tmp/x');
175
+ });
176
+
152
177
  test('bashEscape carries the offending path and the directory to grant', () => {
153
178
  // Directory-ish targets: the path itself is the grant.
154
179
  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
  }
@@ -266,7 +303,7 @@ export function bashEscape(
266
303
  // appended to while we walk, and one command must see one consistent view.
267
304
  const roots = [root, ...extraRoots];
268
305
  const outside = (p: string): boolean => !DEV_SINKS.has(p) && !underAnyRoot(p, roots);
269
- const label = (seg: string): string => (seg.length > 80 ? seg.slice(0, 77) + '…' : seg);
306
+ const label = (raw: string): string => { const seg = unmask(raw); return seg.length > 80 ? seg.slice(0, 77) + '…' : seg; };
270
307
  const hit = (segment: string, does: string, p: string, isDir: boolean): BashEscape => ({
271
308
  reason: `${label(segment)} — ${does} outside the mission folder`,
272
309
  path: p,
@@ -275,7 +312,7 @@ export function bashEscape(
275
312
 
276
313
  let cwd = root; // virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly
277
314
 
278
- const segments = command
315
+ const segments = maskQuoted(command)
279
316
  .split(/&&|\|\||;|\n|\|(?!\|)|(?<![&>\d])&(?![&>])/)
280
317
  .map((s) => s.trim())
281
318
  .filter(Boolean);
@@ -295,6 +332,19 @@ export function bashEscape(
295
332
  const args = words.slice(i + 1);
296
333
  const flagless = args.filter((a) => !a.startsWith('-'));
297
334
 
335
+ // `sh -c '…'`: the quoted body is a command of its own, run from the
336
+ // current virtual cwd. Analysed as one, so quoting cannot hide a `cd /tmp`
337
+ // — masking made the body opaque to the split above, and this is where it
338
+ // is looked at instead.
339
+ if (SHELLS.has(verb)) {
340
+ const c = args.indexOf('-c');
341
+ if (c >= 0 && c + 1 < args.length) {
342
+ const inner = bashEscape(args[c + 1], cwd, roots);
343
+ if (inner) return { ...inner, reason: `${label(segment)} — ${inner.reason.replace(/^.*? — /, '')}` };
344
+ }
345
+ continue;
346
+ }
347
+
298
348
  if (verb === 'cd' || verb === 'pushd') {
299
349
  const target = flagless[0] ?? '~'; // bare `cd` goes home
300
350
  const p = asPath(target, cwd) ?? path.resolve(cwd, target);
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;