pi-harness-delegate 0.7.0 → 0.7.2
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 +13 -2
- package/extensions/acp-runner.ts +60 -24
- package/extensions/command.ts +145 -21
- package/extensions/concurrency.ts +37 -4
- package/extensions/config.ts +36 -34
- package/extensions/engine.ts +167 -17
- package/extensions/fanout.ts +16 -29
- package/extensions/harnesses/amp.ts +2 -0
- package/extensions/harnesses/claude.ts +5 -0
- package/extensions/harnesses/codex.ts +2 -0
- package/extensions/harnesses/devin.ts +6 -1
- package/extensions/harnesses/opencode.ts +3 -0
- package/extensions/harnesses/registry.ts +51 -13
- package/extensions/harnesses/types.ts +5 -8
- package/extensions/index.ts +162 -184
- package/extensions/notify.ts +18 -5
- package/extensions/progress.ts +9 -3
- package/extensions/runner.ts +3 -20
- package/extensions/stream-caps.ts +46 -0
- package/extensions/templates.ts +21 -2
- package/package.json +2 -1
- package/extensions/run-claude.ts +0 -61
- package/extensions/stream-parse.ts +0 -37
package/extensions/index.ts
CHANGED
|
@@ -14,11 +14,24 @@
|
|
|
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
|
-
import {
|
|
21
|
-
import {
|
|
26
|
+
import { formatToolUse, ToolCallIndex } from './activity.ts';
|
|
27
|
+
import {
|
|
28
|
+
aliasUsage,
|
|
29
|
+
delegateUsage,
|
|
30
|
+
isFanoutSpec,
|
|
31
|
+
parseDelegateCommand,
|
|
32
|
+
resolveDefaults,
|
|
33
|
+
resolveHarnessFilter,
|
|
34
|
+
} from './command.ts';
|
|
22
35
|
import { loadConfig } from './config.ts';
|
|
23
36
|
import {
|
|
24
37
|
type DelegateToolParams,
|
|
@@ -27,12 +40,12 @@ import {
|
|
|
27
40
|
injectReport,
|
|
28
41
|
isProjectTrusted,
|
|
29
42
|
runDelegateForTool,
|
|
43
|
+
runMetrics,
|
|
30
44
|
summarize,
|
|
31
|
-
type ToolProgressUpdate,
|
|
32
45
|
takePendingReport,
|
|
33
46
|
} from './engine.ts';
|
|
34
47
|
import { closeWhenMounted, type RunUiState, runFanoutCommand, runFanoutTool } from './fanout.ts';
|
|
35
|
-
import { ALIASES, HARNESS_NAMES, isKnownHarness, resolveHarnessName } from './harnesses/registry.ts';
|
|
48
|
+
import { ALIASES, HARNESS_NAMES, isKnownHarness, isTemplateDanger, resolveHarnessName } from './harnesses/registry.ts';
|
|
36
49
|
import type { ActivityEvent } from './harnesses/types.ts';
|
|
37
50
|
import { delegationHint, stripMarker } from './hint.ts';
|
|
38
51
|
import { showHistory } from './history.ts';
|
|
@@ -42,79 +55,97 @@ import { type DelegateTemplate, loadTemplates } from './templates.ts';
|
|
|
42
55
|
import { mapClaudeUsage } from './usage.ts';
|
|
43
56
|
import { confirmDangerousCommand, confirmDangerousToolCall, confirmToolAddDirs } from './validate.ts';
|
|
44
57
|
|
|
58
|
+
/** Tool-result `details` for the `delegate` tool (and its partial progress updates). */
|
|
59
|
+
type DelegateToolDetails = Record<string, unknown>;
|
|
60
|
+
|
|
61
|
+
const DELEGATE_TOOL_DESCRIPTION =
|
|
62
|
+
'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.';
|
|
63
|
+
|
|
64
|
+
const DELEGATE_TOOL_GUIDELINES: readonly string[] = [
|
|
65
|
+
'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.',
|
|
66
|
+
'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.',
|
|
67
|
+
'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.',
|
|
68
|
+
'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.',
|
|
69
|
+
'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.',
|
|
70
|
+
'pr must be a PR number, an http(s) pull-request URL (https://<host>/<owner>/<repo>/pull/<n>), or owner/repo#123.',
|
|
71
|
+
'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.',
|
|
72
|
+
'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.',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const DELEGATE_TOOL_PARAMS = Type.Object({
|
|
76
|
+
harness: Type.Optional(
|
|
77
|
+
Type.String({
|
|
78
|
+
description:
|
|
79
|
+
'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.',
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
|
|
83
|
+
mode: Type.Optional(
|
|
84
|
+
Type.String({
|
|
85
|
+
description:
|
|
86
|
+
'Template/mode to run: review, plan, implement, security-audit, docs, general, or custom. Defaults to config defaultMode.',
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
scope: Type.Optional(
|
|
90
|
+
Type.String({
|
|
91
|
+
description:
|
|
92
|
+
'Restrict the work: diff (git diff), pr (PR diff), comma/space-separated path list, or omit for whole repo.',
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
model: Type.Optional(Type.String({ description: 'Model (e.g. sonnet, opus, gpt-5). Defaults to template/config.' })),
|
|
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
|
+
|
|
120
|
+
/** The text parts of a tool result, joined — images (never produced by this tool) are skipped. */
|
|
121
|
+
function textOf(content: readonly (TextContent | ImageContent)[] | undefined): string {
|
|
122
|
+
return (content ?? []).flatMap(c => (c.type === 'text' ? [c.text] : [])).join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
45
125
|
export default function (pi: ExtensionAPI) {
|
|
46
126
|
const ui: RunUiState = { activeRunId: 0, activeOverlay: null };
|
|
47
127
|
|
|
48
128
|
// ── Tools ────────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
],
|
|
65
|
-
parameters:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
'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.',
|
|
70
|
-
}),
|
|
71
|
-
),
|
|
72
|
-
task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
|
|
73
|
-
mode: Type.Optional(
|
|
74
|
-
Type.String({
|
|
75
|
-
description:
|
|
76
|
-
'Template/mode to run: review, plan, implement, security-audit, docs, general, or custom. Defaults to config defaultMode.',
|
|
77
|
-
}),
|
|
78
|
-
),
|
|
79
|
-
scope: Type.Optional(
|
|
80
|
-
Type.String({
|
|
81
|
-
description:
|
|
82
|
-
'Restrict the work: diff (git diff), pr (PR diff), comma/space-separated path list, or omit for whole repo.',
|
|
83
|
-
}),
|
|
84
|
-
),
|
|
85
|
-
model: Type.Optional(
|
|
86
|
-
Type.String({ description: 'Model (e.g. sonnet, opus, gpt-5). Defaults to template/config.' }),
|
|
87
|
-
),
|
|
88
|
-
maxBudgetUsd: Type.Optional(Type.Number({ description: 'Hard spend cap in USD for the run.' })),
|
|
89
|
-
sessionId: Type.Optional(
|
|
90
|
-
Type.String({
|
|
91
|
-
description: 'Resume an existing delegated session (pass its session id from a previous run details).',
|
|
92
|
-
}),
|
|
93
|
-
),
|
|
94
|
-
allowDangerous: Type.Optional(
|
|
95
|
-
Type.Boolean({
|
|
96
|
-
description:
|
|
97
|
-
'Escalate to danger permission (unrestricted). Always requires interactive human confirmation; refused without a UI.',
|
|
98
|
-
}),
|
|
99
|
-
),
|
|
100
|
-
pr: Type.Optional(
|
|
101
|
-
Type.String({ description: 'GitHub PR number, http(s) PR URL, or owner/repo#123 (alternative to scope pr).' }),
|
|
102
|
-
),
|
|
103
|
-
addDirs: Type.Optional(
|
|
104
|
-
Type.Array(Type.String(), {
|
|
105
|
-
description:
|
|
106
|
-
'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).',
|
|
107
|
-
}),
|
|
108
|
-
),
|
|
109
|
-
// Deliberately no `verify` param — see the trust-model comment on DelegateToolParams/runVerify.
|
|
110
|
-
}),
|
|
111
|
-
async execute(
|
|
112
|
-
_toolCallId: string,
|
|
113
|
-
params: DelegateToolParams,
|
|
114
|
-
signal: AbortSignal | undefined,
|
|
115
|
-
onUpdate: ((u: ToolProgressUpdate) => void) | undefined,
|
|
116
|
-
ctx: ExtensionContext,
|
|
117
|
-
) {
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build one registration of the `delegate` tool. The primary tool and the deprecated
|
|
132
|
+
* `claude_delegate` alias are the same definition — same schema, guidelines, execute path and
|
|
133
|
+
* renderers — differing only in the fields `spec` sets (name/label/description/snippet) and, for
|
|
134
|
+
* the alias, a pinned harness applied before anything else in `execute`.
|
|
135
|
+
*/
|
|
136
|
+
const makeDelegateTool = (
|
|
137
|
+
name: string,
|
|
138
|
+
spec: { label: string; description: string; promptSnippet: string; forceHarness?: string },
|
|
139
|
+
): ToolDefinition<typeof DELEGATE_TOOL_PARAMS, DelegateToolDetails> => ({
|
|
140
|
+
name,
|
|
141
|
+
label: spec.label,
|
|
142
|
+
description: spec.description,
|
|
143
|
+
promptSnippet: spec.promptSnippet,
|
|
144
|
+
promptGuidelines: [...DELEGATE_TOOL_GUIDELINES],
|
|
145
|
+
parameters: DELEGATE_TOOL_PARAMS,
|
|
146
|
+
async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
|
|
147
|
+
// the deprecated alias pins its harness; everything below sees the effective params
|
|
148
|
+
const params: DelegateToolParams = spec.forceHarness ? { ...rawParams, harness: spec.forceHarness } : rawParams;
|
|
118
149
|
const config = loadConfig();
|
|
119
150
|
// A model-set allowDangerous is never honored on its own — a human confirms it (or, with no
|
|
120
151
|
// UI to ask, it's refused). Checked once up front, before any fan-out. See validate.ts.
|
|
@@ -153,15 +184,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
153
184
|
: `${details.harness} ${details.mode} (${result.numTurns ?? '—'} turn(s), ${formatCost(result.totalCostUsd)})${resumed}`;
|
|
154
185
|
const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
|
|
155
186
|
const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
|
|
156
|
-
|
|
187
|
+
details.markdown = summary.text;
|
|
157
188
|
return {
|
|
158
189
|
content: [{ type: 'text', text: `${head}${body}${footer}` }],
|
|
159
190
|
details,
|
|
160
191
|
usage: result.usage ? mapClaudeUsage({ ...result.usage, totalCostUsd: result.totalCostUsd }) : undefined,
|
|
161
192
|
};
|
|
162
193
|
},
|
|
163
|
-
renderCall(
|
|
164
|
-
const params = args as { harness?: string; mode?: string; task?: string };
|
|
194
|
+
renderCall(params, theme) {
|
|
165
195
|
const harness = params.harness ?? 'delegate';
|
|
166
196
|
const mode = params.mode ?? 'general';
|
|
167
197
|
const task = params.task ?? '';
|
|
@@ -170,19 +200,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
200
|
theme.bg('toolPendingBg', s),
|
|
171
201
|
);
|
|
172
202
|
},
|
|
173
|
-
renderResult(
|
|
174
|
-
result: { content?: { type: string; text: string }[]; details?: Record<string, unknown> },
|
|
175
|
-
options: { isPartial: boolean },
|
|
176
|
-
theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string },
|
|
177
|
-
) {
|
|
203
|
+
renderResult(result, options, theme) {
|
|
178
204
|
if (options.isPartial) {
|
|
179
|
-
const text = (result.content
|
|
180
|
-
.filter(c => c.type === 'text')
|
|
181
|
-
.map(c => c.text)
|
|
182
|
-
.join('\n');
|
|
205
|
+
const text = textOf(result.content);
|
|
183
206
|
return new Text(text, 1, 1, s => theme.bg('toolPendingBg', s));
|
|
184
207
|
}
|
|
185
|
-
const details =
|
|
208
|
+
const details: DelegateToolDetails = result.details ?? {};
|
|
186
209
|
const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
|
|
187
210
|
const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
|
|
188
211
|
const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null;
|
|
@@ -205,10 +228,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
205
228
|
const md = typeof details.markdown === 'string' && details.markdown ? details.markdown : null;
|
|
206
229
|
if (md) container.addChild(new Markdown(md, 1, 1, getMarkdownTheme()));
|
|
207
230
|
else {
|
|
208
|
-
const text = (result.content
|
|
209
|
-
.filter(c => c.type === 'text')
|
|
210
|
-
.map(c => c.text)
|
|
211
|
-
.join('\n');
|
|
231
|
+
const text = textOf(result.content);
|
|
212
232
|
container.addChild(new Text(text, 1, 1));
|
|
213
233
|
}
|
|
214
234
|
const foot: string[] = [];
|
|
@@ -217,46 +237,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
217
237
|
if (foot.length > 0) container.addChild(new Text(theme.fg('dim', foot.join(' ')), 1, 1));
|
|
218
238
|
return container;
|
|
219
239
|
},
|
|
220
|
-
};
|
|
240
|
+
});
|
|
221
241
|
|
|
222
|
-
|
|
223
|
-
|
|
242
|
+
pi.registerTool(
|
|
243
|
+
makeDelegateTool('delegate', {
|
|
244
|
+
label: 'Delegate',
|
|
245
|
+
description: DELEGATE_TOOL_DESCRIPTION,
|
|
246
|
+
promptSnippet: 'Delegate a subtask to a harness and return its report',
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
224
249
|
|
|
225
250
|
// deprecated alias
|
|
226
|
-
pi.registerTool(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
'
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
promptGuidelines: [...(delegateToolDef as unknown as { promptGuidelines: string[] }).promptGuidelines], // SAFETY: promptGuidelines is string[]
|
|
235
|
-
parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
|
|
236
|
-
async execute(
|
|
237
|
-
toolCallId: string,
|
|
238
|
-
params: DelegateToolParams,
|
|
239
|
-
signal: AbortSignal | undefined,
|
|
240
|
-
onUpdate: never,
|
|
241
|
-
ctx: ExtensionContext,
|
|
242
|
-
) {
|
|
243
|
-
return (
|
|
244
|
-
// SAFETY: deprecated alias delegates to primary tool, shape identical
|
|
245
|
-
(
|
|
246
|
-
delegateToolDef as unknown as {
|
|
247
|
-
// SAFETY: alias shape identical
|
|
248
|
-
execute: (a: string, b: unknown, c: unknown, d: unknown, e: unknown) => Promise<unknown>;
|
|
249
|
-
}
|
|
250
|
-
).execute(toolCallId, { ...params, harness: 'claude' }, signal, onUpdate, ctx)
|
|
251
|
-
);
|
|
252
|
-
},
|
|
253
|
-
// SAFETY: delegateToolDef renderCall matches expected signature
|
|
254
|
-
renderCall: (delegateToolDef as unknown as { renderCall: (a: unknown, b: unknown) => unknown }).renderCall, // SAFETY: matches signature
|
|
255
|
-
// SAFETY: delegateToolDef renderResult matches expected signature
|
|
256
|
-
renderResult: (delegateToolDef as unknown as { renderResult: (a: unknown, b: unknown, c: unknown) => unknown }) // SAFETY: matches signature
|
|
257
|
-
.renderResult,
|
|
258
|
-
// SAFETY: final alias tool matches registerTool overload
|
|
259
|
-
} as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: alias tool matches overload
|
|
251
|
+
pi.registerTool(
|
|
252
|
+
makeDelegateTool('claude_delegate', {
|
|
253
|
+
label: 'Claude Delegate (deprecated)',
|
|
254
|
+
description: `Deprecated alias for delegate{harness:claude}. Use delegate tool with harness:claude instead. ${DELEGATE_TOOL_DESCRIPTION}`,
|
|
255
|
+
promptSnippet: 'Delegate a subtask to Claude Code (deprecated alias)',
|
|
256
|
+
forceHarness: 'claude',
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
260
259
|
|
|
261
260
|
// ── Commands ─────────────────────────────────────────────────────────────
|
|
262
261
|
|
|
@@ -502,6 +501,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
502
501
|
const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
|
|
503
502
|
// if forcedHarness provided, it wins
|
|
504
503
|
if (forcedHarness) parsed.harness = forcedHarness;
|
|
504
|
+
// a flag that was given but can't be honored (e.g. --budget=0) runs nothing — never silently dropped
|
|
505
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
506
|
+
const msg = `${parsed.errors.join('; ')}\nUsage: ${forcedHarness ? aliasUsage(forcedHarness) : delegateUsage()}`;
|
|
507
|
+
if (ctx.hasUI) ctx.ui.notify(msg, 'error');
|
|
508
|
+
else process.stderr.write(`${msg}\n`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// non-fatal: e.g. a --flag= that sat inside "quoted" prompt text and so wasn't applied
|
|
512
|
+
if (parsed.notices && parsed.notices.length > 0) {
|
|
513
|
+
const msg = parsed.notices.join('\n');
|
|
514
|
+
if (ctx.hasUI) ctx.ui.notify(msg, 'warning');
|
|
515
|
+
else process.stderr.write(`${msg}\n`);
|
|
516
|
+
}
|
|
505
517
|
|
|
506
518
|
// fan-out: harness field is `all` or a comma list — resolve to detected harnesses and run
|
|
507
519
|
// the engine once per harness instead of the single-harness flow below.
|
|
@@ -513,12 +525,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
513
525
|
const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
|
|
514
526
|
const templates = loadTemplates(ctx.cwd, harnessName, trusted);
|
|
515
527
|
const resolved = resolveDefaults(parsed, templates);
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
|
|
521
|
-
: false);
|
|
528
|
+
// the template delegate() will actually run — the default mode when none was given — so the
|
|
529
|
+
// danger banner agrees with the engine's own gate (isTemplateDanger, same check)
|
|
530
|
+
const template = templates.get(parsed.mode || loadConfig().defaultMode);
|
|
531
|
+
const isDanger = isTemplateDanger(harnessName, template);
|
|
522
532
|
|
|
523
533
|
if (!resolved) {
|
|
524
534
|
if (parsed.mode)
|
|
@@ -526,11 +536,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
526
536
|
`/delegate ${parsed.mode} <what to do> — give a prompt for the "${parsed.mode}" mode`,
|
|
527
537
|
'warning',
|
|
528
538
|
);
|
|
529
|
-
else
|
|
530
|
-
ctx.ui.notify?.(
|
|
531
|
-
'Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=…] [--model=…] [--scope=…] [--pr=…] [--budget=…] [--verify=…] [--resume=…] [--allow-dangerous] <prompt>',
|
|
532
|
-
'warning',
|
|
533
|
-
);
|
|
539
|
+
else ctx.ui.notify?.(`Usage: ${forcedHarness ? aliasUsage(forcedHarness) : delegateUsage()}`, 'warning');
|
|
534
540
|
return;
|
|
535
541
|
}
|
|
536
542
|
|
|
@@ -584,25 +590,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
590
|
const file = (details.file as string) ?? null;
|
|
585
591
|
const sessionId = (details.sessionId as string) ?? null;
|
|
586
592
|
const resumeHint = sessionId ? ` · resume: /delegate --resume=${sessionId} <prompt>` : '';
|
|
587
|
-
const
|
|
588
|
-
| {
|
|
589
|
-
inputTokens?: number;
|
|
590
|
-
outputTokens?: number;
|
|
591
|
-
cacheCreationInputTokens?: number;
|
|
592
|
-
cacheReadInputTokens?: number;
|
|
593
|
-
}
|
|
594
|
-
| undefined;
|
|
595
|
-
const promptTokens = usage
|
|
596
|
-
? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
|
|
597
|
-
: 0;
|
|
598
|
-
const metrics = formatMetrics({
|
|
599
|
-
numTurns: typeof details.numTurns === 'number' ? details.numTurns : null,
|
|
600
|
-
totalCostUsd: typeof details.totalCostUsd === 'number' ? details.totalCostUsd : null,
|
|
601
|
-
promptTokens,
|
|
602
|
-
contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
|
|
603
|
-
durationMs:
|
|
604
|
-
typeof details.durationMs === 'number' && details.durationMs !== null ? (details.durationMs as number) : null,
|
|
605
|
-
});
|
|
593
|
+
const metrics = runMetrics(details);
|
|
606
594
|
injectReport(ctx, {
|
|
607
595
|
harness: details.harness as string,
|
|
608
596
|
mode: details.mode as string,
|
|
@@ -619,34 +607,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
619
607
|
};
|
|
620
608
|
|
|
621
609
|
pi.registerCommand('delegate', {
|
|
622
|
-
description:
|
|
623
|
-
'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp|devin|all] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--pr=<n|url>] [--budget=<usd>] [--verify=<cmd>] [--resume=<id>] [--allow-dangerous] <prompt> — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report. --allow-dangerous runs this one invocation with danger (unrestricted) permission after an interactive confirm; refused headless.',
|
|
610
|
+
description: `Delegate a task to any harness. Usage: ${delegateUsage()} — or use harness as first word: /delegate codex review <prompt>. harness=all or a comma list (e.g. claude,codex) fans out to every detected harness and returns one comparison report. --allow-dangerous runs this one invocation with danger (unrestricted) permission after an interactive confirm; refused headless.`,
|
|
624
611
|
handler: makeHandler(),
|
|
625
612
|
});
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
});
|
|
642
|
-
pi.registerCommand('omp', {
|
|
643
|
-
description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>',
|
|
644
|
-
handler: makeHandler('amp'),
|
|
645
|
-
});
|
|
646
|
-
pi.registerCommand('devin', {
|
|
647
|
-
description: 'Alias for /delegate --harness=devin. Usage: /devin [--mode=...] <prompt>',
|
|
648
|
-
handler: makeHandler('devin'),
|
|
649
|
-
});
|
|
613
|
+
// alias commands: same flag set as /delegate (one source — COMMAND_FLAGS_HINT), harness fixed
|
|
614
|
+
const aliasCommands: [command: string, harness: string, note: string][] = [
|
|
615
|
+
['claude', 'claude', ''],
|
|
616
|
+
['codex', 'codex', ''],
|
|
617
|
+
['opencode', 'opencode', ''],
|
|
618
|
+
['amp', 'amp', ''],
|
|
619
|
+
['omp', 'amp', ' (omp compat)'],
|
|
620
|
+
['devin', 'devin', ''],
|
|
621
|
+
];
|
|
622
|
+
for (const [command, harness, note] of aliasCommands) {
|
|
623
|
+
pi.registerCommand(command, {
|
|
624
|
+
description: `Alias for /delegate --harness=${harness}${note}. Usage: ${aliasUsage(command)}`,
|
|
625
|
+
handler: makeHandler(harness),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
650
628
|
|
|
651
629
|
pi.on('input', async (event, _ctx) => {
|
|
652
630
|
if (event.source === 'extension') return { action: 'continue' };
|
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;
|
package/extensions/progress.ts
CHANGED
|
@@ -47,12 +47,18 @@ export function fmtElapsed(ms: number): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* Fit a feed into `max` rendered lines, reporting how many entries were dropped so the caller can
|
|
51
51
|
* show a "+N earlier" marker instead of silently truncating with no hint older entries existed.
|
|
52
|
-
*
|
|
52
|
+
* The marker itself takes one of the `max` lines: when anything is dropped only the last `max - 1`
|
|
53
|
+
* entries are visible, so marker + entries never exceed `max` (for `max >= 1`). `max <= 0` shows no entries (and
|
|
54
|
+
* reports everything hidden) — never `slice(-0)`'s "everything". Pure — testable without a TUI.
|
|
53
55
|
*/
|
|
54
56
|
export function truncateFeed<T>(entries: T[], max: number): { visible: T[]; hiddenCount: number } {
|
|
55
|
-
|
|
57
|
+
const cap = Math.max(0, Math.floor(max));
|
|
58
|
+
if (entries.length <= cap) return { visible: [...entries], hiddenCount: 0 };
|
|
59
|
+
// one line goes to the marker; with a cap of 0 or 1 there's no room for entries at all
|
|
60
|
+
const keep = Math.max(0, cap - 1);
|
|
61
|
+
return { visible: keep > 0 ? entries.slice(-keep) : [], hiddenCount: entries.length - keep };
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Style one feed entry; the returned string may contain ANSI colors. */
|
package/extensions/runner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import type { Harness, ParseState, StreamedResult } from './harnesses/types.ts';
|
|
4
|
+
import { appendActivities, appendStreamed } from './stream-caps.ts';
|
|
4
5
|
|
|
5
6
|
export interface RunHarnessOptions {
|
|
6
7
|
harness: Harness;
|
|
@@ -96,8 +97,6 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
|
96
97
|
let settled = false;
|
|
97
98
|
let firstTokenAt: number | null = null;
|
|
98
99
|
const startAt = Date.now();
|
|
99
|
-
const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
|
|
100
|
-
const MAX_ACTIVITIES = 5000;
|
|
101
100
|
|
|
102
101
|
const finish = (r: StreamedResult, budgetExceeded = false) => {
|
|
103
102
|
if (settled) return;
|
|
@@ -126,25 +125,9 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
|
126
125
|
const outcome = opts.harness.parseLine(line, state);
|
|
127
126
|
if (outcome.streamedText) {
|
|
128
127
|
if (firstTokenAt === null) firstTokenAt = Date.now();
|
|
129
|
-
|
|
130
|
-
if (state.streamedText.length < MAX_STREAMED) {
|
|
131
|
-
const remaining = MAX_STREAMED - state.streamedText.length;
|
|
132
|
-
const chunk =
|
|
133
|
-
outcome.streamedText.length > remaining
|
|
134
|
-
? `${outcome.streamedText.slice(0, remaining)} [truncated ${outcome.streamedText.length - remaining} chars]`
|
|
135
|
-
: outcome.streamedText;
|
|
136
|
-
state.streamedText += chunk;
|
|
137
|
-
opts.onStream?.(chunk);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
if (outcome.activities) {
|
|
141
|
-
for (const a of outcome.activities) {
|
|
142
|
-
if (state.activities.length < MAX_ACTIVITIES) {
|
|
143
|
-
state.activities.push(a);
|
|
144
|
-
opts.onActivity?.(a);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
128
|
+
appendStreamed(state, outcome.streamedText, opts.onStream); // capped — see stream-caps.ts
|
|
147
129
|
}
|
|
130
|
+
if (outcome.activities) appendActivities(state, outcome.activities, opts.onActivity);
|
|
148
131
|
if (outcome.result) {
|
|
149
132
|
// merge streamedText into result if empty
|
|
150
133
|
if (!outcome.result.result) outcome.result.result = state.streamedText;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory caps shared by both runners (`runner.ts` for stdout harnesses, `acp-runner.ts` for ACP):
|
|
3
|
+
* a misbehaving or compromised harness can stream without bound, so accumulated text and activity
|
|
4
|
+
* events are capped here, once, instead of in two hand-copied blocks that could drift.
|
|
5
|
+
*/
|
|
6
|
+
import type { ActivityEvent, ParseState } from './harnesses/types.ts';
|
|
7
|
+
|
|
8
|
+
/** Max characters of streamed text kept per run (5MB) — beyond this the stream is truncated. */
|
|
9
|
+
export const MAX_STREAMED_CHARS = 5 * 1024 * 1024;
|
|
10
|
+
/** Max activity events kept (and forwarded to `onActivity`) per run. */
|
|
11
|
+
export const MAX_ACTIVITIES = 5000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Append a streamed chunk to `state.streamedText`, truncating at `max` with a
|
|
15
|
+
* `[truncated N chars]` marker, and forward exactly what was kept to `onStream`. Once the cap has
|
|
16
|
+
* been reached, further chunks are dropped (and not forwarded). Returns the appended chunk, or
|
|
17
|
+
* `null` when nothing was appended.
|
|
18
|
+
*/
|
|
19
|
+
export function appendStreamed(
|
|
20
|
+
state: ParseState,
|
|
21
|
+
text: string,
|
|
22
|
+
onStream?: (chunk: string) => void,
|
|
23
|
+
max: number = MAX_STREAMED_CHARS,
|
|
24
|
+
): string | null {
|
|
25
|
+
if (!text || state.streamedText.length >= max) return null;
|
|
26
|
+
const remaining = max - state.streamedText.length;
|
|
27
|
+
const chunk =
|
|
28
|
+
text.length > remaining ? `${text.slice(0, remaining)} [truncated ${text.length - remaining} chars]` : text;
|
|
29
|
+
state.streamedText += chunk;
|
|
30
|
+
onStream?.(chunk);
|
|
31
|
+
return chunk;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Append activity events to `state.activities` up to `max`, forwarding each kept one to `onActivity`. */
|
|
35
|
+
export function appendActivities(
|
|
36
|
+
state: ParseState,
|
|
37
|
+
activities: readonly ActivityEvent[],
|
|
38
|
+
onActivity?: (ev: ActivityEvent) => void,
|
|
39
|
+
max: number = MAX_ACTIVITIES,
|
|
40
|
+
): void {
|
|
41
|
+
for (const a of activities) {
|
|
42
|
+
if (state.activities.length >= max) return;
|
|
43
|
+
state.activities.push(a);
|
|
44
|
+
onActivity?.(a);
|
|
45
|
+
}
|
|
46
|
+
}
|