pi-harness-delegate 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,7 @@ Only the prompt is required. A **harness as first word** and/or **mode as next w
47
47
  | `--scope=<scope>` | `diff` (current `git diff HEAD`), `pr` (`gh pr diff` for the current branch), or a path list |
48
48
  | `--pr=<pr>` | A specific PR to scope to: a number, an http(s) PR URL (`https://<host>/<owner>/<repo>/pull/<n>`, no `user@`), or `owner/repo#123` (implies the PR diff as scope) |
49
49
  | `--budget=<usd>` | Per-run spend cap in USD (same as the tool's `maxBudgetUsd`) |
50
+ | `--timeout=<sec>` | Harness timeout for this run in whole seconds (plain digits), `10`–`7200`; replaces the template's `timeout:` and the config, up or down. Out of range is an error, not ignored. (The tool's `timeoutSec` can only *lower* it — see [Security model](#security-model)) |
50
51
  | `--add-dir=<path>` | Extra directory the harness may access; repeatable (`--add-dir=../shared --add-dir=/opt/lib`) |
51
52
  | `--resume=<session-id>` | Continue a previous delegated session (single harness only — not with a fan-out) |
52
53
  | `--verify="<cmd>"` | Host-run check after the harness exits (see [Verify](#modes-templates)) |
@@ -70,13 +71,13 @@ Flag values may be quoted (`--verify="bun test && bun run lint"`). `--resume`, `
70
71
  Some modes have **default tasks** when the prompt is omitted:
71
72
  `/delegate review` reviews the current git diff (`scope: diff`), `/delegate security-audit` audits the repo. Modes without a default (`plan`, `implement`, `docs`, `general`) print a hint asking for a prompt.
72
73
 
73
- The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `allowDangerous`, `sessionId`, `pr`, `addDirs`. (`verify` is deliberately *not* a tool parameter — see below.) Setting `allowDangerous` from the tool always asks you to confirm interactively, and is refused outright in a non-interactive session — see [Security model](#security-model).
74
+ The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `timeoutSec` (whole seconds, `10`–`7200`; can only shorten the timeout the template/config gives the run, never lengthen it), `allowDangerous`, `sessionId`, `pr`, `addDirs`. (`verify` is deliberately *not* a tool parameter — see below.) Setting `allowDangerous` from the tool always asks you to confirm interactively, and is refused outright in a non-interactive session — see [Security model](#security-model).
74
75
 
75
- `claude_delegate` remains as a deprecated alias for `delegate{harness:claude}`.
76
+ `claude_delegate` remains as a deprecated alias for `delegate{harness:claude}`. A read-only `delegate_modes` tool lets the model list the available modes, their permission tier per harness and the installed harnesses before it delegates (see [Discovering modes](#modes-templates)).
76
77
 
77
78
  ### Fan out to multiple harnesses
78
79
 
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:
80
+ `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
81
 
81
82
  ```bash
82
83
  /delegate all review the auth flow # every *detected* harness
@@ -148,9 +149,9 @@ All frontmatter keys (one `key: value` per line; only `name` is required):
148
149
  | Key | Meaning |
149
150
  | --- | --- |
150
151
  | `name` | The mode name used to select it (`--mode=`/`mode`) |
151
- | `description` | Shown by `/delegate list` |
152
+ | `description` | Shown by `/delegate list` and the `delegate_modes` tool (sanitized, as data) |
152
153
  | `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) |
154
+ | `permissionMode` / `sandbox` | Legacy tier keys, only read when `permission` is absent (see below) |
154
155
  | `model` | Default model for this mode (call → template → harness → global) |
155
156
  | `maxBudgetUsd` | Default per-run spend cap in USD (a call's `maxBudgetUsd`/`--budget` wins) |
156
157
  | `skill` | Appends `Use the "<skill>" skill.` to the prompt |
@@ -159,25 +160,37 @@ All frontmatter keys (one `key: value` per line; only `name` is required):
159
160
  | `verify` | Host-run check command (see below) |
160
161
  | `addDirs` | Comma-separated extra directories the harness may access (merged with the call's `addDirs`/`--add-dir`) |
161
162
  | `harness` | Informational: which harness a template targets (shown by `/delegate list`) |
163
+ | `harnesses` | Default harness(es) for this mode, used **only when no harness is given** (comma list, e.g. `codex` or `claude, codex`). See below |
164
+ | `timeout` | Per-run harness timeout in whole seconds, `10`–`7200`. See below |
162
165
 
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`).
166
+ **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 — or legacy keys that disagree with each other — is ignored but flagged with a `⚠` warning — for a native `permission:` value, such as `permission: plan` next to `sandbox: workspace-write`, the check happens at run time against what that value means on the harness actually running it, e.g. `⚠ template "x": permission: "plan" (readonly on claude) overrides sandbox: "workspace-write" (ignored)`, so it appears on the run rather than in `/delegate list`). 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, and warnings name the key as you wrote it); 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
167
 
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.
168
+ 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
169
 
167
170
  | Harness | Native values treated as non-danger |
168
171
  | --- | --- |
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) |
172
+ | `claude` | **`plan`**, `acceptEdits`, `manual`, `default` (`auto`, `dontAsk`, `bypassPermissions` → danger) |
173
+ | `codex` | **`read-only`**, `workspace-write` |
174
+ | `opencode` | **`plan`**, `build` (custom agents, `build --auto` → danger) |
175
+ | `amp`/`omp` | `always-ask`, `write` (`always-ask` stays `edit`: what `-p` does with an unanswerable ask is undocumented) |
176
+ | `devin` | **`plan`**, `accept-edits`, `ask` (`smart`, `bypass` → danger) |
174
177
 
175
178
  **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
179
 
177
180
  - **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.
181
+ - **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
182
  - A project-local template's `verify` command is gated by the same project-trust check as the rest of the template.
180
183
 
184
+ **Default harnesses (`harnesses:`):** consulted only when the caller names no harness — an explicit `--harness=`, harness-as-first-word, alias command (`/codex`, …) or the tool's `harness` param always wins. The template copy that decides is the `defaultHarness`'s own copy of the mode when it has one (only that copy — copies kept for other harnesses never mix in); a mode kept only under other harnesses' folders (e.g. `~/.pi/agent/delegate/templates/codex/x.md`) uses the first such copy, in harness order (claude, codex, opencode, amp, devin), that declares `harnesses:`. The same rule drives `/delegate`, the `delegate` tool and `delegate_modes`, which shows the result (or that a harness must be named). Project-local copies count only for a trusted project, as for a run (an untrusted project's template can never pick the harness).
185
+ - The list is used the same way by `/delegate <mode> …` and by the `delegate` **tool**: one name is a normal single run on that harness; several make it a normal [fan-out](#fan-out-to-multiple-harnesses): uninstalled/unknown names are skipped and reported, runs queue for `maxConcurrent` slots, a danger escalation (`--allow-dangerous` / the tool's `allowDangerous`) asks once naming every harness and is refused headless, the tool's out-of-cwd `addDirs` confirm covers every harness, and resuming (`--resume` / `sessionId`) is rejected (a session belongs to one harness — name it explicitly). The model sees each mode's default harnesses in `delegate_modes`, and can always pin one with `harness`.
186
+ - `all` is not accepted (name the harnesses), and anything that isn't a plain harness-shaped word is dropped; both are flagged with a `⚠` warning. A template can't change permission through this — every harness runs the mode at its own tier, with the usual gates.
187
+
188
+ **Timeout (`timeout:`):** whole seconds from `10` to `7200` (2 h). The harness timeout for a run is resolved most-specific first: the template's `timeout:` → the per-harness `harnesses.<name>.timeoutMs` → the global `timeoutMs`. A per-call timeout then applies: your `/delegate --timeout=<sec>` replaces it (up or down), while the tool's model-set `timeoutSec` can only lower it — a larger value has no effect. A call or template value never exceeds 7200 s. Every transcript records the timeout the run got (`- timeout: …s`). An invalid template value (out of range, fractional, `600s`, …) is ignored — the next level applies — and flagged with a `⚠` warning in `/delegate list`, `delegate_modes` and on every run (same places as a permission warning); an invalid per-call value is an error and nothing runs.
189
+
190
+ **Discovering modes:** `/delegate list [harness]` shows every mode with its permission tier on each harness, where it came from (`builtin`/`user`/`project`), its defaults and any warnings. The model gets the same data from the read-only **`delegate_modes`** tool (optional `harness` filter), so it can pick a mode before delegating: per-harness tier (danger and unlisted native modes are marked as needing `allowDangerous`), whether a default task/scope or a host-run check is configured, the `harnesses:`/`timeout:` defaults, and which harnesses are on `PATH`. It runs nothing (a `PATH` lookup, not a `--version` probe — so "on PATH" isn't a guarantee it works), lists project-local templates only for a trusted project, and never shows a `verify:` command, prompt body, `defaultTask`/`defaultScope` text or file paths. Template text is untrusted: names, descriptions, models and warnings are stripped of ANSI escapes, control, bidi, zero-width, private-use and other invisible characters (including Unicode tag characters used to smuggle hidden text, Hangul fillers and the blank Braille pattern), runs of combining marks are cut to two, everything is kept to one line and length-capped, and in the tool's output it is JSON-quoted, with descriptions labelled as data, not instructions. Mode and model names also have every non-ASCII character shown escaped (`revi\u{435}w`) with a warning, so a look-alike name can't pass for a real one like `review`. `/delegate list` gets the same sanitizing, so a hostile description can't drive your terminal. When harness-specific copies of a mode differ (model, timeout, `harnesses:`, host check, …), the listing says which fields differ and whose copy it shows. Modes are listed builtin first, then user, then project, at most 50 from each source (the overflow is counted per source) — so a project with many templates can't push the builtin modes out of the list. A permission warning that only makes sense on a chosen harness (a native `permission:` value overriding ignored legacy keys) appears on the run, not in discovery.
191
+
192
+ Templates have no variable substitution (`{{task}}` etc.) — the task and scope are always appended by the extension.
193
+
181
194
  **Template sources (later wins):**
182
195
 
183
196
  - `templates/shared/*.md` — portable prompt bodies
@@ -275,7 +288,9 @@ One deliberate, narrow exception: pi's own `Usage` (the footer/session token+cos
275
288
  - `danger` — unrestricted, **only via an explicit per-call opt-in** — `allowDangerous:true` on the tool, or `--allow-dangerous` on `/delegate` — never a default (and never from config). Shows `⚠ danger` banner. `review`/`plan`/`security-audit` templates stay `readonly`.
276
289
  - When the **model** sets `allowDangerous: true` on the `delegate` tool, the extension asks you to confirm it (`Allow dangerous delegation?`) before anything runs; declining aborts the call, and in a non-interactive session (no UI to ask) it is refused outright. The model alone can never grant `danger`.
277
290
  - `/delegate --allow-dangerous` (and the `/claude`, `/codex`, … aliases) is the human-typed counterpart: it also asks you to confirm (`Allow dangerous delegation?`, naming the harness(es), mode, and that it runs with full, unrestricted permissions) — a fan-out gets one prompt covering every harness — and a decline runs nothing. In a non-interactive session it is refused outright, since there's no one to confirm with. It applies to that invocation only.
291
+ - **Model-set parameters can only narrow resource limits, never widen them.** The tool's `timeoutSec` can only shorten the timeout the template / your config gives a run — a prompt-injected model can't stretch a run (and its concurrency slot) past what you configured. `/delegate --timeout=` is yours and can raise it.
278
292
  - Likewise, when the **model** passes `addDirs` on the `delegate` tool, entries that resolve (after `..` and symlinks) outside the working directory need your interactive confirmation (`Allow access outside the project?`) and are refused in a non-interactive session. Entries inside the working directory, template `addDirs:`, and `/delegate --add-dir` are not gated.
293
+ - Template values echoed back in a `⚠` warning (the offending `sandbox:`/`permissionMode:`/`permission:`/`timeout:`/`harnesses:` value and the template's name) are quoted, with the same control and invisible characters that mode discovery strips (see **Discovering modes** — C0/C1 incl. the 8-bit CSI, bidi overrides/isolates, zero-width, line separators, tag characters, private-use, …) escaped as `\uXXXX` instead, and capped, so a hostile template can't recolor, reorder or hide text in the warning while you still see what it wrote.
279
294
  - Values that end up on a harness's command line (`sessionId`/`--resume`, `model`, `pr`) are validated first — notably, nothing starting with `-` is accepted, so a prompt-injected value can't masquerade as a CLI flag.
280
295
  - External content in the delegated prompt — the `git diff` for `diff`, the `gh pr diff` body for `pr`/`--pr`, and `gh`'s error output if the PR can't be fetched — is wrapped in an **untrusted-data block**: a backtick fence longer than any backtick run inside it, between `BEGIN/END UNTRUSTED DATA <nonce>` markers with a fresh random nonce, plus an instruction that the content is data to analyze, not instructions. A malicious PR can't close the block early or forge its end marker, and the PR is named in the prompt only as `owner/repo#n`/`#n`, never by its raw URL. A free-text `--scope` (e.g. `src/a.ts, src/b`, or a template's `defaultScope`) is delimited the same way between `BEGIN/END SCOPE <nonce>` markers, but framed as a restriction the harness must honor — it can narrow the task, never add to it. Your own task text stays the instruction and isn't fenced. This is a mitigation, not a guarantee — models can still be swayed by injected text.
281
296
 
@@ -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,10 @@ 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;
466
+ /** The harness timeout this run was given (`resolveRunTimeoutMs`), when known. */
467
+ timeoutMs?: number;
464
468
  } & Record<string, unknown>,
465
469
  ): string {
466
470
  const harness = (opts.harness as string | undefined) ?? 'claude';
@@ -514,6 +518,8 @@ export function buildTranscript(
514
518
  `- context: ${context ?? 'n/a'}`,
515
519
  `- duration: ${duration ?? 'n/a'}`,
516
520
  `- stop reason: ${opts.stopReason ?? 'n/a'}`,
521
+ ...(typeof opts.timeoutMs === 'number' ? [`- timeout: ${Math.round(opts.timeoutMs / 1000)}s`] : []),
522
+ ...(opts.warning ? [`- warning: ${opts.warning}`] : []),
517
523
  ...(opts.budget ? [formatBudgetLine(opts.budget)] : []),
518
524
  ...(opts.budget?.message ? ['', opts.budget.message] : []),
519
525
  '',
@@ -1,4 +1,9 @@
1
- import type { DelegateTemplate } from './templates.ts';
1
+ import {
2
+ callTimeoutError,
3
+ type DelegateTemplate,
4
+ TEMPLATE_TIMEOUT_MAX_SEC,
5
+ TEMPLATE_TIMEOUT_MIN_SEC,
6
+ } from './templates.ts';
2
7
 
3
8
  /**
4
9
  * Pure parser for /delegate and alias commands: --key=value flags, with optional
@@ -12,6 +17,8 @@ export interface DelegateCommandArgs {
12
17
  model?: string;
13
18
  scope?: string;
14
19
  budget?: number;
20
+ /** Per-call harness timeout in seconds (--timeout=<sec>); wins over the template's `timeout:`. */
21
+ timeoutSec?: number;
15
22
  /** Resume an existing delegated session (--resume=<id>). */
16
23
  sessionId?: string;
17
24
  /** GitHub PR number/URL to review (--pr=). */
@@ -42,7 +49,7 @@ export type ClaudeCommandArgs = DelegateCommandArgs;
42
49
  * description, so the hints can't drift from what `parseDelegateCommand` actually parses.
43
50
  */
44
51
  export const COMMAND_FLAGS_HINT =
45
- '[--mode=review|plan|implement|security-audit|docs|general] [--model=…] [--scope=diff|pr|paths] [--pr=<n|url>] [--budget=<usd>] [--verify=<cmd>] [--resume=<id>] [--add-dir=<path>] [--allow-dangerous] <prompt>';
52
+ '[--mode=review|plan|implement|security-audit|docs|general] [--model=…] [--scope=diff|pr|paths] [--pr=<n|url>] [--budget=<usd>] [--timeout=<sec>] [--verify=<cmd>] [--resume=<id>] [--add-dir=<path>] [--allow-dangerous] <prompt>';
46
53
 
47
54
  /** Usage line for `/delegate` itself. */
48
55
  export function delegateUsage(): string {
@@ -68,11 +75,12 @@ function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>)
68
75
  }
69
76
 
70
77
  /**
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`.
78
+ * Normalize a harness spec the same way whether it came from `--harness=`, the first word, or the
79
+ * `delegate` tool's `harness` param: lowercased, empty list elements dropped (`claude,` -> `claude`,
80
+ * `,` -> none), and a single name alias-normalized (`omp` -> `amp`). A real list / `all` is left for
81
+ * `resolveHarnessList`. Sharing it is what keeps both paths agreeing on single run vs fan-out.
74
82
  */
75
- function normalizeHarnessSpec(spec: string): string | undefined {
83
+ export function normalizeHarnessSpec(spec: string): string | undefined {
76
84
  const parts = spec
77
85
  .toLowerCase()
78
86
  .split(',')
@@ -83,6 +91,28 @@ function normalizeHarnessSpec(spec: string): string | undefined {
83
91
  return parts.join(',');
84
92
  }
85
93
 
94
+ /**
95
+ * The harness spec a template's `harnesses:` frontmatter contributes when the caller named no
96
+ * harness (`undefined` when it declares none). Callers only consult this when no harness was given —
97
+ * an explicit `--harness=`, first-word harness, alias command or tool `harness` param always wins.
98
+ *
99
+ * The same on both paths (the `/delegate` command and the `delegate` tool): one name is a normal
100
+ * single run (fail-fast at capacity); several are a fan-out spec, which each path hands to its
101
+ * unchanged fan-out runner (`runFanoutCommand` / `runFanoutTool`) — `resolveHarnessList` detection
102
+ * filtering and unknown/skipped reporting, `waitForSlot: true` through `acquireSlot`,
103
+ * `fanoutResumeError`, and the path's own danger/addDirs confirm gates resolved against this spec.
104
+ */
105
+ export function templateHarnessDefault(harnesses: readonly string[] | undefined): string | undefined {
106
+ if (!harnesses || harnesses.length === 0) return undefined;
107
+ return normalizeHarnessSpec(harnesses.join(','));
108
+ }
109
+
110
+ /** The error for a harness spec that normalizes to nothing (`,`, `" , "`) — shared by `/delegate`'s
111
+ * `--harness=` and the `delegate` tool's `harness` param, so neither silently runs the default. */
112
+ export function emptyHarnessSpecError(raw: string): string {
113
+ return `harness ${JSON.stringify(raw)} names no harness: give a harness name, a comma-separated list, or "all" (omit it for the default harness)`;
114
+ }
115
+
86
116
  /**
87
117
  * One pass over the raw command: a backticked or double-quoted prose span is skipped verbatim (so
88
118
  * `explain "--mode=x"` or `` `--allow-dangerous` `` in the prompt is never eaten as a flag); a
@@ -98,6 +128,7 @@ const RECOGNIZED_FLAGS = new Set([
98
128
  'model',
99
129
  'scope',
100
130
  'budget',
131
+ 'timeout',
101
132
  'resume',
102
133
  'pr',
103
134
  'verify',
@@ -121,7 +152,8 @@ export function parseDelegateCommand(
121
152
  const errors: string[] = [];
122
153
  const notices: string[] = [];
123
154
  let allowDangerousBare = false;
124
- let budgetWithoutValue = false;
155
+ // `--budget` / `--timeout` given with no `=value` (e.g. the space form `--budget 5`)
156
+ const valuelessLimits = new Set<string>();
125
157
  const rest = raw.replace(
126
158
  FLAG_OR_PROSE,
127
159
  (
@@ -148,11 +180,11 @@ export function parseDelegateCommand(
148
180
  }
149
181
  const hasValue = dq !== undefined || sq !== undefined || bare !== undefined;
150
182
  if (!hasValue) {
151
- // bare boolean flag: only `--allow-dangerous`. A bare `--budget` (e.g. the space form
152
- // `--budget 5`) is an explicit cap we can't honor — an error, never silently "no cap".
153
- // Any other bare `--word` is prose.
154
- if (k === 'budget') {
155
- budgetWithoutValue = true;
183
+ // bare boolean flag: only `--allow-dangerous`. A bare `--budget`/`--timeout` (e.g. the space
184
+ // form `--budget 5`) is an explicit limit we can't honor — an error, never silently "no
185
+ // limit". Any other bare `--word` is prose.
186
+ if (k === 'budget' || k === 'timeout') {
187
+ valuelessLimits.add(k);
156
188
  return m;
157
189
  }
158
190
  if (k !== 'allow-dangerous') return m;
@@ -181,13 +213,20 @@ export function parseDelegateCommand(
181
213
  'unbalanced " or ` in the command — flags around it can\'t be told apart from prompt text; close the quote (or remove it) and try again',
182
214
  );
183
215
  }
184
- if (budgetWithoutValue && flags.budget === undefined) {
185
- errors.push(
186
- '--budget needs a value: use --budget=<usd> (or wrap the text in backticks if it is part of the prompt)',
187
- );
216
+ for (const [k, unit] of [
217
+ ['budget', 'usd'],
218
+ ['timeout', 'sec'],
219
+ ] as const) {
220
+ if (valuelessLimits.has(k) && flags[k] === undefined)
221
+ errors.push(
222
+ `--${k} needs a value: use --${k}=<${unit}> (or wrap the text in backticks if it is part of the prompt)`,
223
+ );
188
224
  }
189
225
 
190
226
  let harness = flags.harness !== undefined ? normalizeHarnessSpec(flags.harness) : undefined;
227
+ // `--harness=,` / `--harness=" , "` / `--harness=` name no harness at all: an explicit choice we
228
+ // can't honor is an error, never a silent fall-back to the default harness.
229
+ if (flags.harness !== undefined && harness === undefined) errors.push(emptyHarnessSpecError(flags.harness));
191
230
  let mode = flags.mode;
192
231
  let task = rest.trim();
193
232
 
@@ -215,6 +254,16 @@ export function parseDelegateCommand(
215
254
  if (flags.budget.trim() !== '' && Number.isFinite(budget) && budget > 0) out.budget = budget;
216
255
  else errors.push(`--budget must be a positive number of USD (got "${flags.budget}")`);
217
256
  }
257
+ if (flags.timeout !== undefined) {
258
+ const v = flags.timeout.trim();
259
+ const sec = /^\d{1,9}$/.test(v) ? Number(v) : Number.NaN;
260
+ // an explicit time limit that can't be honored is an error, never silently "the configured one"
261
+ if (callTimeoutError(sec) === null) out.timeoutSec = sec;
262
+ else
263
+ errors.push(
264
+ `--timeout must be a whole number of seconds from ${TEMPLATE_TIMEOUT_MIN_SEC} to ${TEMPLATE_TIMEOUT_MAX_SEC} (got "${flags.timeout.slice(0, 40)}")`,
265
+ );
266
+ }
218
267
  if (flags.resume) out.sessionId = flags.resume;
219
268
  if (flags.pr) out.pr = flags.pr;
220
269
  if (flags.verify) out.verify = flags.verify;
@@ -12,6 +12,7 @@ import {
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { DEFAULT_TIMEOUT_MS, type Harness, type Transport } from './harnesses/types.ts';
15
+ import { TEMPLATE_TIMEOUT_MAX_SEC } from './templates.ts';
15
16
 
16
17
  export interface HarnessConfig {
17
18
  model?: string;
@@ -145,21 +146,25 @@ export function parseMaxConcurrent(raw: unknown): DelegateConfig['maxConcurrent'
145
146
  * that changes a default (maxConcurrent 1 -> 4 already did) would never reach that user.
146
147
  */
147
148
  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;
149
+ const defaults = defaultDelegateConfig();
150
+ const out: Partial<DelegateConfig> = {};
151
+ // Typed per key so each value is copied into `out` under its own field type — no casts.
152
+ const keep = <K extends keyof DelegateConfig>(k: K): void => {
153
+ const v = cfg[k];
154
+ if (v !== undefined && JSON.stringify(v) !== JSON.stringify(defaults[k])) out[k] = v;
155
+ };
156
+ for (const k of Object.keys(cfg) as (keyof DelegateConfig)[]) {
152
157
  if (k === 'modelAliases') {
153
158
  // The loader merges aliases additively onto the defaults, so only the keys that differ need
154
159
  // 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;
160
+ const d = defaults.modelAliases;
161
+ const diff = Object.fromEntries(Object.entries(cfg.modelAliases ?? {}).filter(([a, t]) => d[a] !== t));
162
+ if (Object.keys(diff).length > 0) out.modelAliases = diff;
158
163
  continue;
159
164
  }
160
- if (JSON.stringify(v) !== JSON.stringify(defaults[k])) out[k] = v;
165
+ keep(k);
161
166
  }
162
- return out as Partial<DelegateConfig>;
167
+ return out;
163
168
  }
164
169
 
165
170
  /**
@@ -427,6 +432,44 @@ export function writeDelegateConfig(delegateSubtree: unknown): WriteConfigResult
427
432
  }
428
433
  }
429
434
 
435
+ /**
436
+ * The harness timeout for one run. The **configured** timeout is, most specific first:
437
+ *
438
+ * 1. the template's `timeout:` frontmatter;
439
+ * 2. the **per-harness** config `harnesses.<name>.timeoutMs`;
440
+ * 3. the **global** config `timeoutMs` (always set, defaults to `DEFAULT_TIMEOUT_MS`).
441
+ *
442
+ * A **per-call** timeout then applies on top, and who set it matters:
443
+ *
444
+ * - `callMayRaise: true` — a human typed it (`/delegate --timeout=<sec>`): it replaces the configured
445
+ * timeout, up or down;
446
+ * - `callMayRaise: false` (the default) — the model set it (`delegate` tool `timeoutSec`): it can only
447
+ * **lower** the configured timeout, never raise it. A tool param is model-settable and the model's
448
+ * context is attacker-influenceable, so a raising value would let a prompt-injected model hold every
449
+ * `maxConcurrent` slot far past what the user configured. Same "model-settable params only narrow"
450
+ * rule as `allowDangerous`/`addDirs`/`verify`.
451
+ *
452
+ * Never unbounded: a call or template value (seconds) is clamped to `TEMPLATE_TIMEOUT_MAX_SEC` here
453
+ * as defense in depth — `parseTemplateTimeout` / `callTimeoutError` already reject out-of-range
454
+ * values with a clear message — and config values are only ever positive numbers (`loadConfig`).
455
+ * With neither a call nor a template timeout this is exactly the previous
456
+ * `harnesses.<name>.timeoutMs ?? timeoutMs`.
457
+ */
458
+ export function resolveRunTimeoutMs(
459
+ cfg: DelegateConfig,
460
+ harnessName: string,
461
+ templateTimeoutSec?: number,
462
+ callTimeoutSec?: number,
463
+ callMayRaise = false,
464
+ ): number {
465
+ const usable = (v: number | undefined): v is number => typeof v === 'number' && Number.isFinite(v) && v > 0;
466
+ const capMs = (sec: number) => Math.min(sec, TEMPLATE_TIMEOUT_MAX_SEC) * 1000;
467
+ const raw = cfg.harnesses[harnessName]?.timeoutMs;
468
+ const configured = usable(templateTimeoutSec) ? capMs(templateTimeoutSec) : usable(raw) ? raw : cfg.timeoutMs;
469
+ if (!usable(callTimeoutSec)) return configured;
470
+ return callMayRaise ? capMs(callTimeoutSec) : Math.min(capMs(callTimeoutSec), configured);
471
+ }
472
+
430
473
  export function resolveModelForHarness(
431
474
  cfg: DelegateConfig,
432
475
  harness: string,
@@ -30,16 +30,27 @@ import {
30
30
  legacyOutputsDir,
31
31
  loadConfig,
32
32
  resolveModelForHarness,
33
+ resolveRunTimeoutMs,
33
34
  resolveTransport,
34
35
  } from './config.ts';
35
- import { ALIASES, classifyNativePermission, getHarness, HARNESS_NAMES } from './harnesses/registry.ts';
36
+ import {
37
+ ALIASES,
38
+ canonicalSafeNativePermission,
39
+ classifyNativePermission,
40
+ getHarness,
41
+ HARNESS_NAMES,
42
+ nativePermissionTier,
43
+ } from './harnesses/registry.ts';
36
44
  import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
37
45
  import { runHarness } from './runner.ts';
38
46
  import {
47
+ callTimeoutError,
39
48
  type DelegateTemplate,
40
49
  describeSkippedProjectTemplates,
41
50
  loadTemplates,
51
+ nativeOverrideWarning,
42
52
  projectTemplatePresence,
53
+ quoteValue,
43
54
  resolveNativePermission,
44
55
  } from './templates.ts';
45
56
  import { validateDelegateInputs } from './validate.ts';
@@ -61,6 +72,17 @@ export interface DelegateOptions {
61
72
  /** Extra directories the harness may access, merged with the template's `addDirs` (relative
62
73
  * paths resolve against the run's cwd). Per-harness limits apply — see each `buildArgs`. */
63
74
  addDirs?: string[];
75
+ /**
76
+ * Per-call harness timeout in seconds, bounded like a template timeout (an out-of-range value fails
77
+ * the run). By default it can only **lower** the configured timeout (template > per-harness >
78
+ * global) — the model-settable `delegate` tool's semantics; see `resolveRunTimeoutMs`.
79
+ */
80
+ timeoutSec?: number;
81
+ /**
82
+ * The per-call `timeoutSec` was typed by a human (`/delegate --timeout=`), so it may also raise the
83
+ * configured timeout. Only the command paths set this — never the tool path, never from config.
84
+ */
85
+ timeoutSecMayRaise?: boolean;
64
86
  /**
65
87
  * Host-run verification command override — takes precedence over the template's `verify`
66
88
  * frontmatter. Internal engine option only, not exposed on the `delegate` tool's schema — see
@@ -326,6 +348,11 @@ export async function delegate(
326
348
  addDirs: opts.addDirs,
327
349
  cwd: ctx.cwd,
328
350
  });
351
+ // a per-call timeout we can't honor is an error, never silently "the configured timeout"
352
+ if (opts.timeoutSec !== undefined) {
353
+ const timeoutErr = callTimeoutError(opts.timeoutSec);
354
+ if (timeoutErr) throw new Error(timeoutErr);
355
+ }
329
356
  const config = loadConfig();
330
357
  const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
331
358
  const harness = getHarness(harnessName);
@@ -344,28 +371,70 @@ export async function delegate(
344
371
  );
345
372
  const task = opts.task || template.defaultTask;
346
373
  if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
374
+ // permission: normalized, danger requires explicit per-call allowDangerous:true (tool: model-set,
375
+ // human-confirmed in execute(); command: --allow-dangerous, human-confirmed in the handler). Resolved (and
376
+ // the danger refusal thrown) before acquireSlot() — it's pure, so a refused run never occupies
377
+ // (or, for fan-out, waits for) a concurrency slot it can't use.
378
+ const nativeClass = classifyNativePermission(harness, template.nativePermission);
379
+ // The tier the template actually runs at: a native read-only value (`permission: plan`) is filed
380
+ // under `edit` by normalizePermission, but runs as `readonly` — recorded as such and, above all,
381
+ // subject to resolveVerifyPlan's readonly skip. Only ever narrows (never `danger`), and the
382
+ // canonical native value below is still what reaches argv/ACP.
383
+ const templateTier: NormalizedPermission =
384
+ nativeClass === 'safe'
385
+ ? (nativePermissionTier(harness, template.nativePermission) ?? template.permission)
386
+ : template.permission;
387
+ const isNativeDanger = nativeClass === 'danger' || nativeClass === 'unlisted';
388
+
389
+ // A template permission problem (an unrecognized legacy value failed closed to readonly, or an
390
+ // ignored legacy key) is otherwise only visible in `/delegate list` — say so where the run happens.
391
+ // A legacy key ignored next to a *native* `permission:` can only be judged here, where the native
392
+ // value's tier on this harness is known (danger/unlisted run as danger once confirmed). Report-only:
393
+ // the tier above is untouched — `permission:` still wins.
394
+ const permissionWarning =
395
+ template.permissionWarning ??
396
+ (template.ignoredLegacyPermission && template.nativePermission
397
+ ? nativeOverrideWarning(
398
+ template.ignoredLegacyPermission,
399
+ template.nativePermission,
400
+ isNativeDanger ? 'danger' : templateTier,
401
+ harness.name,
402
+ )
403
+ : undefined);
404
+ // The mode is a template `name:` — frontmatter, so quoted/escaped like every other echoed value…
405
+ // …and so is a non-permission frontmatter field that was ignored (an out-of-range `timeout:`, a
406
+ // rejected `harnesses:` entry) — same channels, one line per problem.
407
+ const warning =
408
+ [permissionWarning, ...(template.fieldWarnings ?? [])]
409
+ .filter(Boolean)
410
+ .map(w => `⚠ template ${quoteValue(mode, 200)}: ${w}`)
411
+ .join('\n') || undefined;
412
+ if (warning) {
413
+ if (ctx.hasUI) ctx.ui.notify?.(warning, 'warning');
414
+ else process.stderr.write(`${warning}\n`);
415
+ }
347
416
 
348
417
  // Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
349
418
  // ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
350
419
  // not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
351
420
  const transport = resolveTransport(config, harnessName, harness);
352
421
 
353
- // permission: normalized, danger requires explicit per-call allowDangerous:true (tool: model-set,
354
- // human-confirmed in execute(); command: --allow-dangerous, human-confirmed in the handler). Resolved (and
355
- // the danger refusal thrown) before acquireSlot() — it's pure, so a refused run never occupies
356
- // (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);
360
- const isNativeDanger = nativeClass === 'danger' || nativeClass === 'unlisted';
422
+ let permission: NormalizedPermission = templateTier;
423
+ // A safe native matches its allowlist case-insensitively (`Plan`), but what reaches argv/ACP is
424
+ // always the allowlist's canonical spelling (`plan`, claude's camelCase `acceptEdits`) — never the
425
+ // template's. Danger/unlisted values keep the template's own spelling (see registry.ts).
426
+ const nativePerm =
427
+ nativeClass === 'safe'
428
+ ? canonicalSafeNativePermission(harness, template.nativePermission)
429
+ : template.nativePermission;
361
430
  if (template.permission === 'danger' || isNativeDanger) {
362
431
  if (opts.allowDangerous !== true) {
363
432
  const why =
364
433
  nativeClass === 'unlisted'
365
- ? ` (native permission "${nativePerm}" is not a known readonly/edit mode for ${harnessName}, so it is treated as danger)`
434
+ ? ` (native permission ${quoteValue(String(nativePerm), 200)} is not a known readonly/edit mode for ${harnessName}, so it is treated as danger)`
366
435
  : '';
367
436
  throw new Error(
368
- `template "${mode}" requires danger permission${why} — never a default: pass allowDangerous:true on the delegate tool, or --allow-dangerous on /delegate (both ask you to confirm interactively)`,
437
+ `template ${quoteValue(mode, 200)} requires danger permission${why} — never a default: pass allowDangerous:true on the delegate tool, or --allow-dangerous on /delegate (both ask you to confirm interactively)`,
369
438
  );
370
439
  }
371
440
  permission = 'danger';
@@ -381,12 +450,21 @@ export async function delegate(
381
450
  const nativePermissionForRun =
382
451
  nativeClass === 'unlisted' && permission === 'danger'
383
452
  ? nativePerm
384
- : resolveNativePermission(template.permission, permission, nativePerm);
453
+ : resolveNativePermission(templateTier, permission, nativePerm);
385
454
 
386
455
  const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
387
456
  const addDirs = mergeAddDirs(ctx.cwd, template.addDirs, opts.addDirs);
388
457
  const maxBudgetUsd =
389
458
  opts.maxBudgetUsd ?? template.maxBudgetUsd ?? config.maxBudgetUsd ?? config.harnesses[harnessName]?.maxBudgetUsd;
459
+ // template `timeout:` > per-harness config > global config; a per-call timeout only lowers that unless
460
+ // a human typed it — never past the hard cap. See config.ts.
461
+ const timeoutMs = resolveRunTimeoutMs(
462
+ config,
463
+ harnessName,
464
+ template.timeoutSec,
465
+ opts.timeoutSec,
466
+ opts.timeoutSecMayRaise === true,
467
+ );
390
468
 
391
469
  // concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
392
470
  // exactly as before; fan-out passes waitForSlot:true to queue instead.
@@ -438,7 +516,7 @@ export async function delegate(
438
516
  model,
439
517
  maxBudgetUsd,
440
518
  signal: opts.signal,
441
- timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
519
+ timeoutMs,
442
520
  resumeSessionId: opts.sessionId,
443
521
  addDirs,
444
522
  onStream: (t: string) => {
@@ -480,6 +558,8 @@ export async function delegate(
480
558
  contextWindow: null,
481
559
  activityLog: collectActivityLog(activityEvents),
482
560
  output: streamedFull,
561
+ warning,
562
+ timeoutMs,
483
563
  }),
484
564
  );
485
565
  } catch (_e) {
@@ -548,6 +628,8 @@ export async function delegate(
548
628
  output: result.result || result.streamedText,
549
629
  verify,
550
630
  budget,
631
+ warning,
632
+ timeoutMs,
551
633
  }),
552
634
  );
553
635
  pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
@@ -556,7 +638,7 @@ export async function delegate(
556
638
 
557
639
  const output = result.result || result.streamedText || '(empty result)';
558
640
  return {
559
- content: budget?.message ? `${budget.message}\n\n${output}` : output,
641
+ content: [warning, budget?.message, output].filter(Boolean).join('\n\n'),
560
642
  details: {
561
643
  harness: harnessName,
562
644
  mode,
@@ -580,6 +662,9 @@ export async function delegate(
580
662
  usage: result.usage,
581
663
  verify,
582
664
  budget,
665
+ permissionWarning: permissionWarning ?? null,
666
+ templateWarnings: template.fieldWarnings ?? [],
667
+ timeoutMs,
583
668
  },
584
669
  result,
585
670
  activityLog: collectActivityLog(activityEvents),
@@ -670,6 +755,9 @@ export interface DelegateToolParams {
670
755
  sessionId?: string;
671
756
  pr?: string;
672
757
  addDirs?: string[];
758
+ /** Per-call harness timeout in seconds, bounded — can only lower the configured timeout, never
759
+ * raise it (model-settable). See `DelegateOptions.timeoutSec` / `resolveRunTimeoutMs`. */
760
+ timeoutSec?: number;
673
761
  }
674
762
 
675
763
  /** One `delegate()` call with the tool's live-feed progress reporting (`onUpdate`). Shared by the