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 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 (the reported cost still includes prior turns).
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
@@ -28,6 +28,7 @@ import {
28
28
  isOverBudget,
29
29
  type RunHarnessOptions,
30
30
  } from './runner.ts';
31
+ import { appendActivities, appendStreamed } from './stream-caps.ts';
31
32
 
32
33
  /** Bound on the initial handshake (initialize / session/new / session/set_mode) so a hung agent
33
34
  * doesn't wedge the whole `timeoutMs` budget before `session/prompt` — the actual work — even starts. */
@@ -66,6 +67,37 @@ function supportsSessionModes(sessionResult: unknown): boolean {
66
67
  return false;
67
68
  }
68
69
 
70
+ /**
71
+ * The cost to report for *this* run of a session whose harness reports a session-cumulative running
72
+ * total (opencode over ACP: `usage_update.cost.amount`). On a fresh session that total is this run's
73
+ * spend as-is. On a resume it also includes every prior turn — reporting it unchanged would make
74
+ * `/delegate status`'s spend rollup count those turns again on every resume — so the delta since
75
+ * `baselineUsd` (the same baseline the host budget check uses; see `costBaseline` in
76
+ * `runAcpHarness`) is reported instead.
77
+ *
78
+ * When the baseline wasn't replayed before the prompt (`baselineFromReplay: false`) it is the first
79
+ * running total seen *after* the prompt, so any delta silently omits that first step's spend — an
80
+ * under-report that looks measured (with one sample it's even a fake "$0"). That case is reported
81
+ * as `null` (unmeasured), per the honest-metrics convention. The host budget check still uses that
82
+ * first-sample baseline on purpose (biased toward never killing a resumed run for prior turns'
83
+ * spend); only the *reported* figure refuses to guess. Pure.
84
+ */
85
+ export function resumedRunCost(opts: {
86
+ totalCostUsd: number | null;
87
+ resumed: boolean;
88
+ baselineUsd: number | undefined;
89
+ baselineFromReplay: boolean;
90
+ postPromptCostSamples: number;
91
+ }): number | null {
92
+ const { totalCostUsd, resumed, baselineUsd, baselineFromReplay, postPromptCostSamples } = opts;
93
+ if (!resumed || totalCostUsd === null) return totalCostUsd;
94
+ // no cost reported during this run at all — whatever total we hold is prior turns' spend
95
+ if (baselineUsd === undefined || postPromptCostSamples === 0) return null;
96
+ // baseline is this run's own first sample: the true spend is unknowable, not "the delta"
97
+ if (!baselineFromReplay) return null;
98
+ return Math.max(0, totalCostUsd - baselineUsd);
99
+ }
100
+
69
101
  /** Placeholder for a run the host stops mid-turn before the harness produced anything result-shaped. */
