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 +13 -2
- package/extensions/acp-runner.ts +60 -24
- package/extensions/command.ts +145 -21
- package/extensions/concurrency.ts +37 -4
- package/extensions/config.ts +36 -34
- package/extensions/engine.ts +167 -17
- package/extensions/fanout.ts +16 -29
- package/extensions/harnesses/amp.ts +2 -0
- package/extensions/harnesses/claude.ts +5 -0
- package/extensions/harnesses/codex.ts +2 -0
- package/extensions/harnesses/devin.ts +6 -1
- package/extensions/harnesses/opencode.ts +3 -0
- package/extensions/harnesses/registry.ts +51 -13
- package/extensions/harnesses/types.ts +5 -8
- package/extensions/index.ts +162 -184
- package/extensions/notify.ts +18 -5
- package/extensions/progress.ts +9 -3
- package/extensions/runner.ts +3 -20
- package/extensions/stream-caps.ts +46 -0
- package/extensions/templates.ts +21 -2
- package/package.json +2 -1
- package/extensions/run-claude.ts +0 -61
- package/extensions/stream-parse.ts +0 -37
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,
|
|
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
|
|
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
|
|
package/extensions/acp-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
+
handshakeTimeoutMs,
|
|
402
438
|
);
|
|
403
439
|
sessionId = opts.resumeSessionId;
|
|
404
440
|
} else {
|
|
405
|
-
sessionResult = await sendRequest('session/new', sessionParams,
|
|
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 },
|
|
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;
|
package/extensions/command.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
136
|
+
await pause();
|
|
104
137
|
continue;
|
|
105
138
|
}
|
|
106
139
|
|
package/extensions/config.ts
CHANGED
|
@@ -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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
//
|
|
458
|
-
const mc = cfg.maxConcurrent
|
|
459
|
-
|
|
460
|
-
|
|
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
|
}
|