pi-harness-delegate 0.2.2 → 0.4.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 +47 -13
- package/extensions/activity.ts +223 -16
- package/extensions/command.ts +83 -6
- package/extensions/concurrency.ts +106 -0
- package/extensions/config.ts +1 -1
- package/extensions/harnesses/amp.ts +122 -99
- package/extensions/harnesses/claude.ts +8 -3
- package/extensions/harnesses/codex.ts +92 -45
- package/extensions/harnesses/opencode.ts +86 -74
- package/extensions/harnesses/types.ts +6 -4
- package/extensions/index.ts +797 -233
- package/extensions/notify.ts +50 -0
- package/extensions/progress-multi.ts +178 -0
- package/extensions/progress.ts +1 -1
- package/extensions/run-registry.ts +90 -0
- package/extensions/templates.ts +3 -0
- package/extensions/usage.ts +8 -4
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -30,26 +30,57 @@ import {
|
|
|
30
30
|
} from '@earendil-works/pi-tui';
|
|
31
31
|
import { Type } from 'typebox';
|
|
32
32
|
import {
|
|
33
|
+
aggregateSpend,
|
|
34
|
+
buildFanoutReport,
|
|
33
35
|
buildReportContent,
|
|
34
36
|
buildTranscript,
|
|
37
|
+
buildVerifyResult,
|
|
35
38
|
collectActivityLog,
|
|
39
|
+
type FanoutRunSummary,
|
|
36
40
|
formatMetrics,
|
|
41
|
+
formatSpend,
|
|
37
42
|
formatToolUse,
|
|
43
|
+
orderFanoutResults,
|
|
38
44
|
parseTranscriptMeta,
|
|
39
45
|
pruneOutputs,
|
|
46
|
+
resolveVerifyPlan,
|
|
40
47
|
safeSegmentName,
|
|
48
|
+
skipVerifyResult,
|
|
49
|
+
ToolCallIndex,
|
|
50
|
+
type VerifyResult,
|
|
41
51
|
} from './activity.ts';
|
|
42
|
-
import { parseDelegateCommand, resolveDefaults } from './command.ts';
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
52
|
+
import { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
|
|
53
|
+
import { acquireSlot, activeCount } from './concurrency.ts';
|
|
54
|
+
import {
|
|
55
|
+
type DelegateConfig,
|
|
56
|
+
outputsDir as getOutputsDir,
|
|
57
|
+
legacyOutputsDir,
|
|
58
|
+
loadConfig,
|
|
59
|
+
resolveModelForHarness,
|
|
60
|
+
} from './config.ts';
|
|
61
|
+
import {
|
|
62
|
+
ALIASES,
|
|
63
|
+
detectAll,
|
|
64
|
+
getHarness,
|
|
65
|
+
HARNESS_NAMES,
|
|
66
|
+
isKnownHarness,
|
|
67
|
+
resolveHarnessName,
|
|
68
|
+
} from './harnesses/registry.ts';
|
|
45
69
|
import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
|
|
46
70
|
|
|
47
71
|
import { delegationHint, stripMarker } from './hint.ts';
|
|
72
|
+
import { NotifyBatcher } from './notify.ts';
|
|
48
73
|
import { type FeedEntry, progressWindow } from './progress.ts';
|
|
74
|
+
import { formatFanoutChip, multiProgressWindow, type RunRow } from './progress-multi.ts';
|
|
49
75
|
import { runHarness } from './runner.ts';
|
|
50
76
|
import { type DelegateTemplate, loadTemplates } from './templates.ts';
|
|
51
77
|
import { mapClaudeUsage } from './usage.ts';
|
|
52
78
|
|
|
79
|
+
/** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
|
|
80
|
+
function formatCost(cost: number | null): string {
|
|
81
|
+
return cost !== null ? `$${cost.toFixed(3)}` : '$—';
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
interface DelegateOptions {
|
|
54
85
|
harness?: string;
|
|
55
86
|
task: string;
|
|
@@ -60,21 +91,55 @@ interface DelegateOptions {
|
|
|
60
91
|
allowDangerous?: boolean;
|
|
61
92
|
sessionId?: string;
|
|
62
93
|
pr?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Host-run verification command override — takes precedence over the template's `verify`
|
|
96
|
+
* frontmatter. Internal engine option only, not exposed on the `delegate` tool's schema — see
|
|
97
|
+
* the trust-model note on `runVerify` below for why.
|
|
98
|
+
*/
|
|
99
|
+
verify?: string;
|
|
63
100
|
onStream?: (text: string) => void;
|
|
64
101
|
onActivity?: (ev: ActivityEvent) => void;
|
|
65
102
|
signal?: AbortSignal;
|
|
103
|
+
/** Queue for a concurrency slot instead of failing fast when at capacity — fan-out only, see
|
|
104
|
+
* `acquireSlot` in concurrency.ts. Single-harness runs leave this false (the default). */
|
|
105
|
+
waitForSlot?: boolean;
|
|
106
|
+
/** Called once this run has acquired its concurrency slot and is about to actually start —
|
|
107
|
+
* fan-out uses it to flip a row from "queued" to "running". */
|
|
108
|
+
onAcquired?: () => void;
|
|
66
109
|
}
|
|
67
110
|
|
|
68
|
-
|
|
69
|
-
|
|
111
|
+
/** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
|
|
112
|
+
const VERIFY_TIMEOUT_MS = 5 * 60_000;
|
|
70
113
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Run a verify command in-process on the host (never delegated to the harness). Report-only —
|
|
116
|
+
* callers must not let this flip a run's `isError`.
|
|
117
|
+
*
|
|
118
|
+
* Trust model: a verify command can only come from two places — on-disk template frontmatter
|
|
119
|
+
* (project-local templates are already behind `isTrusted()`) or a human typing `/delegate
|
|
120
|
+
* --verify=<cmd>` at the CLI. It is deliberately **not** a `delegate` tool parameter: a tool
|
|
121
|
+
* param is set by the model, whose context includes repo content and delegated-harness output —
|
|
122
|
+
* both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
|
|
123
|
+
* arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
|
|
124
|
+
* into `delegate({verify: "curl ... | sh"})`). A model that wants verification selects a
|
|
125
|
+
* template that declares one instead.
|
|
126
|
+
*
|
|
127
|
+
* `resolveVerifyPlan` additionally never lets a verify command run on a `readonly` permission —
|
|
128
|
+
* `readonly` guarantees no execution/modification, and a verify command riding along on one
|
|
129
|
+
* would silently break that guarantee (a permission-tier bypass), independent of how trusted its
|
|
130
|
+
* source is. See the matching Conventions entry in AGENTS.md.
|
|
131
|
+
*
|
|
132
|
+
* Runs via `sh -c` (not a fixed binary+argv) so compound commands like `bun test && bun run
|
|
133
|
+
* lint` work — safe only because of the source/permission restrictions above, not because the
|
|
134
|
+
* command itself is sanitized.
|
|
135
|
+
*/
|
|
136
|
+
async function runVerify(pi: ExtensionAPI, cwd: string, command: string): Promise<VerifyResult> {
|
|
137
|
+
try {
|
|
138
|
+
const res = await pi.exec('sh', ['-c', command], { cwd, timeout: VERIFY_TIMEOUT_MS });
|
|
139
|
+
return buildVerifyResult(command, res.code, `${res.stdout}${res.stderr}`);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return buildVerifyResult(command, 1, err instanceof Error ? err.message : String(err));
|
|
142
|
+
}
|
|
78
143
|
}
|
|
79
144
|
|
|
80
145
|
async function closeWhenMounted(getClose: () => (() => void) | null, capMs: number): Promise<void> {
|
|
@@ -160,7 +225,7 @@ interface HistoryEntry {
|
|
|
160
225
|
file: string;
|
|
161
226
|
mode: string;
|
|
162
227
|
harness: string;
|
|
163
|
-
cost: number;
|
|
228
|
+
cost: number | null;
|
|
164
229
|
sessionId: string | null;
|
|
165
230
|
mtime: number;
|
|
166
231
|
}
|
|
@@ -172,7 +237,7 @@ function readHistory(dir: string, harness: string): HistoryEntry[] {
|
|
|
172
237
|
.map(f => {
|
|
173
238
|
const file = join(dir, f);
|
|
174
239
|
let mode = 'delegate';
|
|
175
|
-
let cost =
|
|
240
|
+
let cost: number | null = null;
|
|
176
241
|
let sessionId: string | null = null;
|
|
177
242
|
try {
|
|
178
243
|
const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
|
|
@@ -205,11 +270,11 @@ function readAllHistory(): HistoryEntry[] {
|
|
|
205
270
|
}
|
|
206
271
|
// also legacy dir for migration display
|
|
207
272
|
try {
|
|
208
|
-
const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md'));
|
|
273
|
+
const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md') && !f.includes('-partial'));
|
|
209
274
|
for (const f of legacy) {
|
|
210
275
|
const file = join(legacyOutputsDir(), f);
|
|
211
276
|
let mode = 'delegate';
|
|
212
|
-
let cost =
|
|
277
|
+
let cost: number | null = null;
|
|
213
278
|
let sessionId: string | null = null;
|
|
214
279
|
try {
|
|
215
280
|
const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
|
|
@@ -289,13 +354,13 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
289
354
|
}
|
|
290
355
|
if (!ctx.hasUI) {
|
|
291
356
|
for (const e of entries)
|
|
292
|
-
process.stdout.write(`${e.harness} ${e.mode} ·
|
|
357
|
+
process.stdout.write(`${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${e.sessionId ?? '-'}\n`);
|
|
293
358
|
return;
|
|
294
359
|
}
|
|
295
360
|
const entry = await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
296
361
|
const items: SelectItem[] = entries.map(e => ({
|
|
297
362
|
value: e.file,
|
|
298
|
-
label: `${e.harness} ${e.mode} ·
|
|
363
|
+
label: `${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
|
|
299
364
|
description: e.sessionId ? `session ${e.sessionId.slice(0, 8)}…` : undefined,
|
|
300
365
|
}));
|
|
301
366
|
const list = new SelectList(items, Math.min(items.length, 10), {
|
|
@@ -349,16 +414,26 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
349
414
|
try {
|
|
350
415
|
templates = loadTemplates(ctx.cwd, h).size;
|
|
351
416
|
} catch {}
|
|
352
|
-
|
|
417
|
+
// cross-process count via the file registry, combined with the in-process counter as a fallback
|
|
418
|
+
const active = activeCount(h);
|
|
353
419
|
const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
|
|
354
420
|
lines.push(
|
|
355
421
|
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${active}${hint}`,
|
|
356
422
|
);
|
|
357
423
|
}
|
|
424
|
+
const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
425
|
+
const spend = aggregateSpend(historyEntries.map(e => ({ harness: e.harness, cost: e.cost })));
|
|
426
|
+
lines.push('');
|
|
427
|
+
lines.push('spend:');
|
|
428
|
+
for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
|
|
429
|
+
const s = spend.byHarness[h];
|
|
430
|
+
lines.push(` ${h}: ${s ? formatSpend(s) : '$0.000 over 0 run(s)'}`);
|
|
431
|
+
}
|
|
432
|
+
if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
|
|
358
433
|
if (!harnessFilter) {
|
|
359
434
|
lines.push('');
|
|
360
435
|
lines.push(
|
|
361
|
-
`global active: ${
|
|
436
|
+
`global active: ${activeCount()} · aliases: ${
|
|
362
437
|
Object.entries(ALIASES)
|
|
363
438
|
.map(([k, v]) => `${k}→${v}`)
|
|
364
439
|
.join(', ') || '—'
|
|
@@ -426,6 +501,7 @@ async function delegate(
|
|
|
426
501
|
details: Record<string, unknown>;
|
|
427
502
|
result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string };
|
|
428
503
|
activityLog: string[];
|
|
504
|
+
verify?: VerifyResult;
|
|
429
505
|
}> {
|
|
430
506
|
const config = loadConfig();
|
|
431
507
|
const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
|
|
@@ -444,28 +520,16 @@ async function delegate(
|
|
|
444
520
|
const task = opts.task || template.defaultTask;
|
|
445
521
|
if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
|
|
446
522
|
|
|
447
|
-
// concurrency guard
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if (typeof v === 'number') return v;
|
|
458
|
-
}
|
|
459
|
-
return maxGlobal;
|
|
460
|
-
})();
|
|
461
|
-
if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit)
|
|
462
|
-
throw new Error(`another ${harnessName} run is already in progress`);
|
|
463
|
-
activeRuns.set(harnessName, perHarnessCount + 1);
|
|
464
|
-
globalActiveRuns++;
|
|
465
|
-
const release = () => {
|
|
466
|
-
activeRuns.set(harnessName, (activeRuns.get(harnessName) ?? 1) - 1);
|
|
467
|
-
globalActiveRuns = Math.max(0, globalActiveRuns - 1);
|
|
468
|
-
};
|
|
523
|
+
// concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
|
|
524
|
+
// exactly as before; fan-out passes waitForSlot:true to queue instead.
|
|
525
|
+
const release = await acquireSlot({
|
|
526
|
+
harness: harnessName,
|
|
527
|
+
mode,
|
|
528
|
+
config,
|
|
529
|
+
wait: opts.waitForSlot ?? false,
|
|
530
|
+
signal: opts.signal,
|
|
531
|
+
});
|
|
532
|
+
opts.onAcquired?.();
|
|
469
533
|
|
|
470
534
|
let scopeText: string | null = opts.scope ?? null;
|
|
471
535
|
if (opts.scope === 'diff') {
|
|
@@ -544,8 +608,8 @@ async function delegate(
|
|
|
544
608
|
cwd: ctx.cwd,
|
|
545
609
|
sessionId: null,
|
|
546
610
|
resumed: Boolean(opts.sessionId),
|
|
547
|
-
numTurns:
|
|
548
|
-
totalCostUsd:
|
|
611
|
+
numTurns: null,
|
|
612
|
+
totalCostUsd: null,
|
|
549
613
|
isError: true,
|
|
550
614
|
stopReason: null,
|
|
551
615
|
durationMs: null,
|
|
@@ -575,6 +639,16 @@ async function delegate(
|
|
|
575
639
|
const contextPercent =
|
|
576
640
|
promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
|
|
577
641
|
|
|
642
|
+
// Host-run post-hoc verification — report-only evidence, never flips `result.isError`. Never
|
|
643
|
+
// actually executes on a readonly permission (permission-tier bypass) — recorded as skipped
|
|
644
|
+
// instead of silently dropped. See the trust-model note on runVerify().
|
|
645
|
+
const verifyPlan = resolveVerifyPlan(opts.verify, template.verify, permission);
|
|
646
|
+
const verify = verifyPlan
|
|
647
|
+
? verifyPlan.skip
|
|
648
|
+
? skipVerifyResult(verifyPlan.command, 'readonly run')
|
|
649
|
+
: await runVerify(pi, ctx.cwd, verifyPlan.command)
|
|
650
|
+
: undefined;
|
|
651
|
+
|
|
578
652
|
const file = saveOutput(
|
|
579
653
|
harnessName,
|
|
580
654
|
mode,
|
|
@@ -597,6 +671,7 @@ async function delegate(
|
|
|
597
671
|
contextWindow: result.contextWindow,
|
|
598
672
|
activityLog: collectActivityLog(activityEvents),
|
|
599
673
|
output: result.result || result.streamedText,
|
|
674
|
+
verify,
|
|
600
675
|
}),
|
|
601
676
|
);
|
|
602
677
|
pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
|
|
@@ -626,9 +701,11 @@ async function delegate(
|
|
|
626
701
|
contextPercent,
|
|
627
702
|
promptTokens,
|
|
628
703
|
usage: result.usage,
|
|
704
|
+
verify,
|
|
629
705
|
},
|
|
630
706
|
result,
|
|
631
707
|
activityLog: collectActivityLog(activityEvents),
|
|
708
|
+
verify,
|
|
632
709
|
};
|
|
633
710
|
}
|
|
634
711
|
|
|
@@ -644,7 +721,15 @@ interface PendingReport {
|
|
|
644
721
|
let pendingReport: PendingReport | null = null;
|
|
645
722
|
function injectReport(
|
|
646
723
|
_ctx: ExtensionContext,
|
|
647
|
-
opts: {
|
|
724
|
+
opts: {
|
|
725
|
+
harness: string;
|
|
726
|
+
mode: string;
|
|
727
|
+
metrics: string;
|
|
728
|
+
body: string;
|
|
729
|
+
file?: string;
|
|
730
|
+
sessionId?: string;
|
|
731
|
+
verify?: VerifyResult;
|
|
732
|
+
},
|
|
648
733
|
): void {
|
|
649
734
|
pendingReport = {
|
|
650
735
|
content: buildReportContent({
|
|
@@ -654,6 +739,7 @@ function injectReport(
|
|
|
654
739
|
body: opts.body,
|
|
655
740
|
file: opts.file,
|
|
656
741
|
sessionId: opts.sessionId,
|
|
742
|
+
verify: opts.verify,
|
|
657
743
|
}),
|
|
658
744
|
details: {
|
|
659
745
|
harness: opts.harness,
|
|
@@ -665,6 +751,203 @@ function injectReport(
|
|
|
665
751
|
};
|
|
666
752
|
}
|
|
667
753
|
|
|
754
|
+
interface ToolProgressUpdate {
|
|
755
|
+
content: { type: string; text: string }[];
|
|
756
|
+
details: { progress: number };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* `delegate` tool params. Deliberately has no `verify` field — a tool param is model-controlled,
|
|
761
|
+
* and the model's context (repo content, delegated-harness output) is attacker-influenceable, so
|
|
762
|
+
* a model-settable verify command would be a prompt-injection -> arbitrary-host-command path.
|
|
763
|
+
* Verify only comes from on-disk template frontmatter or a human-typed `/delegate --verify=`.
|
|
764
|
+
*/
|
|
765
|
+
interface DelegateToolParams {
|
|
766
|
+
harness?: string;
|
|
767
|
+
task: string;
|
|
768
|
+
mode?: string;
|
|
769
|
+
scope?: string;
|
|
770
|
+
model?: string;
|
|
771
|
+
maxBudgetUsd?: number;
|
|
772
|
+
allowDangerous?: boolean;
|
|
773
|
+
sessionId?: string;
|
|
774
|
+
pr?: string;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** One `delegate()` call with the tool's live-feed progress reporting (`onUpdate`). Shared by the
|
|
778
|
+
* single-harness tool path and the fan-out loop — `labelPrefix` tags fan-out feed lines by harness. */
|
|
779
|
+
async function runDelegateForTool(
|
|
780
|
+
pi: ExtensionAPI,
|
|
781
|
+
ctx: ExtensionContext,
|
|
782
|
+
config: DelegateConfig,
|
|
783
|
+
callOpts: DelegateOptions,
|
|
784
|
+
signal: AbortSignal | undefined,
|
|
785
|
+
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
786
|
+
labelPrefix: string,
|
|
787
|
+
): Promise<Awaited<ReturnType<typeof delegate>>> {
|
|
788
|
+
const feed: string[] = [];
|
|
789
|
+
const feedIndex = new ToolCallIndex();
|
|
790
|
+
let liveTail = '';
|
|
791
|
+
let thinkingChars = 0;
|
|
792
|
+
let lastPushAt = 0;
|
|
793
|
+
const THROTTLE_MS = 250;
|
|
794
|
+
const pushFeed = () => {
|
|
795
|
+
const now = Date.now();
|
|
796
|
+
if (now - lastPushAt < THROTTLE_MS) return;
|
|
797
|
+
lastPushAt = now;
|
|
798
|
+
const lines: string[] = [...feed.slice(-6)];
|
|
799
|
+
if (thinkingChars > 0)
|
|
800
|
+
lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
|
|
801
|
+
if (liveTail) lines.push(`✍ ${liveTail}`);
|
|
802
|
+
if (lines.length === 0) return;
|
|
803
|
+
onUpdate?.({
|
|
804
|
+
content: [{ type: 'text', text: lines.map(l => `${labelPrefix}${l}`).join('\n') }],
|
|
805
|
+
details: { progress: 0.5 },
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
return delegate(pi, ctx, {
|
|
809
|
+
...callOpts,
|
|
810
|
+
signal,
|
|
811
|
+
onStream: t => {
|
|
812
|
+
liveTail = (liveTail + t).slice(-400);
|
|
813
|
+
pushFeed();
|
|
814
|
+
},
|
|
815
|
+
onActivity: ev => {
|
|
816
|
+
if (ev.kind === 'tool_input') {
|
|
817
|
+
feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
|
|
818
|
+
feedIndex.set(ev.id, feed.length - 1);
|
|
819
|
+
if (feed.length > 40) {
|
|
820
|
+
const removed = feed.length - 40;
|
|
821
|
+
feed.splice(0, removed);
|
|
822
|
+
feedIndex.shift(removed);
|
|
823
|
+
}
|
|
824
|
+
} else if (ev.kind === 'tool_result') {
|
|
825
|
+
const idx = feedIndex.resolve(ev.id, feed.length - 1);
|
|
826
|
+
if (idx >= 0 && feed[idx]?.startsWith('▶')) feed[idx] += ev.isError ? ' ✗' : ' ✓';
|
|
827
|
+
} else if (ev.kind === 'thinking') thinkingChars += ev.chars;
|
|
828
|
+
pushFeed();
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** `delegate({harness:"all"|"a,b"})` — resolve the requested harnesses to detected installs, run the
|
|
834
|
+
* existing `delegate()` engine concurrently across all of them (bounded by `maxConcurrent` via
|
|
835
|
+
* `acquireSlot({wait:true})` — see concurrency.ts), and mechanically synthesize one comparison
|
|
836
|
+
* report ordered by the resolved harness list regardless of completion order. No second model call. */
|
|
837
|
+
async function runFanoutTool(
|
|
838
|
+
pi: ExtensionAPI,
|
|
839
|
+
ctx: ExtensionContext,
|
|
840
|
+
config: DelegateConfig,
|
|
841
|
+
params: DelegateToolParams,
|
|
842
|
+
signal: AbortSignal | undefined,
|
|
843
|
+
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
844
|
+
): Promise<{ content: { type: string; text: string }[]; details: Record<string, unknown>; usage?: unknown }> {
|
|
845
|
+
const detection = await detectAll();
|
|
846
|
+
const { resolved, unknown, skipped } = resolveHarnessList(params.harness ?? 'all', {
|
|
847
|
+
knownHarnesses: HARNESS_NAMES,
|
|
848
|
+
aliasOf: resolveHarnessName,
|
|
849
|
+
isKnown: isKnownHarness,
|
|
850
|
+
detection,
|
|
851
|
+
});
|
|
852
|
+
if (resolved.length === 0) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const mode = params.mode ?? config.defaultMode;
|
|
859
|
+
|
|
860
|
+
type TaskResult = FanoutRunSummary & {
|
|
861
|
+
usage?: import('./harnesses/types.ts').StreamedUsage | null;
|
|
862
|
+
};
|
|
863
|
+
const tasks = resolved.map(async (h): Promise<TaskResult> => {
|
|
864
|
+
onUpdate?.({ content: [{ type: 'text', text: `[${h}] queued…` }], details: { progress: 0.5 } });
|
|
865
|
+
try {
|
|
866
|
+
const run = await runDelegateForTool(
|
|
867
|
+
pi,
|
|
868
|
+
ctx,
|
|
869
|
+
config,
|
|
870
|
+
{
|
|
871
|
+
harness: h,
|
|
872
|
+
task: params.task,
|
|
873
|
+
mode: params.mode,
|
|
874
|
+
scope: params.scope,
|
|
875
|
+
model: params.model,
|
|
876
|
+
maxBudgetUsd: params.maxBudgetUsd,
|
|
877
|
+
allowDangerous: params.allowDangerous === true,
|
|
878
|
+
sessionId: params.sessionId,
|
|
879
|
+
pr: params.pr,
|
|
880
|
+
// no verify: intentionally not model-settable — see DelegateToolParams
|
|
881
|
+
waitForSlot: true,
|
|
882
|
+
onAcquired: () =>
|
|
883
|
+
onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } }),
|
|
884
|
+
},
|
|
885
|
+
signal,
|
|
886
|
+
onUpdate,
|
|
887
|
+
`[${h}] `,
|
|
888
|
+
);
|
|
889
|
+
const summary = summarize(run.content);
|
|
890
|
+
return {
|
|
891
|
+
harness: h,
|
|
892
|
+
ok: !run.result.isError,
|
|
893
|
+
metrics: formatMetrics({
|
|
894
|
+
numTurns: run.result.numTurns,
|
|
895
|
+
totalCostUsd: run.result.totalCostUsd,
|
|
896
|
+
promptTokens: 0,
|
|
897
|
+
contextPercent: typeof run.details.contextPercent === 'number' ? run.details.contextPercent : null,
|
|
898
|
+
durationMs: run.result.durationMs,
|
|
899
|
+
}),
|
|
900
|
+
cost: run.result.totalCostUsd,
|
|
901
|
+
body: summary.text,
|
|
902
|
+
file: (run.details.file as string) ?? undefined,
|
|
903
|
+
sessionId: (run.details.sessionId as string) ?? undefined,
|
|
904
|
+
verify: run.verify,
|
|
905
|
+
usage: run.result.usage,
|
|
906
|
+
};
|
|
907
|
+
} catch (err) {
|
|
908
|
+
return { harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) };
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const settled = await Promise.all(tasks);
|
|
913
|
+
const runs = orderFanoutResults(resolved, settled);
|
|
914
|
+
|
|
915
|
+
let sumInput = 0;
|
|
916
|
+
let sumOutput = 0;
|
|
917
|
+
let sumCacheCreate = 0;
|
|
918
|
+
let sumCacheRead = 0;
|
|
919
|
+
let sumCost = 0;
|
|
920
|
+
let anyCostKnown = false;
|
|
921
|
+
for (const r of runs) {
|
|
922
|
+
if (r.usage) {
|
|
923
|
+
sumInput += r.usage.inputTokens;
|
|
924
|
+
sumOutput += r.usage.outputTokens;
|
|
925
|
+
sumCacheCreate += r.usage.cacheCreationInputTokens;
|
|
926
|
+
sumCacheRead += r.usage.cacheReadInputTokens;
|
|
927
|
+
}
|
|
928
|
+
if (r.cost !== null) {
|
|
929
|
+
sumCost += r.cost;
|
|
930
|
+
anyCostKnown = true;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const report = buildFanoutReport({ runs, skipped, unknown });
|
|
935
|
+
const okCount = runs.filter(r => r.ok).length;
|
|
936
|
+
const head = `## delegate all — ${mode} (${okCount}/${runs.length} ok)`;
|
|
937
|
+
const usage = mapClaudeUsage({
|
|
938
|
+
inputTokens: sumInput,
|
|
939
|
+
outputTokens: sumOutput,
|
|
940
|
+
cacheCreationInputTokens: sumCacheCreate,
|
|
941
|
+
cacheReadInputTokens: sumCacheRead,
|
|
942
|
+
totalCostUsd: anyCostKnown ? sumCost : null,
|
|
943
|
+
});
|
|
944
|
+
return {
|
|
945
|
+
content: [{ type: 'text', text: `${head}\n\n${report}` }],
|
|
946
|
+
details: { fanout: true, harness: 'all', mode, harnesses: resolved, skipped, unknown, runs },
|
|
947
|
+
usage,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
668
951
|
export default function (pi: ExtensionAPI) {
|
|
669
952
|
let activeRunId = 0;
|
|
670
953
|
let activeOverlay: { show(): void; focus(): void; runId: number } | null = null;
|
|
@@ -674,12 +957,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
674
957
|
name: 'delegate',
|
|
675
958
|
label: 'Delegate',
|
|
676
959
|
description:
|
|
677
|
-
'Delegate a task to any harness (claude, codex, opencode, amp) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude). mode selects a template: review, plan, implement, security-audit, docs, general, or custom. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
|
|
960
|
+
'Delegate a task to any harness (claude, codex, opencode, amp) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude) — pass "all" or a comma list (e.g. "claude,codex") to fan out the same task to several harnesses and get back one comparison report. mode selects a template: review, plan, implement, security-audit, docs, general, or custom — some templates run a host-side check (e.g. "bun test") after the harness exits and report pass/fail as separate evidence; that is configured on the template, not a parameter here. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
|
|
678
961
|
promptSnippet: 'Delegate a subtask to a harness and return its report',
|
|
679
962
|
promptGuidelines: [
|
|
680
963
|
'delegate runs a harness headless in the working directory and returns a streamed report with cost, token usage, and a session id for follow-ups.',
|
|
681
964
|
'Pass harness (claude|codex|opencode|amp) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
|
|
682
|
-
'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work.',
|
|
965
|
+
'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.',
|
|
966
|
+
'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.',
|
|
683
967
|
'sessionId resumes a previous delegated session instead of starting fresh.',
|
|
684
968
|
'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission).',
|
|
685
969
|
],
|
|
@@ -687,7 +971,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
687
971
|
harness: Type.Optional(
|
|
688
972
|
Type.String({
|
|
689
973
|
description:
|
|
690
|
-
'Harness to use: claude, codex, opencode, amp (aliases: omp). Defaults to config defaultHarness.',
|
|
974
|
+
'Harness to use: claude, codex, opencode, amp (aliases: omp). "all" or a comma list (e.g. "claude,codex") fans out to each detected harness. Defaults to config defaultHarness.',
|
|
691
975
|
}),
|
|
692
976
|
),
|
|
693
977
|
task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
|
|
@@ -718,72 +1002,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
718
1002
|
}),
|
|
719
1003
|
),
|
|
720
1004
|
pr: Type.Optional(Type.String({ description: 'GitHub PR number/URL (alternative to scope pr).' })),
|
|
1005
|
+
// Deliberately no `verify` param — see the trust-model comment on DelegateToolParams/runVerify.
|
|
721
1006
|
}),
|
|
722
1007
|
async execute(
|
|
723
1008
|
_toolCallId: string,
|
|
724
|
-
params:
|
|
725
|
-
harness?: string;
|
|
726
|
-
task: string;
|
|
727
|
-
mode?: string;
|
|
728
|
-
scope?: string;
|
|
729
|
-
model?: string;
|
|
730
|
-
maxBudgetUsd?: number;
|
|
731
|
-
allowDangerous?: boolean;
|
|
732
|
-
sessionId?: string;
|
|
733
|
-
pr?: string;
|
|
734
|
-
},
|
|
1009
|
+
params: DelegateToolParams,
|
|
735
1010
|
signal: AbortSignal | undefined,
|
|
736
|
-
onUpdate: ((u:
|
|
1011
|
+
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
737
1012
|
ctx: ExtensionContext,
|
|
738
1013
|
) {
|
|
739
1014
|
const config = loadConfig();
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
task: params.task,
|
|
759
|
-
mode: params.mode,
|
|
760
|
-
scope: params.scope,
|
|
761
|
-
model: params.model,
|
|
762
|
-
maxBudgetUsd: params.maxBudgetUsd,
|
|
763
|
-
allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
|
|
764
|
-
sessionId: params.sessionId,
|
|
765
|
-
pr: params.pr,
|
|
766
|
-
signal,
|
|
767
|
-
onStream: text => {
|
|
768
|
-
liveTail = (liveTail + text).slice(-400);
|
|
769
|
-
pushFeed();
|
|
770
|
-
},
|
|
771
|
-
onActivity: ev => {
|
|
772
|
-
if (ev.kind === 'tool_input') {
|
|
773
|
-
feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
|
|
774
|
-
if (feed.length > 40) feed.splice(0, feed.length - 40);
|
|
775
|
-
} else if (ev.kind === 'tool_result') {
|
|
776
|
-
const last = feed.length - 1;
|
|
777
|
-
if (last >= 0 && feed[last].startsWith('▶')) feed[last] += ev.isError ? ' ✗' : ' ✓';
|
|
778
|
-
} else if (ev.kind === 'thinking') thinkingChars += ev.chars;
|
|
779
|
-
pushFeed();
|
|
1015
|
+
if (params.harness && isFanoutSpec(params.harness)) {
|
|
1016
|
+
return runFanoutTool(pi, ctx, config, params, signal, onUpdate);
|
|
1017
|
+
}
|
|
1018
|
+
const { content, details, result } = await runDelegateForTool(
|
|
1019
|
+
pi,
|
|
1020
|
+
ctx,
|
|
1021
|
+
config,
|
|
1022
|
+
{
|
|
1023
|
+
harness: params.harness,
|
|
1024
|
+
task: params.task,
|
|
1025
|
+
mode: params.mode,
|
|
1026
|
+
scope: params.scope,
|
|
1027
|
+
model: params.model,
|
|
1028
|
+
maxBudgetUsd: params.maxBudgetUsd,
|
|
1029
|
+
allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
|
|
1030
|
+
sessionId: params.sessionId,
|
|
1031
|
+
pr: params.pr,
|
|
1032
|
+
// no verify: intentionally not model-settable — see DelegateToolParams
|
|
780
1033
|
},
|
|
781
|
-
|
|
1034
|
+
signal,
|
|
1035
|
+
onUpdate,
|
|
1036
|
+
'',
|
|
1037
|
+
);
|
|
782
1038
|
const summary = summarize(content);
|
|
783
1039
|
const resumed = details.resumed ? ' · resumed' : '';
|
|
784
1040
|
const head = result.isError
|
|
785
1041
|
? `⚠ ${details.harness} reported an error`
|
|
786
|
-
: `${details.harness} ${details.mode} (${result.numTurns} turn(s),
|
|
1042
|
+
: `${details.harness} ${details.mode} (${result.numTurns ?? '—'} turn(s), ${formatCost(result.totalCostUsd)})${resumed}`;
|
|
787
1043
|
const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
|
|
788
1044
|
const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
|
|
789
1045
|
(details as Record<string, unknown>).markdown = summary.text;
|
|
@@ -818,8 +1074,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
818
1074
|
const details = (result.details ?? {}) as Record<string, unknown>;
|
|
819
1075
|
const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
|
|
820
1076
|
const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
|
|
821
|
-
const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd :
|
|
822
|
-
const turns = typeof details.numTurns === 'number' ? details.numTurns :
|
|
1077
|
+
const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null;
|
|
1078
|
+
const turns = typeof details.numTurns === 'number' ? details.numTurns : null;
|
|
823
1079
|
const isError = details.isError === true;
|
|
824
1080
|
const resumed = details.resumed === true;
|
|
825
1081
|
const file = typeof details.file === 'string' ? details.file : null;
|
|
@@ -828,8 +1084,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
828
1084
|
container.addChild(
|
|
829
1085
|
new Text(
|
|
830
1086
|
theme.fg(isError ? 'error' : 'accent', `${harness} ${mode}`) +
|
|
831
|
-
theme.fg('dim', ` · ${turns} turn(s) · `) +
|
|
832
|
-
theme.fg('warning',
|
|
1087
|
+
theme.fg('dim', ` · ${turns ?? '—'} turn(s) · `) +
|
|
1088
|
+
theme.fg('warning', formatCost(cost)) +
|
|
833
1089
|
(resumed ? theme.fg('dim', ' · resumed') : ''),
|
|
834
1090
|
1,
|
|
835
1091
|
1,
|
|
@@ -868,17 +1124,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
868
1124
|
parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
|
|
869
1125
|
async execute(
|
|
870
1126
|
toolCallId: string,
|
|
871
|
-
params:
|
|
872
|
-
harness?: string;
|
|
873
|
-
task: string;
|
|
874
|
-
mode?: string;
|
|
875
|
-
scope?: string;
|
|
876
|
-
model?: string;
|
|
877
|
-
maxBudgetUsd?: number;
|
|
878
|
-
allowDangerous?: boolean;
|
|
879
|
-
sessionId?: string;
|
|
880
|
-
pr?: string;
|
|
881
|
-
},
|
|
1127
|
+
params: DelegateToolParams,
|
|
882
1128
|
signal: AbortSignal | undefined,
|
|
883
1129
|
onUpdate: never,
|
|
884
1130
|
ctx: ExtensionContext,
|
|
@@ -902,6 +1148,413 @@ export default function (pi: ExtensionAPI) {
|
|
|
902
1148
|
} as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: alias tool matches overload
|
|
903
1149
|
|
|
904
1150
|
// ── Commands ─────────────────────────────────────────────────────────────
|
|
1151
|
+
|
|
1152
|
+
/** One `delegate()` call with the command's progress-window UI (spinner, cancel, minimize).
|
|
1153
|
+
* Shared by the single-harness `/delegate` path and the fan-out loop, one call per harness. */
|
|
1154
|
+
const runOneDelegation = async (
|
|
1155
|
+
ctx: ExtensionContext,
|
|
1156
|
+
opts: {
|
|
1157
|
+
harnessName: string;
|
|
1158
|
+
mode?: string;
|
|
1159
|
+
task: string;
|
|
1160
|
+
scope?: string;
|
|
1161
|
+
model?: string;
|
|
1162
|
+
budget?: number;
|
|
1163
|
+
sessionId?: string;
|
|
1164
|
+
pr?: string;
|
|
1165
|
+
verify?: string;
|
|
1166
|
+
template?: DelegateTemplate;
|
|
1167
|
+
isDanger: boolean;
|
|
1168
|
+
},
|
|
1169
|
+
): Promise<{
|
|
1170
|
+
result: Awaited<ReturnType<typeof delegate>> | null;
|
|
1171
|
+
error: Error | null;
|
|
1172
|
+
cancelled: boolean;
|
|
1173
|
+
}> => {
|
|
1174
|
+
const { harnessName, mode, task, scope, model, budget, sessionId, pr, verify, template, isDanger } = opts;
|
|
1175
|
+
const modeForDisplay = mode ?? 'general';
|
|
1176
|
+
|
|
1177
|
+
const feed: FeedEntry[] = [];
|
|
1178
|
+
const feedIndex = new ToolCallIndex();
|
|
1179
|
+
let thinkingChars = 0;
|
|
1180
|
+
let liveTail = '';
|
|
1181
|
+
let requestRender: (() => void) | null = null;
|
|
1182
|
+
const getEntries = (): FeedEntry[] => {
|
|
1183
|
+
const entries = [...feed.slice(-12)];
|
|
1184
|
+
if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
|
|
1185
|
+
if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
|
|
1186
|
+
return entries;
|
|
1187
|
+
};
|
|
1188
|
+
let chipActivity = '';
|
|
1189
|
+
let chipActivityId: string | undefined;
|
|
1190
|
+
let chipLastPush = 0;
|
|
1191
|
+
const pushChip = () => {
|
|
1192
|
+
if (!ctx.hasUI) return;
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
if (now - chipLastPush < 500) return;
|
|
1195
|
+
chipLastPush = now;
|
|
1196
|
+
const theme = ctx.ui.theme;
|
|
1197
|
+
const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
|
|
1198
|
+
ctx.ui.setStatus(
|
|
1199
|
+
'delegate',
|
|
1200
|
+
theme.fg('accent', '●') + theme.fg('dim', ` ${harnessName} ${modeForDisplay}`) + activity,
|
|
1201
|
+
);
|
|
1202
|
+
};
|
|
1203
|
+
const onActivity = (ev: ActivityEvent) => {
|
|
1204
|
+
if (ev.kind === 'tool_input') {
|
|
1205
|
+
chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
|
|
1206
|
+
chipActivityId = ev.id;
|
|
1207
|
+
feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input), id: ev.id });
|
|
1208
|
+
feedIndex.set(ev.id, feed.length - 1);
|
|
1209
|
+
if (feed.length > 40) {
|
|
1210
|
+
const removed = feed.length - 40;
|
|
1211
|
+
feed.splice(0, removed);
|
|
1212
|
+
feedIndex.shift(removed);
|
|
1213
|
+
}
|
|
1214
|
+
} else if (ev.kind === 'tool_result') {
|
|
1215
|
+
// only stamp the chip when the result belongs to the tool it's currently showing
|
|
1216
|
+
if (chipActivity.startsWith('▶') && (ev.id === undefined || ev.id === chipActivityId))
|
|
1217
|
+
chipActivity += ev.isError ? ' ✗' : ' ✓';
|
|
1218
|
+
const idx = feedIndex.resolve(ev.id, feed.length - 1);
|
|
1219
|
+
if (idx >= 0 && feed[idx]?.kind === 'tool') feed[idx] = { ...feed[idx], ok: !ev.isError };
|
|
1220
|
+
} else if (ev.kind === 'thinking') {
|
|
1221
|
+
chipActivity = '💭 thinking…';
|
|
1222
|
+
chipActivityId = undefined;
|
|
1223
|
+
thinkingChars += ev.chars;
|
|
1224
|
+
}
|
|
1225
|
+
pushChip();
|
|
1226
|
+
requestRender?.();
|
|
1227
|
+
};
|
|
1228
|
+
const ac = new AbortController();
|
|
1229
|
+
let cancelled = false;
|
|
1230
|
+
const runState: { error: Error | null } = { error: null };
|
|
1231
|
+
const runId = ++activeRunId;
|
|
1232
|
+
const clearActive = () => {
|
|
1233
|
+
if (activeOverlay?.runId === runId) activeOverlay = null;
|
|
1234
|
+
};
|
|
1235
|
+
const run = delegate(pi, ctx, {
|
|
1236
|
+
harness: harnessName,
|
|
1237
|
+
task,
|
|
1238
|
+
mode,
|
|
1239
|
+
scope,
|
|
1240
|
+
model,
|
|
1241
|
+
maxBudgetUsd: budget,
|
|
1242
|
+
sessionId,
|
|
1243
|
+
pr,
|
|
1244
|
+
verify,
|
|
1245
|
+
signal: ac.signal,
|
|
1246
|
+
onStream: t => {
|
|
1247
|
+
liveTail = (liveTail + t).slice(-400);
|
|
1248
|
+
requestRender?.();
|
|
1249
|
+
},
|
|
1250
|
+
onActivity,
|
|
1251
|
+
}).catch((err: unknown) => {
|
|
1252
|
+
runState.error = err instanceof Error ? err : new Error(String(err));
|
|
1253
|
+
return null;
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
let closeWindow: (() => void) | null = null;
|
|
1257
|
+
let result: Awaited<ReturnType<typeof delegate>> | null = null;
|
|
1258
|
+
if (ctx.hasUI) {
|
|
1259
|
+
let overlayHandle: OverlayHandle | null = null;
|
|
1260
|
+
const uiPromise = ctx.ui
|
|
1261
|
+
.custom(
|
|
1262
|
+
(tui, theme, _kb, done) => {
|
|
1263
|
+
requestRender = () => tui.requestRender();
|
|
1264
|
+
closeWindow = () => done(undefined);
|
|
1265
|
+
return progressWindow(tui, theme, {
|
|
1266
|
+
mode: `${harnessName} ${modeForDisplay}`,
|
|
1267
|
+
model: model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
|
|
1268
|
+
startedAt: Date.now(),
|
|
1269
|
+
getEntries,
|
|
1270
|
+
dangerous: isDanger,
|
|
1271
|
+
onCancel: () => {
|
|
1272
|
+
cancelled = true;
|
|
1273
|
+
ac.abort();
|
|
1274
|
+
},
|
|
1275
|
+
onMinimize: () => {
|
|
1276
|
+
overlayHandle?.setHidden(true);
|
|
1277
|
+
overlayHandle?.unfocus();
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
overlay: true,
|
|
1283
|
+
overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
|
|
1284
|
+
onHandle: h => {
|
|
1285
|
+
overlayHandle = h;
|
|
1286
|
+
activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
1287
|
+
h.focus();
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
)
|
|
1291
|
+
.catch(() => {});
|
|
1292
|
+
result = await run;
|
|
1293
|
+
await closeWhenMounted(() => closeWindow, 2000);
|
|
1294
|
+
await uiPromise;
|
|
1295
|
+
} else {
|
|
1296
|
+
result = await run;
|
|
1297
|
+
}
|
|
1298
|
+
clearActive();
|
|
1299
|
+
if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
|
|
1300
|
+
const failed = cancelled || !result;
|
|
1301
|
+
return { result: failed ? null : result, error: runState.error, cancelled };
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
interface FanoutSpec {
|
|
1305
|
+
harnessName: string;
|
|
1306
|
+
task: string;
|
|
1307
|
+
scope?: string;
|
|
1308
|
+
model?: string;
|
|
1309
|
+
budget?: number;
|
|
1310
|
+
sessionId?: string;
|
|
1311
|
+
pr?: string;
|
|
1312
|
+
verify?: string;
|
|
1313
|
+
isDanger: boolean;
|
|
1314
|
+
}
|
|
1315
|
+
interface FanoutOutcome {
|
|
1316
|
+
harnessName: string;
|
|
1317
|
+
result: Awaited<ReturnType<typeof delegate>> | null;
|
|
1318
|
+
error: Error | null;
|
|
1319
|
+
cancelled: boolean;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Run `delegate()` concurrently across every spec in one multi-run overlay — the fan-out
|
|
1323
|
+
* counterpart to `runOneDelegation`. Concurrency is bounded by `maxConcurrent`: every run passes
|
|
1324
|
+
* `waitForSlot:true`, so `acquireSlot` (concurrency.ts) queues the ones that don't fit instead of
|
|
1325
|
+
* failing them, and a fan-out never exceeds the configured cap just because it's a fan-out.
|
|
1326
|
+
* Double-ESC cancel aborts every in-flight (and still-queued) run via one shared AbortController. */
|
|
1327
|
+
const runFanoutConcurrent = async (ctx: ExtensionContext, mode: string | undefined, specs: FanoutSpec[]) => {
|
|
1328
|
+
const ac = new AbortController();
|
|
1329
|
+
let cancelledAll = false;
|
|
1330
|
+
const runId = ++activeRunId;
|
|
1331
|
+
const clearActive = () => {
|
|
1332
|
+
if (activeOverlay?.runId === runId) activeOverlay = null;
|
|
1333
|
+
};
|
|
1334
|
+
const modeForDisplay = mode ?? 'general';
|
|
1335
|
+
const anyDanger = specs.some(s => s.isDanger);
|
|
1336
|
+
const overallStart = Date.now();
|
|
1337
|
+
const rows: RunRow[] = specs.map(s => ({
|
|
1338
|
+
harness: s.harnessName,
|
|
1339
|
+
startedAt: null,
|
|
1340
|
+
status: 'queued',
|
|
1341
|
+
activity: '',
|
|
1342
|
+
}));
|
|
1343
|
+
let requestRender: (() => void) | null = null;
|
|
1344
|
+
|
|
1345
|
+
let chipLastPush = 0;
|
|
1346
|
+
const pushChip = () => {
|
|
1347
|
+
if (!ctx.hasUI) return;
|
|
1348
|
+
const now = Date.now();
|
|
1349
|
+
if (now - chipLastPush < 500) return;
|
|
1350
|
+
chipLastPush = now;
|
|
1351
|
+
const theme = ctx.ui.theme;
|
|
1352
|
+
ctx.ui.setStatus('delegate', theme.fg('accent', '●') + theme.fg('dim', ` ${formatFanoutChip(rows)}`));
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
const runOne = async (spec: FanoutSpec, idx: number): Promise<FanoutOutcome> => {
|
|
1356
|
+
const setRow = (patch: Partial<RunRow>) => {
|
|
1357
|
+
rows[idx] = { ...rows[idx], ...patch };
|
|
1358
|
+
requestRender?.();
|
|
1359
|
+
pushChip();
|
|
1360
|
+
};
|
|
1361
|
+
let liveTail = '';
|
|
1362
|
+
const onActivity = (ev: ActivityEvent) => {
|
|
1363
|
+
if (ev.kind === 'tool_input') setRow({ activity: `▶ ${formatToolUse(ev.name, ev.input)}` });
|
|
1364
|
+
else if (ev.kind === 'tool_result')
|
|
1365
|
+
setRow({
|
|
1366
|
+
activity: rows[idx].activity ? `${rows[idx].activity}${ev.isError ? ' ✗' : ' ✓'}` : rows[idx].activity,
|
|
1367
|
+
});
|
|
1368
|
+
else if (ev.kind === 'thinking') setRow({ activity: '💭 thinking…' });
|
|
1369
|
+
};
|
|
1370
|
+
const runState: { error: Error | null } = { error: null };
|
|
1371
|
+
const run = delegate(pi, ctx, {
|
|
1372
|
+
harness: spec.harnessName,
|
|
1373
|
+
task: spec.task,
|
|
1374
|
+
mode,
|
|
1375
|
+
scope: spec.scope,
|
|
1376
|
+
model: spec.model,
|
|
1377
|
+
maxBudgetUsd: spec.budget,
|
|
1378
|
+
sessionId: spec.sessionId,
|
|
1379
|
+
pr: spec.pr,
|
|
1380
|
+
verify: spec.verify,
|
|
1381
|
+
signal: ac.signal,
|
|
1382
|
+
waitForSlot: true,
|
|
1383
|
+
onAcquired: () => setRow({ status: 'running', startedAt: Date.now() }),
|
|
1384
|
+
onStream: t => {
|
|
1385
|
+
liveTail = (liveTail + t).slice(-200);
|
|
1386
|
+
setRow({ activity: `✍ ${liveTail}` });
|
|
1387
|
+
},
|
|
1388
|
+
onActivity,
|
|
1389
|
+
}).catch((err: unknown) => {
|
|
1390
|
+
runState.error = err instanceof Error ? err : new Error(String(err));
|
|
1391
|
+
return null;
|
|
1392
|
+
});
|
|
1393
|
+
const result = await run;
|
|
1394
|
+
const failed = cancelledAll || !result;
|
|
1395
|
+
// On failure keep context on the row: the reason if we have one, else whatever the run was
|
|
1396
|
+
// last doing. Blanking it here would drop the only on-screen hint at *why* it failed.
|
|
1397
|
+
const reason = runState.error ? runState.error.message.split('\n')[0].slice(0, 60) : '';
|
|
1398
|
+
setRow({
|
|
1399
|
+
status: failed ? 'failed' : 'done',
|
|
1400
|
+
activity: failed ? reason || rows[idx].activity : '',
|
|
1401
|
+
});
|
|
1402
|
+
return {
|
|
1403
|
+
harnessName: spec.harnessName,
|
|
1404
|
+
result: failed ? null : result,
|
|
1405
|
+
error: runState.error,
|
|
1406
|
+
cancelled: cancelledAll,
|
|
1407
|
+
};
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
const allSettled = Promise.all(specs.map((spec, idx) => runOne(spec, idx)));
|
|
1411
|
+
|
|
1412
|
+
let closeWindow: (() => void) | null = null;
|
|
1413
|
+
let outcomes: FanoutOutcome[];
|
|
1414
|
+
if (ctx.hasUI) {
|
|
1415
|
+
let overlayHandle: OverlayHandle | null = null;
|
|
1416
|
+
const uiPromise = ctx.ui
|
|
1417
|
+
.custom(
|
|
1418
|
+
(tui, theme, _kb, done) => {
|
|
1419
|
+
requestRender = () => tui.requestRender();
|
|
1420
|
+
closeWindow = () => done(undefined);
|
|
1421
|
+
return multiProgressWindow(tui, theme, {
|
|
1422
|
+
mode: modeForDisplay,
|
|
1423
|
+
startedAt: overallStart,
|
|
1424
|
+
getRows: () => rows,
|
|
1425
|
+
dangerous: anyDanger,
|
|
1426
|
+
onCancel: () => {
|
|
1427
|
+
cancelledAll = true;
|
|
1428
|
+
ac.abort();
|
|
1429
|
+
},
|
|
1430
|
+
onMinimize: () => {
|
|
1431
|
+
overlayHandle?.setHidden(true);
|
|
1432
|
+
overlayHandle?.unfocus();
|
|
1433
|
+
},
|
|
1434
|
+
});
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
overlay: true,
|
|
1438
|
+
overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
|
|
1439
|
+
onHandle: h => {
|
|
1440
|
+
overlayHandle = h;
|
|
1441
|
+
activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
1442
|
+
h.focus();
|
|
1443
|
+
},
|
|
1444
|
+
},
|
|
1445
|
+
)
|
|
1446
|
+
.catch(() => {});
|
|
1447
|
+
outcomes = await allSettled;
|
|
1448
|
+
await closeWhenMounted(() => closeWindow, 2000);
|
|
1449
|
+
await uiPromise;
|
|
1450
|
+
} else {
|
|
1451
|
+
outcomes = await allSettled;
|
|
1452
|
+
}
|
|
1453
|
+
clearActive();
|
|
1454
|
+
if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
|
|
1455
|
+
return outcomes;
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
/** `/delegate all …` / `/delegate a,b …` — resolve to detected harnesses, run `delegate()`
|
|
1459
|
+
* concurrently across all of them in one multi-run overlay (see `runFanoutConcurrent`), batch
|
|
1460
|
+
* success notifications, and inject one synthesized comparison report ordered by the resolved
|
|
1461
|
+
* harness list regardless of completion order. */
|
|
1462
|
+
const runFanoutCommand = async (ctx: ExtensionContext, parsed: ReturnType<typeof parseDelegateCommand>) => {
|
|
1463
|
+
const harnessSpec = parsed.harness as string;
|
|
1464
|
+
const detection = await detectAll();
|
|
1465
|
+
const { resolved, unknown, skipped } = resolveHarnessList(harnessSpec, {
|
|
1466
|
+
knownHarnesses: HARNESS_NAMES,
|
|
1467
|
+
aliasOf: resolveHarnessName,
|
|
1468
|
+
isKnown: isKnownHarness,
|
|
1469
|
+
detection,
|
|
1470
|
+
});
|
|
1471
|
+
if (resolved.length === 0) {
|
|
1472
|
+
const msg = `no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`;
|
|
1473
|
+
if (ctx.hasUI) ctx.ui.notify(msg, 'error');
|
|
1474
|
+
else process.stderr.write(`${msg}\n`);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const modeForReport = parsed.mode ?? loadConfig().defaultMode;
|
|
1479
|
+
const batcher = new NotifyBatcher((text, level) => {
|
|
1480
|
+
if (ctx.hasUI) ctx.ui.notify(text, level);
|
|
1481
|
+
else process.stdout.write(`${text}\n`);
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
// Resolve each harness's task/scope/danger flag up front — cheap and synchronous — so a
|
|
1485
|
+
// harness that can't even start (e.g. mode needs a prompt) fails immediately instead of
|
|
1486
|
+
// occupying a concurrency slot.
|
|
1487
|
+
const specs: FanoutSpec[] = [];
|
|
1488
|
+
const immediateFailures: FanoutRunSummary[] = [];
|
|
1489
|
+
for (const h of resolved) {
|
|
1490
|
+
const templates = loadTemplates(ctx.cwd, h);
|
|
1491
|
+
const resolvedTaskScope = resolveDefaults(parsed, templates);
|
|
1492
|
+
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1493
|
+
if (!resolvedTaskScope) {
|
|
1494
|
+
const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
|
|
1495
|
+
immediateFailures.push({ harness: h, ok: false, cost: null, error: message });
|
|
1496
|
+
batcher.failure(`${h}: ${message}`);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
const isDanger =
|
|
1500
|
+
template?.permission === 'danger' ||
|
|
1501
|
+
(template?.nativePermission
|
|
1502
|
+
? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
|
|
1503
|
+
: false);
|
|
1504
|
+
specs.push({
|
|
1505
|
+
harnessName: h,
|
|
1506
|
+
task: resolvedTaskScope.task,
|
|
1507
|
+
scope: resolvedTaskScope.scope,
|
|
1508
|
+
model: parsed.model,
|
|
1509
|
+
budget: parsed.budget,
|
|
1510
|
+
sessionId: parsed.sessionId,
|
|
1511
|
+
pr: parsed.pr,
|
|
1512
|
+
verify: parsed.verify,
|
|
1513
|
+
isDanger,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const outcomes = specs.length > 0 ? await runFanoutConcurrent(ctx, parsed.mode, specs) : [];
|
|
1518
|
+
const completed: FanoutRunSummary[] = outcomes.map(outcome => {
|
|
1519
|
+
if (outcome.cancelled || !outcome.result) {
|
|
1520
|
+
const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
|
|
1521
|
+
batcher.failure(`${outcome.harnessName}: ${outcome.cancelled ? 'cancelled' : 'failed'} — ${message}`);
|
|
1522
|
+
return { harness: outcome.harnessName, ok: false, cost: null, error: message };
|
|
1523
|
+
}
|
|
1524
|
+
const { content, details, result, verify } = outcome.result;
|
|
1525
|
+
const summary = summarize(content);
|
|
1526
|
+
const metrics = formatMetrics({
|
|
1527
|
+
numTurns: result.numTurns,
|
|
1528
|
+
totalCostUsd: result.totalCostUsd,
|
|
1529
|
+
promptTokens: 0,
|
|
1530
|
+
contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
|
|
1531
|
+
durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
|
|
1532
|
+
});
|
|
1533
|
+
batcher.success(`${outcome.harnessName} ${parsed.mode ?? 'general'} — ${metrics}`);
|
|
1534
|
+
return {
|
|
1535
|
+
harness: outcome.harnessName,
|
|
1536
|
+
ok: !result.isError,
|
|
1537
|
+
metrics,
|
|
1538
|
+
cost: result.totalCostUsd,
|
|
1539
|
+
body: summary.text,
|
|
1540
|
+
file: (details.file as string) ?? undefined,
|
|
1541
|
+
sessionId: (details.sessionId as string) ?? undefined,
|
|
1542
|
+
verify,
|
|
1543
|
+
};
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
const runs = orderFanoutResults(resolved, [...immediateFailures, ...completed]);
|
|
1547
|
+
const okCount = runs.filter(r => r.ok).length;
|
|
1548
|
+
const report = buildFanoutReport({ runs, skipped, unknown });
|
|
1549
|
+
injectReport(ctx, {
|
|
1550
|
+
harness: 'all',
|
|
1551
|
+
mode: modeForReport,
|
|
1552
|
+
metrics: `${okCount}/${runs.length} ok`,
|
|
1553
|
+
body: report,
|
|
1554
|
+
});
|
|
1555
|
+
batcher.flush();
|
|
1556
|
+
};
|
|
1557
|
+
|
|
905
1558
|
const makeHandler = (forcedHarness?: string) => async (args: string, ctx: ExtensionContext) => {
|
|
906
1559
|
const sub = args.trim();
|
|
907
1560
|
const subLower = sub.toLowerCase();
|
|
@@ -977,6 +1630,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
977
1630
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
978
1631
|
// if forcedHarness provided, it wins
|
|
979
1632
|
if (forcedHarness) parsed.harness = forcedHarness;
|
|
1633
|
+
|
|
1634
|
+
// fan-out: harness field is `all` or a comma list — resolve to detected harnesses and run
|
|
1635
|
+
// the engine once per harness instead of the single-harness flow below.
|
|
1636
|
+
if (parsed.harness && isFanoutSpec(parsed.harness)) {
|
|
1637
|
+
await runFanoutCommand(ctx, parsed);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
980
1641
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
981
1642
|
const templates = loadTemplates(ctx.cwd, harnessName);
|
|
982
1643
|
const resolved = resolveDefaults(parsed, templates);
|
|
@@ -995,134 +1656,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
995
1656
|
);
|
|
996
1657
|
else
|
|
997
1658
|
ctx.ui.notify?.(
|
|
998
|
-
'Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=…] [--model=…] [--scope=…] <prompt>',
|
|
1659
|
+
'Usage: /delegate [--harness=claude|codex|opencode|amp|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
|
|
999
1660
|
'warning',
|
|
1000
1661
|
);
|
|
1001
1662
|
return;
|
|
1002
1663
|
}
|
|
1003
|
-
const modeForDisplay = parsed.mode ?? 'general';
|
|
1004
|
-
const harnessForDisplay = harnessName;
|
|
1005
1664
|
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
let liveTail = '';
|
|
1009
|
-
let requestRender: (() => void) | null = null;
|
|
1010
|
-
const getEntries = (): FeedEntry[] => {
|
|
1011
|
-
const entries = [...feed.slice(-12)];
|
|
1012
|
-
if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
|
|
1013
|
-
if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
|
|
1014
|
-
return entries;
|
|
1015
|
-
};
|
|
1016
|
-
let chipActivity = '';
|
|
1017
|
-
let chipLastPush = 0;
|
|
1018
|
-
const pushChip = () => {
|
|
1019
|
-
if (!ctx.hasUI) return;
|
|
1020
|
-
const now = Date.now();
|
|
1021
|
-
if (now - chipLastPush < 500) return;
|
|
1022
|
-
chipLastPush = now;
|
|
1023
|
-
const theme = ctx.ui.theme;
|
|
1024
|
-
const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
|
|
1025
|
-
ctx.ui.setStatus(
|
|
1026
|
-
'delegate',
|
|
1027
|
-
theme.fg('accent', '●') + theme.fg('dim', ` ${harnessForDisplay} ${modeForDisplay}`) + activity,
|
|
1028
|
-
);
|
|
1029
|
-
};
|
|
1030
|
-
const onActivity = (ev: ActivityEvent) => {
|
|
1031
|
-
if (ev.kind === 'tool_input') {
|
|
1032
|
-
chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
|
|
1033
|
-
feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input) });
|
|
1034
|
-
if (feed.length > 40) feed.splice(0, feed.length - 40);
|
|
1035
|
-
} else if (ev.kind === 'tool_result') {
|
|
1036
|
-
if (chipActivity.startsWith('▶')) chipActivity += ev.isError ? ' ✗' : ' ✓';
|
|
1037
|
-
const last = feed.length - 1;
|
|
1038
|
-
if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: !ev.isError };
|
|
1039
|
-
} else if (ev.kind === 'thinking') {
|
|
1040
|
-
chipActivity = '💭 thinking…';
|
|
1041
|
-
thinkingChars += ev.chars;
|
|
1042
|
-
}
|
|
1043
|
-
pushChip();
|
|
1044
|
-
requestRender?.();
|
|
1045
|
-
};
|
|
1046
|
-
const ac = new AbortController();
|
|
1047
|
-
let cancelled = false;
|
|
1048
|
-
const runState: { error: Error | null } = { error: null };
|
|
1049
|
-
const runId = ++activeRunId;
|
|
1050
|
-
const clearActive = () => {
|
|
1051
|
-
if (activeOverlay?.runId === runId) activeOverlay = null;
|
|
1052
|
-
};
|
|
1053
|
-
const run = delegate(pi, ctx, {
|
|
1054
|
-
harness: harnessName,
|
|
1055
|
-
task: resolved.task,
|
|
1665
|
+
const outcome = await runOneDelegation(ctx, {
|
|
1666
|
+
harnessName,
|
|
1056
1667
|
mode: parsed.mode,
|
|
1668
|
+
task: resolved.task,
|
|
1057
1669
|
scope: resolved.scope,
|
|
1058
1670
|
model: parsed.model,
|
|
1059
|
-
|
|
1671
|
+
budget: parsed.budget,
|
|
1060
1672
|
sessionId: parsed.sessionId,
|
|
1061
1673
|
pr: parsed.pr,
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
requestRender?.();
|
|
1066
|
-
},
|
|
1067
|
-
onActivity,
|
|
1068
|
-
}).catch((err: unknown) => {
|
|
1069
|
-
runState.error = err instanceof Error ? err : new Error(String(err));
|
|
1070
|
-
return null;
|
|
1674
|
+
verify: parsed.verify,
|
|
1675
|
+
template,
|
|
1676
|
+
isDanger,
|
|
1071
1677
|
});
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
let result: Awaited<ReturnType<typeof delegate>> | null = null;
|
|
1075
|
-
if (ctx.hasUI) {
|
|
1076
|
-
let overlayHandle: OverlayHandle | null = null;
|
|
1077
|
-
const uiPromise = ctx.ui
|
|
1078
|
-
.custom(
|
|
1079
|
-
(tui, theme, _kb, done) => {
|
|
1080
|
-
requestRender = () => tui.requestRender();
|
|
1081
|
-
closeWindow = () => done(undefined);
|
|
1082
|
-
return progressWindow(tui, theme, {
|
|
1083
|
-
mode: `${harnessForDisplay} ${modeForDisplay}`,
|
|
1084
|
-
model:
|
|
1085
|
-
parsed.model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
|
|
1086
|
-
startedAt: Date.now(),
|
|
1087
|
-
getEntries,
|
|
1088
|
-
dangerous: isDanger,
|
|
1089
|
-
onCancel: () => {
|
|
1090
|
-
cancelled = true;
|
|
1091
|
-
ac.abort();
|
|
1092
|
-
},
|
|
1093
|
-
onMinimize: () => {
|
|
1094
|
-
overlayHandle?.setHidden(true);
|
|
1095
|
-
overlayHandle?.unfocus();
|
|
1096
|
-
},
|
|
1097
|
-
});
|
|
1098
|
-
},
|
|
1099
|
-
{
|
|
1100
|
-
overlay: true,
|
|
1101
|
-
overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
|
|
1102
|
-
onHandle: h => {
|
|
1103
|
-
overlayHandle = h;
|
|
1104
|
-
activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
1105
|
-
h.focus();
|
|
1106
|
-
},
|
|
1107
|
-
},
|
|
1108
|
-
)
|
|
1109
|
-
.catch(() => {});
|
|
1110
|
-
result = await run;
|
|
1111
|
-
await closeWhenMounted(() => closeWindow, 2000);
|
|
1112
|
-
await uiPromise;
|
|
1113
|
-
} else {
|
|
1114
|
-
result = await run;
|
|
1115
|
-
}
|
|
1116
|
-
clearActive();
|
|
1117
|
-
if (cancelled || !result) {
|
|
1118
|
-
if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
|
|
1119
|
-
const message = runState.error ? runState.error.message : cancelled ? 'cancelled' : 'delegation failed';
|
|
1678
|
+
if (outcome.cancelled || !outcome.result) {
|
|
1679
|
+
const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
|
|
1120
1680
|
if (ctx.hasUI)
|
|
1121
|
-
ctx.ui.notify(
|
|
1681
|
+
ctx.ui.notify(
|
|
1682
|
+
`delegate ${outcome.cancelled ? 'cancelled' : 'failed'}: ${message}`,
|
|
1683
|
+
outcome.cancelled ? 'warning' : 'error',
|
|
1684
|
+
);
|
|
1122
1685
|
else process.stderr.write(`${message}\n`);
|
|
1123
1686
|
return;
|
|
1124
1687
|
}
|
|
1125
|
-
const { content, details } = result;
|
|
1688
|
+
const { content, details, verify } = outcome.result;
|
|
1126
1689
|
const summary = summarize(content);
|
|
1127
1690
|
const file = (details.file as string) ?? null;
|
|
1128
1691
|
const sessionId = (details.sessionId as string) ?? null;
|
|
@@ -1139,8 +1702,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1139
1702
|
? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
|
|
1140
1703
|
: 0;
|
|
1141
1704
|
const metrics = formatMetrics({
|
|
1142
|
-
numTurns:
|
|
1143
|
-
totalCostUsd:
|
|
1705
|
+
numTurns: typeof details.numTurns === 'number' ? details.numTurns : null,
|
|
1706
|
+
totalCostUsd: typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null,
|
|
1144
1707
|
promptTokens,
|
|
1145
1708
|
contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
|
|
1146
1709
|
durationMs:
|
|
@@ -1153,6 +1716,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1153
1716
|
body: summary.text,
|
|
1154
1717
|
file: file ?? undefined,
|
|
1155
1718
|
sessionId: sessionId ?? undefined,
|
|
1719
|
+
verify,
|
|
1156
1720
|
});
|
|
1157
1721
|
if (ctx.hasUI) {
|
|
1158
1722
|
ctx.ui.setStatus('delegate', undefined);
|
|
@@ -1162,7 +1726,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1162
1726
|
|
|
1163
1727
|
pi.registerCommand('delegate', {
|
|
1164
1728
|
description:
|
|
1165
|
-
'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt
|
|
1729
|
+
'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp|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.',
|
|
1166
1730
|
handler: makeHandler(),
|
|
1167
1731
|
});
|
|
1168
1732
|
pi.registerCommand('claude', {
|