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 +19 -4
- package/extensions/activity.ts +3 -0
- package/extensions/command.ts +51 -12
- package/extensions/config.ts +39 -0
- package/extensions/engine.ts +75 -18
- package/extensions/fanout.ts +5 -0
- package/extensions/index.ts +152 -7
- package/extensions/modes.ts +377 -0
- package/extensions/sanitize.ts +85 -0
- package/extensions/subcommands.ts +25 -25
- package/extensions/templates.ts +240 -43
- package/package.json +1 -1
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
|
|
package/extensions/activity.ts
CHANGED
|
@@ -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] : []),
|
package/extensions/command.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
|
159
|
-
// `--budget 5`) is an explicit
|
|
160
|
-
// Any other bare `--word` is prose.
|
|
161
|
-
if (k === 'budget') {
|
|
162
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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;
|
package/extensions/config.ts
CHANGED
|
@@ -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,
|
package/extensions/engine.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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
|
package/extensions/fanout.ts
CHANGED
|
@@ -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,
|