pi-harness-delegate 0.7.0 → 0.7.2

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
@@ -160,7 +160,17 @@ All frontmatter keys (one `key: value` per line; only `name` is required):
160
160
  | `addDirs` | Comma-separated extra directories the harness may access (merged with the call's `addDirs`/`--add-dir`) |
161
161
  | `harness` | Informational: which harness a template targets (shown by `/delegate list`) |
162
162
 
163
- **Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, use the native key (`permissionMode: dontAsk`, `sandbox: ...`) — it overrides `permission` for that harness.
163
+ **Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, put the native value in `permission:` (e.g. `permission: ask` in a `devin/` template) — it is passed to that harness as-is. Legacy `permissionMode:`/`sandbox:` keys only map onto the normalized tiers (`plan` → `readonly`, `bypassPermissions` → `danger`, anything else → `edit`).
164
+
165
+ Native values are checked against a per-harness **allowlist** of readonly/edit-equivalent modes; anything else — the harness's own danger mode *or a value not on the list* — is treated as `danger` and needs `allowDangerous` / `--allow-dangerous` (fail closed). Once confirmed, an unlisted value still runs as declared rather than being swapped for the harness's danger mode.
166
+
167
+ | Harness | Native values treated as non-danger |
168
+ | --- | --- |
169
+ | `claude` | `plan`, `acceptEdits`, `manual`, `default` (`auto`, `dontAsk`, `bypassPermissions` → danger) |
170
+ | `codex` | `read-only`, `workspace-write` |
171
+ | `opencode` | `plan`, `build` (custom agents, `build --auto` → danger) |
172
+ | `amp`/`omp` | `always-ask`, `write` |
173
+ | `devin` | `plan`, `accept-edits`, `ask` (`smart`, `bypass` → danger) |
164
174
 
165
175
  **Verify:** `verify` is a shell command run **on the host** (never handed to the harness) right after it exits — e.g. `verify: bun test` on an `implement`/`docs`/`general` template turns "the harness says it's done" into an actual pass/fail. It's report-only: a failing verify is appended as its own section in the transcript and injected report, and surfaced in the tool result's `details.verify` (`{command, exitCode, ok}`), but it never changes whether the run itself is reported as an error — that stays whatever the harness reported. No template ships one by default — there's no universally-correct check command, so nothing is invented for you.
166
176
 
@@ -247,7 +257,7 @@ Config lives as a key inside pi's own `~/.pi/agent/settings.json` rather than a
247
257
  A per-run cap, resolved call → template → global config → per-harness config. How it's enforced depends on the harness, and the outcome is always recorded (tool result `details.budget`, a `- budget:` line in the transcript):
248
258
 
249
259
  - **Native** — `claude` gets `--max-budget-usd` and enforces it itself.
250
- - **Host-enforced (best-effort)** — harnesses with no budget flag that *do* stream a running cost (`opencode`, `amp`/`omp`, and `opencode` over ACP): the host checks the reported total each time the harness reports one and kills the run once it's over the cap, recording `budget exceeded` (`stopReason: budget_exceeded`, a `⛔ budget exceeded … run stopped` line at the top of the result, `(host-enforced, best-effort)` in the transcript). This is **not a hard cap**: cost is only visible at the step/turn boundaries the harness reports, so spend already incurred within the step that crossed the line can't be prevented — a run can overshoot by up to one step/turn (more if a single step is expensive). Use a native-budget harness (`claude`) when the cap must be strict. For `opencode` over ACP the reported cost is a session running total, so on a resumed session only the spend since this run started counts against the cap (the reported cost still includes prior turns).
260
+ - **Host-enforced (best-effort)** — harnesses with no budget flag that *do* stream a running cost (`opencode`, `amp`/`omp`, and `opencode` over ACP): the host checks the reported total each time the harness reports one and kills the run once it's over the cap, recording `budget exceeded` (`stopReason: budget_exceeded`, a `⛔ budget exceeded … run stopped` line at the top of the result, `(host-enforced, best-effort)` in the transcript). This is **not a hard cap**: cost is only visible at the step/turn boundaries the harness reports, so spend already incurred within the step that crossed the line can't be prevented — a run can overshoot by up to one step/turn (more if a single step is expensive). Use a native-budget harness (`claude`) when the cap must be strict. For `opencode` over ACP the reported cost is a session running total, so on a resumed session only the spend since this run started counts against the cap, and the reported cost is just this run's share — or `—` (unmeasured) when the session didn't report its prior total before the new prompt, rather than an under-count.
251
261
  - **Not enforceable** — `codex` (no `$` cost on ChatGPT-plan auth) and `devin` report no cost and have no flag, so a budget can't be applied; the run proceeds and the result starts with `⚠ maxBudgetUsd … was not enforced`, never silently.
252
262
 
253
263
  ## Metrics recorded
@@ -267,6 +277,7 @@ One deliberate, narrow exception: pi's own `Usage` (the footer/session token+cos
267
277
  - `/delegate --allow-dangerous` (and the `/claude`, `/codex`, … aliases) is the human-typed counterpart: it also asks you to confirm (`Allow dangerous delegation?`, naming the harness(es), mode, and that it runs with full, unrestricted permissions) — a fan-out gets one prompt covering every harness — and a decline runs nothing. In a non-interactive session it is refused outright, since there's no one to confirm with. It applies to that invocation only.
268
278
  - Likewise, when the **model** passes `addDirs` on the `delegate` tool, entries that resolve (after `..` and symlinks) outside the working directory need your interactive confirmation (`Allow access outside the project?`) and are refused in a non-interactive session. Entries inside the working directory, template `addDirs:`, and `/delegate --add-dir` are not gated.
269
279
  - Values that end up on a harness's command line (`sessionId`/`--resume`, `model`, `pr`) are validated first — notably, nothing starting with `-` is accepted, so a prompt-injected value can't masquerade as a CLI flag.
280
+ - External content in the delegated prompt — the `git diff` for `diff`, the `gh pr diff` body for `pr`/`--pr`, and `gh`'s error output if the PR can't be fetched — is wrapped in an **untrusted-data block**: a backtick fence longer than any backtick run inside it, between `BEGIN/END UNTRUSTED DATA <nonce>` markers with a fresh random nonce, plus an instruction that the content is data to analyze, not instructions. A malicious PR can't close the block early or forge its end marker, and the PR is named in the prompt only as `owner/repo#n`/`#n`, never by its raw URL. A free-text `--scope` (e.g. `src/a.ts, src/b`, or a template's `defaultScope`) is delimited the same way between `BEGIN/END SCOPE <nonce>` markers, but framed as a restriction the harness must honor — it can narrow the task, never add to it. Your own task text stays the instruction and isn't fenced. This is a mitigation, not a guarantee — models can still be swayed by injected text.
270
281
 
