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.
- package/README.md +15 -4
- package/extensions/acp-runner.ts +7 -1
- package/extensions/activity.ts +3 -0
- package/extensions/command.ts +14 -4
- package/extensions/config.ts +49 -43
- package/extensions/engine.ts +182 -19
- package/extensions/fanout.ts +2 -1
- package/extensions/harnesses/amp.ts +5 -0
- package/extensions/harnesses/claude.ts +8 -0
- package/extensions/harnesses/codex.ts +3 -0
- package/extensions/harnesses/devin.ts +9 -1
- package/extensions/harnesses/opencode.ts +6 -0
- package/extensions/harnesses/registry.ts +78 -15
- package/extensions/harnesses/types.ts +12 -0
- package/extensions/index.ts +126 -126
- package/extensions/notify.ts +18 -5
- package/extensions/templates.ts +198 -30
- package/package.json +2 -1
|
@@ -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(['
|
|
59
|
+
const LEGACY_DANGER_TOKENS = new Set(['bypasspermissions', 'danger-full-access', 'danger']);
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
*
|
|
62
|
+
* How a template's native permission string (`permission: <native>` escape hatch) classifies on
|
|
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`.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
76
|
-
|
|
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. */
|
package/extensions/index.ts
CHANGED
|
@@ -14,14 +14,22 @@
|
|
|
14
14
|
* Legacy: { claudeDelegate: {...} } is auto-migrated.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
],
|
|
73
|
-
parameters:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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(
|
|
172
|
-
const
|
|
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 =
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
'
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
package/extensions/notify.ts
CHANGED
|
@@ -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:
|
|
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)
|
|
30
|
-
this.timer =
|
|
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
|
-
|
|
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;
|