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.
@@ -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 { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
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 { type DelegateTemplate, loadTemplates, resolveNativePermission } from './templates.ts';
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 behind `isTrusted()`) or a human typing `/delegate
124
- * --verify=<cmd>` at the CLI. It is deliberately **not** a `delegate` tool parameter: a tool
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) => list.render(w),
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 = loadConfig();
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)} ${active}${hint}`,
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 templates = loadTemplates(ctx.cwd, harnessName);
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 = harness.transport === 'acp' ? await runAcpHarness(baseRunOpts) : await runHarness(baseRunOpts);
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]?.toLowerCase();
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
- if (sub === 'list' || subLower.startsWith('list ')) {
1624
- const h =
1625
- forcedHarness ??
1626
- harnessFlag ??
1627
- (subLower.startsWith('list ') ? sub.slice(5).trim().split(/\s+/)[0]?.toLowerCase() : undefined);
1628
- if (h && isKnownHarness(h)) {
1629
- await showModes(ctx, h);
1630
- return;
1631
- }
1632
- if (sub === 'list' || subLower === `list --harness=${h}`) {
1633
- await showModes(ctx, forcedHarness ?? h);
1634
- return;
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
- // fallback: list without filter or with unknown word — show filtered if known, otherwise all
1637
- await showModes(ctx, forcedHarness);
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
- forcedHarness ??
1643
- harnessFlag ??
1644
- (subLower.startsWith('history ') || subLower.startsWith('logs ')
1645
- ? sub.split(/\s+/)[1]?.toLowerCase()
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
- * Concurrency cap is best-effort, not a hard mutex: `countActiveRuns()` (read) and
10
- * `acquireRun()` (write) are two separate steps with no lock between them, so two pi
11
- * processes starting at the same instant can both observe a count under the limit and both
12
- * proceed — `maxConcurrent` can be exceeded by a small margin under a tight race. This is a
13
- * deliberate simplicity tradeoff (see AGENTS.md); do not rely on it for a hard cap.
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
+ }
@@ -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
- /** Legacy root < shared < harness builtins < legacyUser < user < user/harness < legacyProject < project < project/harness (later wins). */
162
- export function loadTemplates(cwd: string, harnessName?: string): Map<string, DelegateTemplate> {
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 (isTrusted(cwd)) {
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
+ }
@@ -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
- * in the pi footer token/cost stats and /session totals. Returns undefined when cost
17
- * is unknown — `Usage.cost.total` is mandatory, so there's no honest number to put there,
18
- * and reporting a fake $0 would silently under-report spend in pi's session totals.
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 | undefined {
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 | undefined {
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.5.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",