271
282
  Review what the harness is asked to do before granting broad permissions.
272
283
 
@@ -28,6 +28,7 @@ import {
28
28
  isOverBudget,
29
29
  type RunHarnessOptions,
30
30
  } from './runner.ts';
31
+ import { appendActivities, appendStreamed } from './stream-caps.ts';
31
32
 
32
33
  /** Bound on the initial handshake (initialize / session/new / session/set_mode) so a hung agent
33
34
  * doesn't wedge the whole `timeoutMs` budget before `session/prompt` — the actual work — even starts. */
@@ -66,6 +67,37 @@ function supportsSessionModes(sessionResult: unknown): boolean {
66
67
  return false;
67
68
  }
68
69
 
70
+ /**
71
+ * The cost to report for *this* run of a session whose harness reports a session-cumulative running
72
+ * total (opencode over ACP: `usage_update.cost.amount`). On a fresh session that total is this run's
73
+ * spend as-is. On a resume it also includes every prior turn — reporting it unchanged would make
74
+ * `/delegate status`'s spend rollup count those turns again on every resume — so the delta since
75
+ * `baselineUsd` (the same baseline the host budget check uses; see `costBaseline` in
76
+ * `runAcpHarness`) is reported instead.
77
+ *
78
+ * When the baseline wasn't replayed before the prompt (`baselineFromReplay: false`) it is the first
79
+ * running total seen *after* the prompt, so any delta silently omits that first step's spend — an
80
+ * under-report that looks measured (with one sample it's even a fake "$0"). That case is reported
81
+ * as `null` (unmeasured), per the honest-metrics convention. The host budget check still uses that
82
+ * first-sample baseline on purpose (biased toward never killing a resumed run for prior turns'
83
+ * spend); only the *reported* figure refuses to guess. Pure.
84
+ */
85
+ export function resumedRunCost(opts: {
86
+ totalCostUsd: number | null;
87
+ resumed: boolean;
88
+ baselineUsd: number | undefined;
89
+ baselineFromReplay: boolean;
90
+ postPromptCostSamples: number;
91
+ }): number | null {
92
+ const { totalCostUsd, resumed, baselineUsd, baselineFromReplay, postPromptCostSamples } = opts;
93
+ if (!resumed || totalCostUsd === null) return totalCostUsd;
94
+ // no cost reported during this run at all — whatever total we hold is prior turns' spend
95
+ if (baselineUsd === undefined || postPromptCostSamples === 0) return null;
96
+ // baseline is this run's own first sample: the true spend is unknowable, not "the delta"
97
+ if (!baselineFromReplay) return null;
98
+ return Math.max(0, totalCostUsd - baselineUsd);
99
+ }
100
+
69
101
  /** Placeholder for a run the host stops mid-turn before the harness produced anything result-shaped. */
