pi-harness-delegate 0.6.1 → 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.
- package/README.md +66 -10
- package/extensions/acp-runner.ts +98 -11
- package/extensions/activity.ts +91 -1
- package/extensions/command.ts +35 -2
- package/extensions/concurrency.ts +4 -1
- package/extensions/config.ts +114 -20
- package/extensions/engine.ts +586 -0
- package/extensions/fanout.ts +526 -0
- package/extensions/harnesses/amp.ts +7 -6
- package/extensions/harnesses/claude.ts +2 -0
- package/extensions/harnesses/codex.ts +15 -7
- package/extensions/harnesses/opencode.ts +14 -3
- package/extensions/harnesses/types.ts +8 -0
- package/extensions/history.ts +170 -0
- package/extensions/index.ts +85 -1369
- package/extensions/run-registry.ts +36 -3
- package/extensions/runner.ts +72 -10
- package/extensions/subcommands.ts +227 -0
- package/extensions/templates.ts +12 -0
- package/extensions/validate.ts +214 -0
- package/package.json +5 -4
|
@@ -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
|
+
}
|