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.
@@ -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 { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessList } from './command.ts';
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 behind `isTrusted()`) or a human typing `/delegate
123
- * --verify=<cmd>` at the CLI. It is deliberately **not** a `delegate` tool parameter: a tool
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) => list.render(w),
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 = loadConfig();
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)} ${active}${hint}`,
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 = !!nativePerm && ['bypassPermissions', 'danger-full-access', 'danger'].includes(nativePerm);
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
- result = await runHarness({
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]?.toLowerCase();
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
- if (sub === 'list' || subLower.startsWith('list ')) {
1618
- const h =
1619
- forcedHarness ??
1620
- harnessFlag ??
1621
- (subLower.startsWith('list ') ? sub.slice(5).trim().split(/\s+/)[0]?.toLowerCase() : undefined);
1622
- if (h && isKnownHarness(h)) {
1623
- await showModes(ctx, h);
1624
- return;
1625
- }
1626
- if (sub === 'list' || subLower === `list --harness=${h}`) {
1627
- await showModes(ctx, forcedHarness ?? h);
1628
- return;
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
- // fallback: list without filter or with unknown word — show filtered if known, otherwise all
1631
- await showModes(ctx, forcedHarness);
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
- forcedHarness ??
1637
- harnessFlag ??
1638
- (subLower.startsWith('history ') || subLower.startsWith('logs ')
1639
- ? sub.split(/\s+/)[1]?.toLowerCase()
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
- * 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
+ }
@@ -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 === 'acceptEdits' || lower === 'accept-edits')
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 === 'bypassPermissions' ||
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
- /** 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,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
  }