pi-harness-delegate 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,8 @@ function isRecord(v: unknown): v is Record<string, unknown> {
39
39
  }
40
40
 
41
41
  /** Exact structural match to Claude's tiers (session/new's captured `availableModes`: plan, accept-edits,
42
- * smart, ask, bypass). `smart`/`ask` stay reachable via the existing `nativePermission` escape hatch. */
42
+ * smart, ask, bypass). `smart`/`ask` stay reachable via the existing `nativePermission` escape hatch
43
+ * (`smart` only as danger — see `safeNativePermissions`). */
43
44
  const PERMISSION_MAP: Record<NormalizedPermission, string> = {
44
45
  readonly: 'plan',
45
46
  edit: 'accept-edits',
@@ -203,6 +204,13 @@ export const devinHarness: Harness = {
203
204
  edit: [PERMISSION_MAP.edit],
204
205
  danger: [PERMISSION_MAP.danger],
205
206
  },
207
+ // ACP `availableModes` (fixture): accept-edits, smart, ask, plan, bypass. `ask` prompts for every
208
+ // action, which the ACP runner always declines. Not listed (=> danger): `bypass`, and `smart`
209
+ // ("additionally auto-runs actions a fast model judges safe" per `devin --help`).
210
+ safeNativePermissions: ['plan', 'accept-edits', 'ask'],
211
+ // `ask` is not listed: it is read-only here only because the ACP runner declines every permission
212
+ // request — a property of this client, not of the mode.
213
+ readonlyNativePermissions: ['plan'],
206
214
  // ACP-only — no stdout mode exists to select between, so there's nothing to configure.
207
215
  supportsTransports: ['acp'],
208
216
  };
@@ -371,6 +371,12 @@ export const opencodeHarness: Harness = {
371
371
  return null;
372
372
  },
373
373
  permissionMap: { readonly: ['plan'], edit: ['build'], danger: ['build', '--auto'] },
374
+ // `--agent <name>` (stdout) / ACP mode id: only the built-in `plan`/`build` agents are known
375
+ // quantities — a user-defined agent can carry any permission config, so it fails closed.
376
+ safeNativePermissions: ['plan', 'build'],
377
+ // the built-in `plan` agent (stdout) / ACP mode is read-only — live-captured blocking a write
378
+ // (docs/acp-harness-assessment.md §4).
379
+ readonlyNativePermissions: ['plan'],
374
380
  // ACP path — opt-in only (transport defaults to 'stdout'; see config.ts's resolveTransport).
375
381
  // `--model` isn't wired here: `opencode acp --help` has no such flag, unlike `devin acp
376
382
  // --model`; the ACP handshake's own `configOptions` "model" category is the real mechanism
@@ -3,7 +3,7 @@ import { claudeHarness } from './claude.ts';
3
3
  import { codexHarness } from './codex.ts';
4
4
  import { devinHarness } from './devin.ts';
5
5
  import { opencodeHarness } from './opencode.ts';
6
- import type { Harness } from './types.ts';
6
+ import type { Harness, NormalizedPermission } from './types.ts';
7
7
 
