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.
- package/README.md +65 -9
- package/extensions/acp-runner.ts +158 -35
- package/extensions/activity.ts +91 -1
- package/extensions/command.ts +170 -13
- package/extensions/concurrency.ts +41 -5
- package/extensions/engine.ts +604 -0
- package/extensions/fanout.ts +512 -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/registry.ts +16 -0
- package/extensions/harnesses/types.ts +8 -8
- package/extensions/history.ts +170 -0
- package/extensions/index.ts +120 -1419
- package/extensions/progress.ts +9 -3
- package/extensions/run-registry.ts +36 -3
- package/extensions/runner.ts +75 -30
- package/extensions/stream-caps.ts +46 -0
- package/extensions/subcommands.ts +227 -0
- package/extensions/templates.ts +33 -2
- package/extensions/validate.ts +214 -0
- package/package.json +4 -2
- package/extensions/run-claude.ts +0 -61
- package/extensions/stream-parse.ts +0 -37
package/extensions/index.ts
CHANGED
|
@@ -14,1089 +14,44 @@
|
|
|
14
14
|
* Legacy: { claudeDelegate: {...} } is auto-migrated.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { basename, join } from 'node:path';
|
|
19
17
|
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme } from '@earendil-works/pi-coding-agent';
|
|
20
|
-
import {
|
|
21
|
-
Container,
|
|
22
|
-
Key,
|
|
23
|
-
Markdown,
|
|
24
|
-
matchesKey,
|
|
25
|
-
type OverlayHandle,
|
|
26
|
-
type SelectItem,
|
|
27
|
-
SelectList,
|
|
28
|
-
Text,
|
|
29
|
-
truncateToWidth,
|
|
30
|
-
} from '@earendil-works/pi-tui';
|
|
18
|
+
import { Container, Markdown, type OverlayHandle, Text } from '@earendil-works/pi-tui';
|
|
31
19
|
import { Type } from 'typebox';
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
aggregateSpend,
|
|
35
|
-
buildFanoutReport,
|
|
36
|
-
buildReportContent,
|
|
37
|
-
buildTranscript,
|
|
38
|
-
buildVerifyResult,
|
|
39
|
-
collectActivityLog,
|
|
40
|
-
type FanoutRunSummary,
|
|
41
|
-
formatMetrics,
|
|
42
|
-
formatSpend,
|
|
43
|
-
formatToolUse,
|
|
44
|
-
orderFanoutResults,
|
|
45
|
-
parseTranscriptMeta,
|
|
46
|
-
pruneOutputs,
|
|
47
|
-
resolveVerifyPlan,
|
|
48
|
-
safeSegmentName,
|
|
49
|
-
skipVerifyResult,
|
|
50
|
-
ToolCallIndex,
|
|
51
|
-
type VerifyResult,
|
|
52
|
-
} from './activity.ts';
|
|
20
|
+
import { formatToolUse, ToolCallIndex } from './activity.ts';
|
|
53
21
|
import {
|
|
22
|
+
aliasUsage,
|
|
23
|
+
delegateUsage,
|
|
54
24
|
isFanoutSpec,
|
|
55
25
|
parseDelegateCommand,
|
|
56
26
|
resolveDefaults,
|
|
57
27
|
resolveHarnessFilter,
|
|
58
|
-
resolveHarnessList,
|
|
59
28
|
} from './command.ts';
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
buildConfigReport,
|
|
63
|
-
type DelegateConfig,
|
|
64
|
-
describeConfigSource,
|
|
65
|
-
getMaxConcurrent,
|
|
66
|
-
outputsDir as getOutputsDir,
|
|
67
|
-
legacyOutputsDir,
|
|
68
|
-
loadConfig,
|
|
69
|
-
loadConfigWithSource,
|
|
70
|
-
nonDefaultConfig,
|
|
71
|
-
resolveModelForHarness,
|
|
72
|
-
resolveTransport,
|
|
73
|
-
writeDelegateConfig,
|
|
74
|
-
} from './config.ts';
|
|
29
|
+
import { loadConfig } from './config.ts';
|
|
75
30
|
import {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
31
|
+
type DelegateToolParams,
|
|
32
|
+
delegate,
|
|
33
|
+
formatCost,
|
|
34
|
+
injectReport,
|
|
35
|
+
isProjectTrusted,
|
|
36
|
+
runDelegateForTool,
|
|
37
|
+
runMetrics,
|
|
38
|
+
summarize,
|
|
39
|
+
type ToolProgressUpdate,
|
|
40
|
+
takePendingReport,
|
|
41
|
+
} from './engine.ts';
|
|
42
|
+
import { closeWhenMounted, type RunUiState, runFanoutCommand, runFanoutTool } from './fanout.ts';
|
|
43
|
+
import { ALIASES, HARNESS_NAMES, isKnownHarness, isTemplateDanger, resolveHarnessName } from './harnesses/registry.ts';
|
|
44
|
+
import type { ActivityEvent } from './harnesses/types.ts';
|
|
85
45
|
import { delegationHint, stripMarker } from './hint.ts';
|
|
86
|
-
import {
|
|
46
|
+
import { showHistory } from './history.ts';
|
|
87
47
|
import { type FeedEntry, progressWindow } from './progress.ts';
|
|
88
|
-
import {
|
|
89
|
-
import {
|
|
90
|
-
import {
|
|
91
|
-
type DelegateTemplate,
|
|
92
|
-
describeSkippedProjectTemplates,
|
|
93
|
-
loadTemplates,
|
|
94
|
-
projectTemplatePresence,
|
|
95
|
-
resolveNativePermission,
|
|
96
|
-
} from './templates.ts';
|
|
48
|
+
import { initConfig, showConfig, showModes, showStatus } from './subcommands.ts';
|
|
49
|
+
import { type DelegateTemplate, loadTemplates } from './templates.ts';
|
|
97
50
|
import { mapClaudeUsage } from './usage.ts';
|
|
98
|
-
|
|
99
|
-
/** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
|
|
100
|
-
function formatCost(cost: number | null): string {
|
|
101
|
-
return cost !== null ? `$${cost.toFixed(3)}` : '$—';
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface DelegateOptions {
|
|
105
|
-
harness?: string;
|
|
106
|
-
task: string;
|
|
107
|
-
mode?: string;
|
|
108
|
-
scope?: string;
|
|
109
|
-
model?: string;
|
|
110
|
-
maxBudgetUsd?: number;
|
|
111
|
-
allowDangerous?: boolean;
|
|
112
|
-
sessionId?: string;
|
|
113
|
-
pr?: string;
|
|
114
|
-
/**
|
|
115
|
-
* Host-run verification command override — takes precedence over the template's `verify`
|
|
116
|
-
* frontmatter. Internal engine option only, not exposed on the `delegate` tool's schema — see
|
|
117
|
-
* the trust-model note on `runVerify` below for why.
|
|
118
|
-
*/
|
|
119
|
-
verify?: string;
|
|
120
|
-
onStream?: (text: string) => void;
|
|
121
|
-
onActivity?: (ev: ActivityEvent) => void;
|
|
122
|
-
signal?: AbortSignal;
|
|
123
|
-
/** Queue for a concurrency slot instead of failing fast when at capacity — fan-out only, see
|
|
124
|
-
* `acquireSlot` in concurrency.ts. Single-harness runs leave this false (the default). */
|
|
125
|
-
waitForSlot?: boolean;
|
|
126
|
-
/** Called once this run has acquired its concurrency slot and is about to actually start —
|
|
127
|
-
* fan-out uses it to flip a row from "queued" to "running". */
|
|
128
|
-
onAcquired?: () => void;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
|
|
132
|
-
const VERIFY_TIMEOUT_MS = 5 * 60_000;
|
|
133
|
-
/** How long the fan-out overlay lingers on the finished board after the last run resolves, so a
|
|
134
|
-
* user who looked away still catches the final state instead of it clearing instantly. */
|
|
135
|
-
const FANOUT_LINGER_MS = 3000;
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Run a verify command in-process on the host (never delegated to the harness). Report-only —
|
|
139
|
-
* callers must not let this flip a run's `isError`.
|
|
140
|
-
*
|
|
141
|
-
* Trust model: a verify command can only come from two places — on-disk template frontmatter
|
|
142
|
-
* (project-local templates are already gated by `isProjectTrusted(ctx)` — pi's own trust store,
|
|
143
|
-
* never anything inside the project itself) or a human typing `/delegate --verify=<cmd>` at the
|
|
144
|
-
* CLI. It is deliberately **not** a `delegate` tool parameter: a tool
|
|
145
|
-
* param is set by the model, whose context includes repo content and delegated-harness output —
|
|
146
|
-
* both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
|
|
147
|
-
* arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
|
|
148
|
-
* into `delegate({verify: "curl ... | sh"})`). A model that wants verification selects a
|
|
149
|
-
* template that declares one instead.
|
|
150
|
-
*
|
|
151
|
-
* `resolveVerifyPlan` additionally never lets a verify command run on a `readonly` permission —
|
|
152
|
-
* `readonly` guarantees no execution/modification, and a verify command riding along on one
|
|
153
|
-
* would silently break that guarantee (a permission-tier bypass), independent of how trusted its
|
|
154
|
-
* source is. See the matching Conventions entry in AGENTS.md.
|
|
155
|
-
*
|
|
156
|
-
* Runs via `sh -c` (not a fixed binary+argv) so compound commands like `bun test && bun run
|
|
157
|
-
* lint` work — safe only because of the source/permission restrictions above, not because the
|
|
158
|
-
* command itself is sanitized.
|
|
159
|
-
*/
|
|
160
|
-
async function runVerify(pi: ExtensionAPI, cwd: string, command: string): Promise<VerifyResult> {
|
|
161
|
-
try {
|
|
162
|
-
const res = await pi.exec('sh', ['-c', command], { cwd, timeout: VERIFY_TIMEOUT_MS });
|
|
163
|
-
return buildVerifyResult(command, res.code, `${res.stdout}${res.stderr}`);
|
|
164
|
-
} catch (err) {
|
|
165
|
-
return buildVerifyResult(command, 1, err instanceof Error ? err.message : String(err));
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function closeWhenMounted(getClose: () => (() => void) | null, capMs: number): Promise<void> {
|
|
170
|
-
const close = getClose();
|
|
171
|
-
if (close) {
|
|
172
|
-
close();
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
await new Promise<void>(resolve => {
|
|
176
|
-
const start = Date.now();
|
|
177
|
-
const timer = setInterval(() => {
|
|
178
|
-
const fn = getClose();
|
|
179
|
-
if (fn || Date.now() - start > capMs) {
|
|
180
|
-
clearInterval(timer);
|
|
181
|
-
fn?.();
|
|
182
|
-
resolve();
|
|
183
|
-
}
|
|
184
|
-
}, 20);
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function outputsDirFor(harness: string): string {
|
|
189
|
-
return getOutputsDir(harness);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Whether pi's own trust store (`ctx.isProjectTrusted()`, backed by `~/.pi/agent/trust.json`,
|
|
194
|
-
* outside any project) considers `ctx.cwd` trusted. This is the sole source of truth for whether
|
|
195
|
-
* project-local delegate templates load — see the trust-tier comment on `loadTemplates`. Fails
|
|
196
|
-
* closed (untrusted) if the host is old enough not to expose the method, or if it throws.
|
|
197
|
-
*/
|
|
198
|
-
/**
|
|
199
|
-
* Warn once per project when trusted-only content was silently skipped.
|
|
200
|
-
*
|
|
201
|
-
* Before 0.6.0 a committed `.pi/trusted` file (or `PI_TRUSTED=1`) granted trust; both were removed as
|
|
202
|
-
* a security fix. A user who relied on either loses their project-local templates on upgrade with no
|
|
203
|
-
* visible signal — an override shares its name with the builtin it replaces, so the run just uses the
|
|
204
|
-
* builtin and looks fine. `/delegate status` reports trust state, but nobody runs it unless something
|
|
205
|
-
* already looks wrong, so this fires on an actual delegation instead — and only when the project
|
|
206
|
-
* demonstrably has the content being skipped, so it never nags anyone unaffected.
|
|
207
|
-
*/
|
|
208
|
-
const warnedUntrustedProjects = new Set<string>();
|
|
209
|
-
|
|
210
|
-
function warnIfProjectTemplatesSkipped(ctx: ExtensionContext, trusted: boolean): void {
|
|
211
|
-
if (trusted || warnedUntrustedProjects.has(ctx.cwd)) return;
|
|
212
|
-
const lines = describeSkippedProjectTemplates(projectTemplatePresence(ctx.cwd));
|
|
213
|
-
if (lines.length === 0) return;
|
|
214
|
-
warnedUntrustedProjects.add(ctx.cwd);
|
|
215
|
-
const msg = lines.join('\n');
|
|
216
|
-
if (ctx.hasUI) ctx.ui.notify?.(msg, 'warning');
|
|
217
|
-
else process.stderr.write(`${msg}\n`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
|
221
|
-
try {
|
|
222
|
-
return typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true;
|
|
223
|
-
} catch {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function formatTemplateRow(t: DelegateTemplate): string {
|
|
229
|
-
const parts = [
|
|
230
|
-
t.name,
|
|
231
|
-
`[${t.permission}]`,
|
|
232
|
-
t.model ? `model=${t.model}` : '',
|
|
233
|
-
t.defaultTask ? '↳ default task' : '',
|
|
234
|
-
t.harness ? `(${t.harness})` : '',
|
|
235
|
-
];
|
|
236
|
-
return `${parts.filter(Boolean).join(' ')} — ${t.description}`;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function showModes(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
240
|
-
const all = new Map<string, DelegateTemplate>();
|
|
241
|
-
const trusted = isProjectTrusted(ctx);
|
|
242
|
-
// collect from all harnesses if no filter
|
|
243
|
-
if (harnessFilter) {
|
|
244
|
-
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter, trusted)) all.set(k, v);
|
|
245
|
-
} else {
|
|
246
|
-
for (const h of [...HARNESS_NAMES, 'shared']) {
|
|
247
|
-
for (const [k, v] of loadTemplates(ctx.cwd, h, trusted)) if (!all.has(k)) all.set(k, v);
|
|
248
|
-
}
|
|
249
|
-
// also load without harness param
|
|
250
|
-
for (const [k, v] of loadTemplates(ctx.cwd, undefined, trusted)) if (!all.has(k)) all.set(k, v);
|
|
251
|
-
}
|
|
252
|
-
const rows = [...all.values()].map(formatTemplateRow);
|
|
253
|
-
if (!ctx.hasUI) {
|
|
254
|
-
process.stdout.write(`${rows.join('\n')}\n`);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
258
|
-
let offset = 0;
|
|
259
|
-
const height = 12;
|
|
260
|
-
return {
|
|
261
|
-
render(width: number): string[] {
|
|
262
|
-
const header = theme.fg(
|
|
263
|
-
'accent',
|
|
264
|
-
`delegate — modes${harnessFilter ? ` (${harnessFilter})` : ''} (↑↓ scroll · any key to close)`,
|
|
265
|
-
);
|
|
266
|
-
const visible = rows.slice(offset, offset + height);
|
|
267
|
-
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
268
|
-
},
|
|
269
|
-
handleInput(data: string): void {
|
|
270
|
-
if (matchesKey(data, Key.up) && offset > 0) {
|
|
271
|
-
offset--;
|
|
272
|
-
tui.requestRender();
|
|
273
|
-
} else if (matchesKey(data, Key.down) && offset < rows.length - 1) {
|
|
274
|
-
offset++;
|
|
275
|
-
tui.requestRender();
|
|
276
|
-
} else {
|
|
277
|
-
done(undefined);
|
|
278
|
-
}
|
|
279
|
-
},
|
|
280
|
-
invalidate() {},
|
|
281
|
-
};
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
interface HistoryEntry {
|
|
286
|
-
file: string;
|
|
287
|
-
mode: string;
|
|
288
|
-
harness: string;
|
|
289
|
-
cost: number | null;
|
|
290
|
-
sessionId: string | null;
|
|
291
|
-
mtime: number;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function readHistory(dir: string, harness: string): HistoryEntry[] {
|
|
295
|
-
try {
|
|
296
|
-
return readdirSync(dir)
|
|
297
|
-
.filter(f => f.endsWith('.md') && !f.includes('-partial'))
|
|
298
|
-
.map(f => {
|
|
299
|
-
const file = join(dir, f);
|
|
300
|
-
let mode = 'delegate';
|
|
301
|
-
let cost: number | null = null;
|
|
302
|
-
let sessionId: string | null = null;
|
|
303
|
-
try {
|
|
304
|
-
const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
|
|
305
|
-
mode = meta.mode;
|
|
306
|
-
cost = meta.cost;
|
|
307
|
-
sessionId = meta.sessionId;
|
|
308
|
-
} catch (_e) {
|
|
309
|
-
void _e;
|
|
310
|
-
}
|
|
311
|
-
return {
|
|
312
|
-
file,
|
|
313
|
-
mode,
|
|
314
|
-
harness,
|
|
315
|
-
cost,
|
|
316
|
-
sessionId,
|
|
317
|
-
mtime: statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0,
|
|
318
|
-
};
|
|
319
|
-
})
|
|
320
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
321
|
-
} catch {
|
|
322
|
-
return [];
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function readAllHistory(): HistoryEntry[] {
|
|
327
|
-
const entries: HistoryEntry[] = [];
|
|
328
|
-
// new partitioned dir
|
|
329
|
-
for (const h of HARNESS_NAMES) {
|
|
330
|
-
entries.push(...readHistory(getOutputsDir(h), h));
|
|
331
|
-
}
|
|
332
|
-
// also legacy dir for migration display
|
|
333
|
-
try {
|
|
334
|
-
const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md') && !f.includes('-partial'));
|
|
335
|
-
for (const f of legacy) {
|
|
336
|
-
const file = join(legacyOutputsDir(), f);
|
|
337
|
-
let mode = 'delegate';
|
|
338
|
-
let cost: number | null = null;
|
|
339
|
-
let sessionId: string | null = null;
|
|
340
|
-
try {
|
|
341
|
-
const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
|
|
342
|
-
mode = meta.mode;
|
|
343
|
-
cost = meta.cost;
|
|
344
|
-
sessionId = meta.sessionId;
|
|
345
|
-
} catch (_e) {
|
|
346
|
-
void _e;
|
|
347
|
-
}
|
|
348
|
-
entries.push({
|
|
349
|
-
file,
|
|
350
|
-
mode,
|
|
351
|
-
harness: 'claude',
|
|
352
|
-
cost,
|
|
353
|
-
sessionId,
|
|
354
|
-
mtime: statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0,
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
} catch (_e) {
|
|
358
|
-
void _e;
|
|
359
|
-
}
|
|
360
|
-
return entries.sort((a, b) => b.mtime - a.mtime);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function viewTranscript(ctx: ExtensionContext, entry: HistoryEntry): Promise<void> {
|
|
364
|
-
if (!ctx.hasUI) {
|
|
365
|
-
process.stdout.write(readFileSync(entry.file, 'utf8'));
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
369
|
-
const lines = readFileSync(entry.file, 'utf8').split('\n');
|
|
370
|
-
let offset = 0;
|
|
371
|
-
const height = 12;
|
|
372
|
-
return {
|
|
373
|
-
render(width: number): string[] {
|
|
374
|
-
const resume = entry.sessionId ? ` · r resume` : '';
|
|
375
|
-
const header = theme.fg('accent', `${basename(entry.file)} (↑↓ scroll${resume} · esc close)`);
|
|
376
|
-
const visible = lines.slice(offset, offset + height);
|
|
377
|
-
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
378
|
-
},
|
|
379
|
-
handleInput(data: string): void {
|
|
380
|
-
if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
381
|
-
offset++;
|
|
382
|
-
tui.requestRender();
|
|
383
|
-
} else if (matchesKey(data, Key.up) && offset > 0) {
|
|
384
|
-
offset--;
|
|
385
|
-
tui.requestRender();
|
|
386
|
-
} else if (matchesKey(data, Key.escape)) {
|
|
387
|
-
done(undefined);
|
|
388
|
-
} else if (entry.sessionId && data === 'r') {
|
|
389
|
-
ctx.ui.notify?.(`resume with: /delegate --resume=${entry.sessionId} <prompt>`, 'info');
|
|
390
|
-
}
|
|
391
|
-
},
|
|
392
|
-
invalidate() {},
|
|
393
|
-
};
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function saveOutput(harness: string, mode: string, text: string): string {
|
|
398
|
-
const dir = outputsDirFor(harness);
|
|
399
|
-
mkdirSync(dir, { recursive: true });
|
|
400
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
401
|
-
const file = join(dir, `${stamp}-${safeSegmentName(mode)}.md`);
|
|
402
|
-
writeFileSync(file, text, 'utf8');
|
|
403
|
-
return file;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
407
|
-
const entries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
408
|
-
if (entries.length === 0) {
|
|
409
|
-
const msg = harnessFilter
|
|
410
|
-
? `No transcripts yet for ${harnessFilter} — run /delegate ${harnessFilter} <mode> <prompt> first`
|
|
411
|
-
: 'No transcripts yet — run /delegate <harness> <mode> <prompt> first';
|
|
412
|
-
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
413
|
-
else ctx.ui.notify?.(msg, 'info');
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
if (!ctx.hasUI) {
|
|
417
|
-
if (harnessFilter) process.stdout.write(`delegate — history (${harnessFilter})\n`);
|
|
418
|
-
for (const e of entries)
|
|
419
|
-
process.stdout.write(`${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${e.sessionId ?? '-'}\n`);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
const entry = await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
423
|
-
const items: SelectItem[] = entries.map(e => ({
|
|
424
|
-
value: e.file,
|
|
425
|
-
label: `${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
|
|
426
|
-
description: e.sessionId ? `session ${e.sessionId.slice(0, 8)}…` : undefined,
|
|
427
|
-
}));
|
|
428
|
-
const list = new SelectList(items, Math.min(items.length, 10), {
|
|
429
|
-
selectedPrefix: (s: string) => theme.fg('accent', s),
|
|
430
|
-
selectedText: (s: string) => theme.fg('accent', s),
|
|
431
|
-
description: (s: string) => theme.fg('dim', s),
|
|
432
|
-
scrollInfo: (s: string) => theme.fg('dim', s),
|
|
433
|
-
noMatch: (s: string) => theme.fg('warning', s),
|
|
434
|
-
});
|
|
435
|
-
list.onSelect = item => done(item.value);
|
|
436
|
-
list.onCancel = () => done(undefined);
|
|
437
|
-
return {
|
|
438
|
-
render: (w: number) => {
|
|
439
|
-
const rows = list.render(w);
|
|
440
|
-
return harnessFilter ? [theme.fg('accent', `delegate — history (${harnessFilter})`), ...rows] : rows;
|
|
441
|
-
},
|
|
442
|
-
invalidate: () => list.invalidate(),
|
|
443
|
-
handleInput: (data: string) => {
|
|
444
|
-
list.handleInput(data);
|
|
445
|
-
tui.requestRender();
|
|
446
|
-
},
|
|
447
|
-
};
|
|
448
|
-
});
|
|
449
|
-
if (entry) {
|
|
450
|
-
const chosen = entries.find(e => e.file === entry);
|
|
451
|
-
if (chosen) await viewTranscript(ctx, chosen);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
456
|
-
const { config: cfg, source } = loadConfigWithSource();
|
|
457
|
-
const detection = await detectAll();
|
|
458
|
-
const trusted = isProjectTrusted(ctx);
|
|
459
|
-
const allHarnesses = harnessFilter ? [harnessFilter].filter(h => isKnownHarness(h)) : HARNESS_NAMES;
|
|
460
|
-
const lines: string[] = [];
|
|
461
|
-
lines.push(`delegate — status${harnessFilter ? ` (${harnessFilter})` : ''}`);
|
|
462
|
-
lines.push(...describeConfigSource(source));
|
|
463
|
-
lines.push(`defaultHarness: ${cfg.defaultHarness} · defaultMode: ${cfg.defaultMode} · model: ${cfg.model ?? '—'}`);
|
|
464
|
-
lines.push(
|
|
465
|
-
`maxConcurrent: ${typeof cfg.maxConcurrent === 'number' ? cfg.maxConcurrent : JSON.stringify(cfg.maxConcurrent)} · maxTranscripts: ${cfg.maxTranscripts}`,
|
|
466
|
-
);
|
|
467
|
-
lines.push(
|
|
468
|
-
trusted
|
|
469
|
-
? 'project trust: trusted — project-local templates (.pi/delegate/templates/) are loaded'
|
|
470
|
-
: "project trust: untrusted — project-local templates skipped (trust this project via pi's trust prompt, or set defaultProjectTrust, to load them)",
|
|
471
|
-
);
|
|
472
|
-
lines.push('');
|
|
473
|
-
lines.push('harness binary ok version outputs templates active');
|
|
474
|
-
lines.push('─'.repeat(78));
|
|
475
|
-
for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
|
|
476
|
-
const det = detection[h] ?? { ok: false };
|
|
477
|
-
const harness = getHarness(h);
|
|
478
|
-
const bin = harness?.binary ?? h;
|
|
479
|
-
const ver = det.version ? det.version.slice(0, 18) : det.hint ? '—' : '—';
|
|
480
|
-
const ok = det.ok ? '✓' : '✗';
|
|
481
|
-
let outputs = 0;
|
|
482
|
-
try {
|
|
483
|
-
outputs = readdirSync(getOutputsDir(h)).filter(f => f.endsWith('.md')).length;
|
|
484
|
-
} catch {}
|
|
485
|
-
let templates = 0;
|
|
486
|
-
try {
|
|
487
|
-
templates = loadTemplates(ctx.cwd, h, trusted).size;
|
|
488
|
-
} catch {}
|
|
489
|
-
// cross-process count via the file registry, combined with the in-process counter as a fallback
|
|
490
|
-
const active = activeCount(h);
|
|
491
|
-
const cap = getMaxConcurrent(cfg, h);
|
|
492
|
-
const activeCol = `${active}/${cap > 0 ? cap : '∞'}`;
|
|
493
|
-
const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
|
|
494
|
-
lines.push(
|
|
495
|
-
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${activeCol}${hint}`,
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
499
|
-
const spend = aggregateSpend(historyEntries.map(e => ({ harness: e.harness, cost: e.cost })));
|
|
500
|
-
lines.push('');
|
|
501
|
-
lines.push('spend:');
|
|
502
|
-
for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
|
|
503
|
-
const s = spend.byHarness[h];
|
|
504
|
-
lines.push(` ${h}: ${s ? formatSpend(s) : '$0.000 over 0 run(s)'}`);
|
|
505
|
-
}
|
|
506
|
-
if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
|
|
507
|
-
if (!harnessFilter) {
|
|
508
|
-
const globalCap = getMaxConcurrent(cfg);
|
|
509
|
-
lines.push('');
|
|
510
|
-
lines.push(
|
|
511
|
-
`global active: ${activeCount()}/${globalCap > 0 ? globalCap : '∞'} · aliases: ${
|
|
512
|
-
Object.entries(ALIASES)
|
|
513
|
-
.map(([k, v]) => `${k}→${v}`)
|
|
514
|
-
.join(', ') || '—'
|
|
515
|
-
}`,
|
|
516
|
-
);
|
|
517
|
-
lines.push(`outputs dir: ${getOutputsDir()} (plus ${legacyOutputsDir()} legacy)`);
|
|
518
|
-
}
|
|
519
|
-
if (!ctx.hasUI) {
|
|
520
|
-
process.stdout.write(`${lines.join('\n')}\n`);
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
524
|
-
let offset = 0;
|
|
525
|
-
const height = 14;
|
|
526
|
-
return {
|
|
527
|
-
render(width: number): string[] {
|
|
528
|
-
const header = theme.fg(
|
|
529
|
-
'accent',
|
|
530
|
-
`delegate status${harnessFilter ? ` — ${harnessFilter}` : ''} (↑↓ scroll · any key to close)`,
|
|
531
|
-
);
|
|
532
|
-
const visible = lines.slice(offset, offset + height);
|
|
533
|
-
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
534
|
-
},
|
|
535
|
-
handleInput(data: string): void {
|
|
536
|
-
if (matchesKey(data, Key.up) && offset > 0) {
|
|
537
|
-
offset--;
|
|
538
|
-
tui.requestRender();
|
|
539
|
-
} else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
540
|
-
offset++;
|
|
541
|
-
tui.requestRender();
|
|
542
|
-
} else done(undefined);
|
|
543
|
-
},
|
|
544
|
-
invalidate() {},
|
|
545
|
-
};
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* `/delegate config` — the discoverability gap `/delegate status`'s provenance line only hints at:
|
|
551
|
-
* shows exactly what was read from `settings.json` (or why it wasn't) plus the effective config
|
|
552
|
-
* with defaults filled in, formatted as a paste-ready JSON block under the `delegate` key. Print-
|
|
553
|
-
* only — writing is a separate, explicit action (`/delegate config init`, below), never triggered
|
|
554
|
-
* from this default view.
|
|
555
|
-
*/
|
|
556
|
-
async function showConfig(ctx: ExtensionContext): Promise<void> {
|
|
557
|
-
const result = loadConfigWithSource();
|
|
558
|
-
const lines = ['delegate — config', '', ...buildConfigReport(result)];
|
|
559
|
-
if (!ctx.hasUI) {
|
|
560
|
-
process.stdout.write(`${lines.join('\n')}\n`);
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
564
|
-
let offset = 0;
|
|
565
|
-
const height = 20;
|
|
566
|
-
return {
|
|
567
|
-
render(width: number): string[] {
|
|
568
|
-
const header = theme.fg('accent', `delegate config — ${result.source.file} (↑↓ scroll · any key to close)`);
|
|
569
|
-
const visible = lines.slice(offset, offset + height);
|
|
570
|
-
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
571
|
-
},
|
|
572
|
-
handleInput(data: string): void {
|
|
573
|
-
if (matchesKey(data, Key.up) && offset > 0) {
|
|
574
|
-
offset--;
|
|
575
|
-
tui.requestRender();
|
|
576
|
-
} else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
577
|
-
offset++;
|
|
578
|
-
tui.requestRender();
|
|
579
|
-
} else done(undefined);
|
|
580
|
-
},
|
|
581
|
-
invalidate() {},
|
|
582
|
-
};
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* `/delegate config init` — the one place this extension ever writes to `settings.json`, and only
|
|
588
|
-
* because a human explicitly typed this subcommand. Writes the current effective config (defaults
|
|
589
|
-
* merged with whatever was already on disk, minus anything still at its default — pinning defaults would
|
|
590
|
-
* stop later releases' default changes reaching the user) into the `delegate` key via `writeDelegateConfig()`
|
|
591
|
-
* (read-modify-write, atomic, refuses on an unparseable file rather than clobbering it). This is
|
|
592
|
-
* also the practical fix for the legacy-`claudeDelegate`-only gap `describeConfigSource` warns
|
|
593
|
-
* about: writing an explicit `delegate` key (with the correctly-resolved values already folded
|
|
594
|
-
* in — the legacy migration already ran before this point) makes it win from then on, without
|
|
595
|
-
* this command ever touching or deleting the old `claudeDelegate` key itself.
|
|
596
|
-
*/
|
|
597
|
-
async function initConfig(ctx: ExtensionContext): Promise<void> {
|
|
598
|
-
const result = loadConfigWithSource();
|
|
599
|
-
const write = writeDelegateConfig(nonDefaultConfig(result.config));
|
|
600
|
-
const msg = write.ok ? `✓ ${write.message}` : `✗ ${write.message}`;
|
|
601
|
-
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
602
|
-
else ctx.ui.notify?.(msg, write.ok ? 'info' : 'warning');
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function buildPrompt(
|
|
606
|
-
template: DelegateTemplate,
|
|
607
|
-
task: string,
|
|
608
|
-
scopeText: string | null,
|
|
609
|
-
cwd: string,
|
|
610
|
-
harness: string,
|
|
611
|
-
): string {
|
|
612
|
-
let prompt = [
|
|
613
|
-
`You are being delegated a subtask by the pi coding agent.`,
|
|
614
|
-
`Working directory: ${cwd}`,
|
|
615
|
-
`Harness: ${harness}`,
|
|
616
|
-
`Mode: ${template.name}`,
|
|
617
|
-
``,
|
|
618
|
-
template.prompt,
|
|
619
|
-
].join('\n');
|
|
620
|
-
prompt += `\n\n# Task\n${task}`;
|
|
621
|
-
if (scopeText) prompt += `\n\n# Scope\n${scopeText}`;
|
|
622
|
-
if (template.skill) prompt += `\n\nUse the "${template.skill}" skill.`;
|
|
623
|
-
return prompt;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
async function delegate(
|
|
627
|
-
pi: ExtensionAPI,
|
|
628
|
-
ctx: ExtensionContext,
|
|
629
|
-
opts: DelegateOptions,
|
|
630
|
-
): Promise<{
|
|
631
|
-
content: string;
|
|
632
|
-
details: Record<string, unknown>;
|
|
633
|
-
result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string };
|
|
634
|
-
activityLog: string[];
|
|
635
|
-
verify?: VerifyResult;
|
|
636
|
-
}> {
|
|
637
|
-
const config = loadConfig();
|
|
638
|
-
const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
|
|
639
|
-
const harness = getHarness(harnessName);
|
|
640
|
-
if (!harness)
|
|
641
|
-
throw new Error(
|
|
642
|
-
`unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
|
|
643
|
-
);
|
|
644
|
-
const projectTrusted = isProjectTrusted(ctx);
|
|
645
|
-
warnIfProjectTemplatesSkipped(ctx, projectTrusted);
|
|
646
|
-
const templates = loadTemplates(ctx.cwd, harnessName, projectTrusted);
|
|
647
|
-
const mode = opts.mode || config.defaultMode;
|
|
648
|
-
const template = templates.get(mode);
|
|
649
|
-
if (!template)
|
|
650
|
-
throw new Error(
|
|
651
|
-
`unknown delegate mode "${mode}" for harness "${harnessName}". Available: ${[...templates.keys()].sort().join(', ')}`,
|
|
652
|
-
);
|
|
653
|
-
const task = opts.task || template.defaultTask;
|
|
654
|
-
if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
|
|
655
|
-
|
|
656
|
-
// Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
|
|
657
|
-
// ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
|
|
658
|
-
// not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
|
|
659
|
-
const transport = resolveTransport(config, harnessName, harness);
|
|
660
|
-
|
|
661
|
-
// concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
|
|
662
|
-
// exactly as before; fan-out passes waitForSlot:true to queue instead.
|
|
663
|
-
const release = await acquireSlot({
|
|
664
|
-
harness: harnessName,
|
|
665
|
-
mode,
|
|
666
|
-
config,
|
|
667
|
-
wait: opts.waitForSlot ?? false,
|
|
668
|
-
signal: opts.signal,
|
|
669
|
-
});
|
|
670
|
-
opts.onAcquired?.();
|
|
671
|
-
|
|
672
|
-
let scopeText: string | null = opts.scope ?? null;
|
|
673
|
-
if (opts.scope === 'diff') {
|
|
674
|
-
const diff = await pi.exec('git', ['diff', 'HEAD'], { cwd: ctx.cwd });
|
|
675
|
-
scopeText = diff.stdout
|
|
676
|
-
? `Current git diff (working tree vs HEAD):\n${diff.stdout}`
|
|
677
|
-
: 'No git diff vs HEAD (working tree clean).';
|
|
678
|
-
} else if (opts.scope === 'pr' || opts.pr) {
|
|
679
|
-
const target = opts.pr ?? '';
|
|
680
|
-
const pr = await pi.exec('gh', target ? ['pr', 'diff', target] : ['pr', 'diff'], { cwd: ctx.cwd });
|
|
681
|
-
scopeText = pr.stdout
|
|
682
|
-
? `Pull request diff (${target || 'current branch'}):\n${pr.stdout}`
|
|
683
|
-
: `Could not resolve the PR diff${pr.stderr ? ` — ${pr.stderr.trim().slice(0, 300)}` : ''}.`;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// permission: normalized, danger requires explicit per-call allowDangerous:true
|
|
687
|
-
let permission: NormalizedPermission = template.permission;
|
|
688
|
-
const nativePerm = template.nativePermission;
|
|
689
|
-
const isNativeDanger = isNativeDangerPermission(harness, nativePerm);
|
|
690
|
-
if (template.permission === 'danger' || isNativeDanger) {
|
|
691
|
-
if (opts.allowDangerous !== true) {
|
|
692
|
-
throw new Error(
|
|
693
|
-
`template "${mode}" requires danger permission — pass allowDangerous:true to run it (never a default)`,
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
permission = 'danger';
|
|
697
|
-
} else if (opts.allowDangerous === true) {
|
|
698
|
-
// explicit per-call escalation for any template
|
|
699
|
-
permission = 'danger';
|
|
700
|
-
}
|
|
701
|
-
const permissionForDisplay = nativePerm ?? permission;
|
|
702
|
-
// Dropped when an explicit escalation moved us off the template's own tier — see
|
|
703
|
-
// resolveNativePermission(). Applies to both transports.
|
|
704
|
-
const nativePermissionForRun = resolveNativePermission(template.permission, permission, nativePerm);
|
|
705
|
-
|
|
706
|
-
const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
|
|
707
|
-
const prompt = buildPrompt(template, task, scopeText, ctx.cwd, harnessName);
|
|
708
|
-
|
|
709
|
-
const activityEvents: ActivityEvent[] = [];
|
|
710
|
-
let streamedFull = '';
|
|
711
|
-
let result: import('./runner.ts').HarnessResult;
|
|
712
|
-
try {
|
|
713
|
-
const baseRunOpts = {
|
|
714
|
-
harness,
|
|
715
|
-
prompt,
|
|
716
|
-
cwd: ctx.cwd,
|
|
717
|
-
permission,
|
|
718
|
-
model,
|
|
719
|
-
maxBudgetUsd:
|
|
720
|
-
opts.maxBudgetUsd ??
|
|
721
|
-
template.maxBudgetUsd ??
|
|
722
|
-
config.maxBudgetUsd ??
|
|
723
|
-
config.harnesses[harnessName]?.maxBudgetUsd,
|
|
724
|
-
signal: opts.signal,
|
|
725
|
-
timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
|
|
726
|
-
resumeSessionId: opts.sessionId,
|
|
727
|
-
onStream: (t: string) => {
|
|
728
|
-
streamedFull += t;
|
|
729
|
-
opts.onStream?.(t);
|
|
730
|
-
},
|
|
731
|
-
onActivity: (ev: ActivityEvent) => {
|
|
732
|
-
activityEvents.push(ev);
|
|
733
|
-
opts.onActivity?.(ev);
|
|
734
|
-
},
|
|
735
|
-
nativePermission: nativePermissionForRun,
|
|
736
|
-
};
|
|
737
|
-
result =
|
|
738
|
-
transport === 'acp'
|
|
739
|
-
? await runAcpHarness({ ...baseRunOpts, harness: acpView(harness) })
|
|
740
|
-
: await runHarness(baseRunOpts);
|
|
741
|
-
} catch (err) {
|
|
742
|
-
release();
|
|
743
|
-
if (streamedFull.length > 0) {
|
|
744
|
-
try {
|
|
745
|
-
saveOutput(
|
|
746
|
-
harnessName,
|
|
747
|
-
`${mode}-partial`,
|
|
748
|
-
buildTranscript({
|
|
749
|
-
harness: harnessName,
|
|
750
|
-
mode: `${mode} (partial)`,
|
|
751
|
-
permission: permission,
|
|
752
|
-
nativePermission: nativePerm ?? undefined,
|
|
753
|
-
model: model ?? null,
|
|
754
|
-
cwd: ctx.cwd,
|
|
755
|
-
sessionId: null,
|
|
756
|
-
resumed: Boolean(opts.sessionId),
|
|
757
|
-
numTurns: null,
|
|
758
|
-
totalCostUsd: null,
|
|
759
|
-
isError: true,
|
|
760
|
-
stopReason: null,
|
|
761
|
-
durationMs: null,
|
|
762
|
-
usage: null,
|
|
763
|
-
contextPercent: null,
|
|
764
|
-
contextWindow: null,
|
|
765
|
-
activityLog: collectActivityLog(activityEvents),
|
|
766
|
-
output: streamedFull,
|
|
767
|
-
}),
|
|
768
|
-
);
|
|
769
|
-
} catch (_e) {
|
|
770
|
-
void _e;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
throw err;
|
|
774
|
-
}
|
|
775
|
-
release();
|
|
776
|
-
|
|
777
|
-
if (result.isError && !result.result && !result.streamedText)
|
|
778
|
-
throw new Error(`${harnessName} reported an error and produced no output`);
|
|
779
|
-
|
|
780
|
-
const actualModel = result.model ?? model ?? null;
|
|
781
|
-
const promptTokens =
|
|
782
|
-
result.usage === null
|
|
783
|
-
? null
|
|
784
|
-
: result.usage.inputTokens + result.usage.cacheCreationInputTokens + result.usage.cacheReadInputTokens;
|
|
785
|
-
const contextPercent =
|
|
786
|
-
promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
|
|
787
|
-
|
|
788
|
-
// Host-run post-hoc verification — report-only evidence, never flips `result.isError`. Never
|
|
789
|
-
// actually executes on a readonly permission (permission-tier bypass) — recorded as skipped
|
|
790
|
-
// instead of silently dropped. See the trust-model note on runVerify().
|
|
791
|
-
const verifyPlan = resolveVerifyPlan(opts.verify, template.verify, permission);
|
|
792
|
-
const verify = verifyPlan
|
|
793
|
-
? verifyPlan.skip
|
|
794
|
-
? skipVerifyResult(verifyPlan.command, 'readonly run')
|
|
795
|
-
: await runVerify(pi, ctx.cwd, verifyPlan.command)
|
|
796
|
-
: undefined;
|
|
797
|
-
|
|
798
|
-
const file = saveOutput(
|
|
799
|
-
harnessName,
|
|
800
|
-
mode,
|
|
801
|
-
buildTranscript({
|
|
802
|
-
harness: harnessName,
|
|
803
|
-
mode: mode,
|
|
804
|
-
permission: permission,
|
|
805
|
-
nativePermission: nativePerm ?? undefined,
|
|
806
|
-
model: actualModel,
|
|
807
|
-
cwd: ctx.cwd,
|
|
808
|
-
sessionId: result.sessionId,
|
|
809
|
-
resumed: Boolean(opts.sessionId),
|
|
810
|
-
numTurns: result.numTurns,
|
|
811
|
-
totalCostUsd: result.totalCostUsd,
|
|
812
|
-
isError: result.isError,
|
|
813
|
-
stopReason: result.stopReason,
|
|
814
|
-
durationMs: result.durationMs,
|
|
815
|
-
usage: result.usage,
|
|
816
|
-
contextPercent,
|
|
817
|
-
contextWindow: result.contextWindow,
|
|
818
|
-
activityLog: collectActivityLog(activityEvents),
|
|
819
|
-
output: result.result || result.streamedText,
|
|
820
|
-
verify,
|
|
821
|
-
}),
|
|
822
|
-
);
|
|
823
|
-
pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
|
|
824
|
-
// also prune legacy if claude
|
|
825
|
-
if (harnessName === 'claude') pruneOutputs(legacyOutputsDir(), config.maxTranscripts);
|
|
826
|
-
|
|
827
|
-
return {
|
|
828
|
-
content: result.result || result.streamedText || '(empty result)',
|
|
829
|
-
details: {
|
|
830
|
-
harness: harnessName,
|
|
831
|
-
mode,
|
|
832
|
-
permission,
|
|
833
|
-
nativePermission: nativePerm ?? null,
|
|
834
|
-
permissionMode: String(permissionForDisplay),
|
|
835
|
-
model: actualModel,
|
|
836
|
-
numTurns: result.numTurns,
|
|
837
|
-
totalCostUsd: result.totalCostUsd,
|
|
838
|
-
sessionId: result.sessionId,
|
|
839
|
-
stopReason: result.stopReason,
|
|
840
|
-
permissionDenials: result.permissionDenials,
|
|
841
|
-
isError: result.isError,
|
|
842
|
-
resumed: Boolean(opts.sessionId),
|
|
843
|
-
file,
|
|
844
|
-
durationMs: result.durationMs,
|
|
845
|
-
ttftMs: result.ttftMs,
|
|
846
|
-
contextWindow: result.contextWindow,
|
|
847
|
-
contextPercent,
|
|
848
|
-
promptTokens,
|
|
849
|
-
usage: result.usage,
|
|
850
|
-
verify,
|
|
851
|
-
},
|
|
852
|
-
result,
|
|
853
|
-
activityLog: collectActivityLog(activityEvents),
|
|
854
|
-
verify,
|
|
855
|
-
};
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
function summarize(content: string, max = 30_000): { text: string; truncated: boolean } {
|
|
859
|
-
if (content.length <= max) return { text: content, truncated: false };
|
|
860
|
-
return { text: `${content.slice(0, max)}\n…[truncated — full output saved to file]`, truncated: true };
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
interface PendingReport {
|
|
864
|
-
content: string;
|
|
865
|
-
details: Record<string, unknown>;
|
|
866
|
-
}
|
|
867
|
-
let pendingReport: PendingReport | null = null;
|
|
868
|
-
function injectReport(
|
|
869
|
-
_ctx: ExtensionContext,
|
|
870
|
-
opts: {
|
|
871
|
-
harness: string;
|
|
872
|
-
mode: string;
|
|
873
|
-
metrics: string;
|
|
874
|
-
body: string;
|
|
875
|
-
file?: string;
|
|
876
|
-
sessionId?: string;
|
|
877
|
-
verify?: VerifyResult;
|
|
878
|
-
},
|
|
879
|
-
): void {
|
|
880
|
-
pendingReport = {
|
|
881
|
-
content: buildReportContent({
|
|
882
|
-
harness: opts.harness,
|
|
883
|
-
mode: opts.mode,
|
|
884
|
-
metrics: opts.metrics,
|
|
885
|
-
body: opts.body,
|
|
886
|
-
file: opts.file,
|
|
887
|
-
sessionId: opts.sessionId,
|
|
888
|
-
verify: opts.verify,
|
|
889
|
-
}),
|
|
890
|
-
details: {
|
|
891
|
-
harness: opts.harness,
|
|
892
|
-
mode: opts.mode,
|
|
893
|
-
file: opts.file,
|
|
894
|
-
sessionId: opts.sessionId,
|
|
895
|
-
metrics: opts.metrics,
|
|
896
|
-
},
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
interface ToolProgressUpdate {
|
|
901
|
-
content: { type: string; text: string }[];
|
|
902
|
-
details: { progress: number };
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
/**
|
|
906
|
-
* `delegate` tool params. Deliberately has no `verify` field — a tool param is model-controlled,
|
|
907
|
-
* and the model's context (repo content, delegated-harness output) is attacker-influenceable, so
|
|
908
|
-
* a model-settable verify command would be a prompt-injection -> arbitrary-host-command path.
|
|
909
|
-
* Verify only comes from on-disk template frontmatter or a human-typed `/delegate --verify=`.
|
|
910
|
-
*/
|
|
911
|
-
interface DelegateToolParams {
|
|
912
|
-
harness?: string;
|
|
913
|
-
task: string;
|
|
914
|
-
mode?: string;
|
|
915
|
-
scope?: string;
|
|
916
|
-
model?: string;
|
|
917
|
-
maxBudgetUsd?: number;
|
|
918
|
-
allowDangerous?: boolean;
|
|
919
|
-
sessionId?: string;
|
|
920
|
-
pr?: string;
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
/** One `delegate()` call with the tool's live-feed progress reporting (`onUpdate`). Shared by the
|
|
924
|
-
* single-harness tool path and the fan-out loop — `labelPrefix` tags fan-out feed lines by harness. */
|
|
925
|
-
async function runDelegateForTool(
|
|
926
|
-
pi: ExtensionAPI,
|
|
927
|
-
ctx: ExtensionContext,
|
|
928
|
-
config: DelegateConfig,
|
|
929
|
-
callOpts: DelegateOptions,
|
|
930
|
-
signal: AbortSignal | undefined,
|
|
931
|
-
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
932
|
-
labelPrefix: string,
|
|
933
|
-
): Promise<Awaited<ReturnType<typeof delegate>>> {
|
|
934
|
-
const feed: string[] = [];
|
|
935
|
-
const feedIndex = new ToolCallIndex();
|
|
936
|
-
let liveTail = '';
|
|
937
|
-
let thinkingChars = 0;
|
|
938
|
-
let lastPushAt = 0;
|
|
939
|
-
const THROTTLE_MS = 250;
|
|
940
|
-
const pushFeed = () => {
|
|
941
|
-
const now = Date.now();
|
|
942
|
-
if (now - lastPushAt < THROTTLE_MS) return;
|
|
943
|
-
lastPushAt = now;
|
|
944
|
-
const lines: string[] = [...feed.slice(-6)];
|
|
945
|
-
if (thinkingChars > 0)
|
|
946
|
-
lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
|
|
947
|
-
if (liveTail) lines.push(`✍ ${liveTail}`);
|
|
948
|
-
if (lines.length === 0) return;
|
|
949
|
-
onUpdate?.({
|
|
950
|
-
content: [{ type: 'text', text: lines.map(l => `${labelPrefix}${l}`).join('\n') }],
|
|
951
|
-
details: { progress: 0.5 },
|
|
952
|
-
});
|
|
953
|
-
};
|
|
954
|
-
return delegate(pi, ctx, {
|
|
955
|
-
...callOpts,
|
|
956
|
-
signal,
|
|
957
|
-
onStream: t => {
|
|
958
|
-
liveTail = (liveTail + t).slice(-400);
|
|
959
|
-
pushFeed();
|
|
960
|
-
},
|
|
961
|
-
onActivity: ev => {
|
|
962
|
-
if (ev.kind === 'tool_input') {
|
|
963
|
-
feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
|
|
964
|
-
feedIndex.set(ev.id, feed.length - 1);
|
|
965
|
-
if (feed.length > 40) {
|
|
966
|
-
const removed = feed.length - 40;
|
|
967
|
-
feed.splice(0, removed);
|
|
968
|
-
feedIndex.shift(removed);
|
|
969
|
-
}
|
|
970
|
-
} else if (ev.kind === 'tool_result') {
|
|
971
|
-
const idx = feedIndex.resolve(ev.id, feed.length - 1);
|
|
972
|
-
if (idx >= 0 && feed[idx]?.startsWith('▶')) feed[idx] += ev.isError ? ' ✗' : ' ✓';
|
|
973
|
-
} else if (ev.kind === 'thinking') thinkingChars += ev.chars;
|
|
974
|
-
pushFeed();
|
|
975
|
-
},
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
/** `delegate({harness:"all"|"a,b"})` — resolve the requested harnesses to detected installs, run the
|
|
980
|
-
* existing `delegate()` engine concurrently across all of them (bounded by `maxConcurrent` via
|
|
981
|
-
* `acquireSlot({wait:true})` — see concurrency.ts), and mechanically synthesize one comparison
|
|
982
|
-
* report ordered by the resolved harness list regardless of completion order. No second model call. */
|
|
983
|
-
async function runFanoutTool(
|
|
984
|
-
pi: ExtensionAPI,
|
|
985
|
-
ctx: ExtensionContext,
|
|
986
|
-
config: DelegateConfig,
|
|
987
|
-
params: DelegateToolParams,
|
|
988
|
-
signal: AbortSignal | undefined,
|
|
989
|
-
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
990
|
-
): Promise<{ content: { type: string; text: string }[]; details: Record<string, unknown>; usage?: unknown }> {
|
|
991
|
-
const detection = await detectAll();
|
|
992
|
-
const { resolved, unknown, skipped } = resolveHarnessList(params.harness ?? 'all', {
|
|
993
|
-
knownHarnesses: HARNESS_NAMES,
|
|
994
|
-
aliasOf: resolveHarnessName,
|
|
995
|
-
isKnown: isKnownHarness,
|
|
996
|
-
detection,
|
|
997
|
-
});
|
|
998
|
-
if (resolved.length === 0) {
|
|
999
|
-
throw new Error(
|
|
1000
|
-
`no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`,
|
|
1001
|
-
);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const mode = params.mode ?? config.defaultMode;
|
|
1005
|
-
|
|
1006
|
-
type TaskResult = FanoutRunSummary & {
|
|
1007
|
-
usage?: import('./harnesses/types.ts').StreamedUsage | null;
|
|
1008
|
-
};
|
|
1009
|
-
const tasks = resolved.map(async (h): Promise<TaskResult> => {
|
|
1010
|
-
onUpdate?.({ content: [{ type: 'text', text: `[${h}] queued…` }], details: { progress: 0.5 } });
|
|
1011
|
-
try {
|
|
1012
|
-
const run = await runDelegateForTool(
|
|
1013
|
-
pi,
|
|
1014
|
-
ctx,
|
|
1015
|
-
config,
|
|
1016
|
-
{
|
|
1017
|
-
harness: h,
|
|
1018
|
-
task: params.task,
|
|
1019
|
-
mode: params.mode,
|
|
1020
|
-
scope: params.scope,
|
|
1021
|
-
model: params.model,
|
|
1022
|
-
maxBudgetUsd: params.maxBudgetUsd,
|
|
1023
|
-
allowDangerous: params.allowDangerous === true,
|
|
1024
|
-
sessionId: params.sessionId,
|
|
1025
|
-
pr: params.pr,
|
|
1026
|
-
// no verify: intentionally not model-settable — see DelegateToolParams
|
|
1027
|
-
waitForSlot: true,
|
|
1028
|
-
onAcquired: () =>
|
|
1029
|
-
onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } }),
|
|
1030
|
-
},
|
|
1031
|
-
signal,
|
|
1032
|
-
onUpdate,
|
|
1033
|
-
`[${h}] `,
|
|
1034
|
-
);
|
|
1035
|
-
const summary = summarize(run.content);
|
|
1036
|
-
return {
|
|
1037
|
-
harness: h,
|
|
1038
|
-
ok: !run.result.isError,
|
|
1039
|
-
metrics: formatMetrics({
|
|
1040
|
-
numTurns: run.result.numTurns,
|
|
1041
|
-
totalCostUsd: run.result.totalCostUsd,
|
|
1042
|
-
promptTokens: 0,
|
|
1043
|
-
contextPercent: typeof run.details.contextPercent === 'number' ? run.details.contextPercent : null,
|
|
1044
|
-
durationMs: run.result.durationMs,
|
|
1045
|
-
}),
|
|
1046
|
-
cost: run.result.totalCostUsd,
|
|
1047
|
-
body: summary.text,
|
|
1048
|
-
file: (run.details.file as string) ?? undefined,
|
|
1049
|
-
sessionId: (run.details.sessionId as string) ?? undefined,
|
|
1050
|
-
verify: run.verify,
|
|
1051
|
-
usage: run.result.usage,
|
|
1052
|
-
};
|
|
1053
|
-
} catch (err) {
|
|
1054
|
-
return { harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) };
|
|
1055
|
-
}
|
|
1056
|
-
});
|
|
1057
|
-
|
|
1058
|
-
const settled = await Promise.all(tasks);
|
|
1059
|
-
const runs = orderFanoutResults(resolved, settled);
|
|
1060
|
-
|
|
1061
|
-
let sumInput = 0;
|
|
1062
|
-
let sumOutput = 0;
|
|
1063
|
-
let sumCacheCreate = 0;
|
|
1064
|
-
let sumCacheRead = 0;
|
|
1065
|
-
let sumCost = 0;
|
|
1066
|
-
let anyCostKnown = false;
|
|
1067
|
-
for (const r of runs) {
|
|
1068
|
-
if (r.usage) {
|
|
1069
|
-
sumInput += r.usage.inputTokens;
|
|
1070
|
-
sumOutput += r.usage.outputTokens;
|
|
1071
|
-
sumCacheCreate += r.usage.cacheCreationInputTokens;
|
|
1072
|
-
sumCacheRead += r.usage.cacheReadInputTokens;
|
|
1073
|
-
}
|
|
1074
|
-
if (r.cost !== null) {
|
|
1075
|
-
sumCost += r.cost;
|
|
1076
|
-
anyCostKnown = true;
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
const report = buildFanoutReport({ runs, skipped, unknown });
|
|
1081
|
-
const okCount = runs.filter(r => r.ok).length;
|
|
1082
|
-
const head = `## delegate all — ${mode} (${okCount}/${runs.length} ok)`;
|
|
1083
|
-
const usage = mapClaudeUsage({
|
|
1084
|
-
inputTokens: sumInput,
|
|
1085
|
-
outputTokens: sumOutput,
|
|
1086
|
-
cacheCreationInputTokens: sumCacheCreate,
|
|
1087
|
-
cacheReadInputTokens: sumCacheRead,
|
|
1088
|
-
totalCostUsd: anyCostKnown ? sumCost : null,
|
|
1089
|
-
});
|
|
1090
|
-
return {
|
|
1091
|
-
content: [{ type: 'text', text: `${head}\n\n${report}` }],
|
|
1092
|
-
details: { fanout: true, harness: 'all', mode, harnesses: resolved, skipped, unknown, runs },
|
|
1093
|
-
usage,
|
|
1094
|
-
};
|
|
1095
|
-
}
|
|
51
|
+
import { confirmDangerousCommand, confirmDangerousToolCall, confirmToolAddDirs } from './validate.ts';
|
|
1096
52
|
|
|
1097
53
|
export default function (pi: ExtensionAPI) {
|
|
1098
|
-
|
|
1099
|
-
let activeOverlay: { show(): void; focus(): void; runId: number } | null = null;
|
|
54
|
+
const ui: RunUiState = { activeRunId: 0, activeOverlay: null };
|
|
1100
55
|
|
|
1101
56
|
// ── Tools ────────────────────────────────────────────────────────────────
|
|
1102
57
|
const delegateToolDef = {
|
|
@@ -1110,8 +65,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1110
65
|
'Pass harness (claude|codex|opencode|amp|devin) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
|
|
1111
66
|
'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work. Some templates verify their own work (e.g. running tests) automatically after the harness finishes — that is not something you configure here.',
|
|
1112
67
|
'harness: "all" or a comma list (e.g. "codex,opencode") fans the same task out to each detected harness and returns one synthesized comparison report — costs multiply, so only use it when the user actually wants a multi-harness comparison.',
|
|
1113
|
-
'sessionId resumes a previous delegated session instead of starting fresh.',
|
|
1114
|
-
'
|
|
68
|
+
'sessionId resumes a previous delegated session instead of starting fresh — pass the exact session id from a previous run\'s details (letters, digits, . _ : - only). It cannot be combined with a fan-out harness ("all" or a comma list) — a session belongs to one harness.',
|
|
69
|
+
'pr must be a PR number, an http(s) pull-request URL (https://<host>/<owner>/<repo>/pull/<n>), or owner/repo#123.',
|
|
70
|
+
'addDirs inside the working directory are accepted as-is; any entry outside it asks the human to confirm interactively and is refused in a non-interactive session.',
|
|
71
|
+
'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission). Setting it always asks the human to confirm interactively; in a non-interactive session it is refused outright.',
|
|
1115
72
|
],
|
|
1116
73
|
parameters: Type.Object({
|
|
1117
74
|
harness: Type.Optional(
|
|
@@ -1144,10 +101,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
1144
101
|
),
|
|
1145
102
|
allowDangerous: Type.Optional(
|
|
1146
103
|
Type.Boolean({
|
|
1147
|
-
description:
|
|
104
|
+
description:
|
|
105
|
+
'Escalate to danger permission (unrestricted). Always requires interactive human confirmation; refused without a UI.',
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
pr: Type.Optional(
|
|
109
|
+
Type.String({ description: 'GitHub PR number, http(s) PR URL, or owner/repo#123 (alternative to scope pr).' }),
|
|
110
|
+
),
|
|
111
|
+
addDirs: Type.Optional(
|
|
112
|
+
Type.Array(Type.String(), {
|
|
113
|
+
description:
|
|
114
|
+
'Extra directories the harness may access. Relative paths resolve against the working directory. Any entry that resolves (after symlinks) outside the working directory requires interactive human confirmation and is refused without a UI. Not every harness supports this (opencode ignores it; codex ignores it on resume).',
|
|
1148
115
|
}),
|
|
1149
116
|
),
|
|
1150
|
-
pr: Type.Optional(Type.String({ description: 'GitHub PR number/URL (alternative to scope pr).' })),
|
|
1151
117
|
// Deliberately no `verify` param — see the trust-model comment on DelegateToolParams/runVerify.
|
|
1152
118
|
}),
|
|
1153
119
|
async execute(
|
|
@@ -1158,6 +124,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1158
124
|
ctx: ExtensionContext,
|
|
1159
125
|
) {
|
|
1160
126
|
const config = loadConfig();
|
|
127
|
+
// A model-set allowDangerous is never honored on its own — a human confirms it (or, with no
|
|
128
|
+
// UI to ask, it's refused). Checked once up front, before any fan-out. See validate.ts.
|
|
129
|
+
if (params.allowDangerous === true) await confirmDangerousToolCall(ctx, params);
|
|
130
|
+
// Same trust model for model-set addDirs: inside cwd is fine, anything outside needs a human
|
|
131
|
+
// (fail closed without a UI). Covers single, fan-out, and the claude_delegate alias.
|
|
132
|
+
await confirmToolAddDirs(ctx, params.addDirs);
|
|
1161
133
|
if (params.harness && isFanoutSpec(params.harness)) {
|
|
1162
134
|
return runFanoutTool(pi, ctx, config, params, signal, onUpdate);
|
|
1163
135
|
}
|
|
@@ -1175,6 +147,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1175
147
|
allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
|
|
1176
148
|
sessionId: params.sessionId,
|
|
1177
149
|
pr: params.pr,
|
|
150
|
+
addDirs: params.addDirs,
|
|
1178
151
|
// no verify: intentionally not model-settable — see DelegateToolParams
|
|
1179
152
|
},
|
|
1180
153
|
signal,
|
|
@@ -1308,16 +281,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1308
281
|
budget?: number;
|
|
1309
282
|
sessionId?: string;
|
|
1310
283
|
pr?: string;
|
|
284
|
+
addDirs?: string[];
|
|
1311
285
|
verify?: string;
|
|
1312
286
|
template?: DelegateTemplate;
|
|
1313
287
|
isDanger: boolean;
|
|
288
|
+
/** Only ever true after `confirmDangerousCommand` approved this invocation's --allow-dangerous. */
|
|
289
|
+
allowDangerous?: boolean;
|
|
1314
290
|
},
|
|
1315
291
|
): Promise<{
|
|
1316
292
|
result: Awaited<ReturnType<typeof delegate>> | null;
|
|
1317
293
|
error: Error | null;
|
|
1318
294
|
cancelled: boolean;
|
|
1319
295
|
}> => {
|
|
1320
|
-
const { harnessName, mode, task, scope, model, budget, sessionId, pr, verify, template, isDanger } = opts;
|
|
296
|
+
const { harnessName, mode, task, scope, model, budget, sessionId, pr, addDirs, verify, template, isDanger } = opts;
|
|
297
|
+
const allowDangerous = opts.allowDangerous === true;
|
|
1321
298
|
const modeForDisplay = mode ?? 'general';
|
|
1322
299
|
|
|
1323
300
|
const feed: FeedEntry[] = [];
|
|
@@ -1374,9 +351,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1374
351
|
const ac = new AbortController();
|
|
1375
352
|
let cancelled = false;
|
|
1376
353
|
const runState: { error: Error | null } = { error: null };
|
|
1377
|
-
const runId = ++activeRunId;
|
|
354
|
+
const runId = ++ui.activeRunId;
|
|
1378
355
|
const clearActive = () => {
|
|
1379
|
-
if (activeOverlay?.runId === runId) activeOverlay = null;
|
|
356
|
+
if (ui.activeOverlay?.runId === runId) ui.activeOverlay = null;
|
|
1380
357
|
};
|
|
1381
358
|
const run = delegate(pi, ctx, {
|
|
1382
359
|
harness: harnessName,
|
|
@@ -1387,7 +364,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1387
364
|
maxBudgetUsd: budget,
|
|
1388
365
|
sessionId,
|
|
1389
366
|
pr,
|
|
367
|
+
addDirs,
|
|
1390
368
|
verify,
|
|
369
|
+
allowDangerous, // never from config.allowDangerous — only a confirmed --allow-dangerous
|
|
1391
370
|
signal: ac.signal,
|
|
1392
371
|
onStream: t => {
|
|
1393
372
|
liveTail = (liveTail + t).slice(-400);
|
|
@@ -1429,7 +408,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1429
408
|
overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
|
|
1430
409
|
onHandle: h => {
|
|
1431
410
|
overlayHandle = h;
|
|
1432
|
-
activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
411
|
+
ui.activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
1433
412
|
h.focus();
|
|
1434
413
|
},
|
|
1435
414
|
},
|
|
@@ -1447,284 +426,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1447
426
|
return { result: failed ? null : result, error: runState.error, cancelled };
|
|
1448
427
|
};
|
|
1449
428
|
|
|
1450
|
-
interface FanoutSpec {
|
|
1451
|
-
harnessName: string;
|
|
1452
|
-
task: string;
|
|
1453
|
-
scope?: string;
|
|
1454
|
-
model?: string;
|
|
1455
|
-
budget?: number;
|
|
1456
|
-
sessionId?: string;
|
|
1457
|
-
pr?: string;
|
|
1458
|
-
verify?: string;
|
|
1459
|
-
isDanger: boolean;
|
|
1460
|
-
}
|
|
1461
|
-
interface FanoutOutcome {
|
|
1462
|
-
harnessName: string;
|
|
1463
|
-
result: Awaited<ReturnType<typeof delegate>> | null;
|
|
1464
|
-
error: Error | null;
|
|
1465
|
-
cancelled: boolean;
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
/** Run `delegate()` concurrently across every spec in one multi-run overlay — the fan-out
|
|
1469
|
-
* counterpart to `runOneDelegation`. Concurrency is bounded by `maxConcurrent`: every run passes
|
|
1470
|
-
* `waitForSlot:true`, so `acquireSlot` (concurrency.ts) queues the ones that don't fit instead of
|
|
1471
|
-
* failing them, and a fan-out never exceeds the configured cap just because it's a fan-out.
|
|
1472
|
-
* Double-ESC cancel aborts every in-flight (and still-queued) run via one shared AbortController. */
|
|
1473
|
-
const runFanoutConcurrent = async (ctx: ExtensionContext, mode: string | undefined, specs: FanoutSpec[]) => {
|
|
1474
|
-
const ac = new AbortController();
|
|
1475
|
-
let cancelledAll = false;
|
|
1476
|
-
const runId = ++activeRunId;
|
|
1477
|
-
const clearActive = () => {
|
|
1478
|
-
if (activeOverlay?.runId === runId) activeOverlay = null;
|
|
1479
|
-
};
|
|
1480
|
-
const modeForDisplay = mode ?? 'general';
|
|
1481
|
-
const anyDanger = specs.some(s => s.isDanger);
|
|
1482
|
-
const overallStart = Date.now();
|
|
1483
|
-
const rows: RunRow[] = specs.map(s => ({
|
|
1484
|
-
harness: s.harnessName,
|
|
1485
|
-
startedAt: null,
|
|
1486
|
-
status: 'queued',
|
|
1487
|
-
activity: '',
|
|
1488
|
-
costUsd: null,
|
|
1489
|
-
}));
|
|
1490
|
-
let requestRender: (() => void) | null = null;
|
|
1491
|
-
|
|
1492
|
-
let chipLastPush = 0;
|
|
1493
|
-
const pushChip = () => {
|
|
1494
|
-
if (!ctx.hasUI) return;
|
|
1495
|
-
const now = Date.now();
|
|
1496
|
-
if (now - chipLastPush < 500) return;
|
|
1497
|
-
chipLastPush = now;
|
|
1498
|
-
const theme = ctx.ui.theme;
|
|
1499
|
-
ctx.ui.setStatus(
|
|
1500
|
-
'delegate',
|
|
1501
|
-
theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows, now - overallStart)}`),
|
|
1502
|
-
);
|
|
1503
|
-
};
|
|
1504
|
-
|
|
1505
|
-
const runOne = async (spec: FanoutSpec, idx: number): Promise<FanoutOutcome> => {
|
|
1506
|
-
const setRow = (patch: Partial<RunRow>) => {
|
|
1507
|
-
rows[idx] = { ...rows[idx], ...patch };
|
|
1508
|
-
requestRender?.();
|
|
1509
|
-
pushChip();
|
|
1510
|
-
};
|
|
1511
|
-
let liveTail = '';
|
|
1512
|
-
const onActivity = (ev: ActivityEvent) => {
|
|
1513
|
-
if (ev.kind === 'tool_input') setRow({ activity: `▶ ${formatToolUse(ev.name, ev.input)}` });
|
|
1514
|
-
else if (ev.kind === 'tool_result')
|
|
1515
|
-
setRow({
|
|
1516
|
-
activity: rows[idx].activity ? `${rows[idx].activity}${ev.isError ? ' ✗' : ' ✓'}` : rows[idx].activity,
|
|
1517
|
-
});
|
|
1518
|
-
else if (ev.kind === 'thinking') setRow({ activity: '💭 thinking…' });
|
|
1519
|
-
};
|
|
1520
|
-
const runState: { error: Error | null } = { error: null };
|
|
1521
|
-
const run = delegate(pi, ctx, {
|
|
1522
|
-
harness: spec.harnessName,
|
|
1523
|
-
task: spec.task,
|
|
1524
|
-
mode,
|
|
1525
|
-
scope: spec.scope,
|
|
1526
|
-
model: spec.model,
|
|
1527
|
-
maxBudgetUsd: spec.budget,
|
|
1528
|
-
sessionId: spec.sessionId,
|
|
1529
|
-
pr: spec.pr,
|
|
1530
|
-
verify: spec.verify,
|
|
1531
|
-
signal: ac.signal,
|
|
1532
|
-
waitForSlot: true,
|
|
1533
|
-
onAcquired: () => setRow({ status: 'running', startedAt: Date.now() }),
|
|
1534
|
-
onStream: t => {
|
|
1535
|
-
liveTail = (liveTail + t).slice(-200);
|
|
1536
|
-
setRow({ activity: `✍ ${liveTail}` });
|
|
1537
|
-
},
|
|
1538
|
-
onActivity,
|
|
1539
|
-
}).catch((err: unknown) => {
|
|
1540
|
-
runState.error = err instanceof Error ? err : new Error(String(err));
|
|
1541
|
-
return null;
|
|
1542
|
-
});
|
|
1543
|
-
const result = await run;
|
|
1544
|
-
const failed = cancelledAll || !result;
|
|
1545
|
-
// On failure keep context on the row: the reason if we have one, else whatever the run was
|
|
1546
|
-
// last doing. Blanking it here would drop the only on-screen hint at *why* it failed.
|
|
1547
|
-
const reason = runState.error ? runState.error.message.split('\n')[0].slice(0, 60) : '';
|
|
1548
|
-
setRow({
|
|
1549
|
-
status: failed ? 'failed' : 'done',
|
|
1550
|
-
activity: failed ? reason || rows[idx].activity : '',
|
|
1551
|
-
costUsd: !failed && result ? result.result.totalCostUsd : null,
|
|
1552
|
-
});
|
|
1553
|
-
return {
|
|
1554
|
-
harnessName: spec.harnessName,
|
|
1555
|
-
result: failed ? null : result,
|
|
1556
|
-
error: runState.error,
|
|
1557
|
-
cancelled: cancelledAll,
|
|
1558
|
-
};
|
|
1559
|
-
};
|
|
1560
|
-
|
|
1561
|
-
const allSettled = Promise.all(specs.map((spec, idx) => runOne(spec, idx)));
|
|
1562
|
-
|
|
1563
|
-
let closeWindow: (() => void) | null = null;
|
|
1564
|
-
let outcomes: FanoutOutcome[];
|
|
1565
|
-
if (ctx.hasUI) {
|
|
1566
|
-
let overlayHandle: OverlayHandle | null = null;
|
|
1567
|
-
let resolveDismiss = (): void => {};
|
|
1568
|
-
const dismissed = new Promise<void>(resolve => {
|
|
1569
|
-
resolveDismiss = () => resolve();
|
|
1570
|
-
});
|
|
1571
|
-
const uiPromise = ctx.ui
|
|
1572
|
-
.custom(
|
|
1573
|
-
(tui, theme, _kb, done) => {
|
|
1574
|
-
requestRender = () => tui.requestRender();
|
|
1575
|
-
closeWindow = () => done(undefined);
|
|
1576
|
-
return multiProgressWindow(tui, theme, {
|
|
1577
|
-
mode: modeForDisplay,
|
|
1578
|
-
startedAt: overallStart,
|
|
1579
|
-
getRows: () => rows,
|
|
1580
|
-
dangerous: anyDanger,
|
|
1581
|
-
onCancel: () => {
|
|
1582
|
-
cancelledAll = true;
|
|
1583
|
-
ac.abort();
|
|
1584
|
-
},
|
|
1585
|
-
onMinimize: () => {
|
|
1586
|
-
overlayHandle?.setHidden(true);
|
|
1587
|
-
overlayHandle?.unfocus();
|
|
1588
|
-
},
|
|
1589
|
-
onDismiss: () => resolveDismiss(),
|
|
1590
|
-
});
|
|
1591
|
-
},
|
|
1592
|
-
{
|
|
1593
|
-
overlay: true,
|
|
1594
|
-
overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
|
|
1595
|
-
onHandle: h => {
|
|
1596
|
-
overlayHandle = h;
|
|
1597
|
-
activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
1598
|
-
h.focus();
|
|
1599
|
-
},
|
|
1600
|
-
},
|
|
1601
|
-
)
|
|
1602
|
-
.catch(() => {});
|
|
1603
|
-
outcomes = await allSettled;
|
|
1604
|
-
// Cancelling already means "I'm done watching" — skip the linger so the overlay closes
|
|
1605
|
-
// right away instead of sitting on a cancelled board for FANOUT_LINGER_MS.
|
|
1606
|
-
if (cancelledAll) resolveDismiss();
|
|
1607
|
-
// Tear the overlay down after a short linger (or immediately on Esc/m/cancel) — in the
|
|
1608
|
-
// background, so this doesn't delay the outcomes we're about to return (and thus the
|
|
1609
|
-
// injected report). `activeOverlay` stays valid for `/delegate watch` until this settles.
|
|
1610
|
-
void (async () => {
|
|
1611
|
-
const timer = setTimeout(() => resolveDismiss(), FANOUT_LINGER_MS);
|
|
1612
|
-
timer.unref?.();
|
|
1613
|
-
await dismissed;
|
|
1614
|
-
clearTimeout(timer);
|
|
1615
|
-
await closeWhenMounted(() => closeWindow, 2000);
|
|
1616
|
-
await uiPromise;
|
|
1617
|
-
clearActive();
|
|
1618
|
-
ctx.ui.setStatus('delegate', undefined);
|
|
1619
|
-
})();
|
|
1620
|
-
} else {
|
|
1621
|
-
outcomes = await allSettled;
|
|
1622
|
-
clearActive();
|
|
1623
|
-
}
|
|
1624
|
-
return outcomes;
|
|
1625
|
-
};
|
|
1626
|
-
|
|
1627
|
-
/** `/delegate all …` / `/delegate a,b …` — resolve to detected harnesses, run `delegate()`
|
|
1628
|
-
* concurrently across all of them in one multi-run overlay (see `runFanoutConcurrent`), batch
|
|
1629
|
-
* success notifications, and inject one synthesized comparison report ordered by the resolved
|
|
1630
|
-
* harness list regardless of completion order. */
|
|
1631
|
-
const runFanoutCommand = async (ctx: ExtensionContext, parsed: ReturnType<typeof parseDelegateCommand>) => {
|
|
1632
|
-
const harnessSpec = parsed.harness as string;
|
|
1633
|
-
const detection = await detectAll();
|
|
1634
|
-
const { resolved, unknown, skipped } = resolveHarnessList(harnessSpec, {
|
|
1635
|
-
knownHarnesses: HARNESS_NAMES,
|
|
1636
|
-
aliasOf: resolveHarnessName,
|
|
1637
|
-
isKnown: isKnownHarness,
|
|
1638
|
-
detection,
|
|
1639
|
-
});
|
|
1640
|
-
if (resolved.length === 0) {
|
|
1641
|
-
const msg = `no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`;
|
|
1642
|
-
if (ctx.hasUI) ctx.ui.notify(msg, 'error');
|
|
1643
|
-
else process.stderr.write(`${msg}\n`);
|
|
1644
|
-
return;
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
const modeForReport = parsed.mode ?? loadConfig().defaultMode;
|
|
1648
|
-
const batcher = new NotifyBatcher((text, level) => {
|
|
1649
|
-
if (ctx.hasUI) ctx.ui.notify(text, level);
|
|
1650
|
-
else process.stdout.write(`${text}\n`);
|
|
1651
|
-
});
|
|
1652
|
-
|
|
1653
|
-
// Resolve each harness's task/scope/danger flag up front — cheap and synchronous — so a
|
|
1654
|
-
// harness that can't even start (e.g. mode needs a prompt) fails immediately instead of
|
|
1655
|
-
// occupying a concurrency slot.
|
|
1656
|
-
const specs: FanoutSpec[] = [];
|
|
1657
|
-
const immediateFailures: FanoutRunSummary[] = [];
|
|
1658
|
-
const trusted = isProjectTrusted(ctx);
|
|
1659
|
-
for (const h of resolved) {
|
|
1660
|
-
const templates = loadTemplates(ctx.cwd, h, trusted);
|
|
1661
|
-
const resolvedTaskScope = resolveDefaults(parsed, templates);
|
|
1662
|
-
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1663
|
-
if (!resolvedTaskScope) {
|
|
1664
|
-
const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
|
|
1665
|
-
immediateFailures.push({ harness: h, ok: false, cost: null, error: message });
|
|
1666
|
-
batcher.failure(`${h}: ${message}`);
|
|
1667
|
-
continue;
|
|
1668
|
-
}
|
|
1669
|
-
const isDanger =
|
|
1670
|
-
template?.permission === 'danger' ||
|
|
1671
|
-
(template?.nativePermission
|
|
1672
|
-
? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
|
|
1673
|
-
: false);
|
|
1674
|
-
specs.push({
|
|
1675
|
-
harnessName: h,
|
|
1676
|
-
task: resolvedTaskScope.task,
|
|
1677
|
-
scope: resolvedTaskScope.scope,
|
|
1678
|
-
model: parsed.model,
|
|
1679
|
-
budget: parsed.budget,
|
|
1680
|
-
sessionId: parsed.sessionId,
|
|
1681
|
-
pr: parsed.pr,
|
|
1682
|
-
verify: parsed.verify,
|
|
1683
|
-
isDanger,
|
|
1684
|
-
});
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
const outcomes = specs.length > 0 ? await runFanoutConcurrent(ctx, parsed.mode, specs) : [];
|
|
1688
|
-
const completed: FanoutRunSummary[] = outcomes.map(outcome => {
|
|
1689
|
-
if (outcome.cancelled || !outcome.result) {
|
|
1690
|
-
const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
|
|
1691
|
-
batcher.failure(`${outcome.harnessName}: ${outcome.cancelled ? 'cancelled' : 'failed'} — ${message}`);
|
|
1692
|
-
return { harness: outcome.harnessName, ok: false, cost: null, error: message };
|
|
1693
|
-
}
|
|
1694
|
-
const { content, details, result, verify } = outcome.result;
|
|
1695
|
-
const summary = summarize(content);
|
|
1696
|
-
const metrics = formatMetrics({
|
|
1697
|
-
numTurns: result.numTurns,
|
|
1698
|
-
totalCostUsd: result.totalCostUsd,
|
|
1699
|
-
promptTokens: 0,
|
|
1700
|
-
contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
|
|
1701
|
-
durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
|
|
1702
|
-
});
|
|
1703
|
-
batcher.success(`${outcome.harnessName} ${parsed.mode ?? 'general'} — ${metrics}`);
|
|
1704
|
-
return {
|
|
1705
|
-
harness: outcome.harnessName,
|
|
1706
|
-
ok: !result.isError,
|
|
1707
|
-
metrics,
|
|
1708
|
-
cost: result.totalCostUsd,
|
|
1709
|
-
body: summary.text,
|
|
1710
|
-
file: (details.file as string) ?? undefined,
|
|
1711
|
-
sessionId: (details.sessionId as string) ?? undefined,
|
|
1712
|
-
verify,
|
|
1713
|
-
};
|
|
1714
|
-
});
|
|
1715
|
-
|
|
1716
|
-
const runs = orderFanoutResults(resolved, [...immediateFailures, ...completed]);
|
|
1717
|
-
const okCount = runs.filter(r => r.ok).length;
|
|
1718
|
-
const report = buildFanoutReport({ runs, skipped, unknown });
|
|
1719
|
-
injectReport(ctx, {
|
|
1720
|
-
harness: 'all',
|
|
1721
|
-
mode: modeForReport,
|
|
1722
|
-
metrics: `${okCount}/${runs.length} ok`,
|
|
1723
|
-
body: report,
|
|
1724
|
-
});
|
|
1725
|
-
batcher.flush();
|
|
1726
|
-
};
|
|
1727
|
-
|
|
1728
429
|
const makeHandler = (forcedHarness?: string) => async (args: string, ctx: ExtensionContext) => {
|
|
1729
430
|
const sub = args.trim();
|
|
1730
431
|
const subLower = sub.toLowerCase();
|
|
@@ -1758,9 +459,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1758
459
|
// extract --harness flag for list/history subcommands
|
|
1759
460
|
const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1];
|
|
1760
461
|
if (sub === 'watch' || sub === 'show') {
|
|
1761
|
-
if (activeOverlay) {
|
|
1762
|
-
activeOverlay.show();
|
|
1763
|
-
activeOverlay.focus();
|
|
462
|
+
if (ui.activeOverlay) {
|
|
463
|
+
ui.activeOverlay.show();
|
|
464
|
+
ui.activeOverlay.focus();
|
|
1764
465
|
} else {
|
|
1765
466
|
ctx.ui.notify?.('No active delegate run to show — start one with /delegate <harness> <mode> <prompt>', 'info');
|
|
1766
467
|
}
|
|
@@ -1809,23 +510,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
1809
510
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
1810
511
|
// if forcedHarness provided, it wins
|
|
1811
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
|
+
}
|
|
1812
526
|
|
|
1813
527
|
// fan-out: harness field is `all` or a comma list — resolve to detected harnesses and run
|
|
1814
528
|
// the engine once per harness instead of the single-harness flow below.
|
|
1815
529
|
if (parsed.harness && isFanoutSpec(parsed.harness)) {
|
|
1816
|
-
await runFanoutCommand(ctx, parsed);
|
|
530
|
+
await runFanoutCommand(pi, ui, ctx, parsed);
|
|
1817
531
|
return;
|
|
1818
532
|
}
|
|
1819
533
|
|
|
1820
534
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
1821
535
|
const templates = loadTemplates(ctx.cwd, harnessName, trusted);
|
|
1822
536
|
const resolved = resolveDefaults(parsed, templates);
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
|
|
1828
|
-
: 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);
|
|
1829
541
|
|
|
1830
542
|
if (!resolved) {
|
|
1831
543
|
if (parsed.mode)
|
|
@@ -1833,14 +545,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
1833
545
|
`/delegate ${parsed.mode} <what to do> — give a prompt for the "${parsed.mode}" mode`,
|
|
1834
546
|
'warning',
|
|
1835
547
|
);
|
|
1836
|
-
else
|
|
1837
|
-
ctx.ui.notify?.(
|
|
1838
|
-
'Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
|
|
1839
|
-
'warning',
|
|
1840
|
-
);
|
|
548
|
+
else ctx.ui.notify?.(`Usage: ${forcedHarness ? aliasUsage(forcedHarness) : delegateUsage()}`, 'warning');
|
|
1841
549
|
return;
|
|
1842
550
|
}
|
|
1843
551
|
|
|
552
|
+
// --allow-dangerous: honored for this invocation only, and only once a human confirms it in an
|
|
553
|
+
// interactive dialog — headless refuses (fail closed). Escalates a non-danger template too (the
|
|
554
|
+
// engine's existing allowDangerous semantics), so the same confirm applies either way.
|
|
555
|
+
let allowDangerous = false;
|
|
556
|
+
if (parsed.allowDangerous) {
|
|
557
|
+
try {
|
|
558
|
+
await confirmDangerousCommand(ctx, {
|
|
559
|
+
harnesses: [harnessName],
|
|
560
|
+
mode: parsed.mode ?? loadConfig().defaultMode,
|
|
561
|
+
task: resolved.task,
|
|
562
|
+
});
|
|
563
|
+
allowDangerous = true;
|
|
564
|
+
} catch (err) {
|
|
565
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
566
|
+
if (ctx.hasUI) ctx.ui.notify(msg, 'warning');
|
|
567
|
+
else process.stderr.write(`${msg}\n`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
1844
572
|
const outcome = await runOneDelegation(ctx, {
|
|
1845
573
|
harnessName,
|
|
1846
574
|
mode: parsed.mode,
|
|
@@ -1850,9 +578,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1850
578
|
budget: parsed.budget,
|
|
1851
579
|
sessionId: parsed.sessionId,
|
|
1852
580
|
pr: parsed.pr,
|
|
581
|
+
addDirs: parsed.addDirs,
|
|
1853
582
|
verify: parsed.verify,
|
|
1854
583
|
template,
|
|
1855
|
-
isDanger,
|
|
584
|
+
isDanger: isDanger || allowDangerous,
|
|
585
|
+
allowDangerous,
|
|
1856
586
|
});
|
|
1857
587
|
if (outcome.cancelled || !outcome.result) {
|
|
1858
588
|
const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
|
|
@@ -1869,25 +599,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1869
599
|
const file = (details.file as string) ?? null;
|
|
1870
600
|
const sessionId = (details.sessionId as string) ?? null;
|
|
1871
601
|
const resumeHint = sessionId ? ` · resume: /delegate --resume=${sessionId} <prompt>` : '';
|
|
1872
|
-
const
|
|
1873
|
-
| {
|
|
1874
|
-
inputTokens?: number;
|
|
1875
|
-
outputTokens?: number;
|
|
1876
|
-
cacheCreationInputTokens?: number;
|
|
1877
|
-
cacheReadInputTokens?: number;
|
|
1878
|
-
}
|
|
1879
|
-
| undefined;
|
|
1880
|
-
const promptTokens = usage
|
|
1881
|
-
? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
|
|
1882
|
-
: 0;
|
|
1883
|
-
const metrics = formatMetrics({
|
|
1884
|
-
numTurns: typeof details.numTurns === 'number' ? details.numTurns : null,
|
|
1885
|
-
totalCostUsd: typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null,
|
|
1886
|
-
promptTokens,
|
|
1887
|
-
contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
|
|
1888
|
-
durationMs:
|
|
1889
|
-
typeof details.durationMs === 'number' && details.durationMs !== null ? (details.durationMs as number) : null,
|
|
1890
|
-
});
|
|
602
|
+
const metrics = runMetrics(details);
|
|
1891
603
|
injectReport(ctx, {
|
|
1892
604
|
harness: details.harness as string,
|
|
1893
605
|
mode: details.mode as string,
|
|
@@ -1904,34 +616,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
1904
616
|
};
|
|
1905
617
|
|
|
1906
618
|
pi.registerCommand('delegate', {
|
|
1907
|
-
description:
|
|
1908
|
-
'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] [--verify=<cmd>] [--resume=<id>] <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.',
|
|
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.`,
|
|
1909
620
|
handler: makeHandler(),
|
|
1910
621
|
});
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
});
|
|
1927
|
-
pi.registerCommand('omp', {
|
|
1928
|
-
description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>',
|
|
1929
|
-
handler: makeHandler('amp'),
|
|
1930
|
-
});
|
|
1931
|
-
pi.registerCommand('devin', {
|
|
1932
|
-
description: 'Alias for /delegate --harness=devin. Usage: /devin [--mode=...] <prompt>',
|
|
1933
|
-
handler: makeHandler('devin'),
|
|
1934
|
-
});
|
|
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
|
+
}
|
|
1935
637
|
|
|
1936
638
|
pi.on('input', async (event, _ctx) => {
|
|
1937
639
|
if (event.source === 'extension') return { action: 'continue' };
|
|
@@ -1941,9 +643,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1941
643
|
});
|
|
1942
644
|
|
|
1943
645
|
pi.on('before_agent_start', async () => {
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
pendingReport = null;
|
|
646
|
+
const report = takePendingReport();
|
|
647
|
+
if (!report) return;
|
|
1947
648
|
return { message: { customType: 'delegate', content: report.content, display: true, details: report.details } };
|
|
1948
649
|
});
|
|
1949
650
|
}
|