pi-harness-delegate 0.7.2 → 0.7.3

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 CHANGED
@@ -76,7 +76,7 @@ The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff
76
76
 
77
77
  ### Fan out to multiple harnesses
78
78
 
79
- `harness` also accepts `all` or a comma-separated list — the same task runs on every harness **concurrently**, up to `maxConcurrent`, and comes back as one comparison report instead of one report per harness:
79
+ `harness` also accepts `all` or a comma-separated list — the same task runs on every harness **concurrently**, up to `maxConcurrent`, and comes back as one comparison report instead of one report per harness (on `/delegate` and the `delegate` tool alike, the spec is case-insensitive and empty elements are dropped, so `claude,` is a plain single `claude` run, not a one-harness fan-out; a spec with no harness in it at all, like `,`, is an error rather than the default harness):
80
80
 
81
81
  ```bash
82
82
  /delegate all review the auth flow # every *detected* harness
@@ -150,7 +150,7 @@ All frontmatter keys (one `key: value` per line; only `name` is required):
150
150
  | `name` | The mode name used to select it (`--mode=`/`mode`) |
151
151
  | `description` | Shown by `/delegate list` |
152
152
  | `permission` | `readonly` \| `edit` \| `danger` (default `edit`). Any other value is treated as a native permission string |
153
- | `permissionMode` / `sandbox` | Legacy/native escape hatch (see below) |
153
+ | `permissionMode` / `sandbox` | Legacy tier keys, only read when `permission` is absent (see below) |
154
154
  | `model` | Default model for this mode (call → template → harness → global) |
155
155
  | `maxBudgetUsd` | Default per-run spend cap in USD (a call's `maxBudgetUsd`/`--budget` wins) |
156
156
  | `skill` | Appends `Use the "<skill>" skill.` to the prompt |
@@ -160,22 +160,22 @@ All frontmatter keys (one `key: value` per line; only `name` is required):
160
160
  | `addDirs` | Comma-separated extra directories the harness may access (merged with the call's `addDirs`/`--add-dir`) |
161
161
  | `harness` | Informational: which harness a template targets (shown by `/delegate list`) |
162
162
 
163
- **Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, put the native value in `permission:` (e.g. `permission: ask` in a `devin/` template) — it is passed to that harness as-is. Legacy `permissionMode:`/`sandbox:` keys only map onto the normalized tiers (`plan` → `readonly`, `bypassPermissions` → `danger`, anything else → `edit`).
163
+ **Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, put the native value in `permission:` (e.g. `permission: ask` in a `devin/` template) — it is passed to that harness as-is. Legacy `permissionMode:`/`sandbox:` keys only map onto the normalized tiers, and only when `permission:` is absent (`permission:` always wins; a legacy key that disagrees with it is ignored but flagged with a `⚠` warning). Both keys accept both vocabularies, case-, whitespace- and `_`/`-`-insensitively: Claude's `plan` and Codex's `read-only` → `readonly`; `acceptEdits`/`dontAsk`/`auto`/`manual` and `workspace-write` → `edit`; `bypassPermissions` and `danger-full-access` → `danger` (gated behind `allowDangerous` / `--allow-dangerous` like any danger template). The three permission key names (`permission`, `permissionMode`, `sandbox`) are themselves case-insensitive (`Sandbox: read-only` counts); if a key appears more than once, or both legacy keys are set, the least permissive tier wins. An **unrecognized** legacy value fails closed: the template still loads, but as `readonly` (so it cannot write, and any `verify:` is skipped), with a `⚠ unrecognized sandbox: "…"` warning prefixed to its description in `/delegate list` and repeated on every run (a notification, a `- warning:` transcript line, and a prefix on the returned result). It is not dropped, because a dropped override would let a same-named builtin (e.g. `implement`, which is `edit`) run in its place, and that can be wider than what the author meant.
164
164
 
165
- Native values are checked against a per-harness **allowlist** of readonly/edit-equivalent modes; anything else — the harness's own danger mode *or a value not on the list* — is treated as `danger` and needs `allowDangerous` / `--allow-dangerous` (fail closed). Once confirmed, an unlisted value still runs as declared rather than being swapped for the harness's danger mode.
165
+ Native values are checked against a per-harness **allowlist** of readonly/edit-equivalent modes; anything else — the harness's own danger mode *or a value not on the list* — is treated as `danger` and needs `allowDangerous` / `--allow-dangerous` (fail closed). Once confirmed, an unlisted value still runs as declared rather than being swapped for the harness's danger mode. Matching is case-insensitive (`Plan` is claude's `plan`), and an allowlisted value always reaches the harness in its canonical spelling (e.g. `acceptEdits`); a case variant of a danger mode (`Yolo`, `BYPASSPERMISSIONS`) is still danger. A native value that is genuinely read-only on its harness (**bold** below) runs as the `readonly` tier: it is recorded as `readonly` and a `verify:` command on it is skipped, exactly like `permission: readonly`.
166
166
 
167
167
  | Harness | Native values treated as non-danger |
168
168
  | --- | --- |
169
- | `claude` | `plan`, `acceptEdits`, `manual`, `default` (`auto`, `dontAsk`, `bypassPermissions` → danger) |
170
- | `codex` | `read-only`, `workspace-write` |
171
- | `opencode` | `plan`, `build` (custom agents, `build --auto` → danger) |
172
- | `amp`/`omp` | `always-ask`, `write` |
173
- | `devin` | `plan`, `accept-edits`, `ask` (`smart`, `bypass` → danger) |
169
+ | `claude` | **`plan`**, `acceptEdits`, `manual`, `default` (`auto`, `dontAsk`, `bypassPermissions` → danger) |
170
+ | `codex` | **`read-only`**, `workspace-write` |
171
+ | `opencode` | **`plan`**, `build` (custom agents, `build --auto` → danger) |
172
+ | `amp`/`omp` | `always-ask`, `write` (`always-ask` stays `edit`: what `-p` does with an unanswerable ask is undocumented) |
173
+ | `devin` | **`plan`**, `accept-edits`, `ask` (`smart`, `bypass` → danger) |
174
174
 
175
175
  **Verify:** `verify` is a shell command run **on the host** (never handed to the harness) right after it exits — e.g. `verify: bun test` on an `implement`/`docs`/`general` template turns "the harness says it's done" into an actual pass/fail. It's report-only: a failing verify is appended as its own section in the transcript and injected report, and surfaced in the tool result's `details.verify` (`{command, exitCode, ok}`), but it never changes whether the run itself is reported as an error — that stays whatever the harness reported. No template ships one by default — there's no universally-correct check command, so nothing is invented for you.
176
176
 
177
177
  - **Sources, deliberately limited:** a verify command can only come from a template's `verify:` frontmatter, or a human typing `/delegate --verify="<cmd>"` (quotes needed for multi-word commands) — the call-level value wins over the template's. **It is not a parameter on the `delegate` tool** — that's on purpose, not an oversight: a tool param is set by the model, and the model's context includes repo content and delegated-harness output, both of which an attacker could influence, so a model-settable verify command would be a prompt-injection → arbitrary-host-command path. A model that wants verification simply picks a template that declares one.
178
- - **Never runs on a `readonly` template.** `readonly` (`review`/`plan`/`security-audit`) guarantees no execution or modification — a verify command riding along on one would quietly break that guarantee. If a `readonly` template (or override) has a `verify` configured, it's recorded as skipped (`### Verify: \`cmd\`` / `⊘ skipped (readonly run)`) rather than run, and never silently dropped.
178
+ - **Never runs on a `readonly` template** — including a native read-only one (`permission: plan`, codex `read-only`; see the table above). `readonly` (`review`/`plan`/`security-audit`) guarantees no execution or modification — a verify command riding along on one would quietly break that guarantee. If a `readonly` template (or override) has a `verify` configured, it's recorded as skipped (`### Verify: \`cmd\`` / `⊘ skipped (readonly run)`) rather than run, and never silently dropped.
179
179
  - A project-local template's `verify` command is gated by the same project-trust check as the rest of the template.