70
102
  function emptyResult(): StreamedResult {
71
103
  return {
@@ -92,7 +124,14 @@ interface PendingRequest {
92
124
  timer?: ReturnType<typeof setTimeout>;
93
125
  }
94
126
 
95
- export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
127
+ /** Test-only knobs — production callers pass only `opts`. */
128
+ export interface AcpRunnerInternals {
129
+ /** Overrides `HANDSHAKE_TIMEOUT_MS` so a hung-handshake test doesn't take 30s. */
130
+ handshakeTimeoutMs?: number;
131
+ }
132
+
133
+ export function runAcpHarness(opts: RunHarnessOptions, internals: AcpRunnerInternals = {}): Promise<HarnessResult> {
134
+ const handshakeTimeoutMs = internals.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
96
135
  return new Promise((resolve, reject) => {
97
136
  // Already cancelled (e.g. the user hit cancel while this run was still being set up) —
98
137
  // never spawn a process just to kill it.
@@ -112,6 +151,10 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
112
151
  });
113
152
 
114
153
  const proc = spawn(opts.harness.binary, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
154
+ // A write to a child that already exited surfaces as an async EPIPE 'error' on stdin — not as a
155
+ // throw from write() (writeLine's try/catch can't see it). Unhandled, that event would crash the
156
+ // host process; the run itself already ends through the 'close'/timeout/abort paths.
157
+ proc.stdin.on('error', () => {});
115
158
 
116
159
  const state: ParseState = { streamedText: '', activities: [], result: null, _harness: {} };
117
160
  let stderr = '';
@@ -129,9 +172,8 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
129
172
  // never killing a resumed run for spend it didn't incur.
130
173
  let prePromptCost: number | undefined;
131
174
  let costBaseline: number | undefined;
175
+ let postPromptCostSamples = 0;
132
176
  const startAt = Date.now();
133
- const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
134
- const MAX_ACTIVITIES = 5000;
135
177
 
136
178
  let nextId = 1;
137
179
  const pending = new Map<number, PendingRequest>();
@@ -145,6 +187,14 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
145
187
  const ttft = firstTokenAt !== null ? firstTokenAt - startAt : r.ttftMs;
146
188
  resolve({
147
189
  ...r,
190
+ // a resumed session's running total includes prior turns — report only this run's share
191
+ totalCostUsd: resumedRunCost({
192
+ totalCostUsd: r.totalCostUsd,
193
+ resumed: Boolean(opts.resumeSessionId),
194
+ baselineUsd: costBaseline,
195
+ baselineFromReplay: prePromptCost !== undefined,
196
+ postPromptCostSamples,
197
+ }),
148
198
  ttftMs: ttft,
149
199
  streamedText: state.streamedText,
150
200
  harness: opts.harness.name,
@@ -261,6 +311,7 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
261
311
  if (typeof outcome.runningCostUsd === 'number') {
262
312
  if (!promptSent) prePromptCost = outcome.runningCostUsd;
263
313
  else {
314
+ postPromptCostSamples++;
264
315
  costBaseline ??= outcome.runningCostUsd;
265
316
  if (!settled && isCostOverBudget(opts.harness, opts.maxBudgetUsd, outcome.runningCostUsd, costBaseline)) {
266
317
  proc.kill('SIGKILL');
@@ -285,24 +336,9 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
285
336
  // is sent — on a resume that's the replayed prior conversation, not the new turn's own output.
286
337
  if (promptSent && outcome.streamedText) {
287
338
  if (firstTokenAt === null) firstTokenAt = Date.now();
288
- if (state.streamedText.length < MAX_STREAMED) {
289
- const remaining = MAX_STREAMED - state.streamedText.length;
290
- const chunk =
291
- outcome.streamedText.length > remaining
292
- ? `${outcome.streamedText.slice(0, remaining)} [truncated ${outcome.streamedText.length - remaining} chars]`
293
- : outcome.streamedText;
294
- state.streamedText += chunk;
295
- opts.onStream?.(chunk);
296
- }
297
- }
298
- if (promptSent && outcome.activities) {
299
- for (const a of outcome.activities) {
300
- if (state.activities.length < MAX_ACTIVITIES) {
301
- state.activities.push(a);
302
- opts.onActivity?.(a);
303
- }
304
- }
339
+ appendStreamed(state, outcome.streamedText, opts.onStream); // capped — see stream-caps.ts
305
340
  }
341
+ if (promptSent && outcome.activities) appendActivities(state, outcome.activities, opts.onActivity);
306
342
  if (outcome.result) {
307
343
  if (!outcome.result.result) outcome.result.result = state.streamedText;
308
344
  state.result = outcome.result;
@@ -370,7 +406,7 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
370
406
  protocolVersion: PROTOCOL_VERSION,
371
407
  clientCapabilities: {}, // no fs/terminal proxying — decline those requests if asked (see handleServerRequest)
372
408
  },
373
- HANDSHAKE_TIMEOUT_MS,
409
+ handshakeTimeoutMs,
374
410
  );
375
411
  if (settled) return;
376
412
  // The client "should disconnect" (spec text) if the agent didn't echo back the version we
@@ -398,11 +434,11 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
398
434
  sessionResult = await sendRequest(
399
435
  'session/load',
400
436
  { sessionId: opts.resumeSessionId, ...sessionParams },
401
- HANDSHAKE_TIMEOUT_MS,
437
+ handshakeTimeoutMs,
402
438
  );
403
439
  sessionId = opts.resumeSessionId;
404
440
  } else {
405
- sessionResult = await sendRequest('session/new', sessionParams, HANDSHAKE_TIMEOUT_MS);
441
+ sessionResult = await sendRequest('session/new', sessionParams, handshakeTimeoutMs);
406
442
  sessionId =
407
443
  isRecord(sessionResult) && typeof sessionResult.sessionId === 'string' ? sessionResult.sessionId : null;
408
444
  }
@@ -428,7 +464,7 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
428
464
  `cannot verify the "${opts.permission}" permission tier would be honored over ACP`,
429
465
  );
430
466
  }
431
- await sendRequest('session/set_mode', { sessionId, modeId }, HANDSHAKE_TIMEOUT_MS);
467
+ await sendRequest('session/set_mode', { sessionId, modeId }, handshakeTimeoutMs);
432
468
  if (settled) return;
433
469
  promptSent = true;
434
470
  costBaseline = opts.resumeSessionId ? prePromptCost : 0;
@@ -26,20 +26,91 @@ export interface DelegateCommandArgs {
26
26
  * — never inherited from config, never applied headless. Absent unless explicitly true.
27
27
  */
28
28
  allowDangerous?: boolean;
29
+ /** Flag values that were given but are unusable (e.g. `--budget=0`) — the handler reports these
30
+ * and runs nothing, rather than silently dropping the flag. Absent when there are none. */
31
+ errors?: string[];
32
+ /** Non-fatal heads-ups the handler shows before running — e.g. a recognized `--flag=` that sat
33
+ * inside a double-quoted span and so was kept as prompt text, not applied. Absent when none. */
34
+ notices?: string[];
29
35
  }
30
36
 
31
37
  export type ClaudeCommandArgs = DelegateCommandArgs;
32
38
 
33
- const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp']);
39
+ /**
40
+ * The flag set every `/delegate`-family command accepts after the harness — the single source for
41
+ * the `/delegate` description, its usage warning, and every alias command's (`/claude`, `/omp`, …)
42
+ * description, so the hints can't drift from what `parseDelegateCommand` actually parses.
43
+ */
44
+ export const COMMAND_FLAGS_HINT =
45
+ '[--mode=review|plan|implement|security-audit|docs|general] [--model=…] [--scope=diff|pr|paths] [--pr=<n|url>] [--budget=<usd>] [--verify=<cmd>] [--resume=<id>] [--add-dir=<path>] [--allow-dangerous] <prompt>';
46
+
47
+ /** Usage line for `/delegate` itself. */
48
+ export function delegateUsage(): string {
49
+ return `/delegate [--harness=claude|codex|opencode|amp|devin|all|<a,b>] ${COMMAND_FLAGS_HINT}`;
50
+ }
51
+
52
+ /** Usage line for an alias command (`/claude`, `/omp`, …) — same flags, harness fixed. */
53
+ export function aliasUsage(command: string): string {
54
+ return `/${command} ${COMMAND_FLAGS_HINT}`;
55
+ }
34
56
 
35
- /** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them. */
57
+ const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp', 'devin']);
58
+ const HARNESS_ALIASES: Readonly<Record<string, string>> = { omp: 'amp' };
59
+
60
+ /** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them
61
+ * (a stray empty element, e.g. the trailing comma in `claude,`, is ignored). */
36
62
  function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>): boolean {
37
63
  const lower = word.toLowerCase();
38
64
  if (lower === 'all' || knownHarnesses.has(lower)) return true;
65
+ if (!lower.includes(',')) return false;
39
66
  const parts = lower.split(',').filter(Boolean);
40
- return parts.length > 1 && parts.every(p => knownHarnesses.has(p));
67
+ return parts.length > 0 && parts.every(p => knownHarnesses.has(p));
68
+ }
69
+
70
+ /**
71
+ * Normalize a harness spec the same way whether it came from `--harness=` or the first word:
72
+ * lowercased, empty list elements dropped (`claude,` -> `claude`, `,` -> none), and a single name
73
+ * alias-normalized (`omp` -> `amp`). A real list / `all` is left for `resolveHarnessList`.
74
+ */
75
+ function normalizeHarnessSpec(spec: string): string | undefined {
76
+ const parts = spec
77
+ .toLowerCase()
78
+ .split(',')
79
+ .map(p => p.trim())
80
+ .filter(Boolean);
81
+ if (parts.length === 0) return undefined;
82
+ if (parts.length === 1) return HARNESS_ALIASES[parts[0]] ?? parts[0];
83
+ return parts.join(',');
41
84
  }
42
85
 
86
+ /**
87
+ * One pass over the raw command: a backticked or double-quoted prose span is skipped verbatim (so
88
+ * `explain "--mode=x"` or `` `--allow-dangerous` `` in the prompt is never eaten as a flag); a
89
+ * `--key=value` (value bare, "double" or 'single' quoted, or empty) or bare `--allow-dangerous` /
90
+ * `--budget` token that starts a word is a flag. Every other `--word` stays in the text untouched.
91
+ */
92
+ const FLAG_OR_PROSE = /`[^`]*`|"[^"]*"|(^|\s)--([a-zA-Z][a-zA-Z-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S*))|(?=\s|$))/g;
93
+
94
+ /** Every flag `parseDelegateCommand` acts on — used to notice one stranded inside quoted prose. */
95
+ const RECOGNIZED_FLAGS = new Set([
96
+ 'harness',
97
+ 'mode',
98
+ 'model',
99
+ 'scope',
100
+ 'budget',
101
+ 'resume',
102
+ 'pr',
103
+ 'verify',
104
+ 'add-dir',
105
+ 'allow-dangerous',
106
+ ]);
107
+
108
+ /** A recognized flag token starting a word inside a prose span (quote/backtick counts as a word start). */
109
+ const FLAG_IN_PROSE = /(?:^|[\s"`])--([a-zA-Z][a-zA-Z-]*)(?==|[\s"`]|$)/g;
110
+
111
+ /** Prose spans as FLAG_OR_PROSE pairs them; whatever delimiter is left over afterwards is unmatched. */
112
+ const PROSE_SPAN = /`[^`]*`|"[^"]*"/g;
113
+
43
114
  export function parseDelegateCommand(
44
115
  raw: string,
45
116
  knownModes: ReadonlySet<string>,
@@ -47,27 +118,76 @@ export function parseDelegateCommand(
47
118
  ): DelegateCommandArgs {
48
119
  const flags: Record<string, string> = {};
49
120
  const addDirs: string[] = [];
50
- // supports quoted values ("…"/'…') so multi-word flags like --verify="bun test" survive intact
51
- let rest = raw.replace(
52
- /--([a-zA-Z-]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
53
- (_m, k: string, dq: string | undefined, sq: string | undefined, bare: string | undefined) => {
121
+ const errors: string[] = [];
122
+ const notices: string[] = [];
123
+ let allowDangerousBare = false;
124
+ let budgetWithoutValue = false;
125
+ const rest = raw.replace(
126
+ FLAG_OR_PROSE,
127
+ (
128
+ m: string,
129
+ lead: string | undefined,
130
+ k: string | undefined,
131
+ dq: string | undefined,
132
+ sq: string | undefined,
133
+ bare: string | undefined,
134
+ ) => {
135
+ if (k === undefined) {
136
+ // quoted/backticked prose — left alone. A double-quoted span holding a recognized flag is
137
+ // most likely an accident (`fix "bug --budget=5 and "more`): say so instead of dropping it
138
+ // silently. Backticks are the deliberate "this is literal" marker, so they stay quiet.
139
+ if (m.startsWith('"')) {
140
+ for (const [, name] of m.matchAll(FLAG_IN_PROSE)) {
141
+ if (!RECOGNIZED_FLAGS.has(name)) continue;
142
+ notices.push(
143
+ `--${name} inside double quotes was kept as prompt text, not applied — move it outside the quotes to use it as a flag`,
144
+ );
145
+ }
146
+ }
147
+ return m;
148
+ }
149
+ const hasValue = dq !== undefined || sq !== undefined || bare !== undefined;
150
+ if (!hasValue) {
151
+ // bare boolean flag: only `--allow-dangerous`. A bare `--budget` (e.g. the space form
152
+ // `--budget 5`) is an explicit cap we can't honor — an error, never silently "no cap".
153
+ // Any other bare `--word` is prose.
154
+ if (k === 'budget') {
155
+ budgetWithoutValue = true;
156
+ return m;
157
+ }
158
+ if (k !== 'allow-dangerous') return m;
159
+ allowDangerousBare = true;
160
+ return lead ?? '';
161
+ }
54
162
  const value = dq ?? sq ?? bare ?? '';
55
163
  // --add-dir is the one repeatable flag — every occurrence is kept, in order
56
164
  if (k === 'add-dir') {
57
165
  if (value) addDirs.push(value);
58
166
  } else flags[k] = value;
59
- return '';
167
+ return lead ?? '';
60
168
  },
61
169
  );
62
- // bare boolean flag(s): `--allow-dangerous` with no `=value`. Run after the `--key=value` pass, so
63
- // a quoted flag value (e.g. --verify="echo --allow-dangerous") is already gone and can't match.
64
- let allowDangerousBare = false;
65
- rest = rest.replace(/(^|\s)--allow-dangerous(?=\s|$)/g, (_m, lead: string) => {
66
- allowDangerousBare = true;
67
- return lead;
68
- });
69
170
 
70
- let harness = flags.harness?.toLowerCase();
171
+ // An unmatched `"` or backtick makes the pairing above a guess: in `fix "x --verify="echo ok"` the
172
+ // stray quote pairs with the flag's own opening quote, so the real flag is eaten as prose and
173
+ // whatever followed it is parsed as flags instead. Text before the first delimiter is outside any
174
+ // span however the quotes pair, so only a flag-shaped token at or after it is in play; when one
175
+ // is, refuse rather than guess — the result must not depend on quote parity.
176
+ const strayDelimiter = /["`]/.test(rest.replace(PROSE_SPAN, ''));
177
+ const firstDelimiter = raw.search(/["`]/);
178
+ const ambiguous = strayDelimiter && firstDelimiter >= 0 && /--[a-zA-Z]/.test(raw.slice(firstDelimiter));
179
+ if (ambiguous) {
180
+ errors.push(
181
+ 'unbalanced " or ` in the command — flags around it can\'t be told apart from prompt text; close the quote (or remove it) and try again',
182
+ );
183
+ }
184
+ if (budgetWithoutValue && flags.budget === undefined) {
185
+ errors.push(
186
+ '--budget needs a value: use --budget=<usd> (or wrap the text in backticks if it is part of the prompt)',
187
+ );
188
+ }
189
+
190
+ let harness = flags.harness !== undefined ? normalizeHarnessSpec(flags.harness) : undefined;
71
191
  let mode = flags.mode;
72
192
  let task = rest.trim();
73
193
 
@@ -75,9 +195,7 @@ export function parseDelegateCommand(
75
195
  const words = task.split(/\s+/).filter(Boolean);
76
196
  let idx = 0;
77
197
  if (!harness && words[idx] && looksLikeHarnessSpec(words[idx], knownHarnesses)) {
78
- harness = words[idx].toLowerCase();
79
- // single-name alias normalization only — a list/`all` is resolved later by resolveHarnessList
80
- if (harness === 'omp') harness = 'amp';
198
+ harness = normalizeHarnessSpec(words[idx]);
81
199
  idx++;
82
200
  }
83
201
  if (!mode && words[idx] && knownModes.has(words[idx])) {
@@ -93,14 +211,20 @@ export function parseDelegateCommand(
93
211
  if (flags.scope) out.scope = flags.scope;
94
212
  if (flags.budget !== undefined) {
95
213
  const budget = Number(flags.budget);
96
- if (Number.isFinite(budget) && budget > 0) out.budget = budget;
214
+ // an explicit spend cap that can't be honored is an error, never silently "no cap"
215
+ if (flags.budget.trim() !== '' && Number.isFinite(budget) && budget > 0) out.budget = budget;
216
+ else errors.push(`--budget must be a positive number of USD (got "${flags.budget}")`);
97
217
  }
98
218
  if (flags.resume) out.sessionId = flags.resume;
99
219
  if (flags.pr) out.pr = flags.pr;
100
220
  if (flags.verify) out.verify = flags.verify;
101
221
  if (addDirs.length > 0) out.addDirs = addDirs;
102
222
  // `=true` is tolerated; any other explicit value (`=false`, `=yes`, …) means off — fail closed
103
- if (allowDangerousBare || flags['allow-dangerous']?.toLowerCase() === 'true') out.allowDangerous = true;
223
+ // never grant danger off an ambiguous parse — the error above stops the run anyway, but fail closed
224
+ if (!ambiguous && (allowDangerousBare || flags['allow-dangerous']?.toLowerCase() === 'true'))
225
+ out.allowDangerous = true;
226
+ if (errors.length > 0) out.errors = errors;
227
+ if (notices.length > 0) out.notices = [...new Set(notices)];
104
228
  return out;
105
229
  }
106
230
 
@@ -39,6 +39,29 @@ export interface AcquireSlotOptions {
39
39
  /** Aborts a `wait: true` poll early. */
40
40
  signal?: AbortSignal;
41
41
  pollIntervalMs?: number;
42
+ /** Source of the poll jitter (see `pollDelayMs`) — injectable so tests stay deterministic. */
43
+ random?: () => number;
44
+ /** How a poll waits (default: an abortable `setTimeout`) — injectable so tests can observe the
45
+ * actual delay each poll is given. */
46
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
47
+ }
48
+
49
+ /** Max extra delay added to each poll, as a fraction of `pollIntervalMs`. */
50
+ export const POLL_JITTER_FRACTION = 0.25;
51
+
52
+ /**
53
+ * One poll's wait: `pollIntervalMs` plus up to `POLL_JITTER_FRACTION` of it, at random. Without the
54
+ * jitter, every waiter of a fan-out (all launched in the same tick) re-checks in lockstep, so they
55
+ * keep hitting `acquireRunWithinLimits`'s write-then-recheck together — exactly the tight multi-way
56
+ * race that makes contenders all back off at once (a transient under-admission). Spreading the
57
+ * re-checks out breaks the lockstep. Pure given `random`.
58
+ */
59
+ export function pollDelayMs(pollIntervalMs: number, random: () => number = Math.random): number {
60
+ // a non-finite draw (NaN would otherwise survive the clamp and make setTimeout fire at 0ms, i.e.
61
+ // a hot poll loop) gets no jitter
62
+ const raw = random();
63
+ const r = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 0;
64
+ return pollIntervalMs + Math.floor(pollIntervalMs * POLL_JITTER_FRACTION * r);
42
65
  }
43
66
 
44
67
  function abortError(): Error {
@@ -77,21 +100,31 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
77
100
  * that race is handled exactly like losing the check above: throw (wait:false) or poll (wait:true).
78
101
  */
79
102
  export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void> {
80
- const { harness, mode, config, wait, signal, pollIntervalMs = 200 } = opts;
103
+ const {
104
+ harness,
105
+ mode,
106
+ config,
107
+ wait,
108
+ signal,
109
+ pollIntervalMs = 200,
110
+ random = Math.random,
111
+ sleep: sleepFn = sleep,
112
+ } = opts;
113
+ const pause = () => sleepFn(pollDelayMs(pollIntervalMs, random), signal);
81
114
  for (;;) {
82
115
  if (signal?.aborted) throw abortError();
83
116
  const maxGlobal = getMaxConcurrent(config);
84
117
  const globalCount = activeCount();
85
118
  if (maxGlobal > 0 && globalCount >= maxGlobal) {
86
119
  if (!wait) throw new ConcurrencyLimitError('another delegate run is already in progress (global limit)');
87
- await sleep(pollIntervalMs, signal);
120
+ await pause();
88
121
  continue;
89
122
  }
90
123
  const perHarnessLimit = getMaxConcurrent(config, harness);
91
124
  const perHarnessCount = activeCount(harness);
92
125
  if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit) {
93
126
  if (!wait) throw new ConcurrencyLimitError(`another ${harness} run is already in progress`);
94
- await sleep(pollIntervalMs, signal);
127
+ await pause();
95
128
  continue;
96
129
  }
97
130
 
@@ -100,7 +133,7 @@ export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void>
100
133
  // the check above passed, but another racer's write landed first and used up the slot —
101
134
  // handled exactly like hitting the cap on the check itself.
102
135
  if (!wait) throw new ConcurrencyLimitError(`another delegate run claimed the last available slot for ${harness}`);
103
- await sleep(pollIntervalMs, signal);
136
+ await pause();
104
137
  continue;
105
138
  }
106
139
 
@@ -109,6 +109,34 @@ export function defaultDelegateConfig(): DelegateConfig {
109
109
  };
110
110
  }
111
111
 
112
+ function isObject(v: unknown): v is Record<string, unknown> {
113
+ return typeof v === 'object' && v !== null;
114
+ }
115
+
116
+ /** `maxConcurrent.perHarness` from raw settings: only non-negative numeric entries survive.
117
+ * Undefined when the value isn't an object or no entry is valid. */
118
+ export function parsePerHarness(raw: unknown): Record<string, number> | undefined {
119
+ if (!isObject(raw)) return undefined;
120
+ const ph: Record<string, number> = {};
121
+ for (const [k, v] of Object.entries(raw)) {
122
+ if (typeof v === 'number' && v >= 0) ph[k] = v;
123
+ }
124
+ return Object.keys(ph).length > 0 ? ph : undefined;
125
+ }
126
+
127
+ /** `maxConcurrent` from raw settings (shared by the `delegate` and legacy `claudeDelegate` keys):
128
+ * a non-negative number, or `{global?, perHarness?}` with at least one valid part. Undefined
129
+ * means "not configured / invalid — keep the default". */
130
+ export function parseMaxConcurrent(raw: unknown): DelegateConfig['maxConcurrent'] | undefined {
131
+ if (typeof raw === 'number') return raw >= 0 ? raw : undefined;
132
+ if (!isObject(raw)) return undefined;
133
+ const out: { global?: number; perHarness?: Record<string, number> } = {};
134
+ if (typeof raw.global === 'number' && raw.global >= 0) out.global = raw.global;
135
+ const perHarness = parsePerHarness(raw.perHarness);
136
+ if (perHarness) out.perHarness = perHarness;
137
+ return out.global !== undefined || out.perHarness ? out : undefined;
138
+ }
139
+
112
140
  /**
113
141
  * Only the top-level settings whose effective value differs from the built-in default.
114
142
  *
@@ -185,20 +213,8 @@ export function loadConfigWithSource(): ConfigLoadResult {
185
213
  if (typeof v === 'string' && v) cfg.modelAliases[k] = v;
186
214
  }
187
215
  }
188
- if (typeof c.maxConcurrent === 'number' && c.maxConcurrent >= 0) cfg.maxConcurrent = c.maxConcurrent;
189
- else if (c.maxConcurrent && typeof c.maxConcurrent === 'object') {
190
- const obj = c.maxConcurrent as { global?: unknown; perHarness?: unknown };
191
- const out: { global?: number; perHarness?: Record<string, number> } = {};
192
- if (typeof obj.global === 'number' && obj.global >= 0) out.global = obj.global;
193
- if (obj.perHarness && typeof obj.perHarness === 'object') {
194
- const ph: Record<string, number> = {};
195
- for (const [k, v] of Object.entries(obj.perHarness as Record<string, unknown>)) {
196
- if (typeof v === 'number' && v >= 0) ph[k] = v;
197
- }
198
- if (Object.keys(ph).length > 0) out.perHarness = ph;
199
- }
200
- if (out.global !== undefined || out.perHarness) cfg.maxConcurrent = out;
201
- }
216
+ const legacyMaxConcurrent = parseMaxConcurrent(c.maxConcurrent);
217
+ if (legacyMaxConcurrent !== undefined) cfg.maxConcurrent = legacyMaxConcurrent;
202
218
  if (typeof c.maxTranscripts === 'number' && c.maxTranscripts >= 0) cfg.maxTranscripts = c.maxTranscripts;
203
219
  if (c.harnesses && typeof c.harnesses === 'object') {
204
220
  for (const [k, v] of Object.entries(c.harnesses)) {
@@ -225,20 +241,8 @@ export function loadConfigWithSource(): ConfigLoadResult {
225
241
  if (typeof v === 'string' && v) cfg.modelAliases[k] = v;
226
242
  }
227
243
  }
228
- if (typeof d.maxConcurrent === 'number' && d.maxConcurrent >= 0) cfg.maxConcurrent = d.maxConcurrent;
229
- else if (d.maxConcurrent && typeof d.maxConcurrent === 'object') {
230
- const obj = d.maxConcurrent as { global?: unknown; perHarness?: unknown };
231
- const out: { global?: number; perHarness?: Record<string, number> } = {};
232
- if (typeof obj.global === 'number' && obj.global >= 0) out.global = obj.global;
233
- if (obj.perHarness && typeof obj.perHarness === 'object') {
234
- const ph: Record<string, number> = {};
235
- for (const [k, v] of Object.entries(obj.perHarness as Record<string, unknown>)) {
236
- if (typeof v === 'number' && v >= 0) ph[k] = v;
237
- }
238
- if (Object.keys(ph).length > 0) out.perHarness = ph;
239
- }
240
- if (out.global !== undefined || out.perHarness) cfg.maxConcurrent = out;
241
- }
244
+ const maxConcurrent = parseMaxConcurrent(d.maxConcurrent);
245
+ if (maxConcurrent !== undefined) cfg.maxConcurrent = maxConcurrent;
242
246
  if (typeof d.maxTranscripts === 'number' && d.maxTranscripts >= 0) cfg.maxTranscripts = d.maxTranscripts;
243
247
  if (d.harnesses && typeof d.harnesses === 'object') {
244
248
  for (const [k, v] of Object.entries(d.harnesses)) {
@@ -454,12 +458,10 @@ export function resolveTransport(cfg: DelegateConfig, harnessName: string, harne
454
458
 
455
459
  export function getMaxConcurrent(cfg: DelegateConfig, harness?: string): number {
456
460
  if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
457
- // if object shape {global, perHarness}
458
- const mc = cfg.maxConcurrent as unknown as { global?: number; perHarness?: Record<string, number> };
459
- if (harness && mc.perHarness && typeof mc.perHarness[harness] === 'number') {
460
- const v = mc.perHarness[harness];
461
- if (typeof v === 'number') return v;
462
- }
461
+ // object shape {global, perHarness} — narrowed by the number check above
462
+ const mc = cfg.maxConcurrent;
463
+ const perHarness = harness ? mc.perHarness?.[harness] : undefined;
464
+ if (typeof perHarness === 'number') return perHarness;
463
465
  if (typeof mc.global === 'number') return mc.global;
464
466
  return 1;
465
467
  }