pi-harness-delegate 0.6.2 → 0.7.0

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