pi-harness-delegate 0.7.0 → 0.7.1
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/extensions/acp-runner.ts +60 -24
- package/extensions/command.ts +145 -21
- package/extensions/concurrency.ts +37 -4
- package/extensions/engine.ts +19 -1
- package/extensions/fanout.ts +14 -28
- package/extensions/harnesses/registry.ts +16 -0
- package/extensions/harnesses/types.ts +0 -8
- package/extensions/index.ts +46 -59
- 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 +1 -1
- package/extensions/run-claude.ts +0 -61
- package/extensions/stream-parse.ts +0 -37
package/README.md
CHANGED
|
@@ -247,7 +247,7 @@ Config lives as a key inside pi's own `~/.pi/agent/settings.json` rather than a
|
|
|
247
247
|
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
248
|
|
|
249
249
|
- **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
|
|
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, 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
251
|
- **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
252
|
|
|
253
253
|
## Metrics recorded
|
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/engine.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildVerifyResult,
|
|
14
14
|
collectActivityLog,
|
|
15
15
|
describeBudget,
|
|
16
|
+
formatMetrics,
|
|
16
17
|
formatToolUse,
|
|
17
18
|
pruneOutputs,
|
|
18
19
|
resolveVerifyPlan,
|
|
@@ -135,7 +136,7 @@ const warnedUntrustedProjects = new Set<string>();
|
|
|
135
136
|
|
|
136
137
|
export function warnIfProjectTemplatesSkipped(ctx: ExtensionContext, trusted: boolean): void {
|
|
137
138
|
if (trusted || warnedUntrustedProjects.has(ctx.cwd)) return;
|
|
138
|
-
const lines = describeSkippedProjectTemplates(projectTemplatePresence(ctx.cwd));
|
|
139
|
+
const lines = describeSkippedProjectTemplates(projectTemplatePresence(ctx.cwd, HARNESS_NAMES));
|
|
139
140
|
if (lines.length === 0) return;
|
|
140
141
|
warnedUntrustedProjects.add(ctx.cwd);
|
|
141
142
|
const msg = lines.join('\n');
|
|
@@ -454,6 +455,23 @@ export async function delegate(
|
|
|
454
455
|
};
|
|
455
456
|
}
|
|
456
457
|
|
|
458
|
+
/**
|
|
459
|
+
* The one-line metrics summary (`N turn(s) · $X · Nk tok · N% ctx · Ns`) for a finished `delegate()`
|
|
460
|
+
* run, read from its `details`. Shared by the single-run command path and both fan-out paths so a
|
|
461
|
+
* comparison row reports the same real prompt-token figure the single run does (it used to be a
|
|
462
|
+
* hard-coded 0 there, which silently dropped the `tok` column from every fan-out row).
|
|
463
|
+
*/
|
|
464
|
+
export function runMetrics(details: Record<string, unknown>): string {
|
|
465
|
+
const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
466
|
+
return formatMetrics({
|
|
467
|
+
numTurns: num(details.numTurns),
|
|
468
|
+
totalCostUsd: num(details.totalCostUsd),
|
|
469
|
+
promptTokens: num(details.promptTokens) ?? 0,
|
|
470
|
+
contextPercent: num(details.contextPercent),
|
|
471
|
+
durationMs: num(details.durationMs),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
457
475
|
export function summarize(content: string, max = 30_000): { text: string; truncated: boolean } {
|
|
458
476
|
if (content.length <= max) return { text: content, truncated: false };
|
|
459
477
|
return { text: `${content.slice(0, max)}\n…[truncated — full output saved to file]`, truncated: true };
|
package/extensions/fanout.ts
CHANGED
|
@@ -6,13 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import type { OverlayHandle } from '@earendil-works/pi-tui';
|
|
9
|
-
import {
|
|
10
|
-
buildFanoutReport,
|
|
11
|
-
type FanoutRunSummary,
|
|
12
|
-
formatMetrics,
|
|
13
|
-
formatToolUse,
|
|
14
|
-
orderFanoutResults,
|
|
15
|
-
} from './activity.ts';
|
|
9
|
+
import { buildFanoutReport, type FanoutRunSummary, formatToolUse, orderFanoutResults } from './activity.ts';
|
|
16
10
|
import { fanoutResumeError, type parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
|
|
17
11
|
import { type DelegateConfig, loadConfig } from './config.ts';
|
|
18
12
|
import {
|
|
@@ -21,10 +15,17 @@ import {
|
|
|
21
15
|
injectReport,
|
|
22
16
|
isProjectTrusted,
|
|
23
17
|
runDelegateForTool,
|
|
18
|
+
runMetrics,
|
|
24
19
|
summarize,
|
|
25
20
|
type ToolProgressUpdate,
|
|
26
21
|
} from './engine.ts';
|
|
27
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
detectAll,
|
|
24
|
+
HARNESS_NAMES,
|
|
25
|
+
isKnownHarness,
|
|
26
|
+
isTemplateDanger,
|
|
27
|
+
resolveHarnessName,
|
|
28
|
+
} from './harnesses/registry.ts';
|
|
28
29
|
import type { ActivityEvent } from './harnesses/types.ts';
|
|
29
30
|
import { NotifyBatcher } from './notify.ts';
|
|
30
31
|
import { formatFanoutChip, multiProgressWindow, type RunRow } from './progress-multi.ts';
|
|
@@ -124,13 +125,7 @@ export async function runFanoutTool(
|
|
|
124
125
|
return {
|
|
125
126
|
harness: h,
|
|
126
127
|
ok: !run.result.isError,
|
|
127
|
-
metrics:
|
|
128
|
-
numTurns: run.result.numTurns,
|
|
129
|
-
totalCostUsd: run.result.totalCostUsd,
|
|
130
|
-
promptTokens: 0,
|
|
131
|
-
contextPercent: typeof run.details.contextPercent === 'number' ? run.details.contextPercent : null,
|
|
132
|
-
durationMs: run.result.durationMs,
|
|
133
|
-
}),
|
|
128
|
+
metrics: runMetrics(run.details),
|
|
134
129
|
cost: run.result.totalCostUsd,
|
|
135
130
|
body: summary.text,
|
|
136
131
|
file: (run.details.file as string) ?? undefined,
|
|
@@ -437,18 +432,15 @@ export async function runFanoutCommand(
|
|
|
437
432
|
for (const h of resolved) {
|
|
438
433
|
const templates = loadTemplates(ctx.cwd, h, trusted);
|
|
439
434
|
const resolvedTaskScope = resolveDefaults(parsed, templates);
|
|
440
|
-
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
441
435
|
if (!resolvedTaskScope) {
|
|
442
436
|
const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
|
|
443
437
|
immediateFailures.push({ harness: h, ok: false, cost: null, error: message });
|
|
444
438
|
batcher.failure(`${h}: ${message}`);
|
|
445
439
|
continue;
|
|
446
440
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
|
|
451
|
-
: false);
|
|
441
|
+
// the template delegate() will actually run for this harness (default mode when none given),
|
|
442
|
+
// judged by the engine's own danger gate — so the banner can't disagree with the engine
|
|
443
|
+
const isDanger = isTemplateDanger(h, templates.get(modeForReport));
|
|
452
444
|
specs.push({
|
|
453
445
|
harnessName: h,
|
|
454
446
|
task: resolvedTaskScope.task,
|
|
@@ -493,13 +485,7 @@ export async function runFanoutCommand(
|
|
|
493
485
|
}
|
|
494
486
|
const { content, details, result, verify } = outcome.result;
|
|
495
487
|
const summary = summarize(content);
|
|
496
|
-
const metrics =
|
|
497
|
-
numTurns: result.numTurns,
|
|
498
|
-
totalCostUsd: result.totalCostUsd,
|
|
499
|
-
promptTokens: 0,
|
|
500
|
-
contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
|
|
501
|
-
durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
|
|
502
|
-
});
|
|
488
|
+
const metrics = runMetrics(details);
|
|
503
489
|
batcher.success(`${outcome.harnessName} ${parsed.mode ?? 'general'} — ${metrics}`);
|
|
504
490
|
return {
|
|
505
491
|
harness: outcome.harnessName,
|
|
@@ -78,3 +78,19 @@ export function isNativeDangerPermission(harness: Harness | undefined, nativePer
|
|
|
78
78
|
const danger = harness?.permissionMap?.danger;
|
|
79
79
|
return Array.isArray(danger) && danger.length > 0 && native === danger.join(' ');
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Will running `template` on `harnessName` be a danger-tier run? The same test `delegate()` gates on
|
|
84
|
+
* (normalized `danger`, or a native permission that is this harness's own danger mode — see
|
|
85
|
+
* `isNativeDangerPermission`), exposed so the command paths' danger banner can't disagree with the
|
|
86
|
+
* engine about what is actually dangerous.
|
|
87
|
+
*/
|
|
88
|
+
export function isTemplateDanger(
|
|
89
|
+
harnessName: string,
|
|
90
|
+
template: { permission: string; nativePermission?: string } | undefined,
|
|
91
|
+
): boolean {
|
|
92
|
+
if (!template) return false;
|
|
93
|
+
return (
|
|
94
|
+
template.permission === 'danger' || isNativeDangerPermission(getHarness(harnessName), template.nativePermission)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -52,12 +52,6 @@ export interface ParseOutcome {
|
|
|
52
52
|
runningCostUsd?: number;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export interface StreamParseOutcome {
|
|
56
|
-
streamedText: string;
|
|
57
|
-
result: StreamedResult | null;
|
|
58
|
-
activities: ActivityEvent[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
55
|
export interface BuildArgsOpts {
|
|
62
56
|
prompt: string;
|
|
63
57
|
cwd: string;
|
|
@@ -70,8 +64,6 @@ export interface BuildArgsOpts {
|
|
|
70
64
|
resumeId?: string;
|
|
71
65
|
}
|
|
72
66
|
|
|
73
|
-
export type HarnessBuildOpts = BuildArgsOpts;
|
|
74
|
-
|
|
75
67
|
export interface DetectResult {
|
|
76
68
|
ok: boolean;
|
|
77
69
|
version?: string;
|
package/extensions/index.ts
CHANGED
|
@@ -17,8 +17,15 @@
|
|
|
17
17
|
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme } from '@earendil-works/pi-coding-agent';
|
|
18
18
|
import { Container, Markdown, type OverlayHandle, Text } from '@earendil-works/pi-tui';
|
|
19
19
|
import { Type } from 'typebox';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { formatToolUse, ToolCallIndex } from './activity.ts';
|
|
21
|
+
import {
|
|
22
|
+
aliasUsage,
|
|
23
|
+
delegateUsage,
|
|
24
|
+
isFanoutSpec,
|
|
25
|
+
parseDelegateCommand,
|
|
26
|
+
resolveDefaults,
|
|
27
|
+
resolveHarnessFilter,
|
|
28
|
+
} from './command.ts';
|
|
22
29
|
import { loadConfig } from './config.ts';
|
|
23
30
|
import {
|
|
24
31
|
type DelegateToolParams,
|
|
@@ -27,12 +34,13 @@ import {
|
|
|
27
34
|
injectReport,
|
|
28
35
|
isProjectTrusted,
|
|
29
36
|
runDelegateForTool,
|
|
37
|
+
runMetrics,
|
|
30
38
|
summarize,
|
|
31
39
|
type ToolProgressUpdate,
|
|
32
40
|
takePendingReport,
|
|
33
41
|
} from './engine.ts';
|
|
34
42
|
import { closeWhenMounted, type RunUiState, runFanoutCommand, runFanoutTool } from './fanout.ts';
|
|
35
|
-
import { ALIASES, HARNESS_NAMES, isKnownHarness, resolveHarnessName } from './harnesses/registry.ts';
|
|
43
|
+
import { ALIASES, HARNESS_NAMES, isKnownHarness, isTemplateDanger, resolveHarnessName } from './harnesses/registry.ts';
|
|
36
44
|
import type { ActivityEvent } from './harnesses/types.ts';
|
|
37
45
|
import { delegationHint, stripMarker } from './hint.ts';
|
|
38
46
|
import { showHistory } from './history.ts';
|
|
@@ -502,6 +510,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
502
510
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
503
511
|
// if forcedHarness provided, it wins
|
|
504
512
|
if (forcedHarness) parsed.harness = forcedHarness;
|
|
513
|
+
// a flag that was given but can't be honored (e.g. --budget=0) runs nothing — never silently dropped
|
|
514
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
515
|
+
const msg = `${parsed.errors.join('; ')}\nUsage: ${forcedHarness ? aliasUsage(forcedHarness) : delegateUsage()}`;
|
|
516
|
+
if (ctx.hasUI) ctx.ui.notify(msg, 'error');
|
|
517
|
+
else process.stderr.write(`${msg}\n`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// non-fatal: e.g. a --flag= that sat inside "quoted" prompt text and so wasn't applied
|
|
521
|
+
if (parsed.notices && parsed.notices.length > 0) {
|
|
522
|
+
const msg = parsed.notices.join('\n');
|
|
523
|
+
if (ctx.hasUI) ctx.ui.notify(msg, 'warning');
|
|
524
|
+
else process.stderr.write(`${msg}\n`);
|
|
525
|
+
}
|
|
505
526
|
|
|
506
527
|
// fan-out: harness field is `all` or a comma list — resolve to detected harnesses and run
|
|
507
528
|
// the engine once per harness instead of the single-harness flow below.
|
|
@@ -513,12 +534,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
513
534
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
514
535
|
const templates = loadTemplates(ctx.cwd, harnessName, trusted);
|
|
515
536
|
const resolved = resolveDefaults(parsed, templates);
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
|
|
521
|
-
: false);
|
|
537
|
+
// the template delegate() will actually run — the default mode when none was given — so the
|
|
538
|
+
// danger banner agrees with the engine's own gate (isTemplateDanger, same check)
|
|
539
|
+
const template = templates.get(parsed.mode || loadConfig().defaultMode);
|
|
540
|
+
const isDanger = isTemplateDanger(harnessName, template);
|
|
522
541
|
|
|
523
542
|
if (!resolved) {
|
|
524
543
|
if (parsed.mode)
|
|
@@ -526,11 +545,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
526
545
|
`/delegate ${parsed.mode} <what to do> — give a prompt for the "${parsed.mode}" mode`,
|
|
527
546
|
'warning',
|
|
528
547
|
);
|
|
529
|
-
else
|
|
530
|
-
ctx.ui.notify?.(
|
|
531
|
-
'Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=…] [--model=…] [--scope=…] [--pr=…] [--budget=…] [--verify=…] [--resume=…] [--allow-dangerous] <prompt>',
|
|
532
|
-
'warning',
|
|
533
|
-
);
|
|
548
|
+
else ctx.ui.notify?.(`Usage: ${forcedHarness ? aliasUsage(forcedHarness) : delegateUsage()}`, 'warning');
|
|
534
549
|
return;
|
|
535
550
|
}
|
|
536
551
|
|
|
@@ -584,25 +599,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
599
|
const file = (details.file as string) ?? null;
|
|
585
600
|
const sessionId = (details.sessionId as string) ?? null;
|
|
586
601
|
const resumeHint = sessionId ? ` · resume: /delegate --resume=${sessionId} <prompt>` : '';
|
|
587
|
-
const
|
|
588
|
-
| {
|
|
589
|
-
inputTokens?: number;
|
|
590
|
-
outputTokens?: number;
|
|
591
|
-
cacheCreationInputTokens?: number;
|
|
592
|
-
cacheReadInputTokens?: number;
|
|
593
|
-
}
|
|
594
|
-
| undefined;
|
|
595
|
-
const promptTokens = usage
|
|
596
|
-
? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
|
|
597
|
-
: 0;
|
|
598
|
-
const metrics = formatMetrics({
|
|
599
|
-
numTurns: typeof details.numTurns === 'number' ? details.numTurns : null,
|
|
600
|
-
totalCostUsd: typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null,
|
|
601
|
-
promptTokens,
|
|
602
|
-
contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
|
|
603
|
-
durationMs:
|
|
604
|
-
typeof details.durationMs === 'number' && details.durationMs !== null ? (details.durationMs as number) : null,
|
|
605
|
-
});
|
|
602
|
+
const metrics = runMetrics(details);
|
|
606
603
|
injectReport(ctx, {
|
|
607
604
|
harness: details.harness as string,
|
|
608
605
|
mode: details.mode as string,
|
|
@@ -619,34 +616,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
619
616
|
};
|
|
620
617
|
|
|
621
618
|
pi.registerCommand('delegate', {
|
|
622
|
-
description:
|
|
623
|
-
'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--pr=<n|url>] [--budget=<usd>] [--verify=<cmd>] [--resume=<id>] [--allow-dangerous] <prompt> — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report. --allow-dangerous runs this one invocation with danger (unrestricted) permission after an interactive confirm; refused headless.',
|
|
619
|
+
description: `Delegate a task to any harness. Usage: ${delegateUsage()} — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report. --allow-dangerous runs this one invocation with danger (unrestricted) permission after an interactive confirm; refused headless.`,
|
|
624
620
|
handler: makeHandler(),
|
|
625
621
|
});
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
});
|
|
642
|
-
pi.registerCommand('omp', {
|
|
643
|
-
description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>',
|
|
644
|
-
handler: makeHandler('amp'),
|
|
645
|
-
});
|
|
646
|
-
pi.registerCommand('devin', {
|
|
647
|
-
description: 'Alias for /delegate --harness=devin. Usage: /devin [--mode=...] <prompt>',
|
|
648
|
-
handler: makeHandler('devin'),
|
|
649
|
-
});
|
|
622
|
+
// alias commands: same flag set as /delegate (one source — COMMAND_FLAGS_HINT), harness fixed
|
|
623
|
+
const aliasCommands: [command: string, harness: string, note: string][] = [
|
|
624
|
+
['claude', 'claude', ''],
|
|
625
|
+
['codex', 'codex', ''],
|
|
626
|
+
['opencode', 'opencode', ''],
|
|
627
|
+
['amp', 'amp', ''],
|
|
628
|
+
['omp', 'amp', ' (omp compat)'],
|
|
629
|
+
['devin', 'devin', ''],
|
|
630
|
+
];
|
|
631
|
+
for (const [command, harness, note] of aliasCommands) {
|
|
632
|
+
pi.registerCommand(command, {
|
|
633
|
+
description: `Alias for /delegate --harness=${harness}${note}. Usage: ${aliasUsage(command)}`,
|
|
634
|
+
handler: makeHandler(harness),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
650
637
|
|
|
651
638
|
pi.on('input', async (event, _ctx) => {
|
|
652
639
|
if (event.source === 'extension') return { action: 'continue' };
|
package/extensions/progress.ts
CHANGED
|
@@ -47,12 +47,18 @@ export function fmtElapsed(ms: number): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* Fit a feed into `max` rendered lines, reporting how many entries were dropped so the caller can
|
|
51
51
|
* show a "+N earlier" marker instead of silently truncating with no hint older entries existed.
|
|
52
|
-
*
|
|
52
|
+
* The marker itself takes one of the `max` lines: when anything is dropped only the last `max - 1`
|
|
53
|
+
* entries are visible, so marker + entries never exceed `max` (for `max >= 1`). `max <= 0` shows no entries (and
|
|
54
|
+
* reports everything hidden) — never `slice(-0)`'s "everything". Pure — testable without a TUI.
|
|
53
55
|
*/
|
|
54
56
|
export function truncateFeed<T>(entries: T[], max: number): { visible: T[]; hiddenCount: number } {
|
|
55
|
-
|
|
57
|
+
const cap = Math.max(0, Math.floor(max));
|
|
58
|
+
if (entries.length <= cap) return { visible: [...entries], hiddenCount: 0 };
|
|
59
|
+
// one line goes to the marker; with a cap of 0 or 1 there's no room for entries at all
|
|
60
|
+
const keep = Math.max(0, cap - 1);
|
|
61
|
+
return { visible: keep > 0 ? entries.slice(-keep) : [], hiddenCount: entries.length - keep };
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Style one feed entry; the returned string may contain ANSI colors. */
|
package/extensions/runner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import type { Harness, ParseState, StreamedResult } from './harnesses/types.ts';
|
|
4
|
+
import { appendActivities, appendStreamed } from './stream-caps.ts';
|
|
4
5
|
|
|
5
6
|
export interface RunHarnessOptions {
|
|
6
7
|
harness: Harness;
|
|
@@ -96,8 +97,6 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
|
96
97
|
let settled = false;
|
|
97
98
|
let firstTokenAt: number | null = null;
|
|
98
99
|
const startAt = Date.now();
|
|
99
|
-
const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
|
|
100
|
-
const MAX_ACTIVITIES = 5000;
|
|
101
100
|
|
|
102
101
|
const finish = (r: StreamedResult, budgetExceeded = false) => {
|
|
103
102
|
if (settled) return;
|
|
@@ -126,25 +125,9 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
|
126
125
|
const outcome = opts.harness.parseLine(line, state);
|
|
127
126
|
if (outcome.streamedText) {
|
|
128
127
|
if (firstTokenAt === null) firstTokenAt = Date.now();
|
|
129
|
-
|
|
130
|
-
if (state.streamedText.length < MAX_STREAMED) {
|
|
131
|
-
const remaining = MAX_STREAMED - state.streamedText.length;
|
|
132
|
-
const chunk =
|
|
133
|
-
outcome.streamedText.length > remaining
|
|
134
|
-
? `${outcome.streamedText.slice(0, remaining)} [truncated ${outcome.streamedText.length - remaining} chars]`
|
|
135
|
-
: outcome.streamedText;
|
|
136
|
-
state.streamedText += chunk;
|
|
137
|
-
opts.onStream?.(chunk);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
if (outcome.activities) {
|
|
141
|
-
for (const a of outcome.activities) {
|
|
142
|
-
if (state.activities.length < MAX_ACTIVITIES) {
|
|
143
|
-
state.activities.push(a);
|
|
144
|
-
opts.onActivity?.(a);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
128
|
+
appendStreamed(state, outcome.streamedText, opts.onStream); // capped — see stream-caps.ts
|
|
147
129
|
}
|
|
130
|
+
if (outcome.activities) appendActivities(state, outcome.activities, opts.onActivity);
|
|
148
131
|
if (outcome.result) {
|
|
149
132
|
// merge streamedText into result if empty
|
|
150
133
|
if (!outcome.result.result) outcome.result.result = state.streamedText;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory caps shared by both runners (`runner.ts` for stdout harnesses, `acp-runner.ts` for ACP):
|
|
3
|
+
* a misbehaving or compromised harness can stream without bound, so accumulated text and activity
|
|
4
|
+
* events are capped here, once, instead of in two hand-copied blocks that could drift.
|
|
5
|
+
*/
|
|
6
|
+
import type { ActivityEvent, ParseState } from './harnesses/types.ts';
|
|
7
|
+
|
|
8
|
+
/** Max characters of streamed text kept per run (5MB) — beyond this the stream is truncated. */
|
|
9
|
+
export const MAX_STREAMED_CHARS = 5 * 1024 * 1024;
|
|
10
|
+
/** Max activity events kept (and forwarded to `onActivity`) per run. */
|
|
11
|
+
export const MAX_ACTIVITIES = 5000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Append a streamed chunk to `state.streamedText`, truncating at `max` with a
|
|
15
|
+
* `[truncated N chars]` marker, and forward exactly what was kept to `onStream`. Once the cap has
|
|
16
|
+
* been reached, further chunks are dropped (and not forwarded). Returns the appended chunk, or
|
|
17
|
+
* `null` when nothing was appended.
|
|
18
|
+
*/
|
|
19
|
+
export function appendStreamed(
|
|
20
|
+
state: ParseState,
|
|
21
|
+
text: string,
|
|
22
|
+
onStream?: (chunk: string) => void,
|
|
23
|
+
max: number = MAX_STREAMED_CHARS,
|
|
24
|
+
): string | null {
|
|
25
|
+
if (!text || state.streamedText.length >= max) return null;
|
|
26
|
+
const remaining = max - state.streamedText.length;
|
|
27
|
+
const chunk =
|
|
28
|
+
text.length > remaining ? `${text.slice(0, remaining)} [truncated ${text.length - remaining} chars]` : text;
|
|
29
|
+
state.streamedText += chunk;
|
|
30
|
+
onStream?.(chunk);
|
|
31
|
+
return chunk;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Append activity events to `state.activities` up to `max`, forwarding each kept one to `onActivity`. */
|
|
35
|
+
export function appendActivities(
|
|
36
|
+
state: ParseState,
|
|
37
|
+
activities: readonly ActivityEvent[],
|
|
38
|
+
onActivity?: (ev: ActivityEvent) => void,
|
|
39
|
+
max: number = MAX_ACTIVITIES,
|
|
40
|
+
): void {
|
|
41
|
+
for (const a of activities) {
|
|
42
|
+
if (state.activities.length >= max) return;
|
|
43
|
+
state.activities.push(a);
|
|
44
|
+
onActivity?.(a);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/extensions/templates.ts
CHANGED
|
@@ -224,13 +224,32 @@ export function resolveNativePermission(
|
|
|
224
224
|
*
|
|
225
225
|
* Pure filesystem inspection — no trust logic. The caller supplies the trust decision.
|
|
226
226
|
*/
|
|
227
|
-
export function projectTemplatePresence(
|
|
227
|
+
export function projectTemplatePresence(
|
|
228
|
+
cwd: string,
|
|
229
|
+
/** Canonical harness names — the only partitions `loadTemplates` ever reads (callers pass the
|
|
230
|
+
* registry's `HARNESS_NAMES`; injected so this module stays free of the harness registry). */
|
|
231
|
+
harnessNames: readonly string[],
|
|
232
|
+
): {
|
|
228
233
|
/** Template dirs that exist and would load if the project were trusted. */
|
|
229
234
|
dirs: string[];
|
|
230
235
|
/** A leftover `.pi/trusted` file — strong evidence the user relied on the removed mechanism. */
|
|
231
236
|
staleTrustFile: boolean;
|
|
232
237
|
} {
|
|
233
|
-
|
|
238
|
+
// the per-harness partitions (`.pi/delegate/templates/<harness>/`) load too — see loadTemplates —
|
|
239
|
+
// so they count as skipped content just like the shared root does. Only real harness partitions:
|
|
240
|
+
// loadTemplates is always called with a canonical name, so `omp/` (an alias), `archive/`,
|
|
241
|
+
// `shared/` etc. are never read and must not trigger the warning either.
|
|
242
|
+
const known = new Set(harnessNames);
|
|
243
|
+
let partitions: string[] = [];
|
|
244
|
+
try {
|
|
245
|
+
partitions = readdirSync(projectTemplatesDir(cwd), { withFileTypes: true })
|
|
246
|
+
.filter(e => e.isDirectory() && known.has(e.name))
|
|
247
|
+
.map(e => projectTemplatesDir(cwd, e.name))
|
|
248
|
+
.sort();
|
|
249
|
+
} catch {
|
|
250
|
+
// absent or unreadable
|
|
251
|
+
}
|
|
252
|
+
const candidates = [projectTemplatesDir(cwd), ...partitions, legacyProjectTemplatesDir(cwd)];
|
|
234
253
|
const dirs: string[] = [];
|
|
235
254
|
for (const dir of candidates) {
|
|
236
255
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-delegate",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp, Devin) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.14",
|
package/extensions/run-claude.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/** @deprecated use extensions/runner.ts + harnesses/claude.ts directly */
|
|
2
|
-
|
|
3
|
-
import { claudeHarness } from './harnesses/claude.ts';
|
|
4
|
-
import type { ActivityEvent, StreamedResult } from './harnesses/types.ts';
|
|
5
|
-
import { runHarness } from './runner.ts';
|
|
6
|
-
|
|
7
|
-
export interface RunClaudeOptions {
|
|
8
|
-
prompt: string;
|
|
9
|
-
cwd: string;
|
|
10
|
-
permissionMode: string;
|
|
11
|
-
model?: string;
|
|
12
|
-
maxBudgetUsd?: number;
|
|
13
|
-
addDirs?: string[];
|
|
14
|
-
signal?: AbortSignal;
|
|
15
|
-
timeoutMs?: number;
|
|
16
|
-
resumeSessionId?: string;
|
|
17
|
-
onStream?: (text: string) => void;
|
|
18
|
-
onActivity?: (ev: ActivityEvent) => void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ClaudeResult extends StreamedResult {
|
|
22
|
-
streamedText: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const DEFAULT_TIMEOUT_MS = 600_000;
|
|
26
|
-
|
|
27
|
-
function mapPerm(mode: string): 'readonly' | 'edit' | 'danger' {
|
|
28
|
-
if (mode === 'plan') return 'readonly';
|
|
29
|
-
if (mode === 'bypassPermissions') return 'danger';
|
|
30
|
-
return 'edit';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function runClaude(opts: RunClaudeOptions): Promise<ClaudeResult> {
|
|
34
|
-
const perm = mapPerm(opts.permissionMode);
|
|
35
|
-
const _nativePerm =
|
|
36
|
-
perm === 'readonly' && opts.permissionMode !== 'plan'
|
|
37
|
-
? opts.permissionMode
|
|
38
|
-
: perm === 'danger' && opts.permissionMode !== 'bypassPermissions'
|
|
39
|
-
? opts.permissionMode
|
|
40
|
-
: undefined;
|
|
41
|
-
// normalize: if caller passed a non-standard mode, preserve as native
|
|
42
|
-
const isStandard =
|
|
43
|
-
opts.permissionMode === 'plan' ||
|
|
44
|
-
opts.permissionMode === 'acceptEdits' ||
|
|
45
|
-
opts.permissionMode === 'bypassPermissions';
|
|
46
|
-
return runHarness({
|
|
47
|
-
harness: claudeHarness,
|
|
48
|
-
prompt: opts.prompt,
|
|
49
|
-
cwd: opts.cwd,
|
|
50
|
-
permission: perm,
|
|
51
|
-
nativePermission: isStandard ? undefined : opts.permissionMode,
|
|
52
|
-
model: opts.model,
|
|
53
|
-
maxBudgetUsd: opts.maxBudgetUsd,
|
|
54
|
-
addDirs: opts.addDirs,
|
|
55
|
-
signal: opts.signal,
|
|
56
|
-
timeoutMs: opts.timeoutMs,
|
|
57
|
-
resumeSessionId: opts.resumeSessionId,
|
|
58
|
-
onStream: opts.onStream,
|
|
59
|
-
onActivity: opts.onActivity,
|
|
60
|
-
}).then(r => ({ ...r, streamedText: r.streamedText }));
|
|
61
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/** @deprecated use extensions/harnesses/claude.ts parse directly */
|
|
2
|
-
export type {
|
|
3
|
-
ActivityEvent,
|
|
4
|
-
ParseOutcome,
|
|
5
|
-
ParseState,
|
|
6
|
-
StreamedResult,
|
|
7
|
-
StreamedUsage,
|
|
8
|
-
StreamParseOutcome,
|
|
9
|
-
} from './harnesses/types.ts';
|
|
10
|
-
|
|
11
|
-
import { parseClaudeLine } from './harnesses/claude.ts';
|
|
12
|
-
import type { ParseState, StreamParseOutcome } from './harnesses/types.ts';
|
|
13
|
-
|
|
14
|
-
export function parseStreamLines(lines: Iterable<string>): StreamParseOutcome {
|
|
15
|
-
const state: ParseState = { streamedText: '', activities: [], result: null };
|
|
16
|
-
let streamedText = '';
|
|
17
|
-
const activities: StreamParseOutcome['activities'] = [];
|
|
18
|
-
let result: StreamParseOutcome['result'] = null;
|
|
19
|
-
for (const line of lines) {
|
|
20
|
-
const out = parseClaudeLine(line, state);
|
|
21
|
-
if (out.streamedText) {
|
|
22
|
-
streamedText += out.streamedText;
|
|
23
|
-
state.streamedText += out.streamedText;
|
|
24
|
-
}
|
|
25
|
-
if (out.activities) {
|
|
26
|
-
for (const a of out.activities) {
|
|
27
|
-
activities.push(a);
|
|
28
|
-
state.activities.push(a);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
if (out.result) {
|
|
32
|
-
result = out.result;
|
|
33
|
-
state.result = out.result;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return { streamedText, result, activities };
|
|
37
|
-
}
|