pi-harness-delegate 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/extensions/acp-runner.ts +69 -9
- 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 +2 -0
- package/extensions/harnesses/opencode.ts +160 -0
- package/extensions/harnesses/types.ts +21 -6
- package/extensions/index.ts +190 -45
- package/extensions/run-registry.ts +50 -5
- package/extensions/templates.ts +67 -15
- package/extensions/usage.ts +16 -8
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
truncateToWidth,
|
|
30
30
|
} from '@earendil-works/pi-tui';
|
|
31
31
|
import { Type } from 'typebox';
|
|
32
|
-
import { runAcpHarness } from './acp-runner.ts';
|
|
32
|
+
import { acpView, runAcpHarness } from './acp-runner.ts';
|
|
33
33
|
import {
|
|
34
34
|
aggregateSpend,
|
|
35
35
|
buildFanoutReport,
|
|
@@ -50,14 +50,26 @@ import {
|
|
|
50
50
|
ToolCallIndex,
|
|
51
51
|
type VerifyResult,
|
|
52
52
|
} from './activity.ts';
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
isFanoutSpec,
|
|
55
|
+
parseDelegateCommand,
|
|
56
|
+
resolveDefaults,
|
|
57
|
+
resolveHarnessFilter,
|
|
58
|
+
resolveHarnessList,
|
|
59
|
+
} from './command.ts';
|
|
54
60
|
import { acquireSlot, activeCount } from './concurrency.ts';
|
|
55
61
|
import {
|
|
62
|
+
buildConfigReport,
|
|
56
63
|
type DelegateConfig,
|
|
64
|
+
describeConfigSource,
|
|
65
|
+
getMaxConcurrent,
|
|
57
66
|
outputsDir as getOutputsDir,
|
|
58
67
|
legacyOutputsDir,
|
|
59
68
|
loadConfig,
|
|
69
|
+
loadConfigWithSource,
|
|
60
70
|
resolveModelForHarness,
|
|
71
|
+
resolveTransport,
|
|
72
|
+
writeDelegateConfig,
|
|
61
73
|
} from './config.ts';
|
|
62
74
|
import {
|
|
63
75
|
ALIASES,
|
|
@@ -74,7 +86,13 @@ import { NotifyBatcher } from './notify.ts';
|
|
|
74
86
|
import { type FeedEntry, progressWindow } from './progress.ts';
|
|
75
87
|
import { formatFanoutChip, multiProgressWindow, type RunRow } from './progress-multi.ts';
|
|
76
88
|
import { runHarness } from './runner.ts';
|
|
77
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
type DelegateTemplate,
|
|
91
|
+
describeSkippedProjectTemplates,
|
|
92
|
+
loadTemplates,
|
|
93
|
+
projectTemplatePresence,
|
|
94
|
+
resolveNativePermission,
|
|
95
|
+
} from './templates.ts';
|
|
78
96
|
import { mapClaudeUsage } from './usage.ts';
|
|
79
97
|
|
|
80
98
|
/** Render a possibly-unknown cost — `null` means the harness didn't report one, not a measured $0. */
|
|
@@ -120,8 +138,9 @@ const FANOUT_LINGER_MS = 3000;
|
|
|
120
138
|
* callers must not let this flip a run's `isError`.
|
|
121
139
|
*
|
|
122
140
|
* Trust model: a verify command can only come from two places — on-disk template frontmatter
|
|
123
|
-
* (project-local templates are already
|
|
124
|
-
*
|
|
141
|
+
* (project-local templates are already gated by `isProjectTrusted(ctx)` — pi's own trust store,
|
|
142
|
+
* never anything inside the project itself) or a human typing `/delegate --verify=<cmd>` at the
|
|
143
|
+
* CLI. It is deliberately **not** a `delegate` tool parameter: a tool
|
|
125
144
|
* param is set by the model, whose context includes repo content and delegated-harness output —
|
|
126
145
|
* both attacker-influenceable, so a model-settable `verify` would be a prompt-injection ->
|
|
127
146
|
* arbitrary-host-command path (e.g. injected text in a reviewed file steering the parent agent
|
|
@@ -169,6 +188,42 @@ function outputsDirFor(harness: string): string {
|
|
|
169
188
|
return getOutputsDir(harness);
|
|
170
189
|
}
|
|
171
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Whether pi's own trust store (`ctx.isProjectTrusted()`, backed by `~/.pi/agent/trust.json`,
|
|
193
|
+
* outside any project) considers `ctx.cwd` trusted. This is the sole source of truth for whether
|
|
194
|
+
* project-local delegate templates load — see the trust-tier comment on `loadTemplates`. Fails
|
|
195
|
+
* closed (untrusted) if the host is old enough not to expose the method, or if it throws.
|
|
196
|
+
*/
|
|
197
|
+
/**
|
|
198
|
+
* Warn once per project when trusted-only content was silently skipped.
|
|
199
|
+
*
|
|
200
|
+
* Before 0.6.0 a committed `.pi/trusted` file (or `PI_TRUSTED=1`) granted trust; both were removed as
|
|
201
|
+
* a security fix. A user who relied on either loses their project-local templates on upgrade with no
|
|
202
|
+
* visible signal — an override shares its name with the builtin it replaces, so the run just uses the
|
|
203
|
+
* builtin and looks fine. `/delegate status` reports trust state, but nobody runs it unless something
|
|
204
|
+
* already looks wrong, so this fires on an actual delegation instead — and only when the project
|
|
205
|
+
* demonstrably has the content being skipped, so it never nags anyone unaffected.
|
|
206
|
+
*/
|
|
207
|
+
const warnedUntrustedProjects = new Set<string>();
|
|
208
|
+
|
|
209
|
+
function warnIfProjectTemplatesSkipped(ctx: ExtensionContext, trusted: boolean): void {
|
|
210
|
+
if (trusted || warnedUntrustedProjects.has(ctx.cwd)) return;
|
|
211
|
+
const lines = describeSkippedProjectTemplates(projectTemplatePresence(ctx.cwd));
|
|
212
|
+
if (lines.length === 0) return;
|
|
213
|
+
warnedUntrustedProjects.add(ctx.cwd);
|
|
214
|
+
const msg = lines.join('\n');
|
|
215
|
+
if (ctx.hasUI) ctx.ui.notify?.(msg, 'warning');
|
|
216
|
+
else process.stderr.write(`${msg}\n`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
|
220
|
+
try {
|
|
221
|
+
return typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
172
227
|
function formatTemplateRow(t: DelegateTemplate): string {
|
|
173
228
|
const parts = [
|
|
174
229
|
t.name,
|
|
@@ -182,15 +237,16 @@ function formatTemplateRow(t: DelegateTemplate): string {
|
|
|
182
237
|
|
|
183
238
|
async function showModes(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
184
239
|
const all = new Map<string, DelegateTemplate>();
|
|
240
|
+
const trusted = isProjectTrusted(ctx);
|
|
185
241
|
// collect from all harnesses if no filter
|
|
186
242
|
if (harnessFilter) {
|
|
187
|
-
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter)) all.set(k, v);
|
|
243
|
+
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter, trusted)) all.set(k, v);
|
|
188
244
|
} else {
|
|
189
245
|
for (const h of [...HARNESS_NAMES, 'shared']) {
|
|
190
|
-
for (const [k, v] of loadTemplates(ctx.cwd, h)) if (!all.has(k)) all.set(k, v);
|
|
246
|
+
for (const [k, v] of loadTemplates(ctx.cwd, h, trusted)) if (!all.has(k)) all.set(k, v);
|
|
191
247
|
}
|
|
192
248
|
// also load without harness param
|
|
193
|
-
for (const [k, v] of loadTemplates(ctx.cwd)) if (!all.has(k)) all.set(k, v);
|
|
249
|
+
for (const [k, v] of loadTemplates(ctx.cwd, undefined, trusted)) if (!all.has(k)) all.set(k, v);
|
|
194
250
|
}
|
|
195
251
|
const rows = [...all.values()].map(formatTemplateRow);
|
|
196
252
|
if (!ctx.hasUI) {
|
|
@@ -357,6 +413,7 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
357
413
|
return;
|
|
358
414
|
}
|
|
359
415
|
if (!ctx.hasUI) {
|
|
416
|
+
if (harnessFilter) process.stdout.write(`delegate — history (${harnessFilter})\n`);
|
|
360
417
|
for (const e of entries)
|
|
361
418
|
process.stdout.write(`${e.harness} ${e.mode} · ${formatCost(e.cost)} · ${e.sessionId ?? '-'}\n`);
|
|
362
419
|
return;
|
|
@@ -377,7 +434,10 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
377
434
|
list.onSelect = item => done(item.value);
|
|
378
435
|
list.onCancel = () => done(undefined);
|
|
379
436
|
return {
|
|
380
|
-
render: (w: number) =>
|
|
437
|
+
render: (w: number) => {
|
|
438
|
+
const rows = list.render(w);
|
|
439
|
+
return harnessFilter ? [theme.fg('accent', `delegate — history (${harnessFilter})`), ...rows] : rows;
|
|
440
|
+
},
|
|
381
441
|
invalidate: () => list.invalidate(),
|
|
382
442
|
handleInput: (data: string) => {
|
|
383
443
|
list.handleInput(data);
|
|
@@ -392,15 +452,22 @@ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promi
|
|
|
392
452
|
}
|
|
393
453
|
|
|
394
454
|
async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
395
|
-
const cfg =
|
|
455
|
+
const { config: cfg, source } = loadConfigWithSource();
|
|
396
456
|
const detection = await detectAll();
|
|
457
|
+
const trusted = isProjectTrusted(ctx);
|
|
397
458
|
const allHarnesses = harnessFilter ? [harnessFilter].filter(h => isKnownHarness(h)) : HARNESS_NAMES;
|
|
398
459
|
const lines: string[] = [];
|
|
399
460
|
lines.push(`delegate — status${harnessFilter ? ` (${harnessFilter})` : ''}`);
|
|
461
|
+
lines.push(...describeConfigSource(source));
|
|
400
462
|
lines.push(`defaultHarness: ${cfg.defaultHarness} · defaultMode: ${cfg.defaultMode} · model: ${cfg.model ?? '—'}`);
|
|
401
463
|
lines.push(
|
|
402
464
|
`maxConcurrent: ${typeof cfg.maxConcurrent === 'number' ? cfg.maxConcurrent : JSON.stringify(cfg.maxConcurrent)} · maxTranscripts: ${cfg.maxTranscripts}`,
|
|
403
465
|
);
|
|
466
|
+
lines.push(
|
|
467
|
+
trusted
|
|
468
|
+
? 'project trust: trusted — project-local templates (.pi/delegate/templates/) are loaded'
|
|
469
|
+
: "project trust: untrusted — project-local templates skipped (trust this project via pi's trust prompt, or set defaultProjectTrust, to load them)",
|
|
470
|
+
);
|
|
404
471
|
lines.push('');
|
|
405
472
|
lines.push('harness binary ok version outputs templates active');
|
|
406
473
|
lines.push('─'.repeat(78));
|
|
@@ -416,13 +483,15 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
416
483
|
} catch {}
|
|
417
484
|
let templates = 0;
|
|
418
485
|
try {
|
|
419
|
-
templates = loadTemplates(ctx.cwd, h).size;
|
|
486
|
+
templates = loadTemplates(ctx.cwd, h, trusted).size;
|
|
420
487
|
} catch {}
|
|
421
488
|
// cross-process count via the file registry, combined with the in-process counter as a fallback
|
|
422
489
|
const active = activeCount(h);
|
|
490
|
+
const cap = getMaxConcurrent(cfg, h);
|
|
491
|
+
const activeCol = `${active}/${cap > 0 ? cap : '∞'}`;
|
|
423
492
|
const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
|
|
424
493
|
lines.push(
|
|
425
|
-
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${
|
|
494
|
+
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${activeCol}${hint}`,
|
|
426
495
|
);
|
|
427
496
|
}
|
|
428
497
|
const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
@@ -435,9 +504,10 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
435
504
|
}
|
|
436
505
|
if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
|
|
437
506
|
if (!harnessFilter) {
|
|
507
|
+
const globalCap = getMaxConcurrent(cfg);
|
|
438
508
|
lines.push('');
|
|
439
509
|
lines.push(
|
|
440
|
-
`global active: ${activeCount()} · aliases: ${
|
|
510
|
+
`global active: ${activeCount()}/${globalCap > 0 ? globalCap : '∞'} · aliases: ${
|
|
441
511
|
Object.entries(ALIASES)
|
|
442
512
|
.map(([k, v]) => `${k}→${v}`)
|
|
443
513
|
.join(', ') || '—'
|
|
@@ -475,6 +545,61 @@ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promis
|
|
|
475
545
|
});
|
|
476
546
|
}
|
|
477
547
|
|
|
548
|
+
/**
|
|
549
|
+
* `/delegate config` — the discoverability gap `/delegate status`'s provenance line only hints at:
|
|
550
|
+
* shows exactly what was read from `settings.json` (or why it wasn't) plus the effective config
|
|
551
|
+
* with defaults filled in, formatted as a paste-ready JSON block under the `delegate` key. Print-
|
|
552
|
+
* only — writing is a separate, explicit action (`/delegate config init`, below), never triggered
|
|
553
|
+
* from this default view.
|
|
554
|
+
*/
|
|
555
|
+
async function showConfig(ctx: ExtensionContext): Promise<void> {
|
|
556
|
+
const result = loadConfigWithSource();
|
|
557
|
+
const lines = ['delegate — config', '', ...buildConfigReport(result)];
|
|
558
|
+
if (!ctx.hasUI) {
|
|
559
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
563
|
+
let offset = 0;
|
|
564
|
+
const height = 20;
|
|
565
|
+
return {
|
|
566
|
+
render(width: number): string[] {
|
|
567
|
+
const header = theme.fg('accent', `delegate config — ${result.source.file} (↑↓ scroll · any key to close)`);
|
|
568
|
+
const visible = lines.slice(offset, offset + height);
|
|
569
|
+
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
570
|
+
},
|
|
571
|
+
handleInput(data: string): void {
|
|
572
|
+
if (matchesKey(data, Key.up) && offset > 0) {
|
|
573
|
+
offset--;
|
|
574
|
+
tui.requestRender();
|
|
575
|
+
} else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
576
|
+
offset++;
|
|
577
|
+
tui.requestRender();
|
|
578
|
+
} else done(undefined);
|
|
579
|
+
},
|
|
580
|
+
invalidate() {},
|
|
581
|
+
};
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* `/delegate config init` — the one place this extension ever writes to `settings.json`, and only
|
|
587
|
+
* because a human explicitly typed this subcommand. Writes the current effective config (defaults
|
|
588
|
+
* merged with whatever was already on disk) into the `delegate` key via `writeDelegateConfig()`
|
|
589
|
+
* (read-modify-write, atomic, refuses on an unparseable file rather than clobbering it). This is
|
|
590
|
+
* also the practical fix for the legacy-`claudeDelegate`-only gap `describeConfigSource` warns
|
|
591
|
+
* about: writing an explicit `delegate` key (with the correctly-resolved values already folded
|
|
592
|
+
* in — the legacy migration already ran before this point) makes it win from then on, without
|
|
593
|
+
* this command ever touching or deleting the old `claudeDelegate` key itself.
|
|
594
|
+
*/
|
|
595
|
+
async function initConfig(ctx: ExtensionContext): Promise<void> {
|
|
596
|
+
const result = loadConfigWithSource();
|
|
597
|
+
const write = writeDelegateConfig(result.config);
|
|
598
|
+
const msg = write.ok ? `✓ ${write.message}` : `✗ ${write.message}`;
|
|
599
|
+
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
600
|
+
else ctx.ui.notify?.(msg, write.ok ? 'info' : 'warning');
|
|
601
|
+
}
|
|
602
|
+
|
|
478
603
|
function buildPrompt(
|
|
479
604
|
template: DelegateTemplate,
|
|
480
605
|
task: string,
|
|
@@ -514,7 +639,9 @@ async function delegate(
|
|
|
514
639
|
throw new Error(
|
|
515
640
|
`unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
|
|
516
641
|
);
|
|
517
|
-
const
|
|
642
|
+
const projectTrusted = isProjectTrusted(ctx);
|
|
643
|
+
warnIfProjectTemplatesSkipped(ctx, projectTrusted);
|
|
644
|
+
const templates = loadTemplates(ctx.cwd, harnessName, projectTrusted);
|
|
518
645
|
const mode = opts.mode || config.defaultMode;
|
|
519
646
|
const template = templates.get(mode);
|
|
520
647
|
if (!template)
|
|
@@ -524,6 +651,11 @@ async function delegate(
|
|
|
524
651
|
const task = opts.task || template.defaultTask;
|
|
525
652
|
if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
|
|
526
653
|
|
|
654
|
+
// Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
|
|
655
|
+
// ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
|
|
656
|
+
// not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
|
|
657
|
+
const transport = resolveTransport(config, harnessName, harness);
|
|
658
|
+
|
|
527
659
|
// concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
|
|
528
660
|
// exactly as before; fan-out passes waitForSlot:true to queue instead.
|
|
529
661
|
const release = await acquireSlot({
|
|
@@ -600,7 +732,10 @@ async function delegate(
|
|
|
600
732
|
},
|
|
601
733
|
nativePermission: nativePermissionForRun,
|
|
602
734
|
};
|
|
603
|
-
result =
|
|
735
|
+
result =
|
|
736
|
+
transport === 'acp'
|
|
737
|
+
? await runAcpHarness({ ...baseRunOpts, harness: acpView(harness) })
|
|
738
|
+
: await runHarness(baseRunOpts);
|
|
604
739
|
} catch (err) {
|
|
605
740
|
release();
|
|
606
741
|
if (streamedFull.length > 0) {
|
|
@@ -1518,8 +1653,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1518
1653
|
// occupying a concurrency slot.
|
|
1519
1654
|
const specs: FanoutSpec[] = [];
|
|
1520
1655
|
const immediateFailures: FanoutRunSummary[] = [];
|
|
1656
|
+
const trusted = isProjectTrusted(ctx);
|
|
1521
1657
|
for (const h of resolved) {
|
|
1522
|
-
const templates = loadTemplates(ctx.cwd, h);
|
|
1658
|
+
const templates = loadTemplates(ctx.cwd, h, trusted);
|
|
1523
1659
|
const resolvedTaskScope = resolveDefaults(parsed, templates);
|
|
1524
1660
|
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1525
1661
|
if (!resolvedTaskScope) {
|
|
@@ -1609,8 +1745,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1609
1745
|
await showStatus(ctx, h);
|
|
1610
1746
|
return;
|
|
1611
1747
|
}
|
|
1748
|
+
if (subLower === 'config init') {
|
|
1749
|
+
await initConfig(ctx);
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
if (subLower === 'config') {
|
|
1753
|
+
await showConfig(ctx);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1612
1756
|
// extract --harness flag for list/history subcommands
|
|
1613
|
-
const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1]
|
|
1757
|
+
const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1];
|
|
1614
1758
|
if (sub === 'watch' || sub === 'show') {
|
|
1615
1759
|
if (activeOverlay) {
|
|
1616
1760
|
activeOverlay.show();
|
|
@@ -1620,44 +1764,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
1620
1764
|
}
|
|
1621
1765
|
return;
|
|
1622
1766
|
}
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
}
|
|
1632
|
-
if (
|
|
1633
|
-
|
|
1634
|
-
|
|
1767
|
+
// Shared by list/history: resolve their (optional) harness filter to a canonical name via the
|
|
1768
|
+
// same alias/case rules (`omp` -> `amp`, any case), and reject a word that matches nothing —
|
|
1769
|
+
// rather than each falling back to silently showing an unfiltered or empty result.
|
|
1770
|
+
const filterHarness = (bareWord: string | undefined): string | undefined | 'unknown' => {
|
|
1771
|
+
if (forcedHarness) return forcedHarness;
|
|
1772
|
+
const resolution = resolveHarnessFilter(harnessFlag ?? bareWord, {
|
|
1773
|
+
isKnown: isKnownHarness,
|
|
1774
|
+
aliasOf: resolveHarnessName,
|
|
1775
|
+
});
|
|
1776
|
+
if (resolution.kind === 'unknown') {
|
|
1777
|
+
const msg = `unknown harness "${resolution.requested}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`;
|
|
1778
|
+
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
1779
|
+
else ctx.ui.notify?.(msg, 'warning');
|
|
1780
|
+
return 'unknown';
|
|
1635
1781
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1782
|
+
return resolution.kind === 'known' ? resolution.harness : undefined;
|
|
1783
|
+
};
|
|
1784
|
+
if (sub === 'list' || subLower.startsWith('list ')) {
|
|
1785
|
+
const h = filterHarness(subLower.startsWith('list ') ? sub.split(/\s+/)[1] : undefined);
|
|
1786
|
+
if (h === 'unknown') return;
|
|
1787
|
+
await showModes(ctx, h);
|
|
1638
1788
|
return;
|
|
1639
1789
|
}
|
|
1640
1790
|
if (sub === 'history' || sub === 'logs' || subLower.startsWith('history ') || subLower.startsWith('logs ')) {
|
|
1641
|
-
const h =
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
: undefined);
|
|
1647
|
-
if (h && isKnownHarness(h)) {
|
|
1648
|
-
await showHistory(ctx, h);
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
await showHistory(ctx, forcedHarness);
|
|
1791
|
+
const h = filterHarness(
|
|
1792
|
+
subLower.startsWith('history ') || subLower.startsWith('logs ') ? sub.split(/\s+/)[1] : undefined,
|
|
1793
|
+
);
|
|
1794
|
+
if (h === 'unknown') return;
|
|
1795
|
+
await showHistory(ctx, h);
|
|
1652
1796
|
return;
|
|
1653
1797
|
}
|
|
1654
1798
|
|
|
1655
1799
|
// combine forced harness + args for parsing
|
|
1656
1800
|
const rawForParse = forcedHarness ? `${forcedHarness} ${args}`.trim() : args;
|
|
1657
1801
|
// gather known modes across all harnesses for parsing
|
|
1802
|
+
const trusted = isProjectTrusted(ctx);
|
|
1658
1803
|
const allModes = new Set<string>();
|
|
1659
|
-
for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h).keys()) allModes.add(k);
|
|
1660
|
-
for (const k of loadTemplates(ctx.cwd).keys()) allModes.add(k);
|
|
1804
|
+
for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h, trusted).keys()) allModes.add(k);
|
|
1805
|
+
for (const k of loadTemplates(ctx.cwd, undefined, trusted).keys()) allModes.add(k);
|
|
1661
1806
|
const knownHarnessesSet = new Set([...HARNESS_NAMES, ...Object.keys(ALIASES)]);
|
|
1662
1807
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
1663
1808
|
// if forcedHarness provided, it wins
|
|
@@ -1671,7 +1816,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1671
1816
|
}
|
|
1672
1817
|
|
|
1673
1818
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
1674
|
-
const templates = loadTemplates(ctx.cwd, harnessName);
|
|
1819
|
+
const templates = loadTemplates(ctx.cwd, harnessName, trusted);
|
|
1675
1820
|
const resolved = resolveDefaults(parsed, templates);
|
|
1676
1821
|
const template = parsed.mode ? templates.get(parsed.mode) : undefined;
|
|
1677
1822
|
const isDanger =
|
|
@@ -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
|
@@ -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,8 +180,8 @@ 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);
|
|
186
185
|
}
|
|
187
186
|
|
|
188
187
|
/**
|
|
@@ -202,3 +201,56 @@ export function resolveNativePermission(
|
|
|
202
201
|
if (!nativePermission) return undefined;
|
|
203
202
|
return effectivePermission === templatePermission ? nativePermission : undefined;
|
|
204
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What a project has on disk that only loads when the project is trusted.
|
|
207
|
+
*
|
|
208
|
+
* Used to warn a user whose project-local templates stopped loading after the 0.6.0 security fix
|
|
209
|
+
* (§19 of ROADMAP) — before it, a committed `.pi/trusted` file or `PI_TRUSTED=1` granted trust, and
|
|
210
|
+
* both were removed. The failure is otherwise invisible: an override shares its name with the
|
|
211
|
+
* builtin it replaces, so the run silently uses the builtin and produces plausible output.
|
|
212
|
+
*
|
|
213
|
+
* Pure filesystem inspection — no trust logic. The caller supplies the trust decision.
|
|
214
|
+
*/
|
|
215
|
+
export function projectTemplatePresence(cwd: string): {
|
|
216
|
+
/** Template dirs that exist and would load if the project were trusted. */
|
|
217
|
+
dirs: string[];
|
|
218
|
+
/** A leftover `.pi/trusted` file — strong evidence the user relied on the removed mechanism. */
|
|
219
|
+
staleTrustFile: boolean;
|
|
220
|
+
} {
|
|
221
|
+
const candidates = [projectTemplatesDir(cwd), legacyProjectTemplatesDir(cwd)];
|
|
222
|
+
const dirs: string[] = [];
|
|
223
|
+
for (const dir of candidates) {
|
|
224
|
+
try {
|
|
225
|
+
if (readdirSync(dir).some(f => f.endsWith('.md'))) dirs.push(dir);
|
|
226
|
+
} catch {
|
|
227
|
+
// absent or unreadable — nothing to warn about
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
let staleTrustFile = false;
|
|
231
|
+
try {
|
|
232
|
+
staleTrustFile = existsSync(join(cwd, '.pi', 'trusted'));
|
|
233
|
+
} catch {
|
|
234
|
+
staleTrustFile = false;
|
|
235
|
+
}
|
|
236
|
+
return { dirs, staleTrustFile };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** One-line notices for a project whose trusted-only content was skipped. Empty when nothing applies. */
|
|
240
|
+
export function describeSkippedProjectTemplates(presence: { dirs: string[]; staleTrustFile: boolean }): string[] {
|
|
241
|
+
if (presence.dirs.length === 0 && !presence.staleTrustFile) return [];
|
|
242
|
+
const out: string[] = [];
|
|
243
|
+
if (presence.dirs.length > 0) {
|
|
244
|
+
out.push(
|
|
245
|
+
`⚠ project-local templates were NOT loaded — this project is untrusted (${presence.dirs.join(', ')})`,
|
|
246
|
+
" trust it via pi's trust prompt or defaultProjectTrust; /delegate status shows trust state",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (presence.staleTrustFile) {
|
|
250
|
+
out.push(
|
|
251
|
+
' a leftover .pi/trusted file was found — it no longer grants trust (removed in 0.6.0 as a',
|
|
252
|
+
' security fix, since a repo could use it to trust itself) and can be deleted',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
package/extensions/usage.ts
CHANGED
|
@@ -12,13 +12,21 @@ export interface HarnessUsage {
|
|
|
12
12
|
export type ClaudeUsage = HarnessUsage;
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Map harness usage/cost into pi's `Usage` shape so delegated runs appear
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Map harness usage/cost into pi's `Usage` shape so delegated runs appear in the pi footer
|
|
16
|
+
* token/cost stats and /session totals.
|
|
17
|
+
*
|
|
18
|
+
* Deliberate, bounded exception to the "never fake a number" rule: `Usage.cost.total` is
|
|
19
|
+
* mandatory (unlike `StreamedResult.totalCostUsd`, which stays `number | null` everywhere else
|
|
20
|
+
* in this codebase — the transcript still renders `cost: —` and `/delegate status`'s
|
|
21
|
+
* `aggregateSpend` still tracks unknown-cost runs separately). Codex and Devin genuinely report
|
|
22
|
+
* no dollar cost, so treating "cost unknown" as "usage unknown" here would drop 2 of 5 harnesses'
|
|
23
|
+
* tokens out of pi's session totals entirely. Reporting `$0` under-reports spend by a knowable
|
|
24
|
+
* amount (bounded: it's exactly the missing harnesses' true cost, never a guess); reporting no
|
|
25
|
+
* usage at all loses real token counts outright. Between those two errors, under-reporting spend
|
|
26
|
+
* is the lesser one — but this exception applies ONLY to this pi-`Usage` mapping. Do not
|
|
27
|
+
* generalize a `null -> 0` fallback to any other cost/spend path in the codebase.
|
|
19
28
|
*/
|
|
20
|
-
export function mapHarnessUsage(u: HarnessUsage): Usage
|
|
21
|
-
if (u.totalCostUsd === null) return undefined;
|
|
29
|
+
export function mapHarnessUsage(u: HarnessUsage): Usage {
|
|
22
30
|
const input = u.inputTokens + u.cacheCreationInputTokens;
|
|
23
31
|
const cacheRead = u.cacheReadInputTokens;
|
|
24
32
|
const output = u.outputTokens;
|
|
@@ -34,11 +42,11 @@ export function mapHarnessUsage(u: HarnessUsage): Usage | undefined {
|
|
|
34
42
|
output: 0,
|
|
35
43
|
cacheRead: 0,
|
|
36
44
|
cacheWrite: 0,
|
|
37
|
-
total: u.totalCostUsd,
|
|
45
|
+
total: u.totalCostUsd ?? 0,
|
|
38
46
|
},
|
|
39
47
|
};
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
export function mapClaudeUsage(u: ClaudeUsage): Usage
|
|
50
|
+
export function mapClaudeUsage(u: ClaudeUsage): Usage {
|
|
43
51
|
return mapHarnessUsage(u);
|
|
44
52
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-delegate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.14",
|