pi-harness-delegate 0.2.1 → 0.3.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 +35 -12
- package/extensions/activity.ts +212 -16
- package/extensions/command.ts +83 -6
- package/extensions/config.ts +12 -8
- package/extensions/harnesses/amp.ts +160 -42
- package/extensions/harnesses/claude.ts +8 -3
- package/extensions/harnesses/codex.ts +139 -77
- package/extensions/harnesses/opencode.ts +105 -45
- package/extensions/harnesses/types.ts +6 -4
- package/extensions/index.ts +622 -210
- package/extensions/notify.ts +50 -0
- package/extensions/progress.ts +1 -1
- package/extensions/run-claude.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,55 @@ 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,
|
|
38
43
|
parseTranscriptMeta,
|
|
39
44
|
pruneOutputs,
|
|
45
|
+
resolveVerifyPlan,
|
|
40
46
|
safeSegmentName,
|
|
47
|
+
skipVerifyResult,
|
|
48
|
+
ToolCallIndex,
|
|
49
|
+
type VerifyResult,
|
|
41
50
|
} from './activity.ts';
|
|
42
|
-
import { parseDelegateCommand, resolveDefaults } from './command.ts';
|
|
43
|
-
import {
|
|
44
|
-
|
|
51
|
+
import { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
|
|
52
|
+
import {
|
|
53
|
+
type DelegateConfig,
|
|
54
|
+
outputsDir as getOutputsDir,
|
|
55
|
+
legacyOutputsDir,
|
|
56
|
+
loadConfig,
|
|
57
|
+
resolveModelForHarness,
|
|
58
|
+
} from './config.ts';
|
|
59
|
+
import {
|
|
60
|
+
ALIASES,
|
|
61
|
+
detectAll,
|
|
62
|
+
getHarness,
|
|
63
|
+
HARNESS_NAMES,
|
|
64
|
+
isKnownHarness,
|
|
65
|
+
resolveHarnessName,
|
|
66
|
+
} from './harnesses/registry.ts';
|
|
45
67
|
import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
|
|
46
68
|
|
|
47
69
|
import { delegationHint, stripMarker } from './hint.ts';
|
|
70
|
+
import { NotifyBatcher } from './notify.ts';
|
|
48
71
|
import { type FeedEntry, progressWindow } from './progress.ts';
|
|
72
|
+
import { acquireRun, countActiveRuns, releaseRun } from './run-registry.ts';
|
|
49
73
|
import { runHarness } from './runner.ts';
|
|
50
74
|
import { type DelegateTemplate, loadTemplates } from './templates.ts';
|
|
51
75
|
import { mapClaudeUsage } from './usage.ts';
|
|
52
76
|
|
|
77
|
+
/** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
|
|
78
|
+
function formatCost(cost: number | null): string {
|
|
79
|
+
return cost !== null ? `$${cost.toFixed(3)}` : '$—';
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
interface DelegateOptions {
|
|
54
83
|
harness?: string;
|
|
55
84
|
task: string;
|
|
@@ -60,11 +89,51 @@ interface DelegateOptions {
|
|
|
60
89
|
allowDangerous?: boolean;
|
|
61
90
|
sessionId?: string;
|
|
62
91
|
pr?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Host-run verification command override — takes precedence over the template's `verify`
|
|
94
|
+
* frontmatter. Internal engine option only, not exposed on the `delegate` tool's schema — see
|
|
95
|
+
* the trust-model note on `runVerify` below for why.
|
|
96
|
+
*/
|
|
97
|
+
verify?: string;
|
|
63
98
|
onStream?: (text: string) => void;
|
|
64
99
|
onActivity?: (ev: ActivityEvent) => void;
|
|
65
100
|
signal?: AbortSignal;
|
|
66
101
|
}
|
|
67
102
|
|
|
103
|
+
/** Verify commands run on the host after the harness exits — bounded independent of harness timeoutMs. */
|
|
104
|
+
const VERIFY_TIMEOUT_MS = 5 * 60_000;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run a verify command in-process on the host (never delegated to the harness). Report-only —
|
|
108
|
+
* callers must not let this flip a run's `isError`.
|
|
109
|
+
*
|
|
110
|
+
* Trust model: a verify command can only come from two places — on-disk template frontmatter
|
|
111
|
+
* (project-local templates are already behind `isTrusted()`) or a human typing `/delegate
|
|
112
|
+
* --verify=<cmd>` at the CLI. It is deliberately **not** a `delegate` tool parameter: a tool
|
|
113
|
+
* param is set by the model, whose context includes repo content and delegated-harness output —
|
|
114
|
+
* both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
|
|
115
|
+
* arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
|
|
116
|
+
* into `delegate({verify: "curl ... | sh"})`). A model that wants verification selects a
|
|
117
|
+
* template that declares one instead.
|
|
118
|
+
*
|
|
119
|
+
* `resolveVerifyPlan` additionally never lets a verify command run on a `readonly` permission —
|
|
120
|
+
* `readonly` guarantees no execution/modification, and a verify command riding along on one
|
|
121
|
+
* would silently break that guarantee (a permission-tier bypass), independent of how trusted its
|
|
122
|
+
* source is. See the matching Conventions entry in AGENTS.md.
|
|
123
|
+
*
|
|
124
|
+
* Runs via `sh -c` (not a fixed binary+argv) so compound commands like `bun test && bun run
|
|
125
|
+
* lint` work — safe only because of the source/permission restrictions above, not because the
|
|
126
|
+
* command itself is sanitized.
|
|
127
|
+
*/
|
|
128
|
+
async function runVerify(pi: ExtensionAPI, cwd: string, command: string): Promise<VerifyResult> {
|
|
129
|
+
try {
|
|
130
|
+
const res = await pi.exec('sh', ['-c', command], { cwd, timeout: VERIFY_TIMEOUT_MS });
|
|
131
|
+
return buildVerifyResult(command, res.code, `${res.stdout}${res.stderr}`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return buildVerifyResult(command, 1, err instanceof Error ? err.message : String(err));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
68
137
|
const activeRuns = new Map<string, number>();
|
|
69
138
|
let globalActiveRuns = 0;
|
|
70
139
|
|
|
@@ -160,7 +229,7 @@ interface HistoryEntry {
|
|
|
160
229
|
file: string;
|
|
161
230
|
mode: string;
|
|
162
231
|
harness: string;
|
|
163
|
-
cost: number;
|
|
232
|
+
cost: number | null;
|
|
164
233
|
sessionId: string | null;
|
|
165
234
|
mtime: number;
|
|
166
235
|
}
|
|
@@ -172,7 +241,7 @@ function readHistory(dir: string, harness: string): HistoryEntry[] {
|
|
|
172
241
|
.map(f => {
|
|
173
242
|
const file = join(dir, f);
|
|
174
243
|
let mode = 'delegate';
|
|
175
|
-
let cost =
|
|
244
|
+
let cost: number | null = null;
|
|
176
245
|
let sessionId: string | null = null;
|
|
177
246
|
try {
|
|
178
247
|
const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
|
|
@@ -205,11 +274,11 @@ function readAllHistory(): HistoryEntry[] {
|
|
|
205
274
|
}
|
|
206
275
|
// also legacy dir for migration display
|
|
207
276
|
try {
|
|
208
|
-
const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md'));
|
|
277
|
+
const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md') && !f.includes('-partial'));
|
|
209
278
|
for (const f of legacy) {
|
|
210
279
|
const file = join(legacyOutputsDir(), f);
|
|
211
280
|
let mode = 'delegate';
|
|
212
|
-
let cost =
|
|
281
|
+
let cost: number | null = null;
|
|
213
282
|
let sessionId: string | null = null;
|
|
214
283
|
try {
|
|
215
284
|
const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
|
|
@@ -289,13 +358,13 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
289
358
|
}
|
|
290
359
|
if (!ctx.hasUI) {
|
|
291
360
|
for (const e of entries)
|
|
292
|
-
process.stdout.write(`${e.harness} ${e.mode} ·
|
|
361
|
+
process.stdout.write(`${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${e.sessionId ?? '-'}\n`);
|
|
293
362
|
return;
|
|
294
363
|
}
|
|
295
364
|
const entry = await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
296
365
|
const items: SelectItem[] = entries.map(e => ({
|
|
297
366
|
value: e.file,
|
|
298
|
-
label: `${e.harness} ${e.mode} ·
|
|
367
|
+
label: `${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
|
|
299
368
|
description: e.sessionId ? `session ${e.sessionId.slice(0, 8)}…` : undefined,
|
|
300
369
|
}));
|
|
301
370
|
const list = new SelectList(items, Math.min(items.length, 10), {
|
|
@@ -349,16 +418,26 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
349
418
|
try {
|
|
350
419
|
templates = loadTemplates(ctx.cwd, h).size;
|
|
351
420
|
} catch {}
|
|
352
|
-
|
|
421
|
+
// cross-process count via the file registry, combined with the in-process counter as a fallback
|
|
422
|
+
const active = Math.max(activeRuns.get(h) ?? 0, countActiveRuns(h));
|
|
353
423
|
const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
|
|
354
424
|
lines.push(
|
|
355
425
|
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${active}${hint}`,
|
|
356
426
|
);
|
|
357
427
|
}
|
|
428
|
+
const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
429
|
+
const spend = aggregateSpend(historyEntries.map(e => ({ harness: e.harness, cost: e.cost })));
|
|
430
|
+
lines.push('');
|
|
431
|
+
lines.push('spend:');
|
|
432
|
+
for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
|
|
433
|
+
const s = spend.byHarness[h];
|
|
434
|
+
lines.push(` ${h}: ${s ? formatSpend(s) : '$0.000 over 0 run(s)'}`);
|
|
435
|
+
}
|
|
436
|
+
if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
|
|
358
437
|
if (!harnessFilter) {
|
|
359
438
|
lines.push('');
|
|
360
439
|
lines.push(
|
|
361
|
-
`global active: ${globalActiveRuns} · aliases: ${
|
|
440
|
+
`global active: ${Math.max(globalActiveRuns, countActiveRuns())} · aliases: ${
|
|
362
441
|
Object.entries(ALIASES)
|
|
363
442
|
.map(([k, v]) => `${k}→${v}`)
|
|
364
443
|
.join(', ') || '—'
|
|
@@ -426,6 +505,7 @@ async function delegate(
|
|
|
426
505
|
details: Record<string, unknown>;
|
|
427
506
|
result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string };
|
|
428
507
|
activityLog: string[];
|
|
508
|
+
verify?: VerifyResult;
|
|
429
509
|
}> {
|
|
430
510
|
const config = loadConfig();
|
|
431
511
|
const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
|
|
@@ -444,26 +524,31 @@ async function delegate(
|
|
|
444
524
|
const task = opts.task || template.defaultTask;
|
|
445
525
|
if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
|
|
446
526
|
|
|
447
|
-
// concurrency guard
|
|
527
|
+
// concurrency guard — combines the file-based cross-process registry with the in-process
|
|
528
|
+
// counters as a fallback, so registry I/O failures never block a delegation.
|
|
448
529
|
const maxGlobal = getMaxConcurrentGlobal();
|
|
449
|
-
const perHarnessCount = activeRuns.get(harnessName) ?? 0;
|
|
450
|
-
|
|
530
|
+
const perHarnessCount = Math.max(activeRuns.get(harnessName) ?? 0, countActiveRuns(harnessName));
|
|
531
|
+
const globalCount = Math.max(globalActiveRuns, countActiveRuns());
|
|
532
|
+
if (maxGlobal > 0 && globalCount >= maxGlobal)
|
|
451
533
|
throw new Error('another delegate run is already in progress (global limit)');
|
|
452
534
|
// per-harness limit if configured as object
|
|
453
535
|
const perHarnessLimit = (() => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
return
|
|
536
|
+
const mc = config.maxConcurrent as unknown as { perHarness?: Record<string, number> };
|
|
537
|
+
if (mc && typeof mc === 'object' && mc.perHarness && typeof mc.perHarness[harnessName] === 'number') {
|
|
538
|
+
const v = mc.perHarness[harnessName];
|
|
539
|
+
if (typeof v === 'number') return v;
|
|
540
|
+
}
|
|
458
541
|
return maxGlobal;
|
|
459
542
|
})();
|
|
460
543
|
if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit)
|
|
461
544
|
throw new Error(`another ${harnessName} run is already in progress`);
|
|
462
545
|
activeRuns.set(harnessName, perHarnessCount + 1);
|
|
463
546
|
globalActiveRuns++;
|
|
547
|
+
const runHandle = acquireRun(harnessName, mode);
|
|
464
548
|
const release = () => {
|
|
465
|
-
activeRuns.set(harnessName, (activeRuns.get(harnessName) ?? 1) - 1);
|
|
549
|
+
activeRuns.set(harnessName, Math.max(0, (activeRuns.get(harnessName) ?? 1) - 1));
|
|
466
550
|
globalActiveRuns = Math.max(0, globalActiveRuns - 1);
|
|
551
|
+
releaseRun(runHandle);
|
|
467
552
|
};
|
|
468
553
|
|
|
469
554
|
let scopeText: string | null = opts.scope ?? null;
|
|
@@ -543,8 +628,8 @@ async function delegate(
|
|
|
543
628
|
cwd: ctx.cwd,
|
|
544
629
|
sessionId: null,
|
|
545
630
|
resumed: Boolean(opts.sessionId),
|
|
546
|
-
numTurns:
|
|
547
|
-
totalCostUsd:
|
|
631
|
+
numTurns: null,
|
|
632
|
+
totalCostUsd: null,
|
|
548
633
|
isError: true,
|
|
549
634
|
stopReason: null,
|
|
550
635
|
durationMs: null,
|
|
@@ -574,6 +659,16 @@ async function delegate(
|
|
|
574
659
|
const contextPercent =
|
|
575
660
|
promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
|
|
576
661
|
|
|
662
|
+
// Host-run post-hoc verification — report-only evidence, never flips `result.isError`. Never
|
|
663
|
+
// actually executes on a readonly permission (permission-tier bypass) — recorded as skipped
|
|
664
|
+
// instead of silently dropped. See the trust-model note on runVerify().
|
|
665
|
+
const verifyPlan = resolveVerifyPlan(opts.verify, template.verify, permission);
|
|
666
|
+
const verify = verifyPlan
|
|
667
|
+
? verifyPlan.skip
|
|
668
|
+
? skipVerifyResult(verifyPlan.command, 'readonly run')
|
|
669
|
+
: await runVerify(pi, ctx.cwd, verifyPlan.command)
|
|
670
|
+
: undefined;
|
|
671
|
+
|
|
577
672
|
const file = saveOutput(
|
|
578
673
|
harnessName,
|
|
579
674
|
mode,
|
|
@@ -596,6 +691,7 @@ async function delegate(
|
|
|
596
691
|
contextWindow: result.contextWindow,
|
|
597
692
|
activityLog: collectActivityLog(activityEvents),
|
|
598
693
|
output: result.result || result.streamedText,
|
|
694
|
+
verify,
|
|
599
695
|
}),
|
|
600
696
|
);
|
|
601
697
|
pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
|
|
@@ -625,9 +721,11 @@ async function delegate(
|
|
|
625
721
|
contextPercent,
|
|
626
722
|
promptTokens,
|
|
627
723
|
usage: result.usage,
|
|
724
|
+
verify,
|
|
628
725
|
},
|
|
629
726
|
result,
|
|
630
727
|
activityLog: collectActivityLog(activityEvents),
|
|
728
|
+
verify,
|
|
631
729
|
};
|
|
632
730
|
}
|
|
633
731
|
|
|
@@ -643,7 +741,15 @@ interface PendingReport {
|
|
|
643
741
|
let pendingReport: PendingReport | null = null;
|
|
644
742
|
function injectReport(
|
|
645
743
|
_ctx: ExtensionContext,
|
|
646
|
-
opts: {
|
|
744
|
+
opts: {
|
|
745
|
+
harness: string;
|
|
746
|
+
mode: string;
|
|
747
|
+
metrics: string;
|
|
748
|
+
body: string;
|
|
749
|
+
file?: string;
|
|
750
|
+
sessionId?: string;
|
|
751
|
+
verify?: VerifyResult;
|
|
752
|
+
},
|
|
647
753
|
): void {
|
|
648
754
|
pendingReport = {
|
|
649
755
|
content: buildReportContent({
|
|
@@ -653,6 +759,7 @@ function injectReport(
|
|
|
653
759
|
body: opts.body,
|
|
654
760
|
file: opts.file,
|
|
655
761
|
sessionId: opts.sessionId,
|
|
762
|
+
verify: opts.verify,
|
|
656
763
|
}),
|
|
657
764
|
details: {
|
|
658
765
|
harness: opts.harness,
|
|
@@ -664,6 +771,190 @@ function injectReport(
|
|
|
664
771
|
};
|
|
665
772
|
}
|
|
666
773
|
|
|
774
|
+
interface ToolProgressUpdate {
|
|
775
|
+
content: { type: string; text: string }[];
|
|
776
|
+
details: { progress: number };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* `delegate` tool params. Deliberately has no `verify` field — a tool param is model-controlled,
|
|
781
|
+
* and the model's context (repo content, delegated-harness output) is attacker-influenceable, so
|
|
782
|
+
* a model-settable verify command would be a prompt-injection -> arbitrary-host-command path.
|
|
783
|
+
* Verify only comes from on-disk template frontmatter or a human-typed `/delegate --verify=`.
|
|
784
|
+
*/
|
|
785
|
+
interface DelegateToolParams {
|
|
786
|
+
harness?: string;
|
|
787
|
+
task: string;
|
|
788
|
+
mode?: string;
|
|
789
|
+
scope?: string;
|
|
790
|
+
model?: string;
|
|
791
|
+
maxBudgetUsd?: number;
|
|
792
|
+
allowDangerous?: boolean;
|
|
793
|
+
sessionId?: string;
|
|
794
|
+
pr?: string;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** One `delegate()` call with the tool's live-feed progress reporting (`onUpdate`). Shared by the
|
|
798
|
+
* single-harness tool path and the fan-out loop — `labelPrefix` tags fan-out feed lines by harness. */
|
|
799
|
+
async function runDelegateForTool(
|
|
800
|
+
pi: ExtensionAPI,
|
|
801
|
+
ctx: ExtensionContext,
|
|
802
|
+
config: DelegateConfig,
|
|
803
|
+
callOpts: DelegateOptions,
|
|
804
|
+
signal: AbortSignal | undefined,
|
|
805
|
+
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
806
|
+
labelPrefix: string,
|
|
807
|
+
): Promise<Awaited<ReturnType<typeof delegate>>> {
|
|
808
|
+
const feed: string[] = [];
|
|
809
|
+
const feedIndex = new ToolCallIndex();
|
|
810
|
+
let liveTail = '';
|
|
811
|
+
let thinkingChars = 0;
|
|
812
|
+
let lastPushAt = 0;
|
|
813
|
+
const THROTTLE_MS = 250;
|
|
814
|
+
const pushFeed = () => {
|
|
815
|
+
const now = Date.now();
|
|
816
|
+
if (now - lastPushAt < THROTTLE_MS) return;
|
|
817
|
+
lastPushAt = now;
|
|
818
|
+
const lines: string[] = [...feed.slice(-6)];
|
|
819
|
+
if (thinkingChars > 0)
|
|
820
|
+
lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
|
|
821
|
+
if (liveTail) lines.push(`✍ ${liveTail}`);
|
|
822
|
+
if (lines.length === 0) return;
|
|
823
|
+
onUpdate?.({
|
|
824
|
+
content: [{ type: 'text', text: lines.map(l => `${labelPrefix}${l}`).join('\n') }],
|
|
825
|
+
details: { progress: 0.5 },
|
|
826
|
+
});
|
|
827
|
+
};
|
|
828
|
+
return delegate(pi, ctx, {
|
|
829
|
+
...callOpts,
|
|
830
|
+
signal,
|
|
831
|
+
onStream: t => {
|
|
832
|
+
liveTail = (liveTail + t).slice(-400);
|
|
833
|
+
pushFeed();
|
|
834
|
+
},
|
|
835
|
+
onActivity: ev => {
|
|
836
|
+
if (ev.kind === 'tool_input') {
|
|
837
|
+
feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
|
|
838
|
+
feedIndex.set(ev.id, feed.length - 1);
|
|
839
|
+
if (feed.length > 40) {
|
|
840
|
+
const removed = feed.length - 40;
|
|
841
|
+
feed.splice(0, removed);
|
|
842
|
+
feedIndex.shift(removed);
|
|
843
|
+
}
|
|
844
|
+
} else if (ev.kind === 'tool_result') {
|
|
845
|
+
const idx = feedIndex.resolve(ev.id, feed.length - 1);
|
|
846
|
+
if (idx >= 0 && feed[idx]?.startsWith('▶')) feed[idx] += ev.isError ? ' ✗' : ' ✓';
|
|
847
|
+
} else if (ev.kind === 'thinking') thinkingChars += ev.chars;
|
|
848
|
+
pushFeed();
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** `delegate({harness:"all"|"a,b"})` — resolve the requested harnesses to detected installs, run the
|
|
854
|
+
* existing `delegate()` engine once per harness sequentially (respects `maxConcurrent`), and
|
|
855
|
+
* mechanically synthesize one comparison report. No second model call. */
|
|
856
|
+
async function runFanoutTool(
|
|
857
|
+
pi: ExtensionAPI,
|
|
858
|
+
ctx: ExtensionContext,
|
|
859
|
+
config: DelegateConfig,
|
|
860
|
+
params: DelegateToolParams,
|
|
861
|
+
signal: AbortSignal | undefined,
|
|
862
|
+
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
863
|
+
): Promise<{ content: { type: string; text: string }[]; details: Record<string, unknown>; usage?: unknown }> {
|
|
864
|
+
const detection = await detectAll();
|
|
865
|
+
const { resolved, unknown, skipped } = resolveHarnessList(params.harness ?? 'all', {
|
|
866
|
+
knownHarnesses: HARNESS_NAMES,
|
|
867
|
+
aliasOf: resolveHarnessName,
|
|
868
|
+
isKnown: isKnownHarness,
|
|
869
|
+
detection,
|
|
870
|
+
});
|
|
871
|
+
if (resolved.length === 0) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const mode = params.mode ?? config.defaultMode;
|
|
878
|
+
const runs: FanoutRunSummary[] = [];
|
|
879
|
+
let sumInput = 0;
|
|
880
|
+
let sumOutput = 0;
|
|
881
|
+
let sumCacheCreate = 0;
|
|
882
|
+
let sumCacheRead = 0;
|
|
883
|
+
let sumCost = 0;
|
|
884
|
+
let anyCostKnown = false;
|
|
885
|
+
|
|
886
|
+
for (const h of resolved) {
|
|
887
|
+
onUpdate?.({ content: [{ type: 'text', text: `[${h}] running…` }], details: { progress: 0.5 } });
|
|
888
|
+
try {
|
|
889
|
+
const run = await runDelegateForTool(
|
|
890
|
+
pi,
|
|
891
|
+
ctx,
|
|
892
|
+
config,
|
|
893
|
+
{
|
|
894
|
+
harness: h,
|
|
895
|
+
task: params.task,
|
|
896
|
+
mode: params.mode,
|
|
897
|
+
scope: params.scope,
|
|
898
|
+
model: params.model,
|
|
899
|
+
maxBudgetUsd: params.maxBudgetUsd,
|
|
900
|
+
allowDangerous: params.allowDangerous === true,
|
|
901
|
+
sessionId: params.sessionId,
|
|
902
|
+
pr: params.pr,
|
|
903
|
+
// no verify: intentionally not model-settable — see DelegateToolParams
|
|
904
|
+
},
|
|
905
|
+
signal,
|
|
906
|
+
onUpdate,
|
|
907
|
+
`[${h}] `,
|
|
908
|
+
);
|
|
909
|
+
const summary = summarize(run.content);
|
|
910
|
+
runs.push({
|
|
911
|
+
harness: h,
|
|
912
|
+
ok: !run.result.isError,
|
|
913
|
+
metrics: formatMetrics({
|
|
914
|
+
numTurns: run.result.numTurns,
|
|
915
|
+
totalCostUsd: run.result.totalCostUsd,
|
|
916
|
+
promptTokens: 0,
|
|
917
|
+
contextPercent: typeof run.details.contextPercent === 'number' ? run.details.contextPercent : null,
|
|
918
|
+
durationMs: run.result.durationMs,
|
|
919
|
+
}),
|
|
920
|
+
cost: run.result.totalCostUsd,
|
|
921
|
+
body: summary.text,
|
|
922
|
+
file: (run.details.file as string) ?? undefined,
|
|
923
|
+
sessionId: (run.details.sessionId as string) ?? undefined,
|
|
924
|
+
verify: run.verify,
|
|
925
|
+
});
|
|
926
|
+
if (run.result.usage) {
|
|
927
|
+
sumInput += run.result.usage.inputTokens;
|
|
928
|
+
sumOutput += run.result.usage.outputTokens;
|
|
929
|
+
sumCacheCreate += run.result.usage.cacheCreationInputTokens;
|
|
930
|
+
sumCacheRead += run.result.usage.cacheReadInputTokens;
|
|
931
|
+
}
|
|
932
|
+
if (run.result.totalCostUsd !== null) {
|
|
933
|
+
sumCost += run.result.totalCostUsd;
|
|
934
|
+
anyCostKnown = true;
|
|
935
|
+
}
|
|
936
|
+
} catch (err) {
|
|
937
|
+
runs.push({ harness: h, ok: false, cost: null, error: err instanceof Error ? err.message : String(err) });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const report = buildFanoutReport({ runs, skipped, unknown });
|
|
942
|
+
const okCount = runs.filter(r => r.ok).length;
|
|
943
|
+
const head = `## delegate all — ${mode} (${okCount}/${runs.length} ok)`;
|
|
944
|
+
const usage = mapClaudeUsage({
|
|
945
|
+
inputTokens: sumInput,
|
|
946
|
+
outputTokens: sumOutput,
|
|
947
|
+
cacheCreationInputTokens: sumCacheCreate,
|
|
948
|
+
cacheReadInputTokens: sumCacheRead,
|
|
949
|
+
totalCostUsd: anyCostKnown ? sumCost : null,
|
|
950
|
+
});
|
|
951
|
+
return {
|
|
952
|
+
content: [{ type: 'text', text: `${head}\n\n${report}` }],
|
|
953
|
+
details: { fanout: true, harness: 'all', mode, harnesses: resolved, skipped, unknown, runs },
|
|
954
|
+
usage,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
667
958
|
export default function (pi: ExtensionAPI) {
|
|
668
959
|
let activeRunId = 0;
|
|
669
960
|
let activeOverlay: { show(): void; focus(): void; runId: number } | null = null;
|
|
@@ -673,12 +964,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
673
964
|
name: 'delegate',
|
|
674
965
|
label: 'Delegate',
|
|
675
966
|
description:
|
|
676
|
-
'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.',
|
|
967
|
+
'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.',
|
|
677
968
|
promptSnippet: 'Delegate a subtask to a harness and return its report',
|
|
678
969
|
promptGuidelines: [
|
|
679
970
|
'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.',
|
|
680
971
|
'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.',
|
|
681
|
-
'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work.',
|
|
972
|
+
'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.',
|
|
973
|
+
'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.',
|
|
682
974
|
'sessionId resumes a previous delegated session instead of starting fresh.',
|
|
683
975
|
'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission).',
|
|
684
976
|
],
|
|
@@ -686,7 +978,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
686
978
|
harness: Type.Optional(
|
|
687
979
|
Type.String({
|
|
688
980
|
description:
|
|
689
|
-
'Harness to use: claude, codex, opencode, amp (aliases: omp). Defaults to config defaultHarness.',
|
|
981
|
+
'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.',
|
|
690
982
|
}),
|
|
691
983
|
),
|
|
692
984
|
task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
|
|
@@ -717,72 +1009,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
717
1009
|
}),
|
|
718
1010
|
),
|
|
719
1011
|
pr: Type.Optional(Type.String({ description: 'GitHub PR number/URL (alternative to scope pr).' })),
|
|
1012
|
+
// Deliberately no `verify` param — see the trust-model comment on DelegateToolParams/runVerify.
|
|
720
1013
|
}),
|
|
721
1014
|
async execute(
|
|
722
1015
|
_toolCallId: string,
|
|
723
|
-
params:
|
|
724
|
-
harness?: string;
|
|
725
|
-
task: string;
|
|
726
|
-
mode?: string;
|
|
727
|
-
scope?: string;
|
|
728
|
-
model?: string;
|
|
729
|
-
maxBudgetUsd?: number;
|
|
730
|
-
allowDangerous?: boolean;
|
|
731
|
-
sessionId?: string;
|
|
732
|
-
pr?: string;
|
|
733
|
-
},
|
|
1016
|
+
params: DelegateToolParams,
|
|
734
1017
|
signal: AbortSignal | undefined,
|
|
735
|
-
onUpdate: ((u:
|
|
1018
|
+
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
736
1019
|
ctx: ExtensionContext,
|
|
737
1020
|
) {
|
|
738
1021
|
const config = loadConfig();
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
task: params.task,
|
|
758
|
-
mode: params.mode,
|
|
759
|
-
scope: params.scope,
|
|
760
|
-
model: params.model,
|
|
761
|
-
maxBudgetUsd: params.maxBudgetUsd,
|
|
762
|
-
allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
|
|
763
|
-
sessionId: params.sessionId,
|
|
764
|
-
pr: params.pr,
|
|
765
|
-
signal,
|
|
766
|
-
onStream: text => {
|
|
767
|
-
liveTail = (liveTail + text).slice(-400);
|
|
768
|
-
pushFeed();
|
|
769
|
-
},
|
|
770
|
-
onActivity: ev => {
|
|
771
|
-
if (ev.kind === 'tool_input') {
|
|
772
|
-
feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
|
|
773
|
-
if (feed.length > 40) feed.splice(0, feed.length - 40);
|
|
774
|
-
} else if (ev.kind === 'tool_result') {
|
|
775
|
-
const last = feed.length - 1;
|
|
776
|
-
if (last >= 0 && feed[last].startsWith('▶')) feed[last] += ev.isError ? ' ✗' : ' ✓';
|
|
777
|
-
} else if (ev.kind === 'thinking') thinkingChars += ev.chars;
|
|
778
|
-
pushFeed();
|
|
1022
|
+
if (params.harness && isFanoutSpec(params.harness)) {
|
|
1023
|
+
return runFanoutTool(pi, ctx, config, params, signal, onUpdate);
|
|
1024
|
+
}
|
|
1025
|
+
const { content, details, result } = await runDelegateForTool(
|
|
1026
|
+
pi,
|
|
1027
|
+
ctx,
|
|
1028
|
+
config,
|
|
1029
|
+
{
|
|
1030
|
+
harness: params.harness,
|
|
1031
|
+
task: params.task,
|
|
1032
|
+
mode: params.mode,
|
|
1033
|
+
scope: params.scope,
|
|
1034
|
+
model: params.model,
|
|
1035
|
+
maxBudgetUsd: params.maxBudgetUsd,
|
|
1036
|
+
allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
|
|
1037
|
+
sessionId: params.sessionId,
|
|
1038
|
+
pr: params.pr,
|
|
1039
|
+
// no verify: intentionally not model-settable — see DelegateToolParams
|
|
779
1040
|
},
|
|
780
|
-
|
|
1041
|
+
signal,
|
|
1042
|
+
onUpdate,
|
|
1043
|
+
'',
|
|
1044
|
+
);
|
|
781
1045
|
const summary = summarize(content);
|
|
782
1046
|
const resumed = details.resumed ? ' · resumed' : '';
|
|
783
1047
|
const head = result.isError
|
|
784
1048
|
? `⚠ ${details.harness} reported an error`
|
|
785
|
-
: `${details.harness} ${details.mode} (${result.numTurns} turn(s),
|
|
1049
|
+
: `${details.harness} ${details.mode} (${result.numTurns ?? '—'} turn(s), ${formatCost(result.totalCostUsd)})${resumed}`;
|
|
786
1050
|
const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
|
|
787
1051
|
const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
|
|
788
1052
|
(details as Record<string, unknown>).markdown = summary.text;
|
|
@@ -817,8 +1081,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
817
1081
|
const details = (result.details ?? {}) as Record<string, unknown>;
|
|
818
1082
|
const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
|
|
819
1083
|
const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
|
|
820
|
-
const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd :
|
|
821
|
-
const turns = typeof details.numTurns === 'number' ? details.numTurns :
|
|
1084
|
+
const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null;
|
|
1085
|
+
const turns = typeof details.numTurns === 'number' ? details.numTurns : null;
|
|
822
1086
|
const isError = details.isError === true;
|
|
823
1087
|
const resumed = details.resumed === true;
|
|
824
1088
|
const file = typeof details.file === 'string' ? details.file : null;
|
|
@@ -827,8 +1091,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
827
1091
|
container.addChild(
|
|
828
1092
|
new Text(
|
|
829
1093
|
theme.fg(isError ? 'error' : 'accent', `${harness} ${mode}`) +
|
|
830
|
-
theme.fg('dim', ` · ${turns} turn(s) · `) +
|
|
831
|
-
theme.fg('warning',
|
|
1094
|
+
theme.fg('dim', ` · ${turns ?? '—'} turn(s) · `) +
|
|
1095
|
+
theme.fg('warning', formatCost(cost)) +
|
|
832
1096
|
(resumed ? theme.fg('dim', ' · resumed') : ''),
|
|
833
1097
|
1,
|
|
834
1098
|
1,
|
|
@@ -867,17 +1131,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
867
1131
|
parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
|
|
868
1132
|
async execute(
|
|
869
1133
|
toolCallId: string,
|
|
870
|
-
params:
|
|
871
|
-
harness?: string;
|
|
872
|
-
task: string;
|
|
873
|
-
mode?: string;
|
|
874
|
-
scope?: string;
|
|
875
|
-
model?: string;
|
|
876
|
-
maxBudgetUsd?: number;
|
|
877
|
-
allowDangerous?: boolean;
|
|
878
|
-
sessionId?: string;
|
|
879
|
-
pr?: string;
|
|
880
|
-
},
|
|
1134
|
+
params: DelegateToolParams,
|
|
881
1135
|
signal: AbortSignal | undefined,
|
|
882
1136
|
onUpdate: never,
|
|
883
1137
|
ctx: ExtensionContext,
|
|
@@ -901,6 +1155,253 @@ export default function (pi: ExtensionAPI) {
|
|
|
901
1155
|
} as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: alias tool matches overload
|
|
902
1156
|
|
|
903
1157
|
// ── Commands ─────────────────────────────────────────────────────────────
|
|
1158
|
+
|
|
1159
|
+
/** One `delegate()` call with the command's progress-window UI (spinner, cancel, minimize).
|
|
1160
|
+
* Shared by the single-harness `/delegate` path and the fan-out loop, one call per harness. */
|
|
1161
|
+
const runOneDelegation = async (
|
|
1162
|
+
ctx: ExtensionContext,
|
|
1163
|
+
opts: {
|
|
1164
|
+
harnessName: string;
|
|
1165
|
+
mode?: string;
|
|
1166
|
+
task: string;
|
|
1167
|
+
scope?: string;
|
|
1168
|
+
model?: string;
|
|
1169
|
+
budget?: number;
|
|
1170
|
+
sessionId?: string;
|
|
1171
|
+
pr?: string;
|
|
1172
|
+
verify?: string;
|
|
1173
|
+
template?: DelegateTemplate;
|
|
1174
|
+
isDanger: boolean;
|
|
1175
|
+
},
|
|
1176
|
+
): Promise<{
|
|
1177
|
+
result: Awaited<ReturnType<typeof delegate>> | null;
|
|
1178
|
+
error: Error | null;
|
|
1179
|
+
cancelled: boolean;
|
|
1180
|
+
}> => {
|
|
1181
|
+
const { harnessName, mode, task, scope, model, budget, sessionId, pr, verify, template, isDanger } = opts;
|
|
1182
|
+
const modeForDisplay = mode ?? 'general';
|
|
1183
|
+
|
|
1184
|
+
const feed: FeedEntry[] = [];
|
|
1185
|
+
const feedIndex = new ToolCallIndex();
|
|
1186
|
+
let thinkingChars = 0;
|
|
1187
|
+
let liveTail = '';
|
|
1188
|
+
let requestRender: (() => void) | null = null;
|
|
1189
|
+
const getEntries = (): FeedEntry[] => {
|
|
1190
|
+
const entries = [...feed.slice(-12)];
|
|
1191
|
+
if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
|
|
1192
|
+
if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
|
|
1193
|
+
return entries;
|
|
1194
|
+
};
|
|
1195
|
+
let chipActivity = '';
|
|
1196
|
+
let chipActivityId: string | undefined;
|
|
1197
|
+
let chipLastPush = 0;
|
|
1198
|
+
const pushChip = () => {
|
|
1199
|
+
if (!ctx.hasUI) return;
|
|
1200
|
+
const now = Date.now();
|
|
1201
|
+
if (now - chipLastPush < 500) return;
|
|
1202
|
+
chipLastPush = now;
|
|
1203
|
+
const theme = ctx.ui.theme;
|
|
1204
|
+
const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
|
|
1205
|
+
ctx.ui.setStatus(
|
|
1206
|
+
'delegate',
|
|
1207
|
+
theme.fg('accent', '●') + theme.fg('dim', ` ${harnessName} ${modeForDisplay}`) + activity,
|
|
1208
|
+
);
|
|
1209
|
+
};
|
|
1210
|
+
const onActivity = (ev: ActivityEvent) => {
|
|
1211
|
+
if (ev.kind === 'tool_input') {
|
|
1212
|
+
chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
|
|
1213
|
+
chipActivityId = ev.id;
|
|
1214
|
+
feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input), id: ev.id });
|
|
1215
|
+
feedIndex.set(ev.id, feed.length - 1);
|
|
1216
|
+
if (feed.length > 40) {
|
|
1217
|
+
const removed = feed.length - 40;
|
|
1218
|
+
feed.splice(0, removed);
|
|
1219
|
+
feedIndex.shift(removed);
|
|
1220
|
+
}
|
|
1221
|
+
} else if (ev.kind === 'tool_result') {
|
|
1222
|
+
// only stamp the chip when the result belongs to the tool it's currently showing
|
|
1223
|
+
if (chipActivity.startsWith('▶') && (ev.id === undefined || ev.id === chipActivityId))
|
|
1224
|
+
chipActivity += ev.isError ? ' ✗' : ' ✓';
|
|
1225
|
+
const idx = feedIndex.resolve(ev.id, feed.length - 1);
|
|
1226
|
+
if (idx >= 0 && feed[idx]?.kind === 'tool') feed[idx] = { ...feed[idx], ok: !ev.isError };
|
|
1227
|
+
} else if (ev.kind === 'thinking') {
|
|
1228
|
+
chipActivity = '💭 thinking…';
|
|
1229
|
+
chipActivityId = undefined;
|
|
1230
|
+
thinkingChars += ev.chars;
|
|
1231
|
+
}
|
|
1232
|
+
pushChip();
|
|
1233
|
+
requestRender?.();
|
|
1234
|
+
};
|
|
1235
|
+
const ac = new AbortController();
|
|
1236
|
+
let cancelled = false;
|
|
1237
|
+
const runState: { error: Error | null } = { error: null };
|
|
1238
|
+
const runId = ++activeRunId;
|
|
1239
|
+
const clearActive = () => {
|
|
1240
|
+
if (activeOverlay?.runId === runId) activeOverlay = null;
|
|
1241
|
+
};
|
|
1242
|
+
const run = delegate(pi, ctx, {
|
|
1243
|
+
harness: harnessName,
|
|
1244
|
+
task,
|
|
1245
|
+
mode,
|
|
1246
|
+
scope,
|
|
1247
|
+
model,
|
|
1248
|
+
maxBudgetUsd: budget,
|
|
1249
|
+
sessionId,
|
|
1250
|
+
pr,
|
|
1251
|
+
verify,
|
|
1252
|
+
signal: ac.signal,
|
|
1253
|
+
onStream: t => {
|
|
1254
|
+
liveTail = (liveTail + t).slice(-400);
|
|
1255
|
+
requestRender?.();
|
|
1256
|
+
},
|
|
1257
|
+
onActivity,
|
|
1258
|
+
}).catch((err: unknown) => {
|
|
1259
|
+
runState.error = err instanceof Error ? err : new Error(String(err));
|
|
1260
|
+
return null;
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
let closeWindow: (() => void) | null = null;
|
|
1264
|
+
let result: Awaited<ReturnType<typeof delegate>> | null = null;
|
|
1265
|
+
if (ctx.hasUI) {
|
|
1266
|
+
let overlayHandle: OverlayHandle | null = null;
|
|
1267
|
+
const uiPromise = ctx.ui
|
|
1268
|
+
.custom(
|
|
1269
|
+
(tui, theme, _kb, done) => {
|
|
1270
|
+
requestRender = () => tui.requestRender();
|
|
1271
|
+
closeWindow = () => done(undefined);
|
|
1272
|
+
return progressWindow(tui, theme, {
|
|
1273
|
+
mode: `${harnessName} ${modeForDisplay}`,
|
|
1274
|
+
model: model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
|
|
1275
|
+
startedAt: Date.now(),
|
|
1276
|
+
getEntries,
|
|
1277
|
+
dangerous: isDanger,
|
|
1278
|
+
onCancel: () => {
|
|
1279
|
+
cancelled = true;
|
|
1280
|
+
ac.abort();
|
|
1281
|
+
},
|
|
1282
|
+
onMinimize: () => {
|
|
1283
|
+
overlayHandle?.setHidden(true);
|
|
1284
|
+
overlayHandle?.unfocus();
|
|
1285
|
+
},
|
|
1286
|
+
});
|
|
1287
|
+
},
|
|
1288
|
+
{
|
|
1289
|
+
overlay: true,
|
|
1290
|
+
overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
|
|
1291
|
+
onHandle: h => {
|
|
1292
|
+
overlayHandle = h;
|
|
1293
|
+
activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
1294
|
+
h.focus();
|
|
1295
|
+
},
|
|
1296
|
+
},
|
|
1297
|
+
)
|
|
1298
|
+
.catch(() => {});
|
|
1299
|
+
result = await run;
|
|
1300
|
+
await closeWhenMounted(() => closeWindow, 2000);
|
|
1301
|
+
await uiPromise;
|
|
1302
|
+
} else {
|
|
1303
|
+
result = await run;
|
|
1304
|
+
}
|
|
1305
|
+
clearActive();
|
|
1306
|
+
if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
|
|
1307
|
+
const failed = cancelled || !result;
|
|
1308
|
+
return { result: failed ? null : result, error: runState.error, cancelled };
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
/** `/delegate all …` / `/delegate a,b …` — resolve to detected harnesses, run each sequentially
|
|
1312
|
+
* through `runOneDelegation` (respects `maxConcurrent`), batch success notifications, and inject
|
|
1313
|
+
* one synthesized comparison report instead of one report per harness. */
|
|
1314
|
+
const runFanoutCommand = async (ctx: ExtensionContext, parsed: ReturnType<typeof parseDelegateCommand>) => {
|
|
1315
|
+
const harnessSpec = parsed.harness as string;
|
|
1316
|
+
const detection = await detectAll();
|
|
1317
|
+
const { resolved, unknown, skipped } = resolveHarnessList(harnessSpec, {
|
|
1318
|
+
knownHarnesses: HARNESS_NAMES,
|
|
1319
|
+
aliasOf: resolveHarnessName,
|
|
1320
|
+
isKnown: isKnownHarness,
|
|
1321
|
+
detection,
|
|
1322
|
+
});
|
|
1323
|
+
if (resolved.length === 0) {
|
|
1324
|
+
const msg = `no harness available to fan out to (unknown: ${unknown.join(', ') || '—'}; not installed: ${skipped.join(', ') || '—'})`;
|
|
1325
|
+
if (ctx.hasUI) ctx.ui.notify(msg, 'error');
|
|
1326
|
+
else process.stderr.write(`${msg}\n`);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const modeForReport = parsed.mode ?? loadConfig().defaultMode;
|
|
1331
|
+
const runs: FanoutRunSummary[] = [];
|
|
1332
|
+
const batcher = new NotifyBatcher((text, level) => {
|
|
1333
|
+
if (ctx.hasUI) ctx.ui.notify(text, level);
|
|
1334
|
+
else process.stdout.write(`${text}\n`);
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
for (const h of resolved) {
|
|
1338
|
+
const templates = loadTemplates(ctx.cwd, h);
|
|
1339
|
+
const resolvedTaskScope = resolveDefaults(parsed, templates);
|
|
1340
|
+
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1341
|
+
if (!resolvedTaskScope) {
|
|
1342
|
+
const message = `mode "${parsed.mode ?? 'general'}" needs a prompt`;
|
|
1343
|
+
runs.push({ harness: h, ok: false, cost: null, error: message });
|
|
1344
|
+
batcher.failure(`${h}: ${message}`);
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
const isDanger =
|
|
1348
|
+
template?.permission === 'danger' ||
|
|
1349
|
+
(template?.nativePermission
|
|
1350
|
+
? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
|
|
1351
|
+
: false);
|
|
1352
|
+
const outcome = await runOneDelegation(ctx, {
|
|
1353
|
+
harnessName: h,
|
|
1354
|
+
mode: parsed.mode,
|
|
1355
|
+
task: resolvedTaskScope.task,
|
|
1356
|
+
scope: resolvedTaskScope.scope,
|
|
1357
|
+
model: parsed.model,
|
|
1358
|
+
budget: parsed.budget,
|
|
1359
|
+
sessionId: parsed.sessionId,
|
|
1360
|
+
pr: parsed.pr,
|
|
1361
|
+
verify: parsed.verify,
|
|
1362
|
+
template,
|
|
1363
|
+
isDanger,
|
|
1364
|
+
});
|
|
1365
|
+
if (outcome.cancelled || !outcome.result) {
|
|
1366
|
+
const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
|
|
1367
|
+
runs.push({ harness: h, ok: false, cost: null, error: message });
|
|
1368
|
+
batcher.failure(`${h}: ${outcome.cancelled ? 'cancelled' : 'failed'} — ${message}`);
|
|
1369
|
+
if (outcome.cancelled) break; // user cancelled — stop the rest of the fan-out
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const { content, details, result, verify } = outcome.result;
|
|
1373
|
+
const summary = summarize(content);
|
|
1374
|
+
const metrics = formatMetrics({
|
|
1375
|
+
numTurns: result.numTurns,
|
|
1376
|
+
totalCostUsd: result.totalCostUsd,
|
|
1377
|
+
promptTokens: 0,
|
|
1378
|
+
contextPercent: typeof details.contextPercent === 'number' ? details.contextPercent : null,
|
|
1379
|
+
durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
|
|
1380
|
+
});
|
|
1381
|
+
runs.push({
|
|
1382
|
+
harness: h,
|
|
1383
|
+
ok: !result.isError,
|
|
1384
|
+
metrics,
|
|
1385
|
+
cost: result.totalCostUsd,
|
|
1386
|
+
body: summary.text,
|
|
1387
|
+
file: (details.file as string) ?? undefined,
|
|
1388
|
+
sessionId: (details.sessionId as string) ?? undefined,
|
|
1389
|
+
verify,
|
|
1390
|
+
});
|
|
1391
|
+
batcher.success(`${h} ${parsed.mode ?? 'general'} — ${metrics}`);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const okCount = runs.filter(r => r.ok).length;
|
|
1395
|
+
const report = buildFanoutReport({ runs, skipped, unknown });
|
|
1396
|
+
injectReport(ctx, {
|
|
1397
|
+
harness: 'all',
|
|
1398
|
+
mode: modeForReport,
|
|
1399
|
+
metrics: `${okCount}/${runs.length} ok`,
|
|
1400
|
+
body: report,
|
|
1401
|
+
});
|
|
1402
|
+
batcher.flush();
|
|
1403
|
+
};
|
|
1404
|
+
|
|
904
1405
|
const makeHandler = (forcedHarness?: string) => async (args: string, ctx: ExtensionContext) => {
|
|
905
1406
|
const sub = args.trim();
|
|
906
1407
|
const subLower = sub.toLowerCase();
|
|
@@ -976,6 +1477,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
976
1477
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
977
1478
|
// if forcedHarness provided, it wins
|
|
978
1479
|
if (forcedHarness) parsed.harness = forcedHarness;
|
|
1480
|
+
|
|
1481
|
+
// fan-out: harness field is `all` or a comma list — resolve to detected harnesses and run
|
|
1482
|
+
// the engine once per harness instead of the single-harness flow below.
|
|
1483
|
+
if (parsed.harness && isFanoutSpec(parsed.harness)) {
|
|
1484
|
+
await runFanoutCommand(ctx, parsed);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
979
1488
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
980
1489
|
const templates = loadTemplates(ctx.cwd, harnessName);
|
|
981
1490
|
const resolved = resolveDefaults(parsed, templates);
|
|
@@ -994,134 +1503,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
994
1503
|
);
|
|
995
1504
|
else
|
|
996
1505
|
ctx.ui.notify?.(
|
|
997
|
-
'Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=…] [--model=…] [--scope=…] <prompt>',
|
|
1506
|
+
'Usage: /delegate [--harness=claude|codex|opencode|amp|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
|
|
998
1507
|
'warning',
|
|
999
1508
|
);
|
|
1000
1509
|
return;
|
|
1001
1510
|
}
|
|
1002
|
-
const modeForDisplay = parsed.mode ?? 'general';
|
|
1003
|
-
const harnessForDisplay = harnessName;
|
|
1004
1511
|
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
let liveTail = '';
|
|
1008
|
-
let requestRender: (() => void) | null = null;
|
|
1009
|
-
const getEntries = (): FeedEntry[] => {
|
|
1010
|
-
const entries = [...feed.slice(-12)];
|
|
1011
|
-
if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
|
|
1012
|
-
if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
|
|
1013
|
-
return entries;
|
|
1014
|
-
};
|
|
1015
|
-
let chipActivity = '';
|
|
1016
|
-
let chipLastPush = 0;
|
|
1017
|
-
const pushChip = () => {
|
|
1018
|
-
if (!ctx.hasUI) return;
|
|
1019
|
-
const now = Date.now();
|
|
1020
|
-
if (now - chipLastPush < 500) return;
|
|
1021
|
-
chipLastPush = now;
|
|
1022
|
-
const theme = ctx.ui.theme;
|
|
1023
|
-
const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
|
|
1024
|
-
ctx.ui.setStatus(
|
|
1025
|
-
'delegate',
|
|
1026
|
-
theme.fg('accent', '●') + theme.fg('dim', ` ${harnessForDisplay} ${modeForDisplay}`) + activity,
|
|
1027
|
-
);
|
|
1028
|
-
};
|
|
1029
|
-
const onActivity = (ev: ActivityEvent) => {
|
|
1030
|
-
if (ev.kind === 'tool_input') {
|
|
1031
|
-
chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
|
|
1032
|
-
feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input) });
|
|
1033
|
-
if (feed.length > 40) feed.splice(0, feed.length - 40);
|
|
1034
|
-
} else if (ev.kind === 'tool_result') {
|
|
1035
|
-
if (chipActivity.startsWith('▶')) chipActivity += ev.isError ? ' ✗' : ' ✓';
|
|
1036
|
-
const last = feed.length - 1;
|
|
1037
|
-
if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: ev.isError ? false : true };
|
|
1038
|
-
} else if (ev.kind === 'thinking') {
|
|
1039
|
-
chipActivity = '💭 thinking…';
|
|
1040
|
-
thinkingChars += ev.chars;
|
|
1041
|
-
}
|
|
1042
|
-
pushChip();
|
|
1043
|
-
requestRender?.();
|
|
1044
|
-
};
|
|
1045
|
-
const ac = new AbortController();
|
|
1046
|
-
let cancelled = false;
|
|
1047
|
-
const runState: { error: Error | null } = { error: null };
|
|
1048
|
-
const runId = ++activeRunId;
|
|
1049
|
-
const clearActive = () => {
|
|
1050
|
-
if (activeOverlay?.runId === runId) activeOverlay = null;
|
|
1051
|
-
};
|
|
1052
|
-
const run = delegate(pi, ctx, {
|
|
1053
|
-
harness: harnessName,
|
|
1054
|
-
task: resolved.task,
|
|
1512
|
+
const outcome = await runOneDelegation(ctx, {
|
|
1513
|
+
harnessName,
|
|
1055
1514
|
mode: parsed.mode,
|
|
1515
|
+
task: resolved.task,
|
|
1056
1516
|
scope: resolved.scope,
|
|
1057
1517
|
model: parsed.model,
|
|
1058
|
-
|
|
1518
|
+
budget: parsed.budget,
|
|
1059
1519
|
sessionId: parsed.sessionId,
|
|
1060
1520
|
pr: parsed.pr,
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
requestRender?.();
|
|
1065
|
-
},
|
|
1066
|
-
onActivity,
|
|
1067
|
-
}).catch((err: unknown) => {
|
|
1068
|
-
runState.error = err instanceof Error ? err : new Error(String(err));
|
|
1069
|
-
return null;
|
|
1521
|
+
verify: parsed.verify,
|
|
1522
|
+
template,
|
|
1523
|
+
isDanger,
|
|
1070
1524
|
});
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
let result: Awaited<ReturnType<typeof delegate>> | null = null;
|
|
1074
|
-
if (ctx.hasUI) {
|
|
1075
|
-
let overlayHandle: OverlayHandle | null = null;
|
|
1076
|
-
const uiPromise = ctx.ui
|
|
1077
|
-
.custom(
|
|
1078
|
-
(tui, theme, _kb, done) => {
|
|
1079
|
-
requestRender = () => tui.requestRender();
|
|
1080
|
-
closeWindow = () => done(undefined);
|
|
1081
|
-
return progressWindow(tui, theme, {
|
|
1082
|
-
mode: `${harnessForDisplay} ${modeForDisplay}`,
|
|
1083
|
-
model:
|
|
1084
|
-
parsed.model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
|
|
1085
|
-
startedAt: Date.now(),
|
|
1086
|
-
getEntries,
|
|
1087
|
-
dangerous: isDanger,
|
|
1088
|
-
onCancel: () => {
|
|
1089
|
-
cancelled = true;
|
|
1090
|
-
ac.abort();
|
|
1091
|
-
},
|
|
1092
|
-
onMinimize: () => {
|
|
1093
|
-
overlayHandle?.setHidden(true);
|
|
1094
|
-
overlayHandle?.unfocus();
|
|
1095
|
-
},
|
|
1096
|
-
});
|
|
1097
|
-
},
|
|
1098
|
-
{
|
|
1099
|
-
overlay: true,
|
|
1100
|
-
overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
|
|
1101
|
-
onHandle: h => {
|
|
1102
|
-
overlayHandle = h;
|
|
1103
|
-
activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
|
|
1104
|
-
h.focus();
|
|
1105
|
-
},
|
|
1106
|
-
},
|
|
1107
|
-
)
|
|
1108
|
-
.catch(() => {});
|
|
1109
|
-
result = await run;
|
|
1110
|
-
await closeWhenMounted(() => closeWindow, 2000);
|
|
1111
|
-
await uiPromise;
|
|
1112
|
-
} else {
|
|
1113
|
-
result = await run;
|
|
1114
|
-
}
|
|
1115
|
-
clearActive();
|
|
1116
|
-
if (cancelled || !result) {
|
|
1117
|
-
if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
|
|
1118
|
-
const message = runState.error ? runState.error.message : cancelled ? 'cancelled' : 'delegation failed';
|
|
1525
|
+
if (outcome.cancelled || !outcome.result) {
|
|
1526
|
+
const message = outcome.error ? outcome.error.message : outcome.cancelled ? 'cancelled' : 'delegation failed';
|
|
1119
1527
|
if (ctx.hasUI)
|
|
1120
|
-
ctx.ui.notify(
|
|
1528
|
+
ctx.ui.notify(
|
|
1529
|
+
`delegate ${outcome.cancelled ? 'cancelled' : 'failed'}: ${message}`,
|
|
1530
|
+
outcome.cancelled ? 'warning' : 'error',
|
|
1531
|
+
);
|
|
1121
1532
|
else process.stderr.write(`${message}\n`);
|
|
1122
1533
|
return;
|
|
1123
1534
|
}
|
|
1124
|
-
const { content, details } = result;
|
|
1535
|
+
const { content, details, verify } = outcome.result;
|
|
1125
1536
|
const summary = summarize(content);
|
|
1126
1537
|
const file = (details.file as string) ?? null;
|
|
1127
1538
|
const sessionId = (details.sessionId as string) ?? null;
|
|
@@ -1138,8 +1549,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1138
1549
|
? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
|
|
1139
1550
|
: 0;
|
|
1140
1551
|
const metrics = formatMetrics({
|
|
1141
|
-
numTurns:
|
|
1142
|
-
totalCostUsd:
|
|
1552
|
+
numTurns: typeof details.numTurns === 'number' ? details.numTurns : null,
|
|
1553
|
+
totalCostUsd: typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null,
|
|
1143
1554
|
promptTokens,
|
|
1144
1555
|
contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
|
|
1145
1556
|
durationMs:
|
|
@@ -1152,6 +1563,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1152
1563
|
body: summary.text,
|
|
1153
1564
|
file: file ?? undefined,
|
|
1154
1565
|
sessionId: sessionId ?? undefined,
|
|
1566
|
+
verify,
|
|
1155
1567
|
});
|
|
1156
1568
|
if (ctx.hasUI) {
|
|
1157
1569
|
ctx.ui.setStatus('delegate', undefined);
|
|
@@ -1161,7 +1573,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1161
1573
|
|
|
1162
1574
|
pi.registerCommand('delegate', {
|
|
1163
1575
|
description:
|
|
1164
|
-
'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
|
|
1576
|
+
'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.',
|
|
1165
1577
|
handler: makeHandler(),
|
|
1166
1578
|
});
|
|
1167
1579
|
pi.registerCommand('claude', {
|