pi-harness-delegate 0.6.2 → 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.
@@ -0,0 +1,604 @@
1
+ /**
2
+ * The `delegate()` engine — the single source of truth for one harness run — plus its helpers
3
+ * (prompt building, verify, transcripts, trust, the queued report) and the tool-path live-feed
4
+ * wrapper `runDelegateForTool`. Split out of index.ts with no behavior change.
5
+ */
6
+
7
+ import { resolve } from 'node:path';
8
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
9
+ import { acpView, runAcpHarness } from './acp-runner.ts';
10
+ import {
11
+ buildReportContent,
12
+ buildTranscript,
13
+ buildVerifyResult,
14
+ collectActivityLog,
15
+ describeBudget,
16
+ formatMetrics,
17
+ formatToolUse,
18
+ pruneOutputs,
19
+ resolveVerifyPlan,
20
+ skipVerifyResult,
21
+ ToolCallIndex,
22
+ type VerifyResult,
23
+ writeTranscript,
24
+ } from './activity.ts';
25
+ import { acquireSlot } from './concurrency.ts';
26
+ import {
27
+ type DelegateConfig,
28
+ outputsDir as getOutputsDir,
29
+ legacyOutputsDir,
30
+ loadConfig,
31
+ resolveModelForHarness,
32
+ resolveTransport,
33
+ } from './config.ts';
34
+ import { ALIASES, getHarness, HARNESS_NAMES, isNativeDangerPermission } from './harnesses/registry.ts';
35
+ import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
36
+ import { runHarness } from './runner.ts';
37
+ import {
38
+ type DelegateTemplate,
39
+ describeSkippedProjectTemplates,
40
+ loadTemplates,
41
+ projectTemplatePresence,
42
+ resolveNativePermission,
43
+ } from './templates.ts';
44
+ import { validateDelegateInputs } from './validate.ts';
45
+ /** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
46
+ export function formatCost(cost: number | null): string {
47
+ return cost !== null ? `$${cost.toFixed(3)}` : '$—';
48
+ }
49
+
50
+ export interface DelegateOptions {
51
+ harness?: string;
52
+ task: string;
53
+ mode?: string;
54
+ scope?: string;
55
+ model?: string;
56
+ maxBudgetUsd?: number;
57
+ allowDangerous?: boolean;
58
+ sessionId?: string;
59
+ pr?: string;
60
+ /** Extra directories the harness may access, merged with the template's `addDirs` (relative
61
+ * paths resolve against the run's cwd). Per-harness limits apply — see each `buildArgs`. */
62
+ addDirs?: string[];
63
+ /**
64
+ * Host-run verification command override — takes precedence over the template's `verify`
65
+ * frontmatter. Internal engine option only, not exposed on the `delegate` tool's schema — see
66
+ * the trust-model note on `runVerify` below for why.
67
+ */
68
+ verify?: string;
69
+ onStream?: (text: string) => void;
70
+ onActivity?: (ev: ActivityEvent) => void;
71
+ signal?: AbortSignal;
72
+ /** Queue for a concurrency slot instead of failing fast when at capacity — fan-out only, see
73
+ * `acquireSlot` in concurrency.ts. Single-harness runs leave this false (the default). */
74
+ waitForSlot?: boolean;
75
+ /** Called once this run has acquired its concurrency slot and is about to actually start —
76
+ * fan-out uses it to flip a row from "queued" to "running". */
77
+ onAcquired?: () => void;
78
+ }
79
+
80
+ /** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
81
+ export const VERIFY_TIMEOUT_MS = 5 * 60_000;
82
+
83
+ /**
84
+ * Run a verify command in-process on the host (never delegated to the harness). Report-only —
85
+ * callers must not let this flip a run's `isError`.
86
+ *
87
+ * Trust model: a verify command can only come from two places — on-disk template frontmatter
88
+ * (project-local templates are already gated by `isProjectTrusted(ctx)` — pi's own trust store,
89
+ * never anything inside the project itself) or a human typing `/delegate --verify=<cmd>` at the
90
+ * CLI. It is deliberately **not** a `delegate` tool parameter: a tool
91
+ * param is set by the model, whose context includes repo content and delegated-harness output —
92
+ * both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
93
+ * arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
94
+ * into `delegate({verify: "curl ... | sh"})`). A model that wants verification selects a
95
+ * template that declares one instead.
96
+ *
97
+ * `resolveVerifyPlan` additionally never lets a verify command run on a `readonly` permission —
98
+ * `readonly` guarantees no execution/modification, and a verify command riding along on one
99
+ * would silently break that guarantee (a permission-tier bypass), independent of how trusted its
100
+ * source is. See the matching Conventions entry in AGENTS.md.
101
+ *
102
+ * Runs via `sh -c` (not a fixed binary+argv) so compound commands like `bun test && bun run
103
+ * lint` work — safe only because of the source/permission restrictions above, not because the
104
+ * command itself is sanitized.
105
+ */
106
+ export async function runVerify(pi: ExtensionAPI, cwd: string, command: string): Promise<VerifyResult> {
107
+ try {
108
+ const res = await pi.exec('sh', ['-c', command], { cwd, timeout: VERIFY_TIMEOUT_MS });
109
+ return buildVerifyResult(command, res.code, `${res.stdout}${res.stderr}`);
110
+ } catch (err) {
111
+ return buildVerifyResult(command, 1, err instanceof Error ? err.message : String(err));
112
+ }
113
+ }
114
+
115
+ export function outputsDirFor(harness: string): string {
116
+ return getOutputsDir(harness);
117
+ }
118
+
119
+ /**
120
+ * Whether pi's own trust store (`ctx.isProjectTrusted()`, backed by `~/.pi/agent/trust.json`,
121
+ * outside any project) considers `ctx.cwd` trusted. This is the sole source of truth for whether
122
+ * project-local delegate templates load — see the trust-tier comment on `loadTemplates`. Fails
123
+ * closed (untrusted) if the host is old enough not to expose the method, or if it throws.
124
+ */
125
+ /**
126
+ * Warn once per project when trusted-only content was silently skipped.
127
+ *
128
+ * Before 0.6.0 a committed `.pi/trusted` file (or `PI_TRUSTED=1`) granted trust; both were removed as
129
+ * a security fix. A user who relied on either loses their project-local templates on upgrade with no
130
+ * visible signal — an override shares its name with the builtin it replaces, so the run just uses the
131
+ * builtin and looks fine. `/delegate status` reports trust state, but nobody runs it unless something
132
+ * already looks wrong, so this fires on an actual delegation instead — and only when the project
133
+ * demonstrably has the content being skipped, so it never nags anyone unaffected.
134
+ */
135
+ const warnedUntrustedProjects = new Set<string>();
136
+
137
+ export function warnIfProjectTemplatesSkipped(ctx: ExtensionContext, trusted: boolean): void {
138
+ if (trusted || warnedUntrustedProjects.has(ctx.cwd)) return;
139
+ const lines = describeSkippedProjectTemplates(projectTemplatePresence(ctx.cwd, HARNESS_NAMES));
140
+ if (lines.length === 0) return;
141
+ warnedUntrustedProjects.add(ctx.cwd);
142
+ const msg = lines.join('\n');
143
+ if (ctx.hasUI) ctx.ui.notify?.(msg, 'warning');
144
+ else process.stderr.write(`${msg}\n`);
145
+ }
146
+
147
+ export function isProjectTrusted(ctx: ExtensionContext): boolean {
148
+ try {
149
+ return typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ export function saveOutput(harness: string, mode: string, text: string): string {
156
+ return writeTranscript(outputsDirFor(harness), mode, text);
157
+ }
158
+
159
+ /** Union of the template's and the call's extra dirs (template first, deduped), resolved against
160
+ * `cwd`. Undefined when neither declares any, so harness args stay byte-identical. */
161
+ export function mergeAddDirs(cwd: string, fromTemplate?: string[], fromCall?: string[]): string[] | undefined {
162
+ const out: string[] = [];
163
+ for (const d of [...(fromTemplate ?? []), ...(fromCall ?? [])]) {
164
+ const abs = resolve(cwd, d);
165
+ if (!out.includes(abs)) out.push(abs);
166
+ }
167
+ return out.length > 0 ? out : undefined;
168
+ }
169
+
170
+ export function buildPrompt(
171
+ template: DelegateTemplate,
172
+ task: string,
173
+ scopeText: string | null,
174
+ cwd: string,
175
+ harness: string,
176
+ ): string {
177
+ let prompt = [
178
+ `You are being delegated a subtask by the pi coding agent.`,
179
+ `Working directory: ${cwd}`,
180
+ `Harness: ${harness}`,
181
+ `Mode: ${template.name}`,
182
+ ``,
183
+ template.prompt,
184
+ ].join('\n');
185
+ prompt += `\n\n# Task\n${task}`;
186
+ if (scopeText) prompt += `\n\n# Scope\n${scopeText}`;
187
+ if (template.skill) prompt += `\n\nUse the "${template.skill}" skill.`;
188
+ return prompt;
189
+ }
190
+
191
+ /** The shared single-run engine. Exported for tests only — pi loads this module's default export. */
192
+ export async function delegate(
193
+ pi: ExtensionAPI,
194
+ ctx: ExtensionContext,
195
+ opts: DelegateOptions,
196
+ ): Promise<{
197
+ content: string;
198
+ details: Record<string, unknown>;
199
+ result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string };
200
+ activityLog: string[];
201
+ verify?: VerifyResult;
202
+ }> {
203
+ // argv-bound inputs (sessionId/model/pr) are validated here, the one entry both the tool and
204
+ // the /delegate command share — see validate.ts for the argument-injection rationale.
205
+ validateDelegateInputs({
206
+ sessionId: opts.sessionId,
207
+ model: opts.model,
208
+ pr: opts.pr,
209
+ addDirs: opts.addDirs,
210
+ cwd: ctx.cwd,
211
+ });
212
+ const config = loadConfig();
213
+ const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
214
+ const harness = getHarness(harnessName);
215
+ if (!harness)
216
+ throw new Error(
217
+ `unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
218
+ );
219
+ const projectTrusted = isProjectTrusted(ctx);
220
+ warnIfProjectTemplatesSkipped(ctx, projectTrusted);
221
+ const templates = loadTemplates(ctx.cwd, harnessName, projectTrusted);
222
+ const mode = opts.mode || config.defaultMode;
223
+ const template = templates.get(mode);
224
+ if (!template)
225
+ throw new Error(
226
+ `unknown delegate mode "${mode}" for harness "${harnessName}". Available: ${[...templates.keys()].sort().join(', ')}`,
227
+ );
228
+ const task = opts.task || template.defaultTask;
229
+ if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
230
+
231
+ // Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
232
+ // ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
233
+ // not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
234
+ const transport = resolveTransport(config, harnessName, harness);
235
+
236
+ // permission: normalized, danger requires explicit per-call allowDangerous:true (tool: model-set,
237
+ // human-confirmed in execute(); command: --allow-dangerous, human-confirmed in the handler). Resolved (and
238
+ // the danger refusal thrown) before acquireSlot() — it's pure, so a refused run never occupies
239
+ // (or, for fan-out, waits for) a concurrency slot it can't use.
240
+ let permission: NormalizedPermission = template.permission;
241
+ const nativePerm = template.nativePermission;
242
+ const isNativeDanger = isNativeDangerPermission(harness, nativePerm);
243
+ if (template.permission === 'danger' || isNativeDanger) {
244
+ if (opts.allowDangerous !== true) {
245
+ throw new Error(
246
+ `template "${mode}" requires danger permission — never a default: pass allowDangerous:true on the delegate tool, or --allow-dangerous on /delegate (both ask you to confirm interactively)`,
247
+ );
248
+ }
249
+ permission = 'danger';
250
+ } else if (opts.allowDangerous === true) {
251
+ // explicit per-call escalation for any template
252
+ permission = 'danger';
253
+ }
254
+ const permissionForDisplay = nativePerm ?? permission;
255
+ // Dropped when an explicit escalation moved us off the template's own tier — see
256
+ // resolveNativePermission(). Applies to both transports.
257
+ const nativePermissionForRun = resolveNativePermission(template.permission, permission, nativePerm);
258
+
259
+ const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
260
+ const addDirs = mergeAddDirs(ctx.cwd, template.addDirs, opts.addDirs);
261
+ const maxBudgetUsd =
262
+ opts.maxBudgetUsd ?? template.maxBudgetUsd ?? config.maxBudgetUsd ?? config.harnesses[harnessName]?.maxBudgetUsd;
263
+
264
+ // concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
265
+ // exactly as before; fan-out passes waitForSlot:true to queue instead.
266
+ const release = await acquireSlot({
267
+ harness: harnessName,
268
+ mode,
269
+ config,
270
+ wait: opts.waitForSlot ?? false,
271
+ signal: opts.signal,
272
+ });
273
+
274
+ // Everything from here until the harness exits holds the slot — any throw (scope resolution,
275
+ // prompt building, the runner itself) must release it, or the slot leaks for the rest of the
276
+ // process's life. `finally` below is the single release point for that whole span.
277
+ const activityEvents: ActivityEvent[] = [];
278
+ let streamedFull = '';
279
+ let result: import('./runner.ts').HarnessResult;
280
+ try {
281
+ // A cancel that landed while we were waiting on (or just after winning) the slot — don't
282
+ // spawn anything for a run the caller has already given up on.
283
+ if (opts.signal?.aborted) throw new Error('cancelled');
284
+ opts.onAcquired?.();
285
+
286
+ let scopeText: string | null = opts.scope ?? null;
287
+ if (opts.scope === 'diff') {
288
+ const diff = await pi.exec('git', ['diff', 'HEAD'], { cwd: ctx.cwd });
289
+ scopeText = diff.stdout
290
+ ? `Current git diff (working tree vs HEAD):\n${diff.stdout}`
291
+ : 'No git diff vs HEAD (working tree clean).';
292
+ } else if (opts.scope === 'pr' || opts.pr) {
293
+ const target = opts.pr ?? '';
294
+ const pr = await pi.exec('gh', target ? ['pr', 'diff', '--', target] : ['pr', 'diff'], { cwd: ctx.cwd });
295
+ scopeText = pr.stdout
296
+ ? `Pull request diff (${target || 'current branch'}):\n${pr.stdout}`
297
+ : `Could not resolve the PR diff${pr.stderr ? ` — ${pr.stderr.trim().slice(0, 300)}` : ''}.`;
298
+ }
299
+ const prompt = buildPrompt(template, task, scopeText, ctx.cwd, harnessName);
300
+
301
+ const baseRunOpts = {
302
+ harness,
303
+ prompt,
304
+ cwd: ctx.cwd,
305
+ permission,
306
+ model,
307
+ maxBudgetUsd,
308
+ signal: opts.signal,
309
+ timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
310
+ resumeSessionId: opts.sessionId,
311
+ addDirs,
312
+ onStream: (t: string) => {
313
+ streamedFull += t;
314
+ opts.onStream?.(t);
315
+ },
316
+ onActivity: (ev: ActivityEvent) => {
317
+ activityEvents.push(ev);
318
+ opts.onActivity?.(ev);
319
+ },
320
+ nativePermission: nativePermissionForRun,
321
+ };
322
+ result =
323
+ transport === 'acp'
324
+ ? await runAcpHarness({ ...baseRunOpts, harness: acpView(harness) })
325
+ : await runHarness(baseRunOpts);
326
+ } catch (err) {
327
+ if (streamedFull.length > 0) {
328
+ try {
329
+ saveOutput(
330
+ harnessName,
331
+ `${mode}-partial`,
332
+ buildTranscript({
333
+ harness: harnessName,
334
+ mode: `${mode} (partial)`,
335
+ permission: permission,
336
+ nativePermission: nativePerm ?? undefined,
337
+ model: model ?? null,
338
+ cwd: ctx.cwd,
339
+ sessionId: null,
340
+ resumed: Boolean(opts.sessionId),
341
+ numTurns: null,
342
+ totalCostUsd: null,
343
+ isError: true,
344
+ stopReason: null,
345
+ durationMs: null,
346
+ usage: null,
347
+ contextPercent: null,
348
+ contextWindow: null,
349
+ activityLog: collectActivityLog(activityEvents),
350
+ output: streamedFull,
351
+ }),
352
+ );
353
+ } catch (_e) {
354
+ void _e;
355
+ }
356
+ }
357
+ throw err;
358
+ } finally {
359
+ release();
360
+ }
361
+
362
+ if (result.isError && !result.result && !result.streamedText)
363
+ throw new Error(`${harnessName} reported an error and produced no output`);
364
+
365
+ const actualModel = result.model ?? model ?? null;
366
+ const promptTokens =
367
+ result.usage === null
368
+ ? null
369
+ : result.usage.inputTokens + result.usage.cacheCreationInputTokens + result.usage.cacheReadInputTokens;
370
+ const contextPercent =
371
+ promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
372
+
373
+ // Host-run post-hoc verification — report-only evidence, never flips `result.isError`. Never
374
+ // actually executes on a readonly permission (permission-tier bypass) — recorded as skipped
375
+ // instead of silently dropped. See the trust-model note on runVerify().
376
+ const verifyPlan = resolveVerifyPlan(opts.verify, template.verify, permission);
377
+ const verify = verifyPlan
378
+ ? verifyPlan.skip
379
+ ? skipVerifyResult(verifyPlan.command, 'readonly run')
380
+ : await runVerify(pi, ctx.cwd, verifyPlan.command)
381
+ : undefined;
382
+
383
+ // How maxBudgetUsd fared: native (claude), host-enforced best-effort from streamed cost, checked
384
+ // only at the step/turn boundaries the harness reports, so it can overshoot (the runner kills the
385
+ // run — `result.budgetExceeded`), or unenforceable (no native flag and no cost reported) — the
386
+ // last two always surface a message, never silently.
387
+ const budget = describeBudget({
388
+ harness: harnessName,
389
+ limitUsd: maxBudgetUsd,
390
+ native: harness.nativeBudget === true,
391
+ costUsd: result.totalCostUsd,
392
+ stoppedByHost: result.budgetExceeded === true,
393
+ });
394
+
395
+ const file = saveOutput(
396
+ harnessName,
397
+ mode,
398
+ buildTranscript({
399
+ harness: harnessName,
400
+ mode: mode,
401
+ permission: permission,
402
+ nativePermission: nativePerm ?? undefined,
403
+ model: actualModel,
404
+ cwd: ctx.cwd,
405
+ sessionId: result.sessionId,
406
+ resumed: Boolean(opts.sessionId),
407
+ numTurns: result.numTurns,
408
+ totalCostUsd: result.totalCostUsd,
409
+ isError: result.isError,
410
+ stopReason: result.stopReason,
411
+ durationMs: result.durationMs,
412
+ usage: result.usage,
413
+ contextPercent,
414
+ contextWindow: result.contextWindow,
415
+ activityLog: collectActivityLog(activityEvents),
416
+ output: result.result || result.streamedText,
417
+ verify,
418
+ budget,
419
+ }),
420
+ );
421
+ pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
422
+ // also prune legacy if claude
423
+ if (harnessName === 'claude') pruneOutputs(legacyOutputsDir(), config.maxTranscripts);
424
+
425
+ const output = result.result || result.streamedText || '(empty result)';
426
+ return {
427
+ content: budget?.message ? `${budget.message}\n\n${output}` : output,
428
+ details: {
429
+ harness: harnessName,
430
+ mode,
431
+ permission,
432
+ nativePermission: nativePerm ?? null,
433
+ permissionMode: String(permissionForDisplay),
434
+ model: actualModel,
435
+ numTurns: result.numTurns,
436
+ totalCostUsd: result.totalCostUsd,
437
+ sessionId: result.sessionId,
438
+ stopReason: result.stopReason,
439
+ permissionDenials: result.permissionDenials,
440
+ isError: result.isError,
441
+ resumed: Boolean(opts.sessionId),
442
+ file,
443
+ durationMs: result.durationMs,
444
+ ttftMs: result.ttftMs,
445
+ contextWindow: result.contextWindow,
446
+ contextPercent,
447
+ promptTokens,
448
+ usage: result.usage,
449
+ verify,
450
+ budget,
451
+ },
452
+ result,
453
+ activityLog: collectActivityLog(activityEvents),
454
+ verify,
455
+ };
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
+
475
+ export function summarize(content: string, max = 30_000): { text: string; truncated: boolean } {
476
+ if (content.length <= max) return { text: content, truncated: false };
477
+ return { text: `${content.slice(0, max)}\n…[truncated — full output saved to file]`, truncated: true };
478
+ }
479
+
480
+ export interface PendingReport {
481
+ content: string;
482
+ details: Record<string, unknown>;
483
+ }
484
+
485
+ let pendingReport: PendingReport | null = null;
486
+
487
+ export function injectReport(
488
+ _ctx: ExtensionContext,
489
+ opts: {
490
+ harness: string;
491
+ mode: string;
492
+ metrics: string;
493
+ body: string;
494
+ file?: string;
495
+ sessionId?: string;
496
+ verify?: VerifyResult;
497
+ },
498
+ ): void {
499
+ pendingReport = {
500
+ content: buildReportContent({
501
+ harness: opts.harness,
502
+ mode: opts.mode,
503
+ metrics: opts.metrics,
504
+ body: opts.body,
505
+ file: opts.file,
506
+ sessionId: opts.sessionId,
507
+ verify: opts.verify,
508
+ }),
509
+ details: {
510
+ harness: opts.harness,
511
+ mode: opts.mode,
512
+ file: opts.file,
513
+ sessionId: opts.sessionId,
514
+ metrics: opts.metrics,
515
+ },
516
+ };
517
+ }
518
+
519
+ export interface ToolProgressUpdate {
520
+ content: { type: string; text: string }[];
521
+ details: { progress: number };
522
+ }
523
+
524
+ /**
525
+ * `delegate` tool params. Deliberately has no `verify` field — a tool param is model-controlled,
526
+ * and the model's context (repo content, delegated-harness output) is attacker-influenceable, so
527
+ * a model-settable verify command would be a prompt-injection -> arbitrary-host-command path.
528
+ * Verify only comes from on-disk template frontmatter or a human-typed `/delegate --verify=`.
529
+ */
530
+ export interface DelegateToolParams {
531
+ harness?: string;
532
+ task: string;
533
+ mode?: string;
534
+ scope?: string;
535
+ model?: string;
536
+ maxBudgetUsd?: number;
537
+ allowDangerous?: boolean;
538
+ sessionId?: string;
539
+ pr?: string;
540
+ addDirs?: string[];
541
+ }
542
+
543
+ /** One `delegate()` call with the tool's live-feed progress reporting (`onUpdate`). Shared by the
544
+ * single-harness tool path and the fan-out loop — `labelPrefix` tags fan-out feed lines by harness. */
545
+ export async function runDelegateForTool(
546
+ pi: ExtensionAPI,
547
+ ctx: ExtensionContext,
548
+ config: DelegateConfig,
549
+ callOpts: DelegateOptions,
550
+ signal: AbortSignal | undefined,
551
+ onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
552
+ labelPrefix: string,
553
+ ): Promise<Awaited<ReturnType<typeof delegate>>> {
554
+ const feed: string[] = [];
555
+ const feedIndex = new ToolCallIndex();
556
+ let liveTail = '';
557
+ let thinkingChars = 0;
558
+ let lastPushAt = 0;
559
+ const THROTTLE_MS = 250;
560
+ const pushFeed = () => {
561
+ const now = Date.now();
562
+ if (now - lastPushAt < THROTTLE_MS) return;
563
+ lastPushAt = now;
564
+ const lines: string[] = [...feed.slice(-6)];
565
+ if (thinkingChars > 0)
566
+ lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
567
+ if (liveTail) lines.push(`✍ ${liveTail}`);
568
+ if (lines.length === 0) return;
569
+ onUpdate?.({
570
+ content: [{ type: 'text', text: lines.map(l => `${labelPrefix}${l}`).join('\n') }],
571
+ details: { progress: 0.5 },
572
+ });
573
+ };
574
+ return delegate(pi, ctx, {
575
+ ...callOpts,
576
+ signal,
577
+ onStream: t => {
578
+ liveTail = (liveTail + t).slice(-400);
579
+ pushFeed();
580
+ },
581
+ onActivity: ev => {
582
+ if (ev.kind === 'tool_input') {
583
+ feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
584
+ feedIndex.set(ev.id, feed.length - 1);
585
+ if (feed.length > 40) {
586
+ const removed = feed.length - 40;
587
+ feed.splice(0, removed);
588
+ feedIndex.shift(removed);
589
+ }
590
+ } else if (ev.kind === 'tool_result') {
591
+ const idx = feedIndex.resolve(ev.id, feed.length - 1);
592
+ if (idx >= 0 && feed[idx]?.startsWith('▶')) feed[idx] += ev.isError ? ' ✗' : ' ✓';
593
+ } else if (ev.kind === 'thinking') thinkingChars += ev.chars;
594
+ pushFeed();
595
+ },
596
+ });
597
+ }
598
+
599
+ /** Hand off (and clear) the report queued by `injectReport`, for pi's `before_agent_start`. */
600
+ export function takePendingReport(): PendingReport | null {
601
+ const report = pendingReport;
602
+ pendingReport = null;
603
+ return report;
604
+ }