180
180
 
181
181
  **Template sources (later wins):**
@@ -128,6 +128,9 @@ interface PendingRequest {
128
128
  export interface AcpRunnerInternals {
129
129
  /** Overrides `HANDSHAKE_TIMEOUT_MS` so a hung-handshake test doesn't take 30s. */
130
130
  handshakeTimeoutMs?: number;
131
+ /** Replaces `child_process.spawn`, so a test can inject a stdin 'error' (EPIPE) deterministically —
132
+ * bun never emits one for a real closed pipe, which left the stdin error guard untestable. */
133
+ spawn?: typeof spawn;
131
134
  }
132
135
 
133
136
  export function runAcpHarness(opts: RunHarnessOptions, internals: AcpRunnerInternals = {}): Promise<HarnessResult> {
@@ -150,7 +153,10 @@ export function runAcpHarness(opts: RunHarnessOptions, internals: AcpRunnerInter
150
153
  resumeSessionId: opts.resumeSessionId,
151
154
  });
152
155
 
153
- const proc = spawn(opts.harness.binary, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
156
+ const proc = (internals.spawn ?? spawn)(opts.harness.binary, args, {
157
+ cwd: opts.cwd,
158
+ stdio: ['pipe', 'pipe', 'pipe'],
159
+ });
154
160
  // A write to a child that already exited surfaces as an async EPIPE 'error' on stdin — not as a
155
161
  // throw from write() (writeLine's try/catch can't see it). Unhandled, that event would crash the
156
162
  // host process; the run itself already ends through the 'close'/timeout/abort paths.
@@ -461,6 +461,8 @@ export function buildTranscript(
461
461
  verify?: VerifyResult;
462
462
  /** Budget outcome, when `maxBudgetUsd` was set for this run. */
463
463
  budget?: BudgetNote;
464
+ /** A template permission warning (e.g. an unrecognized legacy value failed closed to readonly). */
465
+ warning?: string;
464
466
  } & Record<string, unknown>,
465
467
  ): string {
466
468
  const harness = (opts.harness as string | undefined) ?? 'claude';
@@ -514,6 +516,7 @@ export function buildTranscript(
514
516
  `- context: ${context ?? 'n/a'}`,
515
517
  `- duration: ${duration ?? 'n/a'}`,
516
518
  `- stop reason: ${opts.stopReason ?? 'n/a'}`,
519
+ ...(opts.warning ? [`- warning: ${opts.warning}`] : []),
517
520
  ...(opts.budget ? [formatBudgetLine(opts.budget)] : []),
518
521
  ...(opts.budget?.message ? ['', opts.budget.message] : []),
519
522
  '',
@@ -68,11 +68,12 @@ function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>)
68
68
  }
69
69
 
70
70
  /**
71
- * Normalize a harness spec the same way whether it came from `--harness=` or the first word:
72
- * lowercased, empty list elements dropped (`claude,` -> `claude`, `,` -> none), and a single name
73
- * alias-normalized (`omp` -> `amp`). A real list / `all` is left for `resolveHarnessList`.
71
+ * Normalize a harness spec the same way whether it came from `--harness=`, the first word, or the
72
+ * `delegate` tool's `harness` param: lowercased, empty list elements dropped (`claude,` -> `claude`,
73
+ * `,` -> none), and a single name alias-normalized (`omp` -> `amp`). A real list / `all` is left for
74
+ * `resolveHarnessList`. Sharing it is what keeps both paths agreeing on single run vs fan-out.
74
75
  */
75
- function normalizeHarnessSpec(spec: string): string | undefined {
76
+ export function normalizeHarnessSpec(spec: string): string | undefined {
76
77
  const parts = spec
77
78
  .toLowerCase()
78
79
  .split(',')
@@ -83,6 +84,12 @@ function normalizeHarnessSpec(spec: string): string | undefined {
83
84
  return parts.join(',');
84
85
  }
85
86
 
87
+ /** The error for a harness spec that normalizes to nothing (`,`, `" , "`) — shared by `/delegate`'s
88
+ * `--harness=` and the `delegate` tool's `harness` param, so neither silently runs the default. */
89
+ export function emptyHarnessSpecError(raw: string): string {
90
+ return `harness ${JSON.stringify(raw)} names no harness: give a harness name, a comma-separated list, or "all" (omit it for the default harness)`;
91
+ }
92
+
86
93
  /**
87
94
  * One pass over the raw command: a backticked or double-quoted prose span is skipped verbatim (so
88
95
  * `explain "--mode=x"` or `` `--allow-dangerous` `` in the prompt is never eaten as a flag); a
@@ -188,6 +195,9 @@ export function parseDelegateCommand(
188
195
  }
189
196
 
190
197
  let harness = flags.harness !== undefined ? normalizeHarnessSpec(flags.harness) : undefined;
198
+ // `--harness=,` / `--harness=" , "` / `--harness=` name no harness at all: an explicit choice we
199
+ // can't honor is an error, never a silent fall-back to the default harness.
200
+ if (flags.harness !== undefined && harness === undefined) errors.push(emptyHarnessSpecError(flags.harness));
191
201
  let mode = flags.mode;
192
202
  let task = rest.trim();
193
203
 
@@ -145,21 +145,25 @@ export function parseMaxConcurrent(raw: unknown): DelegateConfig['maxConcurrent'
145
145
  * that changes a default (maxConcurrent 1 -> 4 already did) would never reach that user.
146
146
  */
147
147
  export function nonDefaultConfig(cfg: DelegateConfig): Partial<DelegateConfig> {
148
- const defaults = defaultDelegateConfig() as unknown as Record<string, unknown>;
149
- const out: Record<string, unknown> = {};
150
- for (const [k, v] of Object.entries(cfg as unknown as Record<string, unknown>)) {
151
- if (v === undefined) continue;
148
+ const defaults = defaultDelegateConfig();
149
+ const out: Partial<DelegateConfig> = {};
150
+ // Typed per key so each value is copied into `out` under its own field type — no casts.
151
+ const keep = <K extends keyof DelegateConfig>(k: K): void => {
152
+ const v = cfg[k];
153
+ if (v !== undefined && JSON.stringify(v) !== JSON.stringify(defaults[k])) out[k] = v;
154
+ };
155
+ for (const k of Object.keys(cfg) as (keyof DelegateConfig)[]) {
152
156
  if (k === 'modelAliases') {
153
157
  // The loader merges aliases additively onto the defaults, so only the keys that differ need
154
158
  // writing — whole-object diffing would pin the untouched default aliases next to a custom one.
155
- const d = defaults.modelAliases as Record<string, string>;
156
- const diff = Object.fromEntries(Object.entries(v as Record<string, string>).filter(([a, t]) => d[a] !== t));
157
- if (Object.keys(diff).length > 0) out[k] = diff;
159
+ const d = defaults.modelAliases;
160
+ const diff = Object.fromEntries(Object.entries(cfg.modelAliases ?? {}).filter(([a, t]) => d[a] !== t));
161
+ if (Object.keys(diff).length > 0) out.modelAliases = diff;
158
162
  continue;
159
163
  }
160
- if (JSON.stringify(v) !== JSON.stringify(defaults[k])) out[k] = v;
164
+ keep(k);
161
165
  }
162
- return out as Partial<DelegateConfig>;
166
+ return out;
163
167
  }
164
168
 
165
169
  /**
@@ -32,7 +32,14 @@ import {
32
32
  resolveModelForHarness,
33
33
  resolveTransport,
34
34
  } from './config.ts';
35
- import { ALIASES, classifyNativePermission, getHarness, HARNESS_NAMES } from './harnesses/registry.ts';
35
+ import {
36
+ ALIASES,
37
+ canonicalSafeNativePermission,
38
+ classifyNativePermission,
39
+ getHarness,
40
+ HARNESS_NAMES,
41
+ nativePermissionTier,
42
+ } from './harnesses/registry.ts';
36
43
  import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
37
44
  import { runHarness } from './runner.ts';
38
45
  import {
@@ -344,6 +351,13 @@ export async function delegate(
344
351
  );
345
352
  const task = opts.task || template.defaultTask;
346
353
  if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
354
+ // A template permission problem (an unrecognized legacy value failed closed to readonly, or an
355
+ // ignored legacy key) is otherwise only visible in `/delegate list` — say so where the run happens.
356
+ const warning = template.permissionWarning && `⚠ template "${mode}": ${template.permissionWarning}`;
357
+ if (warning) {
358
+ if (ctx.hasUI) ctx.ui.notify?.(warning, 'warning');
359
+ else process.stderr.write(`${warning}\n`);
360
+ }
347
361
 
348
362
  // Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
349
363
  // ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
@@ -354,9 +368,23 @@ export async function delegate(
354
368
  // human-confirmed in execute(); command: --allow-dangerous, human-confirmed in the handler). Resolved (and
355
369
  // the danger refusal thrown) before acquireSlot() — it's pure, so a refused run never occupies
356
370
  // (or, for fan-out, waits for) a concurrency slot it can't use.
357
- let permission: NormalizedPermission = template.permission;
358
- const nativePerm = template.nativePermission;
359
- const nativeClass = classifyNativePermission(harness, nativePerm);
371
+ const nativeClass = classifyNativePermission(harness, template.nativePermission);
372
+ // The tier the template actually runs at: a native read-only value (`permission: plan`) is filed
373
+ // under `edit` by normalizePermission, but runs as `readonly` — recorded as such and, above all,
374
+ // subject to resolveVerifyPlan's readonly skip. Only ever narrows (never `danger`), and the
375
+ // canonical native value below is still what reaches argv/ACP.
376
+ const templateTier: NormalizedPermission =
377
+ nativeClass === 'safe'
378
+ ? (nativePermissionTier(harness, template.nativePermission) ?? template.permission)
379
+ : template.permission;
380
+ let permission: NormalizedPermission = templateTier;
381
+ // A safe native matches its allowlist case-insensitively (`Plan`), but what reaches argv/ACP is
382
+ // always the allowlist's canonical spelling (`plan`, claude's camelCase `acceptEdits`) — never the
383
+ // template's. Danger/unlisted values keep the template's own spelling (see registry.ts).
384
+ const nativePerm =
385
+ nativeClass === 'safe'
386
+ ? canonicalSafeNativePermission(harness, template.nativePermission)
387
+ : template.nativePermission;
360
388
  const isNativeDanger = nativeClass === 'danger' || nativeClass === 'unlisted';
361
389
  if (template.permission === 'danger' || isNativeDanger) {
362
390
  if (opts.allowDangerous !== true) {
@@ -381,7 +409,7 @@ export async function delegate(
381
409
  const nativePermissionForRun =
382
410
  nativeClass === 'unlisted' && permission === 'danger'
383
411
  ? nativePerm
384
- : resolveNativePermission(template.permission, permission, nativePerm);
412
+ : resolveNativePermission(templateTier, permission, nativePerm);
385
413
 
386
414
  const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
387
415
  const addDirs = mergeAddDirs(ctx.cwd, template.addDirs, opts.addDirs);
@@ -480,6 +508,7 @@ export async function delegate(
480
508
  contextWindow: null,
481
509
  activityLog: collectActivityLog(activityEvents),
482
510
  output: streamedFull,
511
+ warning,
483
512
  }),
484
513
  );
485
514
  } catch (_e) {
@@ -548,6 +577,7 @@ export async function delegate(
548
577
  output: result.result || result.streamedText,
549
578
  verify,
550
579
  budget,
580
+ warning,
551
581
  }),
552
582
  );
553
583
  pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
@@ -556,7 +586,7 @@ export async function delegate(
556
586
 
557
587
  const output = result.result || result.streamedText || '(empty result)';
558
588
  return {
559
- content: budget?.message ? `${budget.message}\n\n${output}` : output,
589
+ content: [warning, budget?.message, output].filter(Boolean).join('\n\n'),
560
590
  details: {
561
591
  harness: harnessName,
562
592
  mode,
@@ -580,6 +610,7 @@ export async function delegate(
580
610
  usage: result.usage,
581
611
  verify,
582
612
  budget,
613
+ permissionWarning: template.permissionWarning ?? null,
583
614
  },
584
615
  result,
585
616
  activityLog: collectActivityLog(activityEvents),
@@ -273,6 +273,9 @@ export const ampHarness: Harness = {
273
273
  permissionMap: { readonly: ['always-ask'], edit: ['write'], danger: ['yolo'] },
274
274
  // `omp --approval-mode` values: always-ask, write, yolo.
275
275
  safeNativePermissions: ['always-ask', 'write'],
276
+ // No `readonlyNativePermissions`: `omp --help` only says `always-ask` asks before tool calls — what a
277
+ // non-interactive `-p` run does with an unanswerable ask (deny, hang, or proceed) is undocumented and
278
+ // unverified, so a native `always-ask` stays `edit` (and its `verify:` still runs) until it is.
276
279
  // `omp acp` is real and live-verified (docs/acp-harness-assessment.md §2/§4) — but deliberately
277
280
  // NOT offered as a config value yet: its ACP mode surface only has 2 tiers (`default`/`plan`),
278
281
  // while the stdout `--approval-mode` above has 3 genuine ones. Adding 'acp' here would let a
@@ -161,6 +161,9 @@ export const claudeHarness: Harness = {
161
161
  // `auto` (a classifier auto-approves actions), and `dontAsk` — its effective reach is whatever the
162
162
  // user's allow rules grant, which this extension can't see, so it fails closed.
163
163
  safeNativePermissions: ['plan', 'acceptEdits', 'manual', 'default'],
164
+ // `plan` is claude's read-only mode. `manual`/`default` are not listed: under `-p` they deny what
165
+ // would need approval, but that is a consequence of the user's allow rules, not a no-write mode.
166
+ readonlyNativePermissions: ['plan'],
164
167
  // `--max-budget-usd` (see buildArgs) — claude enforces the cap itself.
165
168
  nativeBudget: true,
166
169
  // No `acp` subcommand exists (docs/acp-harness-assessment.md §2) — confirmed against the full
@@ -293,6 +293,7 @@ export const codexHarness: Harness = {
293
293
  permissionMap: { readonly: ['read-only'], edit: ['workspace-write'], danger: ['danger-full-access'] },
294
294
  // `codex exec --sandbox` values: read-only, workspace-write, danger-full-access.
295
295
  safeNativePermissions: ['read-only', 'workspace-write'],
296
+ readonlyNativePermissions: ['read-only'],
296
297
  // No `acp` subcommand exists; `app-server` is a different, codex-proprietary JSON-RPC protocol,
297
298
  // not ACP (docs/acp-harness-assessment.md §2) — confirmed against the full `--help` output of
298
299
  // `codex`, `codex mcp-server`, `codex app-server`, and `codex exec`.
@@ -208,6 +208,9 @@ export const devinHarness: Harness = {
208
208
  // action, which the ACP runner always declines. Not listed (=> danger): `bypass`, and `smart`
209
209
  // ("additionally auto-runs actions a fast model judges safe" per `devin --help`).
210
210
  safeNativePermissions: ['plan', 'accept-edits', 'ask'],
211
+ // `ask` is not listed: it is read-only here only because the ACP runner declines every permission
212
+ // request — a property of this client, not of the mode.
213
+ readonlyNativePermissions: ['plan'],
211
214
  // ACP-only — no stdout mode exists to select between, so there's nothing to configure.
212
215
  supportsTransports: ['acp'],
213
216
  };
@@ -374,6 +374,9 @@ export const opencodeHarness: Harness = {
374
374
  // `--agent <name>` (stdout) / ACP mode id: only the built-in `plan`/`build` agents are known
375
375
  // quantities — a user-defined agent can carry any permission config, so it fails closed.
376
376
  safeNativePermissions: ['plan', 'build'],
377
+ // the built-in `plan` agent (stdout) / ACP mode is read-only — live-captured blocking a write
378
+ // (docs/acp-harness-assessment.md §4).
379
+ readonlyNativePermissions: ['plan'],
377
380
  // ACP path — opt-in only (transport defaults to 'stdout'; see config.ts's resolveTransport).
378
381
  // `--model` isn't wired here: `opencode acp --help` has no such flag, unlike `devin acp
379
382
  // --model`; the ACP handshake's own `configOptions` "model" category is the real mechanism
@@ -3,7 +3,7 @@ import { claudeHarness } from './claude.ts';
3
3
  import { codexHarness } from './codex.ts';
4
4
  import { devinHarness } from './devin.ts';
5
5
  import { opencodeHarness } from './opencode.ts';
6
- import type { Harness } from './types.ts';
6
+ import type { Harness, NormalizedPermission } from './types.ts';
7
7
 
8
8
  export const HARNESSES: Record<string, Harness> = {
9
9
  claude: claudeHarness,
@@ -54,8 +54,9 @@ export const normalizeHarnessName = resolveHarnessName;
54
54
 
55
55
  /**
56
56
  * Legacy danger spellings, kept for templates written before harnesses were partitioned.
57
+ * Lowercase: native permissions are compared case-insensitively (see `classifyNativePermission`).
57
58
  */
58
- const LEGACY_DANGER_TOKENS = new Set(['bypassPermissions', 'danger-full-access', 'danger']);
59
+ const LEGACY_DANGER_TOKENS = new Set(['bypasspermissions', 'danger-full-access', 'danger']);
59
60
 
60
61
  /**
61
62
  * How a template's native permission string (`permission: <native>` escape hatch) classifies on
@@ -66,6 +67,13 @@ const LEGACY_DANGER_TOKENS = new Set(['bypassPermissions', 'danger-full-access',
66
67
  * spelling: running it is exactly the harness's normalized danger tier.
67
68
  * - `unlisted` — anything else. Treated as danger (fail closed): a permissive mode nobody listed
68
69
  * (claude `auto`, devin `smart`, a custom opencode agent, …) must not slip past the gate.
70
+ *
71
+ * Matching is whitespace-trimmed and case-insensitive (`Plan` is claude's `plan`), and the danger
72
+ * checks run first, so a case variant of a danger token (`BYPASSPERMISSIONS`, `Yolo`) is still
73
+ * `danger`. A `safe` match is only a classification — the value that reaches argv/`session/set_mode`
74
+ * must be the allowlist's own spelling, via `canonicalSafeNativePermission` (claude's `acceptEdits`
75
+ * is camelCase; harness CLIs are case-sensitive, and a custom opencode agent named `PLAN` must
76
+ * never run in place of the built-in `plan` it was classified as).
69
77
  */
70
78
  export type NativePermissionClass = 'none' | 'safe' | 'danger' | 'unlisted';
71
79
 
@@ -73,15 +81,48 @@ export function classifyNativePermission(
73
81
  harness: Harness | undefined,
74
82
  nativePermission: string | undefined,
75
83
  ): NativePermissionClass {
76
- const native = nativePermission?.trim();
84
+ const native = nativePermission?.trim().toLowerCase();
77
85
  if (!native) return 'none';
78
86
  if (LEGACY_DANGER_TOKENS.has(native)) return 'danger';
79
87
  const danger = harness?.permissionMap?.danger;
80
- if (Array.isArray(danger) && danger.length > 0 && native === danger.join(' ')) return 'danger';
81
- if (harness?.safeNativePermissions?.includes(native)) return 'safe';
88
+ if (Array.isArray(danger) && danger.length > 0 && native === danger.join(' ').toLowerCase()) return 'danger';
89
+ if (canonicalSafeNativePermission(harness, nativePermission) !== undefined) return 'safe';
82
90
  return 'unlisted';
83
91
  }
84
92
 
93
+ /**
94
+ * The harness's own allowlist spelling of a native permission that matches it case-insensitively
95
+ * (whitespace-trimmed), or `undefined` when it isn't on the allowlist. Only meaningful for a value
96
+ * `classifyNativePermission` calls `safe` — that's the one class whose value is ever rewritten.
97
+ */
98
+ export function canonicalSafeNativePermission(
99
+ harness: Harness | undefined,
100
+ nativePermission: string | undefined,
101
+ ): string | undefined {
102
+ const native = nativePermission?.trim().toLowerCase();
103
+ if (!native) return undefined;
104
+ return harness?.safeNativePermissions?.find(v => v.toLowerCase() === native);
105
+ }
106
+
107
+ /**
108
+ * The normalized tier a native escape-hatch value actually runs at, when it is narrower than the
109
+ * `edit` tier `normalizePermission` files every native value under: `readonly` for a value on the
110
+ * harness's `readonlyNativePermissions` (claude/devin/opencode `plan`, codex `read-only`), matched
111
+ * case-insensitively through the same allowlist as `canonicalSafeNativePermission`. `undefined`
112
+ * otherwise — the template's own tier stands. Only ever narrows: it never reports `danger`, so it
113
+ * can't touch the danger gate, and it changes nothing about what reaches argv/ACP (the canonical
114
+ * native value is still what the harness gets). What it does change is the recorded permission and
115
+ * `resolveVerifyPlan`'s readonly skip — a native read-only run must never execute a `verify:` command.
116
+ */
117
+ export function nativePermissionTier(
118
+ harness: Harness | undefined,
119
+ nativePermission: string | undefined,
120
+ ): NormalizedPermission | undefined {
121
+ const canonical = canonicalSafeNativePermission(harness, nativePermission);
122
+ if (canonical === undefined) return undefined;
123
+ return harness?.readonlyNativePermissions?.includes(canonical) ? 'readonly' : undefined;
124
+ }
125
+
85
126
  /**
86
127
  * Does this native permission string require the danger gate on this harness?
87
128
  *
@@ -96,6 +96,13 @@ export interface Harness {
96
96
  * never heard of — is treated as danger (fail closed) by `isNativeDangerPermission` in registry.ts.
97
97
  * Omitted -> no native value is considered safe. Covers both transports' vocabularies. */
98
98
  safeNativePermissions?: readonly string[];
99
+ /** The subset of `safeNativePermissions` that is genuinely read-only (no writes, no command
100
+ * execution) on this harness. A native escape-hatch value listed here runs as the normalized
101
+ * `readonly` tier — recorded as `readonly` and, crucially, never lets a `verify:` command execute
102
+ * host-side (see `resolveVerifyPlan`). Conservative by design: only list a mode whose read-only
103
+ * behavior is documented for a non-interactive run; anything else stays `edit`. Must be spelled
104
+ * exactly as in `safeNativePermissions` (matching is case-insensitive via that allowlist). */
105
+ readonlyNativePermissions?: readonly string[];
99
106
  /** True when `buildArgs` passes `maxBudgetUsd` to a native CLI budget flag (claude's
100
107
  * `--max-budget-usd`). Otherwise the runners enforce it host-side from the streamed
101
108
  * `totalCostUsd` — possible only when the harness actually reports cost. */
@@ -27,7 +27,9 @@ import { formatToolUse, ToolCallIndex } from './activity.ts';
27
27
  import {
28
28
  aliasUsage,
29
29
  delegateUsage,
30
+ emptyHarnessSpecError,
30
31
  isFanoutSpec,
32
+ normalizeHarnessSpec,
31
33
  parseDelegateCommand,
32
34
  resolveDefaults,
33
35
  resolveHarnessFilter,
@@ -144,8 +146,15 @@ export default function (pi: ExtensionAPI) {
144
146
  promptGuidelines: [...DELEGATE_TOOL_GUIDELINES],
145
147
  parameters: DELEGATE_TOOL_PARAMS,
146
148
  async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
147
- // the deprecated alias pins its harness; everything below sees the effective params
148
- const params: DelegateToolParams = spec.forceHarness ? { ...rawParams, harness: spec.forceHarness } : rawParams;
149
+ // the deprecated alias pins its harness; everything below sees the effective params. The spec is
150
+ // normalized exactly as /delegate does (normalizeHarnessSpec), so `claude,` is a single run —
151
+ // fail-fast at capacity, single-run result shape — not a one-harness fan-out, and `omp` is `amp`.
152
+ // A non-empty spec that normalizes to nothing (`,`, `" , "`) is refused rather than silently run
153
+ // on the default harness (`""` stays "unset", the way a model omitting the field means it).
154
+ const harnessSpec = spec.forceHarness ?? rawParams.harness;
155
+ const harness = harnessSpec === undefined ? undefined : normalizeHarnessSpec(harnessSpec);
156
+ if (harnessSpec && harness === undefined) throw new Error(emptyHarnessSpecError(harnessSpec));
157
+ const params: DelegateToolParams = { ...rawParams, harness };
149
158
  const config = loadConfig();
150
159
  // A model-set allowDangerous is never honored on its own — a human confirms it (or, with no
151
160
  // UI to ask, it's refused). Checked once up front, before any fan-out. See validate.ts.
@@ -192,7 +201,7 @@ export default function (pi: ExtensionAPI) {
192
201
  };
193
202
  },
194
203
  renderCall(params, theme) {
195
- const harness = params.harness ?? 'delegate';
204
+ const harness = spec.forceHarness ?? params.harness ?? 'delegate';
196
205
  const mode = params.mode ?? 'general';
197
206
  const task = params.task ?? '';
198
207
  const taskStr = task ? ` — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}` : '';
@@ -23,6 +23,12 @@ export interface DelegateTemplate {
23
23
  nativePermission?: string;
24
24
  /** Legacy raw permissionMode for transcript compat. */
25
25
  permissionMode: PermissionMode;
26
+ /**
27
+ * A problem with the template's permission keys, shown in `/delegate list` and at run time: an
28
+ * unrecognized legacy `permissionMode:`/`sandbox:` value (failed closed to `readonly`), or a
29
+ * legacy key that disagrees with `permission:` and was ignored (tier unchanged).
30
+ */
31
+ permissionWarning?: string;
26
32
  model?: string;
27
33
  maxBudgetUsd?: number;
28
34
  skill?: string;
@@ -36,35 +42,180 @@ export interface DelegateTemplate {
36
42
  harness?: string;
37
43
  }
38
44
 
45
+ /** Tier order, least permissive first — used to pick the safer of two conflicting legacy keys. */
46
+ const TIER_RANK: Record<NormalizedPermission, number> = { readonly: 0, edit: 1, danger: 2 };
47
+
48
+ const TIER_PERMISSION_MODE: Record<NormalizedPermission, PermissionMode> = {
49
+ readonly: 'plan',
50
+ edit: 'acceptEdits',
51
+ danger: 'bypassPermissions',
52
+ };
53
+
54
+ /** Claude `PermissionMode` names, matched case- and `-`/`_`-insensitively (`accept-edits` → `acceptEdits`). */
55
+ const PERMISSION_MODES_BY_LOWER = new Map<string, PermissionMode>([...PERMISSION_MODES].map(m => [m.toLowerCase(), m]));
56
+
57
+ /** Codex `--sandbox` values (the legacy `sandbox:` key) → normalized tier. Keys are lowercase, `_`→`-`. */
58
+ const LEGACY_SANDBOX_TIERS: Record<string, NormalizedPermission> = {
59
+ 'read-only': 'readonly',
60
+ readonly: 'readonly',
61
+ 'workspace-write': 'edit',
62
+ 'danger-full-access': 'danger',
63
+ };
64
+
65
+ /**
66
+ * A frontmatter value echoed back in a warning (`/delegate list`, run-time notes): JSON-quoted so
67
+ * control characters / ANSI escapes are inert, and capped so a huge value can't flood the line.
68
+ */
69
+ function quoteValue(value: string): string {
70
+ return JSON.stringify(value).slice(0, 60);
71
+ }
72
+
73
+ interface LegacyPermission {
74
+ permission: NormalizedPermission;
75
+ permissionMode: PermissionMode;
76
+ /** Set when a legacy value was unrecognized and the template was failed closed to `readonly`. */
77
+ permissionWarning?: string;
78
+ }
79
+
80
+ /**
81
+ * Map one legacy `permissionMode:`/`sandbox:` value onto a normalized tier. Both keys accept both
82
+ * vocabularies (claude PermissionMode names and codex sandbox values), case/whitespace/`-`/`_`-insensitive.
83
+ * Returns `null` for an unrecognized value — the caller fails that closed.
84
+ */
85
+ function classifyLegacyValue(value: string): Omit<LegacyPermission, 'permissionWarning'> | null {
86
+ const lower = value.trim().toLowerCase();
87
+ // Claude names have no separators of their own, so `accept-edits`/`bypass_permissions` can only
88
+ // ever mean that same name (and tier) — danger stays gated behind allowDangerous downstream.
89
+ const mode = PERMISSION_MODES_BY_LOWER.get(lower.replace(/[-_]/g, ''));
90
+ if (mode) {
91
+ if (mode === 'plan') return { permission: 'readonly', permissionMode: mode };
92
+ if (mode === 'bypassPermissions') return { permission: 'danger', permissionMode: mode };
93
+ return { permission: 'edit', permissionMode: mode };
94
+ }
95
+ const tier = LEGACY_SANDBOX_TIERS[lower.replace(/_/g, '-')];
96
+ return tier ? { permission: tier, permissionMode: TIER_PERMISSION_MODE[tier] } : null;
97
+ }
98
+
99
+ /** The legacy keys actually set (non-empty), as `[key, value]` pairs. */
100
+ function legacyEntries(permissionMode: FrontmatterValues, sandbox: FrontmatterValues): [string, string][] {
101
+ return [
102
+ ...values(permissionMode).map(v => ['permissionMode', v] as [string, string]),
103
+ ...values(sandbox).map(v => ['sandbox', v] as [string, string]),
104
+ ];
105
+ }
106
+
107
+ /**
108
+ * A permission key's value(s): the keys are read case-insensitively and every occurrence is kept
109
+ * (`sandbox:` + `Sandbox:` is two values), so callers take an array; a single string still works.
110
+ */
111
+ type FrontmatterValues = string | readonly string[] | undefined;
112
+
113
+ /** Non-empty trimmed values — an empty value means "not set". */
114
+ function values(v: FrontmatterValues): string[] {
115
+ return (typeof v === 'string' ? [v] : (v ?? [])).map(x => x.trim()).filter(Boolean);
116
+ }
117
+
118
+ /**
119
+ * Legacy `permissionMode:`/`sandbox:` keys → normalized tier. Only consulted when `permission:` is
120
+ * absent. An empty/absent value means "not set" (default `edit`, as always).
121
+ *
122
+ * **Fails closed:** an unrecognized value (a typo, a value from some other harness's vocabulary)
123
+ * yields `readonly` plus `permissionWarning` — never the old silent `edit`, and never a refusal
124
+ * to load. Not loading would let a same-named lower tier (e.g. the builtin `implement`, `edit`)
125
+ * silently win instead, which can be MORE permissive than an author who wrote `sandbox: readonyl`
126
+ * meant; `readonly` is the one tier that can never exceed what any author meant (and it also skips
127
+ * `verify:`). When both keys (or several case variants of one) are set, the least permissive wins.
128
+ */
129
+ export function normalizeLegacyPermission(
130
+ permissionMode: FrontmatterValues,
131
+ sandbox: FrontmatterValues,
132
+ ): LegacyPermission {
133
+ const set = legacyEntries(permissionMode, sandbox);
134
+ if (set.length === 0) return { permission: 'edit', permissionMode: 'acceptEdits' };
135
+ let best: Omit<LegacyPermission, 'permissionWarning'> | null = null;
136
+ for (const [key, value] of set) {
137
+ const c = classifyLegacyValue(value);
138
+ if (!c) {
139
+ return {
140
+ permission: 'readonly',
141
+ permissionMode: 'plan',
142
+ permissionWarning: `unrecognized ${key}: ${quoteValue(value)} — loaded as readonly (fail closed)`,
143
+ };
144
+ }
145
+ if (!best || TIER_RANK[c.permission] < TIER_RANK[best.permission]) best = c;
146
+ }
147
+ return best as LegacyPermission;
148
+ }
149
+
39
150
  export function normalizePermission(
40
- raw: string | undefined,
41
- fallbackMode: string | undefined,
42
- ): { permission: NormalizedPermission; nativePermission?: string; permissionMode: PermissionMode } {
151
+ raw: FrontmatterValues,
152
+ fallbackMode: FrontmatterValues,
153
+ sandbox?: FrontmatterValues,
154
+ ): {
155
+ permission: NormalizedPermission;
156
+ nativePermission?: string;
157
+ permissionMode: PermissionMode;
158
+ permissionWarning?: string;
159
+ } {
43
160
  // Prefer normalized permission
44
- if (raw) {
45
- const lower = raw.trim().toLowerCase();
46
- if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
47
- return { permission: 'readonly', permissionMode: 'plan' };
48
- if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
49
- return { permission: 'edit', permissionMode: 'acceptEdits' };
50
- if (
51
- lower === 'danger' ||
52
- lower === 'bypasspermissions' ||
53
- lower === 'danger-full-access' ||
54
- lower === 'danger_full_access'
55
- )
56
- return { permission: 'danger', permissionMode: 'bypassPermissions' };
57
- // Unknown native — treat as native escape hatch
58
- return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
161
+ const raws = values(raw);
162
+ if (raws.length > 1) {
163
+ // `permission:` given more than once (e.g. `permission:` and `Permission:`) — the same tier is
164
+ // fine; disagreeing tiers take the least permissive, and a disagreement involving a native value
165
+ // (whose tier is per-harness, so not comparable here) fails closed to readonly. Never "last wins".
166
+ const all = raws.map(normalizeTierValue);
167
+ const distinct = new Set(all.map(r => `${r.permission}|${r.nativePermission ?? ''}`));
168
+ if (distinct.size > 1) {
169
+ const listed = raws.map(quoteValue).join(', ');
170
+ if (all.some(r => r.nativePermission))
171
+ return {
172
+ permission: 'readonly',
173
+ permissionMode: 'plan',
174
+ permissionWarning: `conflicting permission: values ${listed} — loaded as readonly (fail closed)`,
175
+ };
176
+ const least = all.reduce((a, b) => (TIER_RANK[b.permission] < TIER_RANK[a.permission] ? b : a));
177
+ return {
178
+ ...least,
179
+ permissionWarning: `conflicting permission: values ${listed} — using the least permissive, ${least.permission}`,
180
+ };
181
+ }
59
182
  }
60
- // Legacy permissionMode mapping
61
- if (fallbackMode && PERMISSION_MODES.has(fallbackMode as PermissionMode)) {
62
- const m = fallbackMode as PermissionMode;
63
- if (m === 'plan') return { permission: 'readonly', permissionMode: m };
64
- if (m === 'bypassPermissions') return { permission: 'danger', permissionMode: m };
65
- return { permission: 'edit', permissionMode: m };
183
+ if (raws.length > 0) {
184
+ const resolved = normalizeTierValue(raws[0]);
185
+ // `permission:` always wins; a legacy key that disagrees is ignored, but said so — an author who
186
+ // wrote `sandbox: read-only` next to `permission: edit` should see which one ran. Skipped for a
187
+ // native value: its tier is only known per harness at run time, so "disagrees" isn't decidable here.
188
+ const legacy = legacyEntries(fallbackMode, sandbox);
189
+ if (resolved.nativePermission || legacy.length === 0) return resolved;
190
+ const l = normalizeLegacyPermission(fallbackMode, sandbox);
191
+ if (!l.permissionWarning && l.permission === resolved.permission) return resolved;
192
+ const ignored = legacy.map(([k, v]) => `${k}: ${quoteValue(v)}`).join(', ');
193
+ return { ...resolved, permissionWarning: `permission: ${resolved.permission} overrides ${ignored} (ignored)` };
66
194
  }
67
- return { permission: 'edit', permissionMode: 'acceptEdits' };
195
+ // Legacy permissionMode/sandbox mapping — fails closed to readonly on an unrecognized value
196
+ return normalizeLegacyPermission(fallbackMode, sandbox);
197
+ }
198
+
199
+ /** One `permission:` value → tier, or (unknown value) the native escape hatch. */
200
+ function normalizeTierValue(raw: string): {
201
+ permission: NormalizedPermission;
202
+ nativePermission?: string;
203
+ permissionMode: PermissionMode;
204
+ } {
205
+ const lower = raw.trim().toLowerCase();
206
+ if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
207
+ return { permission: 'readonly', permissionMode: 'plan' };
208
+ if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
209
+ return { permission: 'edit', permissionMode: 'acceptEdits' };
210
+ if (
211
+ lower === 'danger' ||
212
+ lower === 'bypasspermissions' ||
213
+ lower === 'danger-full-access' ||
214
+ lower === 'danger_full_access'
215
+ )
216
+ return { permission: 'danger', permissionMode: 'bypassPermissions' };
217
+ // Unknown native — treat as native escape hatch
218
+ return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
68
219
  }
69
220
 
70
221
  /** Comma-separated frontmatter list (`a, b`) — undefined when absent or empty. */
@@ -76,33 +227,50 @@ function parseList(raw: string | undefined): string[] | undefined {
76
227
  return items.length > 0 ? items : undefined;
77
228
  }
78
229
 
230
+ type PermissionKey = 'permission' | 'permissionMode' | 'sandbox';
231
+ const PERMISSION_KEYS = new Map<string, PermissionKey>([
232
+ ['permission', 'permission'],
233
+ ['permissionmode', 'permissionMode'],
234
+ ['sandbox', 'sandbox'],
235
+ ]);
236
+
79
237
  /** Parse a template file: frontmatter (---\nkey: value\n---) + markdown body. */
80
238
  export function parseTemplate(text: string): DelegateTemplate | null {
81
239
  const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.trimStart());
82
240
  if (!m) return null;
83
241
 
84
242
  const meta: Record<string, string> = {};
243
+ // The keys that decide the tier are matched case-insensitively and keep every occurrence: an
244
+ // exact-case lookup let `Sandbox: read-only` be silently ignored (→ the `edit` default, verify on).
245
+ const perm: Record<PermissionKey, string[]> = { permission: [], permissionMode: [], sandbox: [] };
85
246
  for (const line of m[1].split('\n')) {
86
247
  const i = line.indexOf(':');
87
248
  if (i <= 0) continue;
88
- meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
249
+ const key = line.slice(0, i).trim();
250
+ const value = line.slice(i + 1).trim();
251
+ const permKey = PERMISSION_KEYS.get(key.toLowerCase());
252
+ if (permKey) perm[permKey].push(value);
253
+ else meta[key] = value;
89
254
  }
90
255
 
91
256
  const name = meta.name?.trim();
92
257
  if (!name) return null;
93
258
 
94
- const permRaw = meta.permission?.trim();
95
- const permModeRaw = meta.permissionMode?.trim() ?? meta.sandbox?.trim();
96
- const norm = normalizePermission(permRaw, permModeRaw);
259
+ const norm = normalizePermission(perm.permission, perm.permissionMode, perm.sandbox);
97
260
 
98
261
  const budget = meta.maxBudgetUsd ? Number(meta.maxBudgetUsd) : NaN;
262
+ const description = meta.description ?? '';
99
263
 
100
264
  return {
101
265
  name,
102
- description: meta.description ?? '',
266
+ // A permission-key problem is surfaced where the template is listed (`/delegate list`).
267
+ description: norm.permissionWarning
268
+ ? `⚠ ${norm.permissionWarning}${description ? ` · ${description}` : ''}`
269
+ : description,
103
270
  permission: norm.permission,
104
271
  nativePermission: norm.nativePermission,
105
272
  permissionMode: norm.permissionMode,
273
+ permissionWarning: norm.permissionWarning,
106
274
  model: meta.model || undefined,
107
275
  maxBudgetUsd: Number.isFinite(budget) && budget > 0 ? budget : undefined,
108
276
  skill: meta.skill || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp, Devin) 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",