8
8
  export const HARNESSES: Record<string, Harness> = {
9
9
  claude: claudeHarness,
@@ -54,29 +54,92 @@ export const normalizeHarnessName = resolveHarnessName;
54
54
 
55
55
  /**
56
56
  * Legacy danger spellings, kept for templates written before harnesses were partitioned.
57
+ * Lowercase: native permissions are compared case-insensitively (see `classifyNativePermission`).
57
58
  */
58
- const LEGACY_DANGER_TOKENS = new Set(['bypassPermissions', 'danger-full-access', 'danger']);
59
+ const LEGACY_DANGER_TOKENS = new Set(['bypasspermissions', 'danger-full-access', 'danger']);
59
60
 
60
61
  /**
61
- * Does this native permission string mean "unrestricted" for this harness?
62
+ * How a template's native permission string (`permission: <native>` escape hatch) classifies on
63
+ * this harness:
64
+ * - `none` — no native permission declared.
65
+ * - `safe` — on the harness's `safeNativePermissions` allowlist (readonly/edit-equivalent).
66
+ * - `danger` — the harness's own danger mode (`permissionMap.danger`, joined) or a legacy danger
67
+ * spelling: running it is exactly the harness's normalized danger tier.
68
+ * - `unlisted` — anything else. Treated as danger (fail closed): a permissive mode nobody listed
69
+ * (claude `auto`, devin `smart`, a custom opencode agent, …) must not slip past the gate.
70
+ *
71
+ * Matching is whitespace-trimmed and case-insensitive (`Plan` is claude's `plan`), and the danger
72
+ * checks run first, so a case variant of a danger token (`BYPASSPERMISSIONS`, `Yolo`) is still
73
+ * `danger`. A `safe` match is only a classification — the value that reaches argv/`session/set_mode`
74
+ * must be the allowlist's own spelling, via `canonicalSafeNativePermission` (claude's `acceptEdits`
75
+ * is camelCase; harness CLIs are case-sensitive, and a custom opencode agent named `PLAN` must
76
+ * never run in place of the built-in `plan` it was classified as).
77
+ */
78
+ export type NativePermissionClass = 'none' | 'safe' | 'danger' | 'unlisted';
79
+
80
+ export function classifyNativePermission(
81
+ harness: Harness | undefined,
82
+ nativePermission: string | undefined,
83
+ ): NativePermissionClass {
84
+ const native = nativePermission?.trim().toLowerCase();
85
+ if (!native) return 'none';
86
+ if (LEGACY_DANGER_TOKENS.has(native)) return 'danger';
87
+ const danger = harness?.permissionMap?.danger;
88
+ if (Array.isArray(danger) && danger.length > 0 && native === danger.join(' ').toLowerCase()) return 'danger';
89
+ if (canonicalSafeNativePermission(harness, nativePermission) !== undefined) return 'safe';
90
+ return 'unlisted';
91
+ }
92
+
93
+ /**
94
+ * The harness's own allowlist spelling of a native permission that matches it case-insensitively
95
+ * (whitespace-trimmed), or `undefined` when it isn't on the allowlist. Only meaningful for a value
96
+ * `classifyNativePermission` calls `safe` — that's the one class whose value is ever rewritten.
97
+ */
98
+ export function canonicalSafeNativePermission(
99
+ harness: Harness | undefined,
100
+ nativePermission: string | undefined,
101
+ ): string | undefined {
102
+ const native = nativePermission?.trim().toLowerCase();
103
+ if (!native) return undefined;
104
+ return harness?.safeNativePermissions?.find(v => v.toLowerCase() === native);
105
+ }
106
+
107
+ /**
108
+ * The normalized tier a native escape-hatch value actually runs at, when it is narrower than the
109
+ * `edit` tier `normalizePermission` files every native value under: `readonly` for a value on the
110
+ * harness's `readonlyNativePermissions` (claude/devin/opencode `plan`, codex `read-only`), matched
111
+ * case-insensitively through the same allowlist as `canonicalSafeNativePermission`. `undefined`
112
+ * otherwise — the template's own tier stands. Only ever narrows: it never reports `danger`, so it
113
+ * can't touch the danger gate, and it changes nothing about what reaches argv/ACP (the canonical
114
+ * native value is still what the harness gets). What it does change is the recorded permission and
115
+ * `resolveVerifyPlan`'s readonly skip — a native read-only run must never execute a `verify:` command.
116
+ */
117
+ export function nativePermissionTier(
118
+ harness: Harness | undefined,
119
+ nativePermission: string | undefined,
120
+ ): NormalizedPermission | undefined {
121
+ const canonical = canonicalSafeNativePermission(harness, nativePermission);
122
+ if (canonical === undefined) return undefined;
123
+ return harness?.readonlyNativePermissions?.includes(canonical) ? 'readonly' : undefined;
124
+ }
125
+
126
+ /**
127
+ * Does this native permission string require the danger gate on this harness?
62
128
  *
63
129
  * A template can declare any native mode via the escape hatch (`permission: <native>`), and
64
130
  * `normalizePermission` files anything unrecognised under `nativePermission` with a normalized
65
- * tier of `edit`. Without this check, a template declaring `yolo` (amp) or `bypass` (devin) would
66
- * skip the `allowDangerous` gate entirely and run the harness unsandboxed while `delegate()`
67
- * recorded the run as `edit` — breaking the invariant that danger is only ever reachable through
68
- * an explicit per-call `allowDangerous: true`.
131
+ * tier of `edit`. This used to be a denylist (the harness's own danger mode + legacy spellings),
132
+ * so a permissive mode that wasn't listed ran unsandboxed while recorded as `edit`. It is now an
133
+ * allowlist: only values in the harness's `safeNativePermissions` pass as non-danger; everything
134
+ * else needs an explicit per-call `allowDangerous: true`, preserving the invariant that danger is
135
+ * never reachable without one.
69
136
  *
70
- * Matches the harness's own `permissionMap.danger`, joined, so a multi-token danger mode is
71
- * compared as a whole: opencode's danger is `['build', '--auto']`, and bare `build` is its *edit*
72
- * token — treating each token separately would wrongly gate legitimate `edit` templates.
137
+ * Multi-token danger modes compare joined: opencode's danger is `['build', '--auto']`, and bare
138
+ * `build` is its *edit* token (on the allowlist).
73
139
  */
74
140
  export function isNativeDangerPermission(harness: Harness | undefined, nativePermission: string | undefined): boolean {
75
- if (!nativePermission) return false;
76
- const native = nativePermission.trim();
77
- if (LEGACY_DANGER_TOKENS.has(native)) return true;
78
- const danger = harness?.permissionMap?.danger;
79
- return Array.isArray(danger) && danger.length > 0 && native === danger.join(' ');
141
+ const cls = classifyNativePermission(harness, nativePermission);
142
+ return cls === 'danger' || cls === 'unlisted';
80
143
  }
81
144
 
82
145
  /**
@@ -91,6 +91,18 @@ export interface Harness {
91
91
  /** Normalized -> native CLI arg fragments, used by the 'stdout' transport's buildArgs/nativePermission. */
92
92
  permissionMap?: Record<NormalizedPermission, string[]>;
93
93
  permissionHint?: (permission: NormalizedPermission) => string[];
94
+ /** Allowlist of native permission values (a template's `permission: <native>` escape hatch) that are
95
+ * readonly- or edit-equivalent for this harness. Anything else — including a value this list has
96
+ * never heard of — is treated as danger (fail closed) by `isNativeDangerPermission` in registry.ts.
97
+ * Omitted -> no native value is considered safe. Covers both transports' vocabularies. */
98
+ safeNativePermissions?: readonly string[];
99
+ /** The subset of `safeNativePermissions` that is genuinely read-only (no writes, no command
100
+ * execution) on this harness. A native escape-hatch value listed here runs as the normalized
101
+ * `readonly` tier — recorded as `readonly` and, crucially, never lets a `verify:` command execute
102
+ * host-side (see `resolveVerifyPlan`). Conservative by design: only list a mode whose read-only
103
+ * behavior is documented for a non-interactive run; anything else stays `edit`. Must be spelled
104
+ * exactly as in `safeNativePermissions` (matching is case-insensitive via that allowlist). */
105
+ readonlyNativePermissions?: readonly string[];
94
106
  /** True when `buildArgs` passes `maxBudgetUsd` to a native CLI budget flag (claude's
95
107
  * `--max-budget-usd`). Otherwise the runners enforce it host-side from the streamed
96
108
  * `totalCostUsd` — possible only when the harness actually reports cost. */
@@ -14,14 +14,22 @@
14
14
  * Legacy: { claudeDelegate: {...} } is auto-migrated.
15
15
  */
16
16
 
17
- import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme } from '@earendil-works/pi-coding-agent';
17
+ import type { ImageContent, TextContent } from '@earendil-works/pi-ai';
18
+ import {
19
+ type ExtensionAPI,
20
+ type ExtensionContext,
21
+ getMarkdownTheme,
22
+ type ToolDefinition,
23
+ } from '@earendil-works/pi-coding-agent';
18
24
  import { Container, Markdown, type OverlayHandle, Text } from '@earendil-works/pi-tui';
