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.
@@ -14,11 +14,24 @@
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
- import { formatMetrics, formatToolUse, ToolCallIndex } from './activity.ts';
21
- import { isFanoutSpec, parseDelegateCommand, resolveDefaults, resolveHarnessFilter } from './command.ts';
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
- const delegateToolDef = {
50
- name: 'delegate',
51
- label: 'Delegate',
52
- description:
53
- '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.',
54
- promptSnippet: 'Delegate a subtask to a harness and return its report',
55
- promptGuidelines: [
56
- '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.',
57
- '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.',
58
- '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.',
59
- '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.',
60
- '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.',
61
- 'pr must be a PR number, an http(s) pull-request URL (https://<host>/<owner>/<repo>/pull/<n>), or owner/repo#123.',
62
- '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.',
63
- '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.',
64
- ],
65
- parameters: Type.Object({
66
- harness: Type.Optional(
67
- Type.String({
68
- description:
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
- (details as Record<string, unknown>).markdown = summary.text;
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(args: unknown, theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string }) {
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 = (result.details ?? {}) as Record<string, unknown>;
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
- // SAFETY: delegateToolDef satisfies registerTool params via TypeBox, widened for alias registration
223
- pi.registerTool(delegateToolDef as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: delegateToolDef satisfies registerTool params
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
- name: 'claude_delegate',
228
- label: 'Claude Delegate (deprecated)',
229
- description:
230
- 'Deprecated alias for delegate{harness:claude}. Use delegate tool with harness:claude instead. ' +
231
- (delegateToolDef as { description: string }).description,
232
- promptSnippet: 'Delegate a subtask to Claude Code (deprecated alias)',
233
- // SAFETY: delegateToolDef promptGuidelines is string[] from literal, safe to spread
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
- const template = parsed.mode ? templates.get(parsed.mode) : undefined;
517
- const isDanger =
518
- template?.permission === 'danger' ||
519
- (template?.nativePermission
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 usage = details.usage as
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
- pi.registerCommand('claude', {
627
- description: 'Alias for /delegate --harness=claude. Usage: /claude [--mode=...] <prompt>',
628
- handler: makeHandler('claude'),
629
- });
630
- pi.registerCommand('codex', {
631
- description: 'Alias for /delegate --harness=codex. Usage: /codex [--mode=...] <prompt>',
632
- handler: makeHandler('codex'),
633
- });
634
- pi.registerCommand('opencode', {
635
- description: 'Alias for /delegate --harness=opencode. Usage: /opencode [--mode=...] <prompt>',
636
- handler: makeHandler('opencode'),
637
- });
638
- pi.registerCommand('amp', {
639
- description: 'Alias for /delegate --harness=amp. Usage: /amp [--mode=...] <prompt>',
640
- handler: makeHandler('amp'),
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' };
@@ -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;
@@ -47,12 +47,18 @@ export function fmtElapsed(ms: number): string {
47
47
  }
48
48
 
49
49
  /**
50
- * Slice a feed down to its last `max` entries, reporting how many were dropped so the caller can
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
- * Pure — testable without a TUI.
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
- return { visible: entries.slice(-max), hiddenCount: Math.max(0, entries.length - max) };
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. */
@@ -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
- // Cap streamedText to prevent OOM on compromised harness
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
+ }