pi-harness-delegate 0.7.3 → 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,9 +71,9 @@ 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
 
@@ -148,7 +149,7 @@ 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
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) |
@@ -159,8 +160,10 @@ 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, 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.
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
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
 
@@ -178,6 +181,16 @@ Native values are checked against a per-harness **allowlist** of readonly/edit-e
178
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
 
@@ -463,6 +463,8 @@ export function buildTranscript(
463
463
  budget?: BudgetNote;
464
464
  /** A template permission warning (e.g. an unrecognized legacy value failed closed to readonly). */
465
465
  warning?: string;
466
+ /** The harness timeout this run was given (`resolveRunTimeoutMs`), when known. */
467
+ timeoutMs?: number;
466
468
  } & Record<string, unknown>,
467
469
  ): string {
468
470
  const harness = (opts.harness as string | undefined) ?? 'claude';
@@ -516,6 +518,7 @@ export function buildTranscript(
516
518
  `- context: ${context ?? 'n/a'}`,
517
519
  `- duration: ${duration ?? 'n/a'}`,
518
520
  `- stop reason: ${opts.stopReason ?? 'n/a'}`,
521
+ ...(typeof opts.timeoutMs === 'number' ? [`- timeout: ${Math.round(opts.timeoutMs / 1000)}s`] : []),
519
522
  ...(opts.warning ? [`- warning: ${opts.warning}`] : []),
520
523
  ...(opts.budget ? [formatBudgetLine(opts.budget)] : []),
521
524
  ...(opts.budget?.message ? ['', opts.budget.message] : []),
@@ -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 {
@@ -84,6 +91,22 @@ export function normalizeHarnessSpec(spec: string): string | undefined {
84
91
  return parts.join(',');
85
92
  }
86
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
+
87
110
  /** The error for a harness spec that normalizes to nothing (`,`, `" , "`) — shared by `/delegate`'s
88
111
  * `--harness=` and the `delegate` tool's `harness` param, so neither silently runs the default. */
89
112
  export function emptyHarnessSpecError(raw: string): string {
@@ -105,6 +128,7 @@ const RECOGNIZED_FLAGS = new Set([
105
128
  'model',
106
129
  'scope',
107
130
  'budget',
131
+ 'timeout',
108
132
  'resume',
109
133
  'pr',
110
134
  'verify',
@@ -128,7 +152,8 @@ export function parseDelegateCommand(
128
152
  const errors: string[] = [];
129
153
  const notices: string[] = [];
130
154
  let allowDangerousBare = false;
131
- let budgetWithoutValue = false;
155
+ // `--budget` / `--timeout` given with no `=value` (e.g. the space form `--budget 5`)
156
+ const valuelessLimits = new Set<string>();
132
157
  const rest = raw.replace(
133
158
  FLAG_OR_PROSE,
134
159
  (
@@ -155,11 +180,11 @@ export function parseDelegateCommand(
155
180
  }
156
181
  const hasValue = dq !== undefined || sq !== undefined || bare !== undefined;
157
182
  if (!hasValue) {
158
- // bare boolean flag: only `--allow-dangerous`. A bare `--budget` (e.g. the space form
159
- // `--budget 5`) is an explicit cap we can't honor — an error, never silently "no cap".
160
- // Any other bare `--word` is prose.
161
- if (k === 'budget') {
162
- 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);
163
188
  return m;
164
189
  }
165
190
  if (k !== 'allow-dangerous') return m;
@@ -188,10 +213,14 @@ export function parseDelegateCommand(
188
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',
189
214
  );
190
215
  }
191
- if (budgetWithoutValue && flags.budget === undefined) {
192
- errors.push(
193
- '--budget needs a value: use --budget=<usd> (or wrap the text in backticks if it is part of the prompt)',
194
- );
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
+ );
195
224
  }
196
225
 
197
226
  let harness = flags.harness !== undefined ? normalizeHarnessSpec(flags.harness) : undefined;
@@ -225,6 +254,16 @@ export function parseDelegateCommand(
225
254
  if (flags.budget.trim() !== '' && Number.isFinite(budget) && budget > 0) out.budget = budget;
226
255
  else errors.push(`--budget must be a positive number of USD (got "${flags.budget}")`);
227
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
+ }
228
267
  if (flags.resume) out.sessionId = flags.resume;
229
268
  if (flags.pr) out.pr = flags.pr;
230
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;
@@ -431,6 +432,44 @@ export function writeDelegateConfig(delegateSubtree: unknown): WriteConfigResult
431
432
  }
432
433
  }
433
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
+
434
473
  export function resolveModelForHarness(
435
474
  cfg: DelegateConfig,
436
475
  harness: string,
@@ -30,6 +30,7 @@ import {
30
30
  legacyOutputsDir,
31
31
  loadConfig,
32
32
  resolveModelForHarness,
33
+ resolveRunTimeoutMs,
33
34
  resolveTransport,
34
35
  } from './config.ts';
35
36
  import {
@@ -43,10 +44,13 @@ import {
43
44
  import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
44
45
  import { runHarness } from './runner.ts';
45
46
  import {
47
+ callTimeoutError,
46
48
  type DelegateTemplate,
47
49
  describeSkippedProjectTemplates,
48
50
  loadTemplates,
51
+ nativeOverrideWarning,
49
52
  projectTemplatePresence,
53
+ quoteValue,
50
54
  resolveNativePermission,
51
55
  } from './templates.ts';
52
56
  import { validateDelegateInputs } from './validate.ts';
@@ -68,6 +72,17 @@ export interface DelegateOptions {
68
72
  /** Extra directories the harness may access, merged with the template's `addDirs` (relative
69
73
  * paths resolve against the run's cwd). Per-harness limits apply — see each `buildArgs`. */
70
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;
71
86
  /**
72
87
  * Host-run verification command override — takes precedence over the template's `verify`
73
88
  * frontmatter. Internal engine option only, not exposed on the `delegate` tool's schema — see
@@ -333,6 +348,11 @@ export async function delegate(
333
348
  addDirs: opts.addDirs,
334
349
  cwd: ctx.cwd,
335
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
+ }
336
356
  const config = loadConfig();
337
357
  const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
338
358
  const harness = getHarness(harnessName);
@@ -351,19 +371,6 @@ export async function delegate(
351
371
  );
352
372
  const task = opts.task || template.defaultTask;
353
373
  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
- }
361
-
362
- // Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
363
- // ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
364
- // not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
365
- const transport = resolveTransport(config, harnessName, harness);
366
-
367
374
  // permission: normalized, danger requires explicit per-call allowDangerous:true (tool: model-set,
368
375
  // human-confirmed in execute(); command: --allow-dangerous, human-confirmed in the handler). Resolved (and
369
376
  // the danger refusal thrown) before acquireSlot() — it's pure, so a refused run never occupies
@@ -377,6 +384,41 @@ export async function delegate(
377
384
  nativeClass === 'safe'
378
385
  ? (nativePermissionTier(harness, template.nativePermission) ?? template.permission)
379
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
+ }
416
+
417
+ // Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
418
+ // ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
419
+ // not spawn the process and surface a cryptic native failure. See config.ts's resolveTransport.
420
+ const transport = resolveTransport(config, harnessName, harness);
421
+
380
422
  let permission: NormalizedPermission = templateTier;
381
423
  // A safe native matches its allowlist case-insensitively (`Plan`), but what reaches argv/ACP is
382
424
  // always the allowlist's canonical spelling (`plan`, claude's camelCase `acceptEdits`) — never the
@@ -385,15 +427,14 @@ export async function delegate(
385
427
  nativeClass === 'safe'
386
428
  ? canonicalSafeNativePermission(harness, template.nativePermission)
387
429
  : template.nativePermission;
388
- const isNativeDanger = nativeClass === 'danger' || nativeClass === 'unlisted';
389
430
  if (template.permission === 'danger' || isNativeDanger) {
390
431
  if (opts.allowDangerous !== true) {
391
432
  const why =
392
433
  nativeClass === 'unlisted'
393
- ? ` (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)`
394
435
  : '';
395
436
  throw new Error(
396
- `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)`,
397
438
  );
398
439
  }
399
440
  permission = 'danger';
@@ -415,6 +456,15 @@ export async function delegate(
415
456
  const addDirs = mergeAddDirs(ctx.cwd, template.addDirs, opts.addDirs);
416
457
  const maxBudgetUsd =
417
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
+ );
418
468
 
419
469
  // concurrency guard — see concurrency.ts. Single runs (waitForSlot unset) fail fast at capacity,
420
470
  // exactly as before; fan-out passes waitForSlot:true to queue instead.
@@ -466,7 +516,7 @@ export async function delegate(
466
516
  model,
467
517
  maxBudgetUsd,
468
518
  signal: opts.signal,
469
- timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
519
+ timeoutMs,
470
520
  resumeSessionId: opts.sessionId,
471
521
  addDirs,
472
522
  onStream: (t: string) => {
@@ -509,6 +559,7 @@ export async function delegate(
509
559
  activityLog: collectActivityLog(activityEvents),
510
560
  output: streamedFull,
511
561
  warning,
562
+ timeoutMs,
512
563
  }),
513
564
  );
514
565
  } catch (_e) {
@@ -578,6 +629,7 @@ export async function delegate(
578
629
  verify,
579
630
  budget,
580
631
  warning,
632
+ timeoutMs,
581
633
  }),
582
634
  );
583
635
  pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
@@ -610,7 +662,9 @@ export async function delegate(
610
662
  usage: result.usage,
611
663
  verify,
612
664
  budget,
613
- permissionWarning: template.permissionWarning ?? null,
665
+ permissionWarning: permissionWarning ?? null,
666
+ templateWarnings: template.fieldWarnings ?? [],
667
+ timeoutMs,
614
668
  },
615
669
  result,
616
670
  activityLog: collectActivityLog(activityEvents),
@@ -701,6 +755,9 @@ export interface DelegateToolParams {
701
755
  sessionId?: string;
702
756
  pr?: string;
703
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;
704
761
  }
705
762
 
706
763
  /** One `delegate()` call with the tool's live-feed progress reporting (`onUpdate`). Shared by the
@@ -109,6 +109,7 @@ export async function runFanoutTool(
109
109
  scope: params.scope,
110
110
  model: params.model,
111
111
  maxBudgetUsd: params.maxBudgetUsd,
112
+ timeoutSec: params.timeoutSec,
112
113
  allowDangerous: params.allowDangerous === true,
113
114
  sessionId: params.sessionId,
114
115
  pr: params.pr,
@@ -190,6 +191,7 @@ export interface FanoutSpec {
190
191
  scope?: string;
191
192
  model?: string;
192
193
  budget?: number;
194
+ timeoutSec?: number;
193
195
  sessionId?: string;
194
196
  pr?: string;
195
197
  addDirs?: string[];
@@ -271,6 +273,8 @@ export async function runFanoutConcurrent(
271
273
  scope: spec.scope,
272
274
  model: spec.model,
273
275
  maxBudgetUsd: spec.budget,
276
+ timeoutSec: spec.timeoutSec,
277
+ timeoutSecMayRaise: true, // command path only: --timeout= is human-typed
274
278
  sessionId: spec.sessionId,
275
279
  pr: spec.pr,
276
280
  addDirs: spec.addDirs,
@@ -448,6 +452,7 @@ export async function runFanoutCommand(
448
452
  scope: resolvedTaskScope.scope,
449
453
  model: parsed.model,
450
454
  budget: parsed.budget,
455
+ timeoutSec: parsed.timeoutSec,
451
456
  sessionId: parsed.sessionId,
452
457
  pr: parsed.pr,
453
458
  addDirs: parsed.addDirs,