pi-harness-delegate 0.4.1 → 0.6.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 +31 -9
- package/extensions/acp-runner.ts +370 -0
- package/extensions/command.ts +21 -0
- package/extensions/concurrency.ts +16 -3
- package/extensions/config.ts +216 -13
- package/extensions/harnesses/amp.ts +24 -4
- package/extensions/harnesses/claude.ts +3 -0
- package/extensions/harnesses/codex.ts +13 -1
- package/extensions/harnesses/devin.ts +208 -0
- package/extensions/harnesses/opencode.ts +160 -0
- package/extensions/harnesses/registry.ts +29 -0
- package/extensions/harnesses/types.ts +27 -3
- package/extensions/index.ts +180 -55
- package/extensions/run-registry.ts +50 -5
- package/extensions/templates.ts +34 -17
- package/extensions/usage.ts +16 -8
- package/package.json +1 -1
- package/templates/devin/docs.md +10 -0
- package/templates/devin/general.md +8 -0
- package/templates/devin/implement.md +12 -0
- package/templates/devin/plan.md +17 -0
- package/templates/devin/review.md +18 -0
- package/templates/devin/security-audit.md +17 -0
package/extensions/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers:
|
|
5
5
|
* - `delegate` tool (primary) + `claude_delegate` alias
|
|
6
|
-
* - `/delegate` command (primary) + `/claude`, `/codex`, `/opencode`, `/amp`, `/omp` aliases
|
|
6
|
+
* - `/delegate` command (primary) + `/claude`, `/codex`, `/opencode`, `/amp`, `/omp`, `/devin` aliases
|
|
7
7
|
*
|
|
8
8
|
* Templates ship in ../templates/shared + ../templates/<harness>; users add custom ones in
|
|
9
9
|
* ~/.pi/agent/delegate/templates/<harness>/ (global)
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
truncateToWidth,
|
|
30
30
|
} from '@earendil-works/pi-tui';
|
|
31
31
|
import { Type } from 'typebox';
|
|
32
|
+
import { acpView, runAcpHarness } from './acp-runner.ts';
|
|
32
33
|
import {
|
|
33
34
|
aggregateSpend,
|
|
34
35
|
buildFanoutReport,
|
|
@@ -49,14 +50,26 @@ import {
|
|
|
49
50
|
ToolCallIndex,
|
|
50
51
|
type VerifyResult,
|
|
51
52
|
} from './activity.ts';
|
|
52
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
isFanoutSpec,
|
|
55
|
+
parseDelegateCommand,
|
|
56
|
+
resolveDefaults,
|
|
57
|
+
resolveHarnessFilter,
|
|
58
|
+
resolveHarnessList,
|
|
59
|
+
} from './command.ts';
|
|
53
60
|
import { acquireSlot, activeCount } from './concurrency.ts';
|
|
54
61
|
import {
|
|
62
|
+
buildConfigReport,
|
|
55
63
|
type DelegateConfig,
|
|
64
|
+
describeConfigSource,
|
|
65
|
+
getMaxConcurrent,
|
|
56
66
|
outputsDir as getOutputsDir,
|
|
57
67
|
legacyOutputsDir,
|
|
58
68
|
loadConfig,
|
|
69
|
+
loadConfigWithSource,
|
|
59
70
|
resolveModelForHarness,
|
|
71
|
+
resolveTransport,
|
|
72
|
+
writeDelegateConfig,
|
|
60
73
|
} from './config.ts';
|
|
61
74
|
import {
|
|
62
75
|
ALIASES,
|
|
@@ -64,16 +77,16 @@ import {
|
|
|
64
77
|
getHarness,
|
|
65
78
|
HARNESS_NAMES,
|
|
66
79
|
isKnownHarness,
|
|
80
|
+
isNativeDangerPermission,
|
|
67
81
|
resolveHarnessName,
|
|
68
82
|
} from './harnesses/registry.ts';
|
|
69
83
|
import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
|
|
70
|
-
|
|
71
84
|
import { delegationHint, stripMarker } from './hint.ts';
|
|
72
85
|
import { NotifyBatcher } from './notify.ts';
|
|
73
86
|
import { type FeedEntry, progressWindow } from './progress.ts';
|
|
74
87
|
import { formatFanoutChip, multiProgressWindow, type RunRow } from './progress-multi.ts';
|
|
75
88
|
import { runHarness } from './runner.ts';
|
|
76
|
-
import { type DelegateTemplate, loadTemplates } from './templates.ts';
|
|
89
|
+
import { type DelegateTemplate, loadTemplates, resolveNativePermission } from './templates.ts';
|
|
77
90
|
import { mapClaudeUsage } from './usage.ts';
|
|
78
91
|
|
|
79
92
|
/** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
|
|
@@ -119,8 +132,9 @@ const FANOUT_LINGER_MS = 3000;
|
|
|
119
132
|
* callers must not let this flip a run's `isError`.
|
|
120
133
|
*
|
|
121
134
|
* Trust model: a verify command can only come from two places — on-disk template frontmatter
|
|
122
|
-
* (project-local templates are already
|
|
123
|
-
*
|
|
135
|
+
* (project-local templates are already gated by `isProjectTrusted(ctx)` — pi's own trust store,
|
|
136
|
+
* never anything inside the project itself) or a human typing `/delegate --verify=<cmd>` at the
|
|
137
|
+
* CLI. It is deliberately **not** a `delegate` tool parameter: a tool
|
|
124
138
|
* param is set by the model, whose context includes repo content and delegated-harness output —
|
|
125
139
|
* both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
|
|
126
140
|
* arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
|
|
@@ -168,6 +182,20 @@ function outputsDirFor(harness: string): string {
|
|
|
168
182
|
return getOutputsDir(harness);
|
|
169
183
|
}
|
|
170
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Whether pi's own trust store (`ctx.isProjectTrusted()`, backed by `~/.pi/agent/trust.json`,
|
|
187
|
+
* outside any project) considers `ctx.cwd` trusted. This is the sole source of truth for whether
|
|
188
|
+
* project-local delegate templates load — see the trust-tier comment on `loadTemplates`. Fails
|
|
189
|
+
* closed (untrusted) if the host is old enough not to expose the method, or if it throws.
|
|
190
|
+
*/
|
|
191
|
+
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
|
192
|
+
try {
|
|
193
|
+
return typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
171
199
|
function formatTemplateRow(t: DelegateTemplate): string {
|
|
172
200
|
const parts = [
|
|
173
201
|
t.name,
|
|
@@ -181,15 +209,16 @@ function formatTemplateRow(t: DelegateTemplate): string {
|
|
|
181
209
|
|
|
182
210
|
async function showModes(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
183
211
|
const all = new Map<string, DelegateTemplate>();
|
|
212
|
+
const trusted = isProjectTrusted(ctx);
|
|
184
213
|
// collect from all harnesses if no filter
|
|
185
214
|
if (harnessFilter) {
|
|
186
|
-
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter)) all.set(k, v);
|
|
215
|
+
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter, trusted)) all.set(k, v);
|
|
187
216
|
} else {
|
|
188
217
|
for (const h of [...HARNESS_NAMES, 'shared']) {
|
|
189
|
-
for (const [k, v] of loadTemplates(ctx.cwd, h)) if (!all.has(k)) all.set(k, v);
|
|
218
|
+
for (const [k, v] of loadTemplates(ctx.cwd, h, trusted)) if (!all.has(k)) all.set(k, v);
|
|
190
219
|
}
|
|
191
220
|
// also load without harness param
|
|
192
|
-
for (const [k, v] of loadTemplates(ctx.cwd)) if (!all.has(k)) all.set(k, v);
|
|
221
|
+
for (const [k, v] of loadTemplates(ctx.cwd, undefined, trusted)) if (!all.has(k)) all.set(k, v);
|
|
193
222
|
}
|
|
194
223
|
const rows = [...all.values()].map(formatTemplateRow);
|
|
195
224
|
if (!ctx.hasUI) {
|
|
@@ -356,6 +385,7 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
356
385
|
return;
|
|
357
386
|
}
|
|
358
387
|
if (!ctx.hasUI) {
|
|
388
|
+
if (harnessFilter) process.stdout.write(`delegate — history (${harnessFilter})\n`);
|
|
359
389
|
for (const e of entries)
|
|
360
390
|
process.stdout.write(`${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${e.sessionId ?? '-'}\n`);
|
|
361
391
|
return;
|
|
@@ -376,7 +406,10 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
376
406
|
list.onSelect = item => done(item.value);
|
|
377
407
|
list.onCancel = () => done(undefined);
|
|
378
408
|
return {
|
|
379
|
-
render: (w: number) =>
|
|
409
|
+
render: (w: number) => {
|
|
410
|
+
const rows = list.render(w);
|
|
411
|
+
return harnessFilter ? [theme.fg('accent', `delegate — history (${harnessFilter})`), ...rows] : rows;
|
|
412
|
+
},
|
|
380
413
|
invalidate: () => list.invalidate(),
|
|
381
414
|
handleInput: (data: string) => {
|
|
382
415
|
list.handleInput(data);
|
|
@@ -391,15 +424,22 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
391
424
|
}
|
|
392
425
|
|
|
393
426
|
async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
394
|
-
const cfg =
|
|
427
|
+
const { config: cfg, source } = loadConfigWithSource();
|
|
395
428
|
const detection = await detectAll();
|
|
429
|
+
const trusted = isProjectTrusted(ctx);
|
|
396
430
|
const allHarnesses = harnessFilter ? [harnessFilter].filter(h => isKnownHarness(h)) : HARNESS_NAMES;
|
|
397
431
|
const lines: string[] = [];
|
|
398
432
|
lines.push(`delegate — status${harnessFilter ? ` (${harnessFilter})` : ''}`);
|
|
433
|
+
lines.push(...describeConfigSource(source));
|
|
399
434
|
lines.push(`defaultHarness: ${cfg.defaultHarness} · defaultMode: ${cfg.defaultMode} · model: ${cfg.model ?? '—'}`);
|
|
400
435
|
lines.push(
|
|
401
436
|
`maxConcurrent: ${typeof cfg.maxConcurrent === 'number' ? cfg.maxConcurrent : JSON.stringify(cfg.maxConcurrent)} · maxTranscripts: ${cfg.maxTranscripts}`,
|
|
402
437
|
);
|
|
438
|
+
lines.push(
|
|
439
|
+
trusted
|
|
440
|
+
? 'project trust: trusted — project-local templates (.pi/delegate/templates/) are loaded'
|
|
441
|
+
: "project trust: untrusted — project-local templates skipped (trust this project via pi's trust prompt, or set defaultProjectTrust, to load them)",
|
|
442
|
+
);
|
|
403
443
|
lines.push('');
|
|
404
444
|
lines.push('harness binary ok version outputs templates active');
|
|
405
445
|
lines.push('─'.repeat(78));
|
|
@@ -415,13 +455,15 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
415
455
|
} catch {}
|
|
416
456
|
let templates = 0;
|
|
417
457
|
try {
|
|
418
|
-
templates = loadTemplates(ctx.cwd, h).size;
|
|
458
|
+
templates = loadTemplates(ctx.cwd, h, trusted).size;
|
|
419
459
|
} catch {}
|
|
420
460
|
// cross-process count via the file registry, combined with the in-process counter as a fallback
|
|
421
461
|
const active = activeCount(h);
|
|
462
|
+
const cap = getMaxConcurrent(cfg, h);
|
|
463
|
+
const activeCol = `${active}/${cap > 0 ? cap : '∞'}`;
|
|
422
464
|
const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
|
|
423
465
|
lines.push(
|
|
424
|
-
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${
|
|
466
|
+
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${activeCol}${hint}`,
|
|
425
467
|
);
|
|
426
468
|
}
|
|
427
469
|
const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
@@ -434,9 +476,10 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
434
476
|
}
|
|
435
477
|
if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
|
|
436
478
|
if (!harnessFilter) {
|
|
479
|
+
const globalCap = getMaxConcurrent(cfg);
|
|
437
480
|
lines.push('');
|
|
438
481
|
lines.push(
|
|
439
|
-
`global active: ${activeCount()} · aliases: ${
|
|
482
|
+
`global active: ${activeCount()}/${globalCap > 0 ? globalCap : '∞'} · aliases: ${
|
|
440
483
|
Object.entries(ALIASES)
|
|
441
484
|
.map(([k, v]) => `${k}→${v}`)
|
|
442
485
|
.join(', ') || '—'
|
|
@@ -474,6 +517,61 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
474
517
|
});
|
|
475
518
|
}
|
|
476
519
|
|
|
520
|
+
/**
|
|
521
|
+
* `/delegate config` — the discoverability gap `/delegate status`'s provenance line only hints at:
|
|
522
|
+
* shows exactly what was read from `settings.json` (or why it wasn't) plus the effective config
|
|
523
|
+
* with defaults filled in, formatted as a paste-ready JSON block under the `delegate` key. Print-
|
|
524
|
+
* only — writing is a separate, explicit action (`/delegate config init`, below), never triggered
|
|
525
|
+
* from this default view.
|
|
526
|
+
*/
|
|
527
|
+
async function showConfig(ctx: ExtensionContext): Promise<void> {
|
|
528
|
+
const result = loadConfigWithSource();
|
|
529
|
+
const lines = ['delegate — config', '', ...buildConfigReport(result)];
|
|
530
|
+
if (!ctx.hasUI) {
|
|
531
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
535
|
+
let offset = 0;
|
|
536
|
+
const height = 20;
|
|
537
|
+
return {
|
|
538
|
+
render(width: number): string[] {
|
|
539
|
+
const header = theme.fg('accent', `delegate config — ${result.source.file} (↑↓ scroll · any key to close)`);
|
|
540
|
+
const visible = lines.slice(offset, offset + height);
|
|
541
|
+
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
542
|
+
},
|
|
543
|
+
handleInput(data: string): void {
|
|
544
|
+
if (matchesKey(data, Key.up) && offset > 0) {
|
|
545
|
+
offset--;
|
|
546
|
+
tui.requestRender();
|
|
547
|
+
} else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
548
|
+
offset++;
|
|
549
|
+
tui.requestRender();
|
|
550
|
+
} else done(undefined);
|
|
551
|
+
},
|
|
552
|
+
invalidate() {},
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* `/delegate config init` — the one place this extension ever writes to `settings.json`, and only
|
|
559
|
+
* because a human explicitly typed this subcommand. Writes the current effective config (defaults
|
|
560
|
+
* merged with whatever was already on disk) into the `delegate` key via `writeDelegateConfig()`
|
|
561
|
+
* (read-modify-write, atomic, refuses on an unparseable file rather than clobbering it). This is
|
|
562
|
+
* also the practical fix for the legacy-`claudeDelegate`-only gap `describeConfigSource` warns
|
|
563
|
+
* about: writing an explicit `delegate` key (with the correctly-resolved values already folded
|
|
564
|
+
* in — the legacy migration already ran before this point) makes it win from then on, without
|
|
565
|
+
* this command ever touching or deleting the old `claudeDelegate` key itself.
|
|
566
|
+
*/
|
|
567
|
+
async function initConfig(ctx: ExtensionContext): Promise<void> {
|
|
568
|
+
const result = loadConfigWithSource();
|
|
569
|
+
const write = writeDelegateConfig(result.config);
|
|
570
|
+
const msg = write.ok ? `✓ ${write.message}` : `✗ ${write.message}`;
|
|
571
|
+
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
572
|
+
else ctx.ui.notify?.(msg, write.ok ? 'info' : 'warning');
|
|
573
|
+
}
|
|
574
|
+
|
|
477
575
|
function buildPrompt(
|
|
478
576
|
template: DelegateTemplate,
|
|
479
577
|
task: string,
|
|
@@ -513,7 +611,7 @@ async function delegate(
|
|
|
513
611
|
throw new Error(
|
|
514
612
|
`unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
|
|
515
613
|
);
|
|
516
|
-
const templates = loadTemplates(ctx.cwd, harnessName);
|
|
614
|
+
const templates = loadTemplates(ctx.cwd, harnessName, isProjectTrusted(ctx));
|
|
517
615
|
const mode = opts.mode || config.defaultMode;
|
|
518
616
|
const template = templates.get(mode);
|
|
519
617
|
if (!template)
|
|
@@ -523,6 +621,11 @@ async function delegate(
|
|
|
523
621
|
const task = opts.task || template.defaultTask;
|
|
524
622
|
if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
|
|
525
623
|
|
|
624
|
+
// Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
|
|
625
|
+
// ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
|
|
626
|
+
// not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
|
|
627
|
+
const transport = resolveTransport(config, harnessName, harness);
|
|
628
|
+
|
|
526
629
|
// concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
|
|
527
630
|
// exactly as before; fan-out passes waitForSlot:true to queue instead.
|
|
528
631
|
const release = await acquireSlot({
|
|
@@ -551,7 +654,7 @@ async function delegate(
|
|
|
551
654
|
// permission: normalized, danger requires explicit per-call allowDangerous:true
|
|
552
655
|
let permission: NormalizedPermission = template.permission;
|
|
553
656
|
const nativePerm = template.nativePermission;
|
|
554
|
-
const isNativeDanger =
|
|
657
|
+
const isNativeDanger = isNativeDangerPermission(harness, nativePerm);
|
|
555
658
|
if (template.permission === 'danger' || isNativeDanger) {
|
|
556
659
|
if (opts.allowDangerous !== true) {
|
|
557
660
|
throw new Error(
|
|
@@ -564,6 +667,9 @@ async function delegate(
|
|
|
564
667
|
permission = 'danger';
|
|
565
668
|
}
|
|
566
669
|
const permissionForDisplay = nativePerm ?? permission;
|
|
670
|
+
// Dropped when an explicit escalation moved us off the template's own tier — see
|
|
671
|
+
// resolveNativePermission(). Applies to both transports.
|
|
672
|
+
const nativePermissionForRun = resolveNativePermission(template.permission, permission, nativePerm);
|
|
567
673
|
|
|
568
674
|
const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
|
|
569
675
|
const prompt = buildPrompt(template, task, scopeText, ctx.cwd, harnessName);
|
|
@@ -572,7 +678,7 @@ async function delegate(
|
|
|
572
678
|
let streamedFull = '';
|
|
573
679
|
let result: import('./runner.ts').HarnessResult;
|
|
574
680
|
try {
|
|
575
|
-
|
|
681
|
+
const baseRunOpts = {
|
|
576
682
|
harness,
|
|
577
683
|
prompt,
|
|
578
684
|
cwd: ctx.cwd,
|
|
@@ -586,15 +692,20 @@ async function delegate(
|
|
|
586
692
|
signal: opts.signal,
|
|
587
693
|
timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
|
|
588
694
|
resumeSessionId: opts.sessionId,
|
|
589
|
-
onStream: t => {
|
|
695
|
+
onStream: (t: string) => {
|
|
590
696
|
streamedFull += t;
|
|
591
697
|
opts.onStream?.(t);
|
|
592
698
|
},
|
|
593
|
-
onActivity: ev => {
|
|
699
|
+
onActivity: (ev: ActivityEvent) => {
|
|
594
700
|
activityEvents.push(ev);
|
|
595
701
|
opts.onActivity?.(ev);
|
|
596
702
|
},
|
|
597
|
-
|
|
703
|
+
nativePermission: nativePermissionForRun,
|
|
704
|
+
};
|
|
705
|
+
result =
|
|
706
|
+
transport === 'acp'
|
|
707
|
+
? await runAcpHarness({ ...baseRunOpts, harness: acpView(harness) })
|
|
708
|
+
: await runHarness(baseRunOpts);
|
|
598
709
|
} catch (err) {
|
|
599
710
|
release();
|
|
600
711
|
if (streamedFull.length > 0) {
|
|
@@ -960,11 +1071,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
960
1071
|
name: 'delegate',
|
|
961
1072
|
label: 'Delegate',
|
|
962
1073
|
description:
|
|
963
|
-
'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.',
|
|
1074
|
+
'Delegate a task to any harness (claude, codex, opencode, amp, devin) 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.',
|
|
964
1075
|
promptSnippet: 'Delegate a subtask to a harness and return its report',
|
|
965
1076
|
promptGuidelines: [
|
|
966
1077
|
'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.',
|
|
967
|
-
'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.',
|
|
1078
|
+
'Pass harness (claude|codex|opencode|amp|devin) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
|
|
968
1079
|
'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.',
|
|
969
1080
|
'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.',
|
|
970
1081
|
'sessionId resumes a previous delegated session instead of starting fresh.',
|
|
@@ -974,7 +1085,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
974
1085
|
harness: Type.Optional(
|
|
975
1086
|
Type.String({
|
|
976
1087
|
description:
|
|
977
|
-
'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.',
|
|
1088
|
+
'Harness to use: claude, codex, opencode, amp (aliases: omp), devin. "all" or a comma list (e.g. "claude,codex") fans out to each detected harness. Defaults to config defaultHarness.',
|
|
978
1089
|
}),
|
|
979
1090
|
),
|
|
980
1091
|
task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
|
|
@@ -1512,8 +1623,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1512
1623
|
// occupying a concurrency slot.
|
|
1513
1624
|
const specs: FanoutSpec[] = [];
|
|
1514
1625
|
const immediateFailures: FanoutRunSummary[] = [];
|
|
1626
|
+
const trusted = isProjectTrusted(ctx);
|
|
1515
1627
|
for (const h of resolved) {
|
|
1516
|
-
const templates = loadTemplates(ctx.cwd, h);
|
|
1628
|
+
const templates = loadTemplates(ctx.cwd, h, trusted);
|
|
1517
1629
|
const resolvedTaskScope = resolveDefaults(parsed, templates);
|
|
1518
1630
|
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1519
1631
|
if (!resolvedTaskScope) {
|
|
@@ -1603,8 +1715,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1603
1715
|
await showStatus(ctx, h);
|
|
1604
1716
|
return;
|
|
1605
1717
|
}
|
|
1718
|
+
if (subLower === 'config init') {
|
|
1719
|
+
await initConfig(ctx);
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (subLower === 'config') {
|
|
1723
|
+
await showConfig(ctx);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1606
1726
|
// extract --harness flag for list/history subcommands
|
|
1607
|
-
const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1]
|
|
1727
|
+
const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1];
|
|
1608
1728
|
if (sub === 'watch' || sub === 'show') {
|
|
1609
1729
|
if (activeOverlay) {
|
|
1610
1730
|
activeOverlay.show();
|
|
@@ -1614,44 +1734,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1614
1734
|
}
|
|
1615
1735
|
return;
|
|
1616
1736
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
}
|
|
1626
|
-
if (
|
|
1627
|
-
|
|
1628
|
-
|
|
1737
|
+
// Shared by list/history: resolve their (optional) harness filter to a canonical name via the
|
|
1738
|
+
// same alias/case rules (`omp` -> `amp`, any case), and reject a word that matches nothing —
|
|
1739
|
+
// rather than each falling back to silently showing an unfiltered or empty result.
|
|
1740
|
+
const filterHarness = (bareWord: string | undefined): string | undefined | 'unknown' => {
|
|
1741
|
+
if (forcedHarness) return forcedHarness;
|
|
1742
|
+
const resolution = resolveHarnessFilter(harnessFlag ?? bareWord, {
|
|
1743
|
+
isKnown: isKnownHarness,
|
|
1744
|
+
aliasOf: resolveHarnessName,
|
|
1745
|
+
});
|
|
1746
|
+
if (resolution.kind === 'unknown') {
|
|
1747
|
+
const msg = `unknown harness "${resolution.requested}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`;
|
|
1748
|
+
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
1749
|
+
else ctx.ui.notify?.(msg, 'warning');
|
|
1750
|
+
return 'unknown';
|
|
1629
1751
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1752
|
+
return resolution.kind === 'known' ? resolution.harness : undefined;
|
|
1753
|
+
};
|
|
1754
|
+
if (sub === 'list' || subLower.startsWith('list ')) {
|
|
1755
|
+
const h = filterHarness(subLower.startsWith('list ') ? sub.split(/\s+/)[1] : undefined);
|
|
1756
|
+
if (h === 'unknown') return;
|
|
1757
|
+
await showModes(ctx, h);
|
|
1632
1758
|
return;
|
|
1633
1759
|
}
|
|
1634
1760
|
if (sub === 'history' || sub === 'logs' || subLower.startsWith('history ') || subLower.startsWith('logs ')) {
|
|
1635
|
-
const h =
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
: undefined);
|
|
1641
|
-
if (h && isKnownHarness(h)) {
|
|
1642
|
-
await showHistory(ctx, h);
|
|
1643
|
-
return;
|
|
1644
|
-
}
|
|
1645
|
-
await showHistory(ctx, forcedHarness);
|
|
1761
|
+
const h = filterHarness(
|
|
1762
|
+
subLower.startsWith('history ') || subLower.startsWith('logs ') ? sub.split(/\s+/)[1] : undefined,
|
|
1763
|
+
);
|
|
1764
|
+
if (h === 'unknown') return;
|
|
1765
|
+
await showHistory(ctx, h);
|
|
1646
1766
|
return;
|
|
1647
1767
|
}
|
|
1648
1768
|
|
|
1649
1769
|
// combine forced harness + args for parsing
|
|
1650
1770
|
const rawForParse = forcedHarness ? `${forcedHarness} ${args}`.trim() : args;
|
|
1651
1771
|
// gather known modes across all harnesses for parsing
|
|
1772
|
+
const trusted = isProjectTrusted(ctx);
|
|
1652
1773
|
const allModes = new Set<string>();
|
|
1653
|
-
for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h).keys()) allModes.add(k);
|
|
1654
|
-
for (const k of loadTemplates(ctx.cwd).keys()) allModes.add(k);
|
|
1774
|
+
for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h, trusted).keys()) allModes.add(k);
|
|
1775
|
+
for (const k of loadTemplates(ctx.cwd, undefined, trusted).keys()) allModes.add(k);
|
|
1655
1776
|
const knownHarnessesSet = new Set([...HARNESS_NAMES, ...Object.keys(ALIASES)]);
|
|
1656
1777
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
1657
1778
|
// if forcedHarness provided, it wins
|
|
@@ -1665,7 +1786,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1665
1786
|
}
|
|
1666
1787
|
|
|
1667
1788
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
1668
|
-
const templates = loadTemplates(ctx.cwd, harnessName);
|
|
1789
|
+
const templates = loadTemplates(ctx.cwd, harnessName, trusted);
|
|
1669
1790
|
const resolved = resolveDefaults(parsed, templates);
|
|
1670
1791
|
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1671
1792
|
const isDanger =
|
|
@@ -1682,7 +1803,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1682
1803
|
);
|
|
1683
1804
|
else
|
|
1684
1805
|
ctx.ui.notify?.(
|
|
1685
|
-
'Usage: /delegate [--harness=claude|codex|opencode|amp|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
|
|
1806
|
+
'Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=…] [--model=…] [--scope=…] [--verify=…] <prompt>',
|
|
1686
1807
|
'warning',
|
|
1687
1808
|
);
|
|
1688
1809
|
return;
|
|
@@ -1752,7 +1873,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1752
1873
|
|
|
1753
1874
|
pi.registerCommand('delegate', {
|
|
1754
1875
|
description:
|
|
1755
|
-
'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.',
|
|
1876
|
+
'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--verify=<cmd>] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report.',
|
|
1756
1877
|
handler: makeHandler(),
|
|
1757
1878
|
});
|
|
1758
1879
|
pi.registerCommand('claude', {
|
|
@@ -1775,6 +1896,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1775
1896
|
description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>',
|
|
1776
1897
|
handler: makeHandler('amp'),
|
|
1777
1898
|
});
|
|
1899
|
+
pi.registerCommand('devin', {
|
|
1900
|
+
description: 'Alias for /delegate --harness=devin. Usage: /devin [--mode=...] <prompt>',
|
|
1901
|
+
handler: makeHandler('devin'),
|
|
1902
|
+
});
|
|
1778
1903
|
|
|
1779
1904
|
pi.on('input', async (event, _ctx) => {
|
|
1780
1905
|
if (event.source === 'extension') return { action: 'continue' };
|
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* registry I/O failures never break a delegation — callers should combine this with their own
|
|
7
7
|
* in-process counters as a fallback.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* `acquireRun()` + `countActiveRuns()` alone are a plain check-then-act pair: read the count,
|
|
10
|
+
* decide, write — with no lock between the read and the write, so two pi processes starting at
|
|
11
|
+
* the same instant can both observe a count under the limit and both proceed, over-admitting for
|
|
12
|
+
* the full lifetime of both runs. `acquireRunWithinLimits()` below closes that specific window by
|
|
13
|
+
* re-verifying *after* writing: a write that turns out to push either count over its limit is
|
|
14
|
+
* undone immediately, so the cap can never be permanently exceeded — see its own doc comment for
|
|
15
|
+
* exactly what guarantee that is (and isn't). Callers that don't need the cap enforced — `/delegate
|
|
16
|
+
* status`'s display, or a caller happy with the plain best-effort behavior — can still use
|
|
17
|
+
* `acquireRun`/`countActiveRuns` directly.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -88,3 +92,44 @@ export function countActiveRuns(harness?: string): number {
|
|
|
88
92
|
}
|
|
89
93
|
return count;
|
|
90
94
|
}
|
|
95
|
+
|
|
96
|
+
export type AcquireWithinLimitsResult =
|
|
97
|
+
| { status: 'acquired'; handle: RunHandle }
|
|
98
|
+
/** Writing succeeded, but the run would push a limit over the top — undone, nothing held. */
|
|
99
|
+
| { status: 'full' }
|
|
100
|
+
/** Registry I/O failed — best-effort, same as `acquireRun` returning null: caller should fall
|
|
101
|
+
* back to in-process-only accounting and proceed rather than block the run. */
|
|
102
|
+
| { status: 'unavailable' };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Register an active run, then atomically-in-effect verify it's still within `maxGlobal` and
|
|
106
|
+
* `maxPerHarness` (either `<= 0` means "no limit" for that dimension) — undoing the registration
|
|
107
|
+
* if not. This turns the classic count-then-act race into a write-then-recheck one: because the
|
|
108
|
+
* recheck happens strictly *after* the write is committed to disk, whichever of two racing
|
|
109
|
+
* processes writes last is guaranteed to see both entries and correctly back off — over-admission
|
|
110
|
+
* (more than the limit standing at once) is impossible by construction, unlike plain
|
|
111
|
+
* `countActiveRuns()` + `acquireRun()`.
|
|
112
|
+
*
|
|
113
|
+
* This is not a perfect mutex, and doesn't try to be: in a tight enough multi-way race, more than
|
|
114
|
+
* one contender can each write, then each see the other's (or others') entry when it rechecks, and
|
|
115
|
+
* each concludes it's over the limit and backs off — even though exactly one of them could have
|
|
116
|
+
* fit. That's a transient *under*-admission (self-heals on the caller's next attempt, e.g. via
|
|
117
|
+
* `acquireSlot({wait: true})`'s poll loop) — the property this function actually guarantees is
|
|
118
|
+
* that the limit is never exceeded, not that it's always saturated.
|
|
119
|
+
*/
|
|
120
|
+
export function acquireRunWithinLimits(
|
|
121
|
+
harness: string,
|
|
122
|
+
mode: string,
|
|
123
|
+
maxGlobal: number,
|
|
124
|
+
maxPerHarness: number,
|
|
125
|
+
): AcquireWithinLimitsResult {
|
|
126
|
+
const handle = acquireRun(harness, mode);
|
|
127
|
+
if (!handle) return { status: 'unavailable' };
|
|
128
|
+
const overGlobal = maxGlobal > 0 && countActiveRuns() > maxGlobal;
|
|
129
|
+
const overHarness = maxPerHarness > 0 && countActiveRuns(harness) > maxPerHarness;
|
|
130
|
+
if (overGlobal || overHarness) {
|
|
131
|
+
releaseRun(handle);
|
|
132
|
+
return { status: 'full' };
|
|
133
|
+
}
|
|
134
|
+
return { status: 'acquired', handle };
|
|
135
|
+
}
|
package/extensions/templates.ts
CHANGED
|
@@ -43,11 +43,11 @@ export function normalizePermission(
|
|
|
43
43
|
const lower = raw.trim().toLowerCase();
|
|
44
44
|
if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
|
|
45
45
|
return { permission: 'readonly', permissionMode: 'plan' };
|
|
46
|
-
if (lower === 'edit' || lower === '
|
|
46
|
+
if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
|
|
47
47
|
return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
48
48
|
if (
|
|
49
49
|
lower === 'danger' ||
|
|
50
|
-
lower === '
|
|
50
|
+
lower === 'bypasspermissions' ||
|
|
51
51
|
lower === 'danger-full-access' ||
|
|
52
52
|
lower === 'danger_full_access'
|
|
53
53
|
)
|
|
@@ -139,16 +139,6 @@ export function projectTemplatesDir(cwd: string, harness?: string): string {
|
|
|
139
139
|
return join(cwd, '.pi', 'delegate', 'templates');
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/** Minimal trust gate for project-local templates — untrusted clones must not override builtins. */
|
|
143
|
-
function isTrusted(cwd: string): boolean {
|
|
144
|
-
if (process.env.PI_TRUSTED === '1' || process.env.PI_DELEGATE_TRUSTED === '1') return true;
|
|
145
|
-
try {
|
|
146
|
-
return readFileSync(join(cwd, '.pi', 'trusted'), 'utf8').trim() === '1';
|
|
147
|
-
} catch {
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
142
|
/** Legacy dirs for compat */
|
|
153
143
|
function legacyUserTemplatesDir(): string {
|
|
154
144
|
const dir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
|
|
@@ -158,8 +148,17 @@ function legacyProjectTemplatesDir(cwd: string): string {
|
|
|
158
148
|
return join(cwd, '.pi', 'claude-delegate', 'templates');
|
|
159
149
|
}
|
|
160
150
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Legacy root < shared < harness builtins < legacyUser < user < user/harness < legacyProject <
|
|
153
|
+
* project < project/harness (later wins).
|
|
154
|
+
*
|
|
155
|
+
* `trusted` gates the project-local tiers only (global/user tiers always load — they're the
|
|
156
|
+
* operator's own files, not the project's). It must come from pi's own trust store
|
|
157
|
+
* (`ctx.isProjectTrusted()`), never from anything inside `cwd` itself: a trust anchor that lives
|
|
158
|
+
* in the content it's supposed to gate can simply declare itself trusted. Callers that fail to
|
|
159
|
+
* resolve trust should pass `false` — untrusted is the safe default.
|
|
160
|
+
*/
|
|
161
|
+
export function loadTemplates(cwd: string, harnessName?: string, trusted = false): Map<string, DelegateTemplate> {
|
|
163
162
|
const out = new Map<string, DelegateTemplate>();
|
|
164
163
|
const harness = harnessName ?? 'claude';
|
|
165
164
|
// legacy root builtins (templates/*.md) lowest — for migration from pi-claude-delegate
|
|
@@ -173,7 +172,7 @@ export function loadTemplates(cwd: string, harnessName?: string): Map<string, De
|
|
|
173
172
|
loadDir(userTemplatesDir(), out);
|
|
174
173
|
loadDir(userTemplatesDir(harness), out);
|
|
175
174
|
// project locals: legacy before new so new wins — only if trusted
|
|
176
|
-
if (
|
|
175
|
+
if (trusted) {
|
|
177
176
|
loadDir(legacyProjectTemplatesDir(cwd), out);
|
|
178
177
|
loadDir(projectTemplatesDir(cwd), out);
|
|
179
178
|
loadDir(projectTemplatesDir(cwd, harness), out);
|
|
@@ -181,6 +180,24 @@ export function loadTemplates(cwd: string, harnessName?: string): Map<string, De
|
|
|
181
180
|
return out;
|
|
182
181
|
}
|
|
183
182
|
|
|
184
|
-
export function loadAllTemplates(cwd: string): Map<string, DelegateTemplate> {
|
|
185
|
-
return loadTemplates(cwd);
|
|
183
|
+
export function loadAllTemplates(cwd: string, trusted = false): Map<string, DelegateTemplate> {
|
|
184
|
+
return loadTemplates(cwd, undefined, trusted);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Which native permission string (if any) to hand the harness for this run.
|
|
189
|
+
*
|
|
190
|
+
* A template's native escape hatch (`permissionMode`/`sandbox`) applies only while the effective
|
|
191
|
+
* permission is still the template's own. Every harness's `buildArgs` prefers `nativePermission`
|
|
192
|
+
* over the normalized map, so passing it unconditionally would let a template's native mode
|
|
193
|
+
* silently override an explicit `allowDangerous` escalation — a per-call escalation would be
|
|
194
|
+
* quietly downgraded back to whatever the template declared.
|
|
195
|
+
*/
|
|
196
|
+
export function resolveNativePermission(
|
|
197
|
+
templatePermission: NormalizedPermission,
|
|
198
|
+
effectivePermission: NormalizedPermission,
|
|
199
|
+
nativePermission: string | undefined,
|
|
200
|
+
): string | undefined {
|
|
201
|
+
if (!nativePermission) return undefined;
|
|
202
|
+
return effectivePermission === templatePermission ? nativePermission : undefined;
|
|
186
203
|
}
|