19
25
  import { Type } from 'typebox';
20
26
  import { formatToolUse, ToolCallIndex } from './activity.ts';
21
27
  import {
22
28
  aliasUsage,
23
29
  delegateUsage,
30
+ emptyHarnessSpecError,
24
31
  isFanoutSpec,
32
+ normalizeHarnessSpec,
25
33
  parseDelegateCommand,
26
34
  resolveDefaults,
27
35
  resolveHarnessFilter,
@@ -36,7 +44,6 @@ import {
36
44
  runDelegateForTool,
37
45
  runMetrics,
38
46
  summarize,
39
- type ToolProgressUpdate,
40
47
  takePendingReport,
41
48
  } from './engine.ts';
42
49
  import { closeWhenMounted, type RunUiState, runFanoutCommand, runFanoutTool } from './fanout.ts';
@@ -50,79 +57,104 @@ import { type DelegateTemplate, loadTemplates } from './templates.ts';
50
57
  import { mapClaudeUsage } from './usage.ts';
51
58
  import { confirmDangerousCommand, confirmDangerousToolCall, confirmToolAddDirs } from './validate.ts';
52
59
 
60
+ /** Tool-result `details` for the `delegate` tool (and its partial progress updates). */
61
+ type DelegateToolDetails = Record<string, unknown>;
62
+
63
+ const DELEGATE_TOOL_DESCRIPTION =
64
+ 'Delegate a task to any harness (claude, codex, opencode, amp, devin) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude) — pass "all" or a comma list (e.g. "claude,codex") to fan out the same task to several harnesses and get back one comparison report. mode selects a template: review, plan, implement, security-audit, docs, general, or custom — some templates run a host-side check (e.g. "bun test") after the harness exits and report pass/fail as separate evidence; that is configured on the template, not a parameter here. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.';
65
+
66
+ const DELEGATE_TOOL_GUIDELINES: readonly string[] = [
67
+ 'delegate runs a harness headless in the working directory and returns a streamed report with cost, token usage, and a session id for follow-ups.',
68
+ 'Pass harness (claude|codex|opencode|amp|devin) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
69
+ 'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work. Some templates verify their own work (e.g. running tests) automatically after the harness finishes — that is not something you configure here.',
70
+ 'harness: "all" or a comma list (e.g. "codex,opencode") fans the same task out to each detected harness and returns one synthesized comparison report — costs multiply, so only use it when the user actually wants a multi-harness comparison.',
71
+ 'sessionId resumes a previous delegated session instead of starting fresh — pass the exact session id from a previous run\'s details (letters, digits, . _ : - only). It cannot be combined with a fan-out harness ("all" or a comma list) — a session belongs to one harness.',
72
+ 'pr must be a PR number, an http(s) pull-request URL (https://<host>/<owner>/<repo>/pull/<n>), or owner/repo#123.',
73
+ 'addDirs inside the working directory are accepted as-is; any entry outside it asks the human to confirm interactively and is refused in a non-interactive session.',
74
+ 'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission). Setting it always asks the human to confirm interactively; in a non-interactive session it is refused outright.',
75
+ ];
76
+
77
+ const DELEGATE_TOOL_PARAMS = Type.Object({
78
+ harness: Type.Optional(
79
+ Type.String({
80
+ description:
81
+ 'Harness to use: claude, codex, opencode, amp (aliases: omp), devin. "all" or a comma list (e.g. "claude,codex") fans out to each detected harness. Defaults to config defaultHarness.',
82
+ }),
83
+ ),
84
+ task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
85
+ mode: Type.Optional(
86
+ Type.String({
87
+ description:
88
+ 'Template/mode to run: review, plan, implement, security-audit, docs, general, or custom. Defaults to config defaultMode.',
89
+ }),
90
+ ),
91
+ scope: Type.Optional(
92
+ Type.String({
93
+ description:
94
+ 'Restrict the work: diff (git diff), pr (PR diff), comma/space-separated path list, or omit for whole repo.',
95
+ }),
96
+ ),
97
+ model: Type.Optional(Type.String({ description: 'Model (e.g. sonnet, opus, gpt-5). Defaults to template/config.' })),
98
+ maxBudgetUsd: Type.Optional(Type.Number({ description: 'Hard spend cap in USD for the run.' })),
99
+ sessionId: Type.Optional(
100
+ Type.String({
101
+ description: 'Resume an existing delegated session (pass its session id from a previous run details).',
102
+ }),
103
+ ),
104
+ allowDangerous: Type.Optional(
105
+ Type.Boolean({
106
+ description:
107
+ 'Escalate to danger permission (unrestricted). Always requires interactive human confirmation; refused without a UI.',
108
+ }),
109
+ ),
110
+ pr: Type.Optional(
111
+ Type.String({ description: 'GitHub PR number, http(s) PR URL, or owner/repo#123 (alternative to scope pr).' }),
112
+ ),
113
+ addDirs: Type.Optional(
114
+ Type.Array(Type.String(), {
115
+ description:
116
+ 'Extra directories the harness may access. Relative paths resolve against the working directory. Any entry that resolves (after symlinks) outside the working directory requires interactive human confirmation and is refused without a UI. Not every harness supports this (opencode ignores it; codex ignores it on resume).',
117
+ }),
118
+ ),
119
+ // Deliberately no `verify` param — see the trust-model comment on DelegateToolParams/runVerify.
120
+ });
121
+
122
+ /** The text parts of a tool result, joined — images (never produced by this tool) are skipped. */
123
+ function textOf(content: readonly (TextContent | ImageContent)[] | undefined): string {
124
+ return (content ?? []).flatMap(c => (c.type === 'text' ? [c.text] : [])).join('\n');
125
+ }
126
+
53
127
  export default function (pi: ExtensionAPI) {
54
128
  const ui: RunUiState = { activeRunId: 0, activeOverlay: null };
55
129
 
56
130
  // ── Tools ────────────────────────────────────────────────────────────────
57
- const delegateToolDef = {
58
- name: 'delegate',
59
- label: 'Delegate',
60
- description:
61
- 'Delegate a task to any harness (claude, codex, opencode, amp, devin) running headless in the repo and return its streamed report (cost, token usage, context %, session id). harness selects the backend (default from config, fallback claude) — pass "all" or a comma list (e.g. "claude,codex") to fan out the same task to several harnesses and get back one comparison report. mode selects a template: review, plan, implement, security-audit, docs, general, or custom — some templates run a host-side check (e.g. "bun test") after the harness exits and report pass/fail as separate evidence; that is configured on the template, not a parameter here. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
62
- promptSnippet: 'Delegate a subtask to a harness and return its report',
63
- promptGuidelines: [
64
- 'delegate runs a harness headless in the working directory and returns a streamed report with cost, token usage, and a session id for follow-ups.',
65
- 'Pass harness (claude|codex|opencode|amp|devin) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
66
- 'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work. Some templates verify their own work (e.g. running tests) automatically after the harness finishes — that is not something you configure here.',
67
- 'harness: "all" or a comma list (e.g. "codex,opencode") fans the same task out to each detected harness and returns one synthesized comparison report — costs multiply, so only use it when the user actually wants a multi-harness comparison.',
68
- 'sessionId resumes a previous delegated session instead of starting fresh — pass the exact session id from a previous run\'s details (letters, digits, . _ : - only). It cannot be combined with a fan-out harness ("all" or a comma list) — a session belongs to one harness.',
69
- 'pr must be a PR number, an http(s) pull-request URL (https://<host>/<owner>/<repo>/pull/<n>), or owner/repo#123.',
70
- 'addDirs inside the working directory are accepted as-is; any entry outside it asks the human to confirm interactively and is refused in a non-interactive session.',
71
- 'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission). Setting it always asks the human to confirm interactively; in a non-interactive session it is refused outright.',
72
- ],
73
- parameters: Type.Object({
74
- harness: Type.Optional(
75
- Type.String({
76
- description:
77
- 'Harness to use: claude, codex, opencode, amp (aliases: omp), devin. "all" or a comma list (e.g. "claude,codex") fans out to each detected harness. Defaults to config defaultHarness.',
78
- }),
79
- ),
80
- task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
81
- mode: Type.Optional(
82
- Type.String({
83
- description:
84
- 'Template/mode to run: review, plan, implement, security-audit, docs, general, or custom. Defaults to config defaultMode.',
85
- }),
86
- ),
87
- scope: Type.Optional(
88
- Type.String({
89
- description:
90
- 'Restrict the work: diff (git diff), pr (PR diff), comma/space-separated path list, or omit for whole repo.',
91
- }),
92
- ),
93
- model: Type.Optional(
94
- Type.String({ description: 'Model (e.g. sonnet, opus, gpt-5). Defaults to template/config.' }),
95
- ),
96
- maxBudgetUsd: Type.Optional(Type.Number({ description: 'Hard spend cap in USD for the run.' })),
97
- sessionId: Type.Optional(
98
- Type.String({
99
- description: 'Resume an existing delegated session (pass its session id from a previous run details).',
100
- }),
101
- ),
102
- allowDangerous: Type.Optional(
103
- Type.Boolean({
104
- description:
105
- 'Escalate to danger permission (unrestricted). Always requires interactive human confirmation; refused without a UI.',
106
- }),
107
- ),
108
- pr: Type.Optional(
109
- Type.String({ description: 'GitHub PR number, http(s) PR URL, or owner/repo#123 (alternative to scope pr).' }),
110
- ),
111
- addDirs: Type.Optional(
112
- Type.Array(Type.String(), {
113
- description:
114
- 'Extra directories the harness may access. Relative paths resolve against the working directory. Any entry that resolves (after symlinks) outside the working directory requires interactive human confirmation and is refused without a UI. Not every harness supports this (opencode ignores it; codex ignores it on resume).',
115
- }),
116
- ),
117
- // Deliberately no `verify` param — see the trust-model comment on DelegateToolParams/runVerify.
118
- }),
119
- async execute(
120
- _toolCallId: string,
121
- params: DelegateToolParams,
122
- signal: AbortSignal | undefined,
123
- onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
124
- ctx: ExtensionContext,
125
- ) {
131
+
132
+ /**
133
+ * Build one registration of the `delegate` tool. The primary tool and the deprecated
134
+ * `claude_delegate` alias are the same definition — same schema, guidelines, execute path and
135
+ * renderers — differing only in the fields `spec` sets (name/label/description/snippet) and, for
136
+ * the alias, a pinned harness applied before anything else in `execute`.
137
+ */
138
+ const makeDelegateTool = (
139
+ name: string,
140
+ spec: { label: string; description: string; promptSnippet: string; forceHarness?: string },
141
+ ): ToolDefinition<typeof DELEGATE_TOOL_PARAMS, DelegateToolDetails> => ({
142
+ name,
143
+ label: spec.label,
144
+ description: spec.description,
145
+ promptSnippet: spec.promptSnippet,
146
+ promptGuidelines: [...DELEGATE_TOOL_GUIDELINES],
147
+ parameters: DELEGATE_TOOL_PARAMS,
148
+ async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
149
+ // the deprecated alias pins its harness; everything below sees the effective params. The spec is
150
+ // normalized exactly as /delegate does (normalizeHarnessSpec), so `claude,` is a single run —
151
+ // fail-fast at capacity, single-run result shape — not a one-harness fan-out, and `omp` is `amp`.
152
+ // A non-empty spec that normalizes to nothing (`,`, `" , "`) is refused rather than silently run
153
+ // on the default harness (`""` stays "unset", the way a model omitting the field means it).
154
+ const harnessSpec = spec.forceHarness ?? rawParams.harness;
155
+ const harness = harnessSpec === undefined ? undefined : normalizeHarnessSpec(harnessSpec);
156
+ if (harnessSpec && harness === undefined) throw new Error(emptyHarnessSpecError(harnessSpec));
157
+ const params: DelegateToolParams = { ...rawParams, harness };
126
158
  const config = loadConfig();
127
159
  // A model-set allowDangerous is never honored on its own — a human confirms it (or, with no
128
160
  // UI to ask, it's refused). Checked once up front, before any fan-out. See validate.ts.
@@ -161,16 +193,15 @@ export default function (pi: ExtensionAPI) {
161
193
  : `${details.harness} ${details.mode} (${result.numTurns ?? '—'} turn(s), ${formatCost(result.totalCostUsd)})${resumed}`;
162
194
  const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
163
195
  const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
164
- (details as Record<string, unknown>).markdown = summary.text;
196
+ details.markdown = summary.text;
165
197
  return {
166
198
  content: [{ type: 'text', text: `${head}${body}${footer}` }],
167
199
  details,
168
200
  usage: result.usage ? mapClaudeUsage({ ...result.usage, totalCostUsd: result.totalCostUsd }) : undefined,
169
201
  };
170
202
  },
171
- renderCall(args: unknown, theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string }) {
172
- const params = args as { harness?: string; mode?: string; task?: string };
173
- const harness = params.harness ?? 'delegate';
203
+ renderCall(params, theme) {
204
+ const harness = spec.forceHarness ?? params.harness ?? 'delegate';
174
205
  const mode = params.mode ?? 'general';
175
206
  const task = params.task ?? '';
176
207
  const taskStr = task ? ` — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}` : '';
@@ -178,19 +209,12 @@ export default function (pi: ExtensionAPI) {
178
209
  theme.bg('toolPendingBg', s),
179
210
  );
180
211
  },
181
- renderResult(
182
- result: { content?: { type: string; text: string }[]; details?: Record<string, unknown> },
183
- options: { isPartial: boolean },
184
- theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string },
185
- ) {
212
+ renderResult(result, options, theme) {
186
213
  if (options.isPartial) {
187
- const text = (result.content ?? [])
188
- .filter(c => c.type === 'text')
189
- .map(c => c.text)
190
- .join('\n');
214
+ const text = textOf(result.content);
191
215
  return new Text(text, 1, 1, s => theme.bg('toolPendingBg', s));
192
216
  }
193
- const details = (result.details ?? {}) as Record<string, unknown>;
217
+ const details: DelegateToolDetails = result.details ?? {};
194
218
  const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
195
219
  const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
196
220
  const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null;
@@ -213,10 +237,7 @@ export default function (pi: ExtensionAPI) {
213
237
  const md = typeof details.markdown === 'string' && details.markdown ? details.markdown : null;
214
238
  if (md) container.addChild(new Markdown(md, 1, 1, getMarkdownTheme()));
215
239
  else {
216
- const text = (result.content ?? [])
217
- .filter(c => c.type === 'text')
218
- .map(c => c.text)
219
- .join('\n');
240
+ const text = textOf(result.content);
220
241
  container.addChild(new Text(text, 1, 1));
221
242
  }
222
243
  const foot: string[] = [];
@@ -225,46 +246,25 @@ export default function (pi: ExtensionAPI) {
225
246
  if (foot.length > 0) container.addChild(new Text(theme.fg('dim', foot.join(' ')), 1, 1));
226
247
  return container;
227
248
  },
228
- };
249
+ });
229
250
 
230
- // SAFETY: delegateToolDef satisfies registerTool params via TypeBox, widened for alias registration
231
- pi.registerTool(delegateToolDef as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: delegateToolDef satisfies registerTool params
251
+ pi.registerTool(
252
+ makeDelegateTool('delegate', {
253
+ label: 'Delegate',
254
+ description: DELEGATE_TOOL_DESCRIPTION,
255
+ promptSnippet: 'Delegate a subtask to a harness and return its report',
256
+ }),
257
+ );
232
258
 
233
259
  // deprecated alias
234
- pi.registerTool({
235
- name: 'claude_delegate',
236
- label: 'Claude Delegate (deprecated)',
237
- description:
238
- 'Deprecated alias for delegate{harness:claude}. Use delegate tool with harness:claude instead. ' +
239
- (delegateToolDef as { description: string }).description,
240
- promptSnippet: 'Delegate a subtask to Claude Code (deprecated alias)',
241
- // SAFETY: delegateToolDef promptGuidelines is string[] from literal, safe to spread
242
- promptGuidelines: [...(delegateToolDef as unknown as { promptGuidelines: string[] }).promptGuidelines], // SAFETY: promptGuidelines is string[]
243
- parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
244
- async execute(
245
- toolCallId: string,
246
- params: DelegateToolParams,
247
- signal: AbortSignal | undefined,
248
- onUpdate: never,
249
- ctx: ExtensionContext,
250
- ) {
251
- return (
252
- // SAFETY: deprecated alias delegates to primary tool, shape identical
253
- (
254
- delegateToolDef as unknown as {
255
- // SAFETY: alias shape identical
256
- execute: (a: string, b: unknown, c: unknown, d: unknown, e: unknown) => Promise<unknown>;
257
- }
258
- ).execute(toolCallId, { ...params, harness: 'claude' }, signal, onUpdate, ctx)
259
- );
260
- },
261
- // SAFETY: delegateToolDef renderCall matches expected signature
262
- renderCall: (delegateToolDef as unknown as { renderCall: (a: unknown, b: unknown) => unknown }).renderCall, // SAFETY: matches signature
263
- // SAFETY: delegateToolDef renderResult matches expected signature
264
- renderResult: (delegateToolDef as unknown as { renderResult: (a: unknown, b: unknown, c: unknown) => unknown }) // SAFETY: matches signature
265
- .renderResult,
266
- // SAFETY: final alias tool matches registerTool overload
267
- } as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: alias tool matches overload
260
+ pi.registerTool(
261
+ makeDelegateTool('claude_delegate', {
262
+ label: 'Claude Delegate (deprecated)',
263
+ description: `Deprecated alias for delegate{harness:claude}. Use delegate tool with harness:claude instead. ${DELEGATE_TOOL_DESCRIPTION}`,
264
+ promptSnippet: 'Delegate a subtask to Claude Code (deprecated alias)',
265
+ forceHarness: 'claude',
266
+ }),
267
+ );
268
268
 
269
269
  // ── Commands ─────────────────────────────────────────────────────────────
270
270
 
@@ -14,20 +14,33 @@ export function joinBatch(lines: string[]): string {
14
14
  return `${lines.length} runs completed:\n${lines.map(l => ` · ${l}`).join('\n')}`;
15
15
  }
16
16
 
17
+ /** The timer primitives the batcher schedules its debounce with — injectable so tests can drive a
18
+ * fake clock instead of sleeping for real. */
19
+ export interface BatcherTimers {
20
+ set(fn: () => void, ms: number): unknown;
21
+ clear(handle: unknown): void;
22
+ }
23
+
24
+ const REAL_TIMERS: BatcherTimers = {
25
+ set: (fn, ms) => setTimeout(fn, ms),
26
+ clear: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
27
+ };
28
+
17
29
  export class NotifyBatcher {
18
30
  private pending: string[] = [];
19
- private timer: ReturnType<typeof setTimeout> | null = null;
31
+ private timer: unknown = null;
20
32
 
21
33
  constructor(
22
34
  private readonly emit: NotifyFn,
23
35
  private readonly debounceMs = 400,
36
+ private readonly timers: BatcherTimers = REAL_TIMERS,
24
37
  ) {}
25
38
 
26
39
  /** Queue a successful-completion line; flushes as one combined notification after a quiet period. */
27
40
  success(line: string): void {
28
41
  this.pending.push(line);
29
- if (this.timer) clearTimeout(this.timer);
30
- this.timer = setTimeout(() => this.flush(), this.debounceMs);
42
+ if (this.timer !== null) this.timers.clear(this.timer);
43
+ this.timer = this.timers.set(() => this.flush(), this.debounceMs);
31
44
  }
32
45
 
33
46
  /** Flush any pending batch immediately, then emit this failure on its own — never delayed. */
@@ -38,8 +51,8 @@ export class NotifyBatcher {
38
51
 
39
52
  /** Emit whatever is pending as one notification, then clear it. No-op when nothing is pending. */
40
53
  flush(): void {
41
- if (this.timer) {
42
- clearTimeout(this.timer);
54
+ if (this.timer !== null) {
55
+ this.timers.clear(this.timer);
43
56
  this.timer = null;
44
57
  }
45
58
  if (this.pending.length === 0) return;