70
102
  function emptyResult(): StreamedResult {
71
103
  return {
@@ -92,7 +124,14 @@ interface PendingRequest {
92
124
  timer?: ReturnType<typeof setTimeout>;
93
125
  }
94
126
 
95
- export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
127
+ /** Test-only knobs — production callers pass only `opts`. */
128
+ export interface AcpRunnerInternals {
129
+ /** Overrides `HANDSHAKE_TIMEOUT_MS` so a hung-handshake test doesn't take 30s. */
130
+ handshakeTimeoutMs?: number;
131
+ }
132
+
133
+ export function runAcpHarness(opts: RunHarnessOptions, internals: AcpRunnerInternals = {}): Promise<HarnessResult> {
134
+ const handshakeTimeoutMs = internals.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
96
135
  return new Promise((resolve, reject) => {
97
136
  // Already cancelled (e.g. the user hit cancel while this run was still being set up) —
98
137
  // never spawn a process just to kill it.
@@ -112,6 +151,10 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
112
151
  });
113
152
 
114
153
  const proc = spawn(opts.harness.binary, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
154
+ // A write to a child that already exited surfaces as an async EPIPE 'error' on stdin — not as a
155
+ // throw from write() (writeLine's try/catch can't see it). Unhandled, that event would crash the
156
+ // host process; the run itself already ends through the 'close'/timeout/abort paths.
157
+ proc.stdin.on('error', () => {});
115
158
 
116
159
  const state: ParseState = { streamedText: '', activities: [], result: null, _harness: {} };
117
160
  let stderr = '';
@@ -129,9 +172,8 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
129
172
  // never killing a resumed run for spend it didn't incur.
130
173
  let prePromptCost: number | undefined;
131
174
  let costBaseline: number | undefined;
175
+ let postPromptCostSamples = 0;
132
176
  const startAt = Date.now();
133
- const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
134
- const MAX_ACTIVITIES = 5000;
135
177
 
136
178
  let nextId = 1;
137
179
  const pending = new Map<number, PendingRequest>();
@@ -145,6 +187,14 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
145
187
  const ttft = firstTokenAt !== null ? firstTokenAt - startAt : r.ttftMs;
146
188
  resolve({
147
189
  ...r,
190
+ // a resumed session's running total includes prior turns — report only this run's share
191
+ totalCostUsd: resumedRunCost({
192
+ totalCostUsd: r.totalCostUsd,
193
+ resumed: Boolean(opts.resumeSessionId),
194
+ baselineUsd: costBaseline,
195
+ baselineFromReplay: prePromptCost !== undefined,
196
+ postPromptCostSamples,
197
+ }),
148
198
  ttftMs: ttft,
149
199
  streamedText: state.streamedText,
150
200
  harness: opts.harness.name,
@@ -261,6 +311,7 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
261
311
  if (typeof outcome.runningCostUsd === 'number') {
262
312
  if (!promptSent) prePromptCost = outcome.runningCostUsd;
263
313
  else {
314
+ postPromptCostSamples++;
264
315
  costBaseline ??= outcome.runningCostUsd;
265
316
  if (!settled && isCostOverBudget(opts.harness, opts.maxBudgetUsd, outcome.runningCostUsd, costBaseline)) {
266
317
  proc.kill('SIGKILL');
@@ -285,24 +336,9 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
285
336
  // is sent — on a resume that's the replayed prior conversation, not the new turn's own output.
286
337
  if (promptSent && outcome.streamedText) {
287
338
  if (firstTokenAt === null) firstTokenAt = Date.now();
288
- if (state.streamedText.length < MAX_STREAMED) {
289
- const remaining = MAX_STREAMED - state.streamedText.length;
290
- const chunk =
291
- outcome.streamedText.length > remaining
292
- ? `${outcome.streamedText.slice(0, remaining)} [truncated ${outcome.streamedText.length - remaining} chars]`
293
- : outcome.streamedText;
294
- state.streamedText += chunk;
295
- opts.onStream?.(chunk);
296
- }
297
- }
298
- if (promptSent && outcome.activities) {
299
- for (const a of outcome.activities) {
300
- if (state.activities.length < MAX_ACTIVITIES) {
301
- state.activities.push(a);
302
- opts.onActivity?.(a);
303
- }
304
- }
339
+ appendStreamed(state, outcome.streamedText, opts.onStream); // capped — see stream-caps.ts
305
340
  }
341
+ if (promptSent && outcome.activities) appendActivities(state, outcome.activities, opts.onActivity);
306
342
  if (outcome.result) {
307
343
  if (!outcome.result.result) outcome.result.result = state.streamedText;
308
344
  state.result = outcome.result;
@@ -370,7 +406,7 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
370
406
  protocolVersion: PROTOCOL_VERSION,
371
407
  clientCapabilities: {}, // no fs/terminal proxying — decline those requests if asked (see handleServerRequest)
372
408
  },
373
- HANDSHAKE_TIMEOUT_MS,
409
+ handshakeTimeoutMs,
374
410
  );
375
411
  if (settled) return;
376
412
  // The client "should disconnect" (spec text) if the agent didn't echo back the version we
@@ -398,11 +434,11 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
398
434
  sessionResult = await sendRequest(
399
435
  'session/load',
400
436
  { sessionId: opts.resumeSessionId, ...sessionParams },
401
- HANDSHAKE_TIMEOUT_MS,
437
+ handshakeTimeoutMs,
402
438
  );
403
439
  sessionId = opts.resumeSessionId;
404
440
  } else {
405
- sessionResult = await sendRequest('session/new', sessionParams, HANDSHAKE_TIMEOUT_MS);
441
+ sessionResult = await sendRequest('session/new', sessionParams, handshakeTimeoutMs);
406
442
  sessionId =
407
443
  isRecord(sessionResult) && typeof sessionResult.sessionId === 'string' ? sessionResult.sessionId : null;
408
444
  }
@@ -428,7 +464,7 @@ export function runAcpHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
428
464
  `cannot verify the "${opts.permission}" permission tier would be honored over ACP`,
429
465
  );
430
466
  }
431
- await sendRequest('session/set_mode', { sessionId, modeId }, HANDSHAKE_TIMEOUT_MS);
467
+ await sendRequest('session/set_mode', { sessionId, modeId }, handshakeTimeoutMs);
432
468
  if (settled) return;
433
469
  promptSent = true;
434
470
  costBaseline = opts.resumeSessionId ? prePromptCost : 0;
@@ -26,20 +26,91 @@ export interface DelegateCommandArgs {
26
26
  * — never inherited from config, never applied headless. Absent unless explicitly true.
27
27
  */
28
28
  allowDangerous?: boolean;
29
+ /** Flag values that were given but are unusable (e.g. `--budget=0`) — the handler reports these
30
+ * and runs nothing, rather than silently dropping the flag. Absent when there are none. */
31
+ errors?: string[];
32
+ /** Non-fatal heads-ups the handler shows before running — e.g. a recognized `--flag=` that sat
33
+ * inside a double-quoted span and so was kept as prompt text, not applied. Absent when none. */
34
+ notices?: string[];
29
35
  }
30
36
 
31
37
  export type ClaudeCommandArgs = DelegateCommandArgs;
32
38
 
33
- const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp']);
39
+ /**
40
+ * The flag set every `/delegate`-family command accepts after the harness — the single source for
41
+ * the `/delegate` description, its usage warning, and every alias command's (`/claude`, `/omp`, …)
42
+ * description, so the hints can't drift from what `parseDelegateCommand` actually parses.
43
+ */
44
+ export const COMMAND_FLAGS_HINT =
45
+ '[--mode=review|plan|implement|security-audit|docs|general] [--model=…] [--scope=diff|pr|paths] [--pr=<n|url>] [--budget=<usd>] [--verify=<cmd>] [--resume=<id>] [--add-dir=<path>] [--allow-dangerous] <prompt>';
46
+
47
+ /** Usage line for `/delegate` itself. */
48
+ export function delegateUsage(): string {
49
+ return `/delegate [--harness=claude|codex|opencode|amp|devin|all|<a,b>] ${COMMAND_FLAGS_HINT}`;
50
+ }
51
+
52
+ /** Usage line for an alias command (`/claude`, `/omp`, …) — same flags, harness fixed. */
53
+ export function aliasUsage(command: string): string {
54
+ return `/${command} ${COMMAND_FLAGS_HINT}`;
55
+ }
34
56
 
35
- /** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them. */
57
+ const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp', 'devin']);
58
+ const HARNESS_ALIASES: Readonly<Record<string, string>> = { omp: 'amp' };
59
+
60
+ /** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them
61
+ * (a stray empty element, e.g. the trailing comma in `claude,`, is ignored). */
36
62
  function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>): boolean {
37
63
  const lower = word.toLowerCase();
38
64
  if (lower === 'all' || knownHarnesses.has(lower)) return true;
65
+ if (!lower.includes(',')) return false;
39
66
  const parts = lower.split(',').filter(Boolean);
40
- return parts.length > 1 && parts.every(p => knownHarnesses.has(p));
67
+ return parts.length > 0 && parts.every(p => knownHarnesses.has(p));
68
+ }
69
+
70
+ /**
71
+ * Normalize a harness spec the same way whether it came from `--harness=` or the first word:
72
+ * lowercased, empty list elements dropped (`claude,` -> `claude`, `,` -> none), and a single name
73
+ * alias-normalized (`omp` -> `amp`). A real list / `all` is left for `resolveHarnessList`.
74
+ */
75
+ function normalizeHarnessSpec(spec: string): string | undefined {
76
+ const parts = spec
77
+ .toLowerCase()
78
+ .split(',')
79
+ .map(p => p.trim())
80
+ .filter(Boolean);
81
+ if (parts.length === 0) return undefined;
82
+ if (parts.length === 1) return HARNESS_ALIASES[parts[0]] ?? parts[0];
83
+ return parts.join(',');
41
84
  }
42
85
 
86
+ /**
87
+ * One pass over the raw command: a backticked or double-quoted prose span is skipped verbatim (so
88
+ * `explain "--mode=x"` or `` `--allow-dangerous` `` in the prompt is never eaten as a flag); a
89
+ * `--key=value` (value bare, "double" or 'single' quoted, or empty) or bare `--allow-dangerous` /
90
+ * `--budget` token that starts a word is a flag. Every other `--word` stays in the text untouched.
91
+ */
92
+ const FLAG_OR_PROSE = /`[^`]*`|"[^"]*"|(^|\s)--([a-zA-Z][a-zA-Z-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S*))|(?=\s|$))/g;
93
+
94
+ /** Every flag `parseDelegateCommand` acts on — used to notice one stranded inside quoted prose. */
95
+ const RECOGNIZED_FLAGS = new Set([
96
+ 'harness',
97
+ 'mode',
98
+ 'model',
99
+ 'scope',
100
+ 'budget',
101
+ 'resume',
102
+ 'pr',
103
+ 'verify',
104
+ 'add-dir',
105
+ 'allow-dangerous',
106
+ ]);
107
+
108
+ /** A recognized flag token starting a word inside a prose span (quote/backtick counts as a word start). */
109
+ const FLAG_IN_PROSE = /(?:^|[\s"`])--([a-zA-Z][a-zA-Z-]*)(?==|[\s"`]|$)/g;
110
+
111
+ /** Prose spans as FLAG_OR_PROSE pairs them; whatever delimiter is left over afterwards is unmatched. */
112
+ const PROSE_SPAN = /`[^`]*`|"[^"]*"/g;
113
+
43
114
  export function parseDelegateCommand(
44
115
  raw: string,
45
116
  knownModes: ReadonlySet<string>,
@@ -47,27 +118,76 @@ export function parseDelegateCommand(
47
118
  ): DelegateCommandArgs {
48
119
  const flags: Record<string, string> = {};
49
120
  const addDirs: string[] = [];
50
- // supports quoted values ("…"/'…') so multi-word flags like --verify="bun test" survive intact
51
- let rest = raw.replace(
52
- /--([a-zA-Z-]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
53
- (_m, k: string, dq: string | undefined, sq: string | undefined, bare: string | undefined) => {
121
+ const errors: string[] = [];
122
+ const notices: string[] = [];
123
+ let allowDangerousBare = false;
124
+ let budgetWithoutValue = false;
125
+ const rest = raw.replace(
126
+ FLAG_OR_PROSE,
127
+ (
128
+ m: string,
129
+ lead: string | undefined,
130
+ k: string | undefined,
131
+ dq: string | undefined,
132
+ sq: string | undefined,
133
+ bare: string | undefined,
134
+ ) => {
135
+ if (k === undefined) {
136
+ // quoted/backticked prose — left alone. A double-quoted span holding a recognized flag is
137
+ // most likely an accident (`fix "bug --budget=5 and "more`): say so instead of dropping it
138
+ // silently. Backticks are the deliberate "this is literal" marker, so they stay quiet.
139
+ if (m.startsWith('"')) {
140
+ for (const [, name] of m.matchAll(FLAG_IN_PROSE)) {
141
+ if (!RECOGNIZED_FLAGS.has(name)) continue;
142
+ notices.push(
143
+ `--${name} inside double quotes was kept as prompt text, not applied — move it outside the quotes to use it as a flag`,
144
+ );
145
+ }
146
+ }
147
+ return m;
148
+ }
149
+ const hasValue = dq !== undefined || sq !== undefined || bare !== undefined;
150
+ if (!hasValue) {
151
+ // bare boolean flag: only `--allow-dangerous`. A bare `--budget` (e.g. the space form
152
+ // `--budget 5`) is an explicit cap we can't honor — an error, never silently "no cap".
153
+ // Any other bare `--word` is prose.
154
+ if (k === 'budget') {
155
+ budgetWithoutValue = true;
156
+ return m;
157
+ }
158
+ if (k !== 'allow-dangerous') return m;
159
+ allowDangerousBare = true;
160
+ return lead ?? '';
161
+ }
54
162
  const value = dq ?? sq ?? bare ?? '';
55
163
  // --add-dir is the one repeatable flag — every occurrence is kept, in order
56
164
  if (k === 'add-dir') {
57
165
  if (value) addDirs.push(value);
58
166
  } else flags[k] = value;
59
- return '';
167
+ return lead ?? '';
60
168
  },
61
169
  );
62
- // bare boolean flag(s): `--allow-dangerous` with no `=value`. Run after the `--key=value` pass, so
63
- // a quoted flag value (e.g. --verify="echo --allow-dangerous") is already gone and can't match.
64
- let allowDangerousBare = false;
65
- rest = rest.replace(/(^|\s)--allow-dangerous(?=\s|$)/g, (_m, lead: string) => {
66
- allowDangerousBare = true;
67
- return lead;
68
- });
69
170
 
70
- let harness = flags.harness?.toLowerCase();
171
+ // An unmatched `"` or backtick makes the pairing above a guess: in `fix "x --verify="echo ok"` the
172
+ // stray quote pairs with the flag's own opening quote, so the real flag is eaten as prose and
173
+ // whatever followed it is parsed as flags instead. Text before the first delimiter is outside any
174
+ // span however the quotes pair, so only a flag-shaped token at or after it is in play; when one
175
+ // is, refuse rather than guess — the result must not depend on quote parity.
176
+ const strayDelimiter = /["`]/.test(rest.replace(PROSE_SPAN, ''));
177
+ const firstDelimiter = raw.search(/["`]/);
178
+ const ambiguous = strayDelimiter && firstDelimiter >= 0 && /--[a-zA-Z]/.test(raw.slice(firstDelimiter));
179
+ if (ambiguous) {
180
+ errors.push(
181
+ 'unbalanced " or ` in the command — flags around it can\'t be told apart from prompt text; close the quote (or remove it) and try again',
182
+ );
183
+ }
184
+ if (budgetWithoutValue && flags.budget === undefined) {
185
+ errors.push(
186
+ '--budget needs a value: use --budget=<usd> (or wrap the text in backticks if it is part of the prompt)',
187
+ );
188
+ }
189
+
190
+ let harness = flags.harness !== undefined ? normalizeHarnessSpec(flags.harness) : undefined;
71
191
  let mode = flags.mode;
72
192
  let task = rest.trim();
73
193
 
@@ -75,9 +195,7 @@ export function parseDelegateCommand(
75
195
  const words = task.split(/\s+/).filter(Boolean);
76
196
  let idx = 0;
77
197
  if (!harness && words[idx] && looksLikeHarnessSpec(words[idx], knownHarnesses)) {
78
- harness = words[idx].toLowerCase();
79
- // single-name alias normalization only — a list/`all` is resolved later by resolveHarnessList
80
- if (harness === 'omp') harness = 'amp';
198
+ harness = normalizeHarnessSpec(words[idx]);
81
199
  idx++;
82
200
  }
83
201
  if (!mode && words[idx] && knownModes.has(words[idx])) {
@@ -93,14 +211,20 @@ export function parseDelegateCommand(
93
211
  if (flags.scope) out.scope = flags.scope;
94
212
  if (flags.budget !== undefined) {
95
213
  const budget = Number(flags.budget);
96
- if (Number.isFinite(budget) && budget > 0) out.budget = budget;
214
+ // an explicit spend cap that can't be honored is an error, never silently "no cap"
215
+ if (flags.budget.trim() !== '' && Number.isFinite(budget) && budget > 0) out.budget = budget;
216
+ else errors.push(`--budget must be a positive number of USD (got "${flags.budget}")`);
97
217
  }
98
218
  if (flags.resume) out.sessionId = flags.resume;
99
219
  if (flags.pr) out.pr = flags.pr;
100
220
  if (flags.verify) out.verify = flags.verify;
101
221
  if (addDirs.length > 0) out.addDirs = addDirs;
102
222
  // `=true` is tolerated; any other explicit value (`=false`, `=yes`, …) means off — fail closed
103
- if (allowDangerousBare || flags['allow-dangerous']?.toLowerCase() === 'true') out.allowDangerous = true;
223
+ // never grant danger off an ambiguous parse — the error above stops the run anyway, but fail closed
224
+ if (!ambiguous && (allowDangerousBare || flags['allow-dangerous']?.toLowerCase() === 'true'))
225
+ out.allowDangerous = true;
226
+ if (errors.length > 0) out.errors = errors;
227
+ if (notices.length > 0) out.notices = [...new Set(notices)];
104
228
  return out;
105
229
  }
106
230
 
@@ -39,6 +39,29 @@ export interface AcquireSlotOptions {
39
39
  /** Aborts a `wait: true` poll early. */
40
40
  signal?: AbortSignal;
41
41
  pollIntervalMs?: number;
42
+ /** Source of the poll jitter (see `pollDelayMs`) — injectable so tests stay deterministic. */
43
+ random?: () => number;
44
+ /** How a poll waits (default: an abortable `setTimeout`) — injectable so tests can observe the
45
+ * actual delay each poll is given. */
46
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
47
+ }
48
+
49
+ /** Max extra delay added to each poll, as a fraction of `pollIntervalMs`. */
50
+ export const POLL_JITTER_FRACTION = 0.25;
51
+
52
+ /**
53
+ * One poll's wait: `pollIntervalMs` plus up to `POLL_JITTER_FRACTION` of it, at random. Without the
54
+ * jitter, every waiter of a fan-out (all launched in the same tick) re-checks in lockstep, so they
55
+ * keep hitting `acquireRunWithinLimits`'s write-then-recheck together — exactly the tight multi-way
56
+ * race that makes contenders all back off at once (a transient under-admission). Spreading the
57
+ * re-checks out breaks the lockstep. Pure given `random`.
58
+ */
59
+ export function pollDelayMs(pollIntervalMs: number, random: () => number = Math.random): number {
60
+ // a non-finite draw (NaN would otherwise survive the clamp and make setTimeout fire at 0ms, i.e.
61
+ // a hot poll loop) gets no jitter
62
+ const raw = random();
63
+ const r = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 0;
64
+ return pollIntervalMs + Math.floor(pollIntervalMs * POLL_JITTER_FRACTION * r);
42
65
  }
43
66
 
44
67
  function abortError(): Error {
@@ -77,21 +100,31 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
77
100
  * that race is handled exactly like losing the check above: throw (wait:false) or poll (wait:true).
78
101
  */
79
102
  export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void> {
80
- const { harness, mode, config, wait, signal, pollIntervalMs = 200 } = opts;
103
+ const {
104
+ harness,
105
+ mode,
106
+ config,
107
+ wait,
108
+ signal,
109
+ pollIntervalMs = 200,
110
+ random = Math.random,
111
+ sleep: sleepFn = sleep,
112
+ } = opts;
113
+ const pause = () => sleepFn(pollDelayMs(pollIntervalMs, random), signal);
81
114
  for (;;) {
82
115
  if (signal?.aborted) throw abortError();
83
116
  const maxGlobal = getMaxConcurrent(config);
84
117
  const globalCount = activeCount();
85
118
  if (maxGlobal > 0 && globalCount >= maxGlobal) {
86
119
  if (!wait) throw new ConcurrencyLimitError('another delegate run is already in progress (global limit)');
87
- await sleep(pollIntervalMs, signal);
120
+ await pause();
88
121
  continue;
89
122
  }
90
123
  const perHarnessLimit = getMaxConcurrent(config, harness);
91
124
  const perHarnessCount = activeCount(harness);
92
125
  if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit) {
93
126
  if (!wait) throw new ConcurrencyLimitError(`another ${harness} run is already in progress`);
94
- await sleep(pollIntervalMs, signal);
127
+ await pause();
95
128
  continue;
96
129
  }
97
130
 
@@ -100,7 +133,7 @@ export async function acquireSlot(opts: AcquireSlotOptions): Promise<() => void>
100
133
  // the check above passed, but another racer's write landed first and used up the slot —
101
134
  // handled exactly like hitting the cap on the check itself.
102
135
  if (!wait) throw new ConcurrencyLimitError(`another delegate run claimed the last available slot for ${harness}`);
103
- await sleep(pollIntervalMs, signal);
136
+ await pause();
104
137
  continue;
105
138
  }
106
139
 
@@ -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 };
@@ -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 { detectAll, HARNESS_NAMES, isKnownHarness, resolveHarnessName } from './harnesses/registry.ts';
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: formatMetrics({
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
- const isDanger =
448
- template?.permission === 'danger' ||
449
- (template?.nativePermission
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 = formatMetrics({
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;
@@ -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 { formatMetrics, formatToolUse, ToolCallIndex } from './activity.ts';
21
- import { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessFilter } from './command.ts';
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
- const template = parsed.mode ? templates.get(parsed.mode) : undefined;
517
- const isDanger =
518
- template?.permission === 'danger' ||
519
- (template?.nativePermission
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 usage = details.usage as
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
- pi.registerCommand('claude', {
627
- description: 'Alias for /delegate --harness=claude. Usage: /claude [--mode=...] <prompt>',
628
- handler: makeHandler('claude'),
629
- });
630
- pi.registerCommand('codex', {
631
- description: 'Alias for /delegate --harness=codex. Usage: /codex [--mode=...] <prompt>',
632
- handler: makeHandler('codex'),
633
- });
634
- pi.registerCommand('opencode', {
635
- description: 'Alias for /delegate --harness=opencode. Usage: /opencode [--mode=...] <prompt>',
636
- handler: makeHandler('opencode'),
637
- });
638
- pi.registerCommand('amp', {
639
- description: 'Alias for /delegate --harness=amp. Usage: /amp [--mode=...] <prompt>',
640
- handler: makeHandler('amp'),
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' };
@@ -47,12 +47,18 @@ export function fmtElapsed(ms: number): string {
47
47
  }
48
48
 
49
49
  /**
50
- * Slice a feed down to its last `max` entries, reporting how many were dropped so the caller can
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
- * Pure — testable without a TUI.
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
- return { visible: entries.slice(-max), hiddenCount: Math.max(0, entries.length - max) };
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. */
@@ -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
- // Cap streamedText to prevent OOM on compromised harness
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
+ }
@@ -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(cwd: string): {
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
- const candidates = [projectTemplatesDir(cwd), legacyProjectTemplatesDir(cwd)];
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.0",
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",
@@ -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
- }