pi-harness-delegate 0.1.0 → 0.1.1

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,803 +14,1073 @@
14
14
  * Legacy: { claudeDelegate: {...} } is auto-migrated.
15
15
  */
16
16
 
17
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
18
- import { homedir } from 'node:os';
17
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
19
18
  import { basename, join } from 'node:path';
19
+ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme } from '@earendil-works/pi-coding-agent';
20
+ import {
21
+ Container,
22
+ Key,
23
+ Markdown,
24
+ matchesKey,
25
+ type OverlayHandle,
26
+ type SelectItem,
27
+ SelectList,
28
+ Text,
29
+ truncateToWidth,
30
+ } from '@earendil-works/pi-tui';
20
31
  import { Type } from 'typebox';
21
- import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
22
- import { Container, Key, Markdown, matchesKey, SelectList, Text, truncateToWidth, type Component, type OverlayHandle, type SelectItem } from '@earendil-works/pi-tui';
23
- import { runHarness } from './runner.ts';
24
- import { DEFAULT_TIMEOUT_MS } from './harnesses/types.ts';
25
- import { parseDelegateCommand, parseClaudeCommand, resolveDefaults } from './command.ts';
32
+ import {
33
+ buildReportContent,
34
+ buildTranscript,
35
+ collectActivityLog,
36
+ formatMetrics,
37
+ formatToolUse,
38
+ parseTranscriptMeta,
39
+ pruneOutputs,
40
+ safeSegmentName,
41
+ } from './activity.ts';
42
+ import { parseDelegateCommand, resolveDefaults } from './command.ts';
43
+ import { outputsDir as getOutputsDir, legacyOutputsDir, loadConfig, resolveModelForHarness } from './config.ts';
44
+ import { ALIASES, getHarness, HARNESS_NAMES, isKnownHarness } from './harnesses/registry.ts';
45
+ import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
46
+
26
47
  import { delegationHint, stripMarker } from './hint.ts';
27
- import { progressWindow, type FeedEntry } from './progress.ts';
28
- import { loadTemplates, type DelegateTemplate } from './templates.ts';
48
+ import { type FeedEntry, progressWindow } from './progress.ts';
49
+ import { runHarness } from './runner.ts';
50
+ import { type DelegateTemplate, loadTemplates } from './templates.ts';
29
51
  import { mapClaudeUsage } from './usage.ts';
30
- import { buildReportContent, buildTranscript, collectActivityLog, formatMetrics, formatToolUse, parseTranscriptMeta, pruneOutputs, safeSegmentName } from './activity.ts';
31
- import { loadConfig, outputsDir as getOutputsDir, legacyOutputsDir, resolveModelForHarness, agentDir } from './config.ts';
32
- import { getHarness, HARNESS_NAMES, ALIASES, isKnownHarness } from './harnesses/registry.ts';
33
- import type { ActivityEvent } from './harnesses/types.ts';
34
- import type { NormalizedPermission } from './harnesses/types.ts';
35
52
 
36
53
  interface DelegateOptions {
37
- harness?: string;
38
- task: string;
39
- mode?: string;
40
- scope?: string;
41
- model?: string;
42
- maxBudgetUsd?: number;
43
- allowDangerous?: boolean;
44
- sessionId?: string;
45
- pr?: string;
46
- onStream?: (text: string) => void;
47
- onActivity?: (ev: ActivityEvent) => void;
48
- signal?: AbortSignal;
54
+ harness?: string;
55
+ task: string;
56
+ mode?: string;
57
+ scope?: string;
58
+ model?: string;
59
+ maxBudgetUsd?: number;
60
+ allowDangerous?: boolean;
61
+ sessionId?: string;
62
+ pr?: string;
63
+ onStream?: (text: string) => void;
64
+ onActivity?: (ev: ActivityEvent) => void;
65
+ signal?: AbortSignal;
49
66
  }
50
67
 
51
68
  const activeRuns = new Map<string, number>();
52
69
  let globalActiveRuns = 0;
53
70
 
54
71
  function getMaxConcurrentGlobal(): number {
55
- const cfg = loadConfig();
56
- if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
57
- const mc = cfg.maxConcurrent as unknown as { global?: number };
58
- if (typeof mc.global === 'number') return mc.global;
59
- return 1;
72
+ const cfg = loadConfig();
73
+ if (typeof cfg.maxConcurrent === 'number') return cfg.maxConcurrent;
74
+ // SAFETY: maxConcurrent is validated to be number or object with global/perHarness in loadConfig
75
+ const mc = cfg.maxConcurrent as unknown as { global?: number }; // SAFETY: maxConcurrent validated in loadConfig
76
+ if (typeof mc.global === 'number') return mc.global;
77
+ return 1;
60
78
  }
61
79
 
62
80
  async function closeWhenMounted(getClose: () => (() => void) | null, capMs: number): Promise<void> {
63
- const close = getClose();
64
- if (close) {
65
- close();
66
- return;
67
- }
68
- await new Promise<void>((resolve) => {
69
- const start = Date.now();
70
- const timer = setInterval(() => {
71
- const fn = getClose();
72
- if (fn || Date.now() - start > capMs) {
73
- clearInterval(timer);
74
- fn?.();
75
- resolve();
76
- }
77
- }, 20);
78
- });
81
+ const close = getClose();
82
+ if (close) {
83
+ close();
84
+ return;
85
+ }
86
+ await new Promise<void>(resolve => {
87
+ const start = Date.now();
88
+ const timer = setInterval(() => {
89
+ const fn = getClose();
90
+ if (fn || Date.now() - start > capMs) {
91
+ clearInterval(timer);
92
+ fn?.();
93
+ resolve();
94
+ }
95
+ }, 20);
96
+ });
79
97
  }
80
98
 
81
99
  function outputsDirFor(harness: string): string {
82
- return getOutputsDir(harness);
100
+ return getOutputsDir(harness);
83
101
  }
84
102
 
85
103
  function formatTemplateRow(t: DelegateTemplate): string {
86
- const parts = [t.name, `[${t.permission}]`, t.model ? `model=${t.model}` : '', t.defaultTask ? '↳ default task' : '', t.harness ? `(${t.harness})` : ''];
87
- return `${parts.filter(Boolean).join(' ')} — ${t.description}`;
104
+ const parts = [
105
+ t.name,
106
+ `[${t.permission}]`,
107
+ t.model ? `model=${t.model}` : '',
108
+ t.defaultTask ? '↳ default task' : '',
109
+ t.harness ? `(${t.harness})` : '',
110
+ ];
111
+ return `${parts.filter(Boolean).join(' ')} — ${t.description}`;
88
112
  }
89
113
 
90
114
  async function showModes(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
91
- const all = new Map<string, DelegateTemplate>();
92
- // collect from all harnesses if no filter
93
- if (harnessFilter) {
94
- for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter)) all.set(k, v);
95
- } else {
96
- for (const h of [...HARNESS_NAMES, 'shared']) {
97
- for (const [k, v] of loadTemplates(ctx.cwd, h)) if (!all.has(k)) all.set(k, v);
98
- }
99
- // also load without harness param
100
- for (const [k, v] of loadTemplates(ctx.cwd)) if (!all.has(k)) all.set(k, v);
101
- }
102
- const rows = [...all.values()].map(formatTemplateRow);
103
- if (!ctx.hasUI) {
104
- process.stdout.write(`${rows.join('\n')}\n`);
105
- return;
106
- }
107
- await ctx.ui.custom((tui, theme, _kb, done) => {
108
- let offset = 0;
109
- const height = 12;
110
- return {
111
- render(width: number): string[] {
112
- const header = theme.fg('accent', `delegate — modes${harnessFilter ? ` (${harnessFilter})` : ''} (↑↓ scroll · any key to close)`);
113
- const visible = rows.slice(offset, offset + height);
114
- return [header, ...visible.map((l) => theme.fg('muted', truncateToWidth(l, width)))];
115
- },
116
- handleInput(data: string): void {
117
- if (matchesKey(data, Key.up) && offset > 0) {
118
- offset--;
119
- tui.requestRender();
120
- } else if (matchesKey(data, Key.down) && offset < rows.length - 1) {
121
- offset++;
122
- tui.requestRender();
123
- } else {
124
- done(undefined);
125
- }
126
- },
127
- invalidate() {},
128
- };
129
- });
115
+ const all = new Map<string, DelegateTemplate>();
116
+ // collect from all harnesses if no filter
117
+ if (harnessFilter) {
118
+ for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter)) all.set(k, v);
119
+ } else {
120
+ for (const h of [...HARNESS_NAMES, 'shared']) {
121
+ for (const [k, v] of loadTemplates(ctx.cwd, h)) if (!all.has(k)) all.set(k, v);
122
+ }
123
+ // also load without harness param
124
+ for (const [k, v] of loadTemplates(ctx.cwd)) if (!all.has(k)) all.set(k, v);
125
+ }
126
+ const rows = [...all.values()].map(formatTemplateRow);
127
+ if (!ctx.hasUI) {
128
+ process.stdout.write(`${rows.join('\n')}\n`);
129
+ return;
130
+ }
131
+ await ctx.ui.custom((tui, theme, _kb, done) => {
132
+ let offset = 0;
133
+ const height = 12;
134
+ return {
135
+ render(width: number): string[] {
136
+ const header = theme.fg(
137
+ 'accent',
138
+ `delegate — modes${harnessFilter ? ` (${harnessFilter})` : ''} (↑↓ scroll · any key to close)`,
139
+ );
140
+ const visible = rows.slice(offset, offset + height);
141
+ return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
142
+ },
143
+ handleInput(data: string): void {
144
+ if (matchesKey(data, Key.up) && offset > 0) {
145
+ offset--;
146
+ tui.requestRender();
147
+ } else if (matchesKey(data, Key.down) && offset < rows.length - 1) {
148
+ offset++;
149
+ tui.requestRender();
150
+ } else {
151
+ done(undefined);
152
+ }
153
+ },
154
+ invalidate() {},
155
+ };
156
+ });
130
157
  }
131
158
 
132
159
  interface HistoryEntry {
133
- file: string;
134
- mode: string;
135
- harness: string;
136
- cost: number;
137
- sessionId: string | null;
138
- mtime: number;
160
+ file: string;
161
+ mode: string;
162
+ harness: string;
163
+ cost: number;
164
+ sessionId: string | null;
165
+ mtime: number;
139
166
  }
140
167
 
141
168
  function readHistory(dir: string, harness: string): HistoryEntry[] {
142
- try {
143
- return readdirSync(dir)
144
- .filter((f) => f.endsWith('.md') && !f.includes('-partial'))
145
- .map((f) => {
146
- const file = join(dir, f);
147
- let mode = 'delegate';
148
- let cost = 0;
149
- let sessionId: string | null = null;
150
- try {
151
- const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
152
- mode = meta.mode;
153
- cost = meta.cost;
154
- sessionId = meta.sessionId;
155
- } catch {}
156
- return { file, mode, harness, cost, sessionId, mtime: statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0 };
157
- })
158
- .sort((a, b) => b.mtime - a.mtime);
159
- } catch {
160
- return [];
161
- }
169
+ try {
170
+ return readdirSync(dir)
171
+ .filter(f => f.endsWith('.md') && !f.includes('-partial'))
172
+ .map(f => {
173
+ const file = join(dir, f);
174
+ let mode = 'delegate';
175
+ let cost = 0;
176
+ let sessionId: string | null = null;
177
+ try {
178
+ const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
179
+ mode = meta.mode;
180
+ cost = meta.cost;
181
+ sessionId = meta.sessionId;
182
+ } catch (_e) {
183
+ void _e;
184
+ }
185
+ return {
186
+ file,
187
+ mode,
188
+ harness,
189
+ cost,
190
+ sessionId,
191
+ mtime: statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0,
192
+ };
193
+ })
194
+ .sort((a, b) => b.mtime - a.mtime);
195
+ } catch {
196
+ return [];
197
+ }
162
198
  }
163
199
 
164
200
  function readAllHistory(): HistoryEntry[] {
165
- const entries: HistoryEntry[] = [];
166
- // new partitioned dir
167
- for (const h of HARNESS_NAMES) {
168
- entries.push(...readHistory(getOutputsDir(h), h));
169
- }
170
- // also legacy dir for migration display
171
- try {
172
- const legacy = readdirSync(legacyOutputsDir()).filter((f) => f.endsWith('.md'));
173
- for (const f of legacy) {
174
- const file = join(legacyOutputsDir(), f);
175
- let mode = 'delegate';
176
- let cost = 0;
177
- let sessionId: string | null = null;
178
- try {
179
- const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
180
- mode = meta.mode;
181
- cost = meta.cost;
182
- sessionId = meta.sessionId;
183
- } catch {}
184
- entries.push({ file, mode, harness: 'claude', cost, sessionId, mtime: statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0 });
185
- }
186
- } catch {}
187
- return entries.sort((a, b) => b.mtime - a.mtime);
201
+ const entries: HistoryEntry[] = [];
202
+ // new partitioned dir
203
+ for (const h of HARNESS_NAMES) {
204
+ entries.push(...readHistory(getOutputsDir(h), h));
205
+ }
206
+ // also legacy dir for migration display
207
+ try {
208
+ const legacy = readdirSync(legacyOutputsDir()).filter(f => f.endsWith('.md'));
209
+ for (const f of legacy) {
210
+ const file = join(legacyOutputsDir(), f);
211
+ let mode = 'delegate';
212
+ let cost = 0;
213
+ let sessionId: string | null = null;
214
+ try {
215
+ const meta = parseTranscriptMeta(readFileSync(file, 'utf8').slice(0, 2000));
216
+ mode = meta.mode;
217
+ cost = meta.cost;
218
+ sessionId = meta.sessionId;
219
+ } catch (_e) {
220
+ void _e;
221
+ }
222
+ entries.push({
223
+ file,
224
+ mode,
225
+ harness: 'claude',
226
+ cost,
227
+ sessionId,
228
+ mtime: statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0,
229
+ });
230
+ }
231
+ } catch (_e) {
232
+ void _e;
233
+ }
234
+ return entries.sort((a, b) => b.mtime - a.mtime);
188
235
  }
189
236
 
190
237
  async function viewTranscript(ctx: ExtensionContext, entry: HistoryEntry): Promise<void> {
191
- if (!ctx.hasUI) {
192
- process.stdout.write(readFileSync(entry.file, 'utf8'));
193
- return;
194
- }
195
- await ctx.ui.custom((tui, theme, _kb, done) => {
196
- const lines = readFileSync(entry.file, 'utf8').split('\n');
197
- let offset = 0;
198
- const height = 12;
199
- return {
200
- render(width: number): string[] {
201
- const resume = entry.sessionId ? ` · r resume` : '';
202
- const header = theme.fg('accent', `${basename(entry.file)} (↑↓ scroll${resume} · esc close)`);
203
- const visible = lines.slice(offset, offset + height);
204
- return [header, ...visible.map((l) => theme.fg('muted', truncateToWidth(l, width)))];
205
- },
206
- handleInput(data: string): void {
207
- if (matchesKey(data, Key.down) && offset < lines.length - 1) {
208
- offset++;
209
- tui.requestRender();
210
- } else if (matchesKey(data, Key.up) && offset > 0) {
211
- offset--;
212
- tui.requestRender();
213
- } else if (matchesKey(data, Key.escape)) {
214
- done(undefined);
215
- } else if (entry.sessionId && data === 'r') {
216
- ctx.ui.notify?.(`resume with: /delegate --resume=${entry.sessionId} <prompt>`, 'info');
217
- }
218
- },
219
- invalidate() {},
220
- };
221
- });
238
+ if (!ctx.hasUI) {
239
+ process.stdout.write(readFileSync(entry.file, 'utf8'));
240
+ return;
241
+ }
242
+ await ctx.ui.custom((tui, theme, _kb, done) => {
243
+ const lines = readFileSync(entry.file, 'utf8').split('\n');
244
+ let offset = 0;
245
+ const height = 12;
246
+ return {
247
+ render(width: number): string[] {
248
+ const resume = entry.sessionId ? ` · r resume` : '';
249
+ const header = theme.fg('accent', `${basename(entry.file)} (↑↓ scroll${resume} · esc close)`);
250
+ const visible = lines.slice(offset, offset + height);
251
+ return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
252
+ },
253
+ handleInput(data: string): void {
254
+ if (matchesKey(data, Key.down) && offset < lines.length - 1) {
255
+ offset++;
256
+ tui.requestRender();
257
+ } else if (matchesKey(data, Key.up) && offset > 0) {
258
+ offset--;
259
+ tui.requestRender();
260
+ } else if (matchesKey(data, Key.escape)) {
261
+ done(undefined);
262
+ } else if (entry.sessionId && data === 'r') {
263
+ ctx.ui.notify?.(`resume with: /delegate --resume=${entry.sessionId} <prompt>`, 'info');
264
+ }
265
+ },
266
+ invalidate() {},
267
+ };
268
+ });
222
269
  }
223
270
 
224
271
  async function showHistory(ctx: ExtensionContext): Promise<void> {
225
- const entries = readAllHistory();
226
- if (entries.length === 0) {
227
- ctx.ui.notify?.('No transcripts yet — run /delegate <harness> <mode> <prompt> first', 'info');
228
- return;
229
- }
230
- if (!ctx.hasUI) {
231
- for (const e of entries) {
232
- process.stdout.write(`${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${e.sessionId ?? '-'}\n`);
233
- }
234
- return;
235
- }
236
- const entry = await ctx.ui.custom((tui, theme, _kb, done) => {
237
- const items: SelectItem[] = entries.map((e) => ({
238
- value: e.file,
239
- label: `${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
240
- description: e.sessionId ? `session ${e.sessionId.slice(0, 8)}…` : undefined,
241
- }));
242
- const list = new SelectList(items, Math.min(items.length, 10), {
243
- selectedPrefix: (s: string) => theme.fg('accent', s),
244
- selectedText: (s: string) => theme.fg('accent', s),
245
- description: (s: string) => theme.fg('dim', s),
246
- scrollInfo: (s: string) => theme.fg('dim', s),
247
- noMatch: (s: string) => theme.fg('warning', s),
248
- });
249
- list.onSelect = (item) => done(item.value);
250
- list.onCancel = () => done(undefined);
251
- return {
252
- render: (w: number) => list.render(w),
253
- invalidate: () => list.invalidate(),
254
- handleInput: (data: string) => {
255
- list.handleInput(data);
256
- tui.requestRender();
257
- },
258
- };
259
- });
260
- if (entry) {
261
- const chosen = entries.find((e) => e.file === entry);
262
- if (chosen) await viewTranscript(ctx, chosen);
263
- }
272
+ const entries = readAllHistory();
273
+ if (entries.length === 0) {
274
+ ctx.ui.notify?.('No transcripts yet — run /delegate <harness> <mode> <prompt> first', 'info');
275
+ return;
276
+ }
277
+ if (!ctx.hasUI) {
278
+ for (const e of entries) {
279
+ process.stdout.write(`${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${e.sessionId ?? '-'}\n`);
280
+ }
281
+ return;
282
+ }
283
+ const entry = await ctx.ui.custom((tui, theme, _kb, done) => {
284
+ const items: SelectItem[] = entries.map(e => ({
285
+ value: e.file,
286
+ label: `${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
287
+ description: e.sessionId ? `session ${e.sessionId.slice(0, 8)}…` : undefined,
288
+ }));
289
+ const list = new SelectList(items, Math.min(items.length, 10), {
290
+ selectedPrefix: (s: string) => theme.fg('accent', s),
291
+ selectedText: (s: string) => theme.fg('accent', s),
292
+ description: (s: string) => theme.fg('dim', s),
293
+ scrollInfo: (s: string) => theme.fg('dim', s),
294
+ noMatch: (s: string) => theme.fg('warning', s),
295
+ });
296
+ list.onSelect = item => done(item.value);
297
+ list.onCancel = () => done(undefined);
298
+ return {
299
+ render: (w: number) => list.render(w),
300
+ invalidate: () => list.invalidate(),
301
+ handleInput: (data: string) => {
302
+ list.handleInput(data);
303
+ tui.requestRender();
304
+ },
305
+ };
306
+ });
307
+ if (entry) {
308
+ const chosen = entries.find(e => e.file === entry);
309
+ if (chosen) await viewTranscript(ctx, chosen);
310
+ }
264
311
  }
265
312
 
266
313
  function saveOutput(harness: string, mode: string, text: string): string {
267
- const dir = outputsDirFor(harness);
268
- mkdirSync(dir, { recursive: true });
269
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
270
- const file = join(dir, `${stamp}-${safeSegmentName(mode)}.md`);
271
- writeFileSync(file, text, 'utf8');
272
- return file;
314
+ const dir = outputsDirFor(harness);
315
+ mkdirSync(dir, { recursive: true });
316
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
317
+ const file = join(dir, `${stamp}-${safeSegmentName(mode)}.md`);
318
+ writeFileSync(file, text, 'utf8');
319
+ return file;
273
320
  }
274
321
 
275
- function buildPrompt(template: DelegateTemplate, task: string, scopeText: string | null, cwd: string, harness: string): string {
276
- let prompt = [`You are being delegated a subtask by the pi coding agent.`, `Working directory: ${cwd}`, `Harness: ${harness}`, `Mode: ${template.name}`, ``, template.prompt].join('\n');
277
- prompt += `\n\n# Task\n${task}`;
278
- if (scopeText) prompt += `\n\n# Scope\n${scopeText}`;
279
- if (template.skill) prompt += `\n\nUse the "${template.skill}" skill.`;
280
- return prompt;
322
+ function buildPrompt(
323
+ template: DelegateTemplate,
324
+ task: string,
325
+ scopeText: string | null,
326
+ cwd: string,
327
+ harness: string,
328
+ ): string {
329
+ let prompt = [
330
+ `You are being delegated a subtask by the pi coding agent.`,
331
+ `Working directory: ${cwd}`,
332
+ `Harness: ${harness}`,
333
+ `Mode: ${template.name}`,
334
+ ``,
335
+ template.prompt,
336
+ ].join('\n');
337
+ prompt += `\n\n# Task\n${task}`;
338
+ if (scopeText) prompt += `\n\n# Scope\n${scopeText}`;
339
+ if (template.skill) prompt += `\n\nUse the "${template.skill}" skill.`;
340
+ return prompt;
281
341
  }
282
342
 
283
343
  async function delegate(
284
- pi: ExtensionAPI,
285
- ctx: ExtensionContext,
286
- opts: DelegateOptions,
287
- ): Promise<{ content: string; details: Record<string, unknown>; result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string }; activityLog: string[] }> {
288
- const config = loadConfig();
289
- const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
290
- const harness = getHarness(harnessName);
291
- if (!harness) throw new Error(`unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`);
292
- const templates = loadTemplates(ctx.cwd, harnessName);
293
- const mode = opts.mode || config.defaultMode;
294
- const template = templates.get(mode);
295
- if (!template) throw new Error(`unknown delegate mode "${mode}" for harness "${harnessName}". Available: ${[...templates.keys()].sort().join(', ')}`);
296
- const task = opts.task || template.defaultTask;
297
- if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
298
-
299
- // concurrency guard
300
- const maxGlobal = getMaxConcurrentGlobal();
301
- const perHarnessCount = activeRuns.get(harnessName) ?? 0;
302
- if (maxGlobal > 0 && globalActiveRuns >= maxGlobal) throw new Error('another delegate run is already in progress (global limit)');
303
- // per-harness limit if configured as object
304
- const perHarnessLimit = (() => {
305
- const mc = config.maxConcurrent as unknown as { perHarness?: Record<string, number> };
306
- if (mc && typeof mc === 'object' && mc.perHarness && typeof mc.perHarness[harnessName] === 'number') return mc.perHarness[harnessName]!;
307
- return maxGlobal;
308
- })();
309
- if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit) throw new Error(`another ${harnessName} run is already in progress`);
310
- activeRuns.set(harnessName, perHarnessCount + 1);
311
- globalActiveRuns++;
312
- const release = () => {
313
- activeRuns.set(harnessName, (activeRuns.get(harnessName) ?? 1) - 1);
314
- globalActiveRuns = Math.max(0, globalActiveRuns - 1);
315
- };
316
-
317
- let scopeText: string | null = opts.scope ?? null;
318
- if (opts.scope === 'diff') {
319
- const diff = await pi.exec('git', ['diff', 'HEAD'], { cwd: ctx.cwd });
320
- scopeText = diff.stdout ? `Current git diff (working tree vs HEAD):\n${diff.stdout}` : 'No git diff vs HEAD (working tree clean).';
321
- } else if (opts.scope === 'pr' || opts.pr) {
322
- const target = opts.pr ?? '';
323
- const pr = await pi.exec('gh', target ? ['pr', 'diff', target] : ['pr', 'diff'], { cwd: ctx.cwd });
324
- scopeText = pr.stdout ? `Pull request diff (${target || 'current branch'}):\n${pr.stdout}` : `Could not resolve the PR diff${pr.stderr ? ` — ${pr.stderr.trim().slice(0, 300)}` : ''}.`;
325
- }
326
-
327
- // permission: normalized, danger requires explicit per-call allowDangerous:true
328
- let permission: NormalizedPermission = template.permission;
329
- const nativePerm = template.nativePermission;
330
- const isNativeDanger = !!nativePerm && ['bypassPermissions', 'danger-full-access', 'danger'].includes(nativePerm);
331
- if (template.permission === 'danger' || isNativeDanger) {
332
- if (opts.allowDangerous !== true) {
333
- throw new Error(`template "${mode}" requires danger permission — pass allowDangerous:true to run it (never a default)`);
334
- }
335
- permission = 'danger';
336
- } else if (opts.allowDangerous === true) {
337
- // explicit per-call escalation for any template
338
- permission = 'danger';
339
- }
340
- const permissionForDisplay = nativePerm ?? permission;
341
-
342
- const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
343
- const prompt = buildPrompt(template, task, scopeText, ctx.cwd, harnessName);
344
-
345
- const activityEvents: ActivityEvent[] = [];
346
- let streamedFull = '';
347
- let result: import('./runner.ts').HarnessResult;
348
- try {
349
- result = await runHarness({
350
- harness,
351
- prompt,
352
- cwd: ctx.cwd,
353
- permission,
354
- model,
355
- maxBudgetUsd: opts.maxBudgetUsd ?? template.maxBudgetUsd ?? config.maxBudgetUsd ?? config.harnesses[harnessName]?.maxBudgetUsd,
356
- signal: opts.signal,
357
- timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
358
- resumeSessionId: opts.sessionId,
359
- onStream: (t) => {
360
- streamedFull += t;
361
- opts.onStream?.(t);
362
- },
363
- onActivity: (ev) => {
364
- activityEvents.push(ev);
365
- opts.onActivity?.(ev);
366
- },
367
- });
368
- } catch (err) {
369
- release();
370
- if (streamedFull.length > 0) {
371
- try {
372
- saveOutput(
373
- harnessName,
374
- `${mode}-partial`,
375
- buildTranscript({
376
- harness: harnessName,
377
- mode: `${mode} (partial)`,
378
- permission: permission,
379
- nativePermission: nativePerm ?? undefined,
380
- model: model ?? null,
381
- cwd: ctx.cwd,
382
- sessionId: null,
383
- resumed: Boolean(opts.sessionId),
384
- numTurns: 0,
385
- totalCostUsd: 0,
386
- isError: true,
387
- stopReason: null,
388
- durationMs: null,
389
- usage: null,
390
- contextPercent: null,
391
- contextWindow: null,
392
- activityLog: collectActivityLog(activityEvents),
393
- output: streamedFull,
394
- }),
395
- );
396
- } catch {}
397
- }
398
- throw err;
399
- }
400
- release();
401
-
402
- if (result.isError && !result.result && !result.streamedText) throw new Error(`${harnessName} reported an error and produced no output`);
403
-
404
- const actualModel = result.model ?? model ?? null;
405
- const promptTokens = result.usage === null ? null : result.usage.inputTokens + result.usage.cacheCreationInputTokens + result.usage.cacheReadInputTokens;
406
- const contextPercent = promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
407
-
408
- const file = saveOutput(
409
- harnessName,
410
- mode,
411
- buildTranscript({
412
- harness: harnessName,
413
- mode: mode,
414
- permission: permission,
415
- nativePermission: nativePerm ?? undefined,
416
- model: actualModel,
417
- cwd: ctx.cwd,
418
- sessionId: result.sessionId,
419
- resumed: Boolean(opts.sessionId),
420
- numTurns: result.numTurns,
421
- totalCostUsd: result.totalCostUsd,
422
- isError: result.isError,
423
- stopReason: result.stopReason,
424
- durationMs: result.durationMs,
425
- usage: result.usage,
426
- contextPercent,
427
- contextWindow: result.contextWindow,
428
- activityLog: collectActivityLog(activityEvents),
429
- output: result.result || result.streamedText,
430
- }),
431
- );
432
- pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
433
- // also prune legacy if claude
434
- if (harnessName === 'claude') pruneOutputs(legacyOutputsDir(), config.maxTranscripts);
435
-
436
- return {
437
- content: result.result || result.streamedText || '(empty result)',
438
- details: {
439
- harness: harnessName,
440
- mode,
441
- permission,
442
- nativePermission: nativePerm ?? null,
443
- permissionMode: String(permissionForDisplay),
444
- model: actualModel,
445
- numTurns: result.numTurns,
446
- totalCostUsd: result.totalCostUsd,
447
- sessionId: result.sessionId,
448
- stopReason: result.stopReason,
449
- permissionDenials: result.permissionDenials,
450
- isError: result.isError,
451
- resumed: Boolean(opts.sessionId),
452
- file,
453
- durationMs: result.durationMs,
454
- ttftMs: result.ttftMs,
455
- contextWindow: result.contextWindow,
456
- contextPercent,
457
- promptTokens,
458
- usage: result.usage,
459
- },
460
- result,
461
- activityLog: collectActivityLog(activityEvents),
462
- };
344
+ pi: ExtensionAPI,
345
+ ctx: ExtensionContext,
346
+ opts: DelegateOptions,
347
+ ): Promise<{
348
+ content: string;
349
+ details: Record<string, unknown>;
350
+ result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string };
351
+ activityLog: string[];
352
+ }> {
353
+ const config = loadConfig();
354
+ const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
355
+ const harness = getHarness(harnessName);
356
+ if (!harness)
357
+ throw new Error(
358
+ `unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
359
+ );
360
+ const templates = loadTemplates(ctx.cwd, harnessName);
361
+ const mode = opts.mode || config.defaultMode;
362
+ const template = templates.get(mode);
363
+ if (!template)
364
+ throw new Error(
365
+ `unknown delegate mode "${mode}" for harness "${harnessName}". Available: ${[...templates.keys()].sort().join(', ')}`,
366
+ );
367
+ const task = opts.task || template.defaultTask;
368
+ if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
369
+
370
+ // concurrency guard
371
+ const maxGlobal = getMaxConcurrentGlobal();
372
+ const perHarnessCount = activeRuns.get(harnessName) ?? 0;
373
+ if (maxGlobal > 0 && globalActiveRuns >= maxGlobal)
374
+ throw new Error('another delegate run is already in progress (global limit)');
375
+ // per-harness limit if configured as object
376
+ const perHarnessLimit = (() => {
377
+ // SAFETY: maxConcurrent shape checked for perHarness record before access
378
+ const mc = config.maxConcurrent as unknown as { perHarness?: Record<string, number> }; // SAFETY: shape checked before access
379
+ if (mc && typeof mc === 'object' && mc.perHarness && typeof mc.perHarness[harnessName] === 'number')
380
+ return mc.perHarness[harnessName]!;
381
+ return maxGlobal;
382
+ })();
383
+ if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit)
384
+ throw new Error(`another ${harnessName} run is already in progress`);
385
+ activeRuns.set(harnessName, perHarnessCount + 1);
386
+ globalActiveRuns++;
387
+ const release = () => {
388
+ activeRuns.set(harnessName, (activeRuns.get(harnessName) ?? 1) - 1);
389
+ globalActiveRuns = Math.max(0, globalActiveRuns - 1);
390
+ };
391
+
392
+ let scopeText: string | null = opts.scope ?? null;
393
+ if (opts.scope === 'diff') {
394
+ const diff = await pi.exec('git', ['diff', 'HEAD'], { cwd: ctx.cwd });
395
+ scopeText = diff.stdout
396
+ ? `Current git diff (working tree vs HEAD):\n${diff.stdout}`
397
+ : 'No git diff vs HEAD (working tree clean).';
398
+ } else if (opts.scope === 'pr' || opts.pr) {
399
+ const target = opts.pr ?? '';
400
+ const pr = await pi.exec('gh', target ? ['pr', 'diff', target] : ['pr', 'diff'], { cwd: ctx.cwd });
401
+ scopeText = pr.stdout
402
+ ? `Pull request diff (${target || 'current branch'}):\n${pr.stdout}`
403
+ : `Could not resolve the PR diff${pr.stderr ? ` — ${pr.stderr.trim().slice(0, 300)}` : ''}.`;
404
+ }
405
+
406
+ // permission: normalized, danger requires explicit per-call allowDangerous:true
407
+ let permission: NormalizedPermission = template.permission;
408
+ const nativePerm = template.nativePermission;
409
+ const isNativeDanger = !!nativePerm && ['bypassPermissions', 'danger-full-access', 'danger'].includes(nativePerm);
410
+ if (template.permission === 'danger' || isNativeDanger) {
411
+ if (opts.allowDangerous !== true) {
412
+ throw new Error(
413
+ `template "${mode}" requires danger permission — pass allowDangerous:true to run it (never a default)`,
414
+ );
415
+ }
416
+ permission = 'danger';
417
+ } else if (opts.allowDangerous === true) {
418
+ // explicit per-call escalation for any template
419
+ permission = 'danger';
420
+ }
421
+ const permissionForDisplay = nativePerm ?? permission;
422
+
423
+ const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
424
+ const prompt = buildPrompt(template, task, scopeText, ctx.cwd, harnessName);
425
+
426
+ const activityEvents: ActivityEvent[] = [];
427
+ let streamedFull = '';
428
+ let result: import('./runner.ts').HarnessResult;
429
+ try {
430
+ result = await runHarness({
431
+ harness,
432
+ prompt,
433
+ cwd: ctx.cwd,
434
+ permission,
435
+ model,
436
+ maxBudgetUsd:
437
+ opts.maxBudgetUsd ??
438
+ template.maxBudgetUsd ??
439
+ config.maxBudgetUsd ??
440
+ config.harnesses[harnessName]?.maxBudgetUsd,
441
+ signal: opts.signal,
442
+ timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
443
+ resumeSessionId: opts.sessionId,
444
+ onStream: t => {
445
+ streamedFull += t;
446
+ opts.onStream?.(t);
447
+ },
448
+ onActivity: ev => {
449
+ activityEvents.push(ev);
450
+ opts.onActivity?.(ev);
451
+ },
452
+ });
453
+ } catch (err) {
454
+ release();
455
+ if (streamedFull.length > 0) {
456
+ try {
457
+ saveOutput(
458
+ harnessName,
459
+ `${mode}-partial`,
460
+ buildTranscript({
461
+ harness: harnessName,
462
+ mode: `${mode} (partial)`,
463
+ permission: permission,
464
+ nativePermission: nativePerm ?? undefined,
465
+ model: model ?? null,
466
+ cwd: ctx.cwd,
467
+ sessionId: null,
468
+ resumed: Boolean(opts.sessionId),
469
+ numTurns: 0,
470
+ totalCostUsd: 0,
471
+ isError: true,
472
+ stopReason: null,
473
+ durationMs: null,
474
+ usage: null,
475
+ contextPercent: null,
476
+ contextWindow: null,
477
+ activityLog: collectActivityLog(activityEvents),
478
+ output: streamedFull,
479
+ }),
480
+ );
481
+ } catch (_e) {
482
+ void _e;
483
+ }
484
+ }
485
+ throw err;
486
+ }
487
+ release();
488
+
489
+ if (result.isError && !result.result && !result.streamedText)
490
+ throw new Error(`${harnessName} reported an error and produced no output`);
491
+
492
+ const actualModel = result.model ?? model ?? null;
493
+ const promptTokens =
494
+ result.usage === null
495
+ ? null
496
+ : result.usage.inputTokens + result.usage.cacheCreationInputTokens + result.usage.cacheReadInputTokens;
497
+ const contextPercent =
498
+ promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
499
+
500
+ const file = saveOutput(
501
+ harnessName,
502
+ mode,
503
+ buildTranscript({
504
+ harness: harnessName,
505
+ mode: mode,
506
+ permission: permission,
507
+ nativePermission: nativePerm ?? undefined,
508
+ model: actualModel,
509
+ cwd: ctx.cwd,
510
+ sessionId: result.sessionId,
511
+ resumed: Boolean(opts.sessionId),
512
+ numTurns: result.numTurns,
513
+ totalCostUsd: result.totalCostUsd,
514
+ isError: result.isError,
515
+ stopReason: result.stopReason,
516
+ durationMs: result.durationMs,
517
+ usage: result.usage,
518
+ contextPercent,
519
+ contextWindow: result.contextWindow,
520
+ activityLog: collectActivityLog(activityEvents),
521
+ output: result.result || result.streamedText,
522
+ }),
523
+ );
524
+ pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
525
+ // also prune legacy if claude
526
+ if (harnessName === 'claude') pruneOutputs(legacyOutputsDir(), config.maxTranscripts);
527
+
528
+ return {
529
+ content: result.result || result.streamedText || '(empty result)',
530
+ details: {
531
+ harness: harnessName,
532
+ mode,
533
+ permission,
534
+ nativePermission: nativePerm ?? null,
535
+ permissionMode: String(permissionForDisplay),
536
+ model: actualModel,
537
+ numTurns: result.numTurns,
538
+ totalCostUsd: result.totalCostUsd,
539
+ sessionId: result.sessionId,
540
+ stopReason: result.stopReason,
541
+ permissionDenials: result.permissionDenials,
542
+ isError: result.isError,
543
+ resumed: Boolean(opts.sessionId),
544
+ file,
545
+ durationMs: result.durationMs,
546
+ ttftMs: result.ttftMs,
547
+ contextWindow: result.contextWindow,
548
+ contextPercent,
549
+ promptTokens,
550
+ usage: result.usage,
551
+ },
552
+ result,
553
+ activityLog: collectActivityLog(activityEvents),
554
+ };
463
555
  }
464
556
 
465
557
  function summarize(content: string, max = 30_000): { text: string; truncated: boolean } {
466
- if (content.length <= max) return { text: content, truncated: false };
467
- return { text: `${content.slice(0, max)}\n…[truncated — full output saved to file]`, truncated: true };
558
+ if (content.length <= max) return { text: content, truncated: false };
559
+ return { text: `${content.slice(0, max)}\n…[truncated — full output saved to file]`, truncated: true };
468
560
  }
469
561
 
470
562
  interface PendingReport {
471
- content: string;
472
- details: Record<string, unknown>;
563
+ content: string;
564
+ details: Record<string, unknown>;
473
565
  }
474
566
  let pendingReport: PendingReport | null = null;
475
- function injectReport(_ctx: ExtensionContext, opts: { harness: string; mode: string; metrics: string; body: string; file?: string; sessionId?: string }): void {
476
- pendingReport = {
477
- content: buildReportContent({ harness: opts.harness, mode: opts.mode, metrics: opts.metrics, body: opts.body, file: opts.file, sessionId: opts.sessionId }),
478
- details: { harness: opts.harness, mode: opts.mode, file: opts.file, sessionId: opts.sessionId, metrics: opts.metrics },
479
- };
567
+ function injectReport(
568
+ _ctx: ExtensionContext,
569
+ opts: { harness: string; mode: string; metrics: string; body: string; file?: string; sessionId?: string },
570
+ ): void {
571
+ pendingReport = {
572
+ content: buildReportContent({
573
+ harness: opts.harness,
574
+ mode: opts.mode,
575
+ metrics: opts.metrics,
576
+ body: opts.body,
577
+ file: opts.file,
578
+ sessionId: opts.sessionId,
579
+ }),
580
+ details: {
581
+ harness: opts.harness,
582
+ mode: opts.mode,
583
+ file: opts.file,
584
+ sessionId: opts.sessionId,
585
+ metrics: opts.metrics,
586
+ },
587
+ };
480
588
  }
481
589
 
482
590
  export default function (pi: ExtensionAPI) {
483
- let activeRunId = 0;
484
- let activeOverlay: { show(): void; focus(): void; runId: number } | null = null;
485
-
486
- // ── Tools ────────────────────────────────────────────────────────────────
487
- const delegateToolDef = {
488
- name: 'delegate',
489
- label: 'Delegate',
490
- description:
491
- 'Delegate a task to any harness (claude, codex, opencode, amp) 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). mode selects a template: review, plan, implement, security-audit, docs, general, or custom. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
492
- promptSnippet: 'Delegate a subtask to a harness and return its report',
493
- promptGuidelines: [
494
- '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.',
495
- 'Pass harness (claude|codex|opencode|amp) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
496
- 'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work.',
497
- 'sessionId resumes a previous delegated session instead of starting fresh.',
498
- 'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission).',
499
- ],
500
- parameters: Type.Object({
501
- harness: Type.Optional(Type.String({ description: 'Harness to use: claude, codex, opencode, amp (aliases: omp). Defaults to config defaultHarness.' })),
502
- task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
503
- mode: Type.Optional(Type.String({ description: 'Template/mode to run: review, plan, implement, security-audit, docs, general, or custom. Defaults to config defaultMode.' })),
504
- scope: Type.Optional(Type.String({ description: 'Restrict the work: diff (git diff), pr (PR diff), comma/space-separated path list, or omit for whole repo.' })),
505
- model: Type.Optional(Type.String({ description: 'Model (e.g. sonnet, opus, gpt-5). Defaults to template/config.' })),
506
- maxBudgetUsd: Type.Optional(Type.Number({ description: 'Hard spend cap in USD for the run.' })),
507
- sessionId: Type.Optional(Type.String({ description: 'Resume an existing delegated session (pass its session id from a previous run details).' })),
508
- allowDangerous: Type.Optional(Type.Boolean({ description: 'Escalate to danger permission (unrestricted). Only with explicit user approval.' })),
509
- pr: Type.Optional(Type.String({ description: 'GitHub PR number/URL (alternative to scope pr).' })),
510
- }),
511
- async execute(_toolCallId: string, params: { harness?: string; task: string; mode?: string; scope?: string; model?: string; maxBudgetUsd?: number; allowDangerous?: boolean; sessionId?: string; pr?: string }, signal: AbortSignal | undefined, onUpdate: ((u: { content: { type: string; text: string }[]; details: { progress: number } }) => void) | undefined, ctx: ExtensionContext) {
512
- const config = loadConfig();
513
- const feed: string[] = [];
514
- let liveTail = '';
515
- let thinkingChars = 0;
516
- let lastPushAt = 0;
517
- const THROTTLE_MS = 250;
518
- const pushFeed = () => {
519
- const now = Date.now();
520
- if (now - lastPushAt < THROTTLE_MS) return;
521
- lastPushAt = now;
522
- const lines: string[] = [...feed.slice(-6)];
523
- if (thinkingChars > 0) lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
524
- if (liveTail) lines.push(`✍ ${liveTail}`);
525
- if (lines.length === 0) return;
526
- onUpdate?.({ content: [{ type: 'text', text: lines.join('\n') }], details: { progress: 0.5 } });
527
- };
528
- const { content, details, result } = await delegate(pi, ctx, {
529
- harness: params.harness,
530
- task: params.task,
531
- mode: params.mode,
532
- scope: params.scope,
533
- model: params.model,
534
- maxBudgetUsd: params.maxBudgetUsd,
535
- allowDangerous: params.allowDangerous ?? config.allowDangerous,
536
- sessionId: params.sessionId,
537
- pr: params.pr,
538
- signal,
539
- onStream: (text) => {
540
- liveTail = (liveTail + text).slice(-400);
541
- pushFeed();
542
- },
543
- onActivity: (ev) => {
544
- if (ev.kind === 'tool_input') {
545
- feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
546
- if (feed.length > 40) feed.splice(0, feed.length - 40);
547
- } else if (ev.kind === 'tool_result') {
548
- const last = feed.length - 1;
549
- if (last >= 0 && feed[last].startsWith('▶')) feed[last] += ev.isError ? ' ✗' : ' ✓';
550
- } else if (ev.kind === 'thinking') thinkingChars += ev.chars;
551
- pushFeed();
552
- },
553
- });
554
- const summary = summarize(content);
555
- const resumed = details.resumed ? ' · resumed' : '';
556
- const head = result.isError ? `⚠ ${details.harness} reported an error` : `${details.harness} ${details.mode} (${result.numTurns} turn(s), $${result.totalCostUsd.toFixed(3)})${resumed}`;
557
- const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
558
- const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
559
- (details as Record<string, unknown>).markdown = summary.text;
560
- return { content: [{ type: 'text', text: `${head}${body}${footer}` }], details, usage: result.usage ? mapClaudeUsage({ ...result.usage, totalCostUsd: result.totalCostUsd }) : undefined };
561
- },
562
- renderCall(args: unknown, theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string }) {
563
- const params = args as { harness?: string; mode?: string; task?: string };
564
- const harness = params.harness ?? 'delegate';
565
- const mode = params.mode ?? 'general';
566
- const task = params.task ?? '';
567
- const taskStr = task ? ` — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}` : '';
568
- return new Text(theme.fg('accent', `${harness} ${mode}`) + theme.fg('dim', taskStr), 1, 1, (s) => theme.bg('toolPendingBg', s));
569
- },
570
- renderResult(result: { content?: { type: string; text: string }[]; details?: Record<string, unknown> }, options: { isPartial: boolean }, theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string }) {
571
- if (options.isPartial) {
572
- const text = (result.content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
573
- return new Text(text, 1, 1, (s) => theme.bg('toolPendingBg', s));
574
- }
575
- const details = (result.details ?? {}) as Record<string, unknown>;
576
- const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
577
- const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
578
- const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : 0;
579
- const turns = typeof details.numTurns === 'number' ? details.numTurns : 0;
580
- const isError = details.isError === true;
581
- const resumed = details.resumed === true;
582
- const file = typeof details.file === 'string' ? details.file : null;
583
- const sessionId = typeof details.sessionId === 'string' ? details.sessionId : null;
584
- const container = new Container();
585
- container.addChild(new Text(theme.fg(isError ? 'error' : 'accent', `${harness} ${mode}`) + theme.fg('dim', ` · ${turns} turn(s) · `) + theme.fg('warning', `$${cost.toFixed(3)}`) + (resumed ? theme.fg('dim', ' · resumed') : ''), 1, 1));
586
- const md = typeof details.markdown === 'string' && details.markdown ? details.markdown : null;
587
- if (md) container.addChild(new Markdown(md, 1, 1, getMarkdownTheme()));
588
- else {
589
- const text = (result.content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
590
- container.addChild(new Text(text, 1, 1));
591
- }
592
- const foot: string[] = [];
593
- if (file) foot.push(`Transcript: ${file}`);
594
- if (sessionId) foot.push(`Resume: /delegate --resume=${sessionId} <prompt>`);
595
- if (foot.length > 0) container.addChild(new Text(theme.fg('dim', foot.join(' ')), 1, 1));
596
- return container;
597
- },
598
- };
599
-
600
- pi.registerTool(delegateToolDef as unknown as Parameters<typeof pi.registerTool>[0]);
601
-
602
- // deprecated alias
603
- pi.registerTool({
604
- name: 'claude_delegate',
605
- label: 'Claude Delegate (deprecated)',
606
- description: 'Deprecated alias for delegate{harness:claude}. Use delegate tool with harness:claude instead. ' + (delegateToolDef as { description: string }).description,
607
- promptSnippet: 'Delegate a subtask to Claude Code (deprecated alias)',
608
- promptGuidelines: [...(delegateToolDef as unknown as { promptGuidelines: string[] }).promptGuidelines],
609
- parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
610
- async execute(toolCallId: string, params: { harness?: string; task: string; mode?: string; scope?: string; model?: string; maxBudgetUsd?: number; allowDangerous?: boolean; sessionId?: string; pr?: string }, signal: AbortSignal | undefined, onUpdate: never, ctx: ExtensionContext) {
611
- return (delegateToolDef as unknown as { execute: (a: string, b: unknown, c: unknown, d: unknown, e: unknown) => Promise<unknown> }).execute(toolCallId, { ...params, harness: 'claude' }, signal, onUpdate, ctx);
612
- },
613
- renderCall: (delegateToolDef as unknown as { renderCall: (a: unknown, b: unknown) => unknown }).renderCall,
614
- renderResult: (delegateToolDef as unknown as { renderResult: (a: unknown, b: unknown, c: unknown) => unknown }).renderResult,
615
- } as unknown as Parameters<typeof pi.registerTool>[0]);
616
-
617
- // ── Commands ─────────────────────────────────────────────────────────────
618
- const makeHandler = (forcedHarness?: string) => async (args: string, ctx: ExtensionContext) => {
619
- const sub = args.trim();
620
- if (sub === 'watch' || sub === 'show') {
621
- if (activeOverlay) {
622
- activeOverlay.show();
623
- activeOverlay.focus();
624
- } else {
625
- ctx.ui.notify?.('No active delegate run to show — start one with /delegate <harness> <mode> <prompt>', 'info');
626
- }
627
- return;
628
- }
629
- if (sub === 'list') {
630
- await showModes(ctx, forcedHarness);
631
- return;
632
- }
633
- if (sub.startsWith('list ')) {
634
- const h = sub.slice(5).trim();
635
- if (isKnownHarness(h)) {
636
- await showModes(ctx, h);
637
- return;
638
- }
639
- }
640
- if (sub === 'history' || sub === 'logs') {
641
- await showHistory(ctx);
642
- return;
643
- }
644
-
645
- // combine forced harness + args for parsing
646
- const rawForParse = forcedHarness ? `${forcedHarness} ${args}`.trim() : args;
647
- // gather known modes across all harnesses for parsing
648
- const allModes = new Set<string>();
649
- for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h).keys()) allModes.add(k);
650
- for (const k of loadTemplates(ctx.cwd).keys()) allModes.add(k);
651
- const knownHarnessesSet = new Set([...HARNESS_NAMES, ...Object.keys(ALIASES)]);
652
- const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
653
- // if forcedHarness provided, it wins
654
- if (forcedHarness) parsed.harness = forcedHarness;
655
- const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
656
- const templates = loadTemplates(ctx.cwd, harnessName);
657
- const resolved = resolveDefaults(parsed, templates);
658
- const template = parsed.mode ? templates.get(parsed.mode) : undefined;
659
- const isDanger = template?.permission === 'danger' || (template?.nativePermission ? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission) : false);
660
-
661
- if (!resolved) {
662
- if (parsed.mode) ctx.ui.notify?.(`/delegate ${parsed.mode} <what to do> — give a prompt for the "${parsed.mode}" mode`, 'warning');
663
- else ctx.ui.notify?.('Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=…] [--model=…] [--scope=…] <prompt>', 'warning');
664
- return;
665
- }
666
- const modeForDisplay = parsed.mode ?? 'general';
667
- const harnessForDisplay = harnessName;
668
-
669
- const feed: FeedEntry[] = [];
670
- let thinkingChars = 0;
671
- let liveTail = '';
672
- let requestRender: (() => void) | null = null;
673
- const getEntries = (): FeedEntry[] => {
674
- const entries = [...feed.slice(-12)];
675
- if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
676
- if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
677
- return entries;
678
- };
679
- let chipActivity = '';
680
- let chipLastPush = 0;
681
- const pushChip = () => {
682
- if (!ctx.hasUI) return;
683
- const now = Date.now();
684
- if (now - chipLastPush < 500) return;
685
- chipLastPush = now;
686
- const theme = ctx.ui.theme;
687
- const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
688
- ctx.ui.setStatus('delegate', theme.fg('accent', '●') + theme.fg('dim', ` ${harnessForDisplay} ${modeForDisplay}`) + activity);
689
- };
690
- const onActivity = (ev: ActivityEvent) => {
691
- if (ev.kind === 'tool_input') {
692
- chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
693
- feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input) });
694
- if (feed.length > 40) feed.splice(0, feed.length - 40);
695
- } else if (ev.kind === 'tool_result') {
696
- if (chipActivity.startsWith('▶')) chipActivity += ev.isError ? ' ✗' : ' ✓';
697
- const last = feed.length - 1;
698
- if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: ev.isError ? false : true };
699
- } else if (ev.kind === 'thinking') {
700
- chipActivity = '💭 thinking…';
701
- thinkingChars += ev.chars;
702
- }
703
- pushChip();
704
- requestRender?.();
705
- };
706
- const ac = new AbortController();
707
- let cancelled = false;
708
- const runState: { error: Error | null } = { error: null };
709
- const runId = ++activeRunId;
710
- const clearActive = () => {
711
- if (activeOverlay?.runId === runId) activeOverlay = null;
712
- };
713
- const run = delegate(pi, ctx, {
714
- harness: harnessName,
715
- task: resolved.task,
716
- mode: parsed.mode,
717
- scope: resolved.scope,
718
- model: parsed.model,
719
- maxBudgetUsd: parsed.budget,
720
- sessionId: parsed.sessionId,
721
- pr: parsed.pr,
722
- signal: ac.signal,
723
- onStream: (t) => {
724
- liveTail = (liveTail + t).slice(-400);
725
- requestRender?.();
726
- },
727
- onActivity,
728
- }).catch((err: unknown) => {
729
- runState.error = err instanceof Error ? err : new Error(String(err));
730
- return null;
731
- });
732
-
733
- let closeWindow: (() => void) | null = null;
734
- let result: Awaited<ReturnType<typeof delegate>> | null = null;
735
- if (ctx.hasUI) {
736
- let overlayHandle: OverlayHandle | null = null;
737
- const uiPromise = ctx.ui
738
- .custom(
739
- (tui, theme, _kb, done) => {
740
- requestRender = () => tui.requestRender();
741
- closeWindow = () => done(undefined);
742
- return progressWindow(tui, theme, {
743
- mode: `${harnessForDisplay} ${modeForDisplay}`,
744
- model: parsed.model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
745
- startedAt: Date.now(),
746
- getEntries,
747
- dangerous: isDanger,
748
- onCancel: () => {
749
- cancelled = true;
750
- ac.abort();
751
- },
752
- onMinimize: () => {
753
- overlayHandle?.setHidden(true);
754
- overlayHandle?.unfocus();
755
- },
756
- });
757
- },
758
- { overlay: true, overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' }, onHandle: (h) => { overlayHandle = h; activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId }; h.focus(); } },
759
- )
760
- .catch(() => {});
761
- result = await run;
762
- await closeWhenMounted(() => closeWindow, 2000);
763
- await uiPromise;
764
- } else {
765
- result = await run;
766
- }
767
- clearActive();
768
- if (cancelled || !result) {
769
- if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
770
- const message = runState.error ? runState.error.message : cancelled ? 'cancelled' : 'delegation failed';
771
- if (ctx.hasUI) ctx.ui.notify(`delegate ${cancelled ? 'cancelled' : 'failed'}: ${message}`, cancelled ? 'warning' : 'error');
772
- else process.stderr.write(`${message}\n`);
773
- return;
774
- }
775
- const { content, details } = result;
776
- const summary = summarize(content);
777
- const file = (details.file as string) ?? null;
778
- const sessionId = (details.sessionId as string) ?? null;
779
- const resumeHint = sessionId ? ` · resume: /delegate --resume=${sessionId} <prompt>` : '';
780
- const usage = details.usage as { inputTokens?: number; outputTokens?: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } | undefined;
781
- const promptTokens = usage ? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0) : 0;
782
- const metrics = formatMetrics({
783
- numTurns: (details.numTurns as number) ?? 0,
784
- totalCostUsd: (details.totalCostUsd as number) ?? 0,
785
- promptTokens,
786
- contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
787
- durationMs: typeof details.durationMs === 'number' && details.durationMs !== null ? (details.durationMs as number) : null,
788
- });
789
- injectReport(ctx, { harness: details.harness as string, mode: details.mode as string, metrics, body: summary.text, file: file ?? undefined, sessionId: sessionId ?? undefined });
790
- if (ctx.hasUI) {
791
- ctx.ui.setStatus('delegate', undefined);
792
- ctx.ui.notify(`${details.harness} ${details.mode} done — ${metrics}${resumeHint} · transcript: ${file}`, 'info');
793
- } else process.stdout.write(`${summary.text}\n`);
794
- };
795
-
796
- pi.registerCommand('delegate', { description: 'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt>', handler: makeHandler() });
797
- pi.registerCommand('claude', { description: 'Alias for /delegate --harness=claude. Usage: /claude [--mode=...] <prompt>', handler: makeHandler('claude') });
798
- pi.registerCommand('codex', { description: 'Alias for /delegate --harness=codex. Usage: /codex [--mode=...] <prompt>', handler: makeHandler('codex') });
799
- pi.registerCommand('opencode', { description: 'Alias for /delegate --harness=opencode. Usage: /opencode [--mode=...] <prompt>', handler: makeHandler('opencode') });
800
- pi.registerCommand('amp', { description: 'Alias for /delegate --harness=amp. Usage: /amp [--mode=...] <prompt>', handler: makeHandler('amp') });
801
- pi.registerCommand('omp', { description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>', handler: makeHandler('amp') });
802
-
803
- pi.on('input', async (event, ctx) => {
804
- if (event.source === 'extension') return { action: 'continue' };
805
- const hint = delegationHint(event.text, { autoDelegateHints: loadConfig().autoDelegateHints });
806
- if (!hint) return { action: 'continue' };
807
- return { action: 'transform', text: `${stripMarker(event.text)}\n\n${hint}` };
808
- });
809
-
810
- pi.on('before_agent_start', async () => {
811
- if (!pendingReport) return;
812
- const report = pendingReport;
813
- pendingReport = null;
814
- return { message: { customType: 'delegate', content: report.content, display: true, details: report.details } };
815
- });
591
+ let activeRunId = 0;
592
+ let activeOverlay: { show(): void; focus(): void; runId: number } | null = null;
593
+
594
+ // ── Tools ────────────────────────────────────────────────────────────────
595
+ const delegateToolDef = {
596
+ name: 'delegate',
597
+ label: 'Delegate',
598
+ description:
599
+ 'Delegate a task to any harness (claude, codex, opencode, amp) 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). mode selects a template: review, plan, implement, security-audit, docs, general, or custom. scope restricts work: diff for current git diff, pr for PR diff, path list, or whole repo. sessionId continues a prior session.',
600
+ promptSnippet: 'Delegate a subtask to a harness and return its report',
601
+ promptGuidelines: [
602
+ '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.',
603
+ 'Pass harness (claude|codex|opencode|amp) + focused task string + intent and constraints. Use scope: diff for current git diff, pr for PR diff, path list, or omit for whole repo.',
604
+ 'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work.',
605
+ 'sessionId resumes a previous delegated session instead of starting fresh.',
606
+ 'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission).',
607
+ ],
608
+ parameters: Type.Object({
609
+ harness: Type.Optional(
610
+ Type.String({
611
+ description:
612
+ 'Harness to use: claude, codex, opencode, amp (aliases: omp). Defaults to config defaultHarness.',
613
+ }),
614
+ ),
615
+ task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
616
+ mode: Type.Optional(
617
+ Type.String({
618
+ description:
619
+ 'Template/mode to run: review, plan, implement, security-audit, docs, general, or custom. Defaults to config defaultMode.',
620
+ }),
621
+ ),
622
+ scope: Type.Optional(
623
+ Type.String({
624
+ description:
625
+ 'Restrict the work: diff (git diff), pr (PR diff), comma/space-separated path list, or omit for whole repo.',
626
+ }),
627
+ ),
628
+ model: Type.Optional(
629
+ Type.String({ description: 'Model (e.g. sonnet, opus, gpt-5). Defaults to template/config.' }),
630
+ ),
631
+ maxBudgetUsd: Type.Optional(Type.Number({ description: 'Hard spend cap in USD for the run.' })),
632
+ sessionId: Type.Optional(
633
+ Type.String({
634
+ description: 'Resume an existing delegated session (pass its session id from a previous run details).',
635
+ }),
636
+ ),
637
+ allowDangerous: Type.Optional(
638
+ Type.Boolean({
639
+ description: 'Escalate to danger permission (unrestricted). Only with explicit user approval.',
640
+ }),
641
+ ),
642
+ pr: Type.Optional(Type.String({ description: 'GitHub PR number/URL (alternative to scope pr).' })),
643
+ }),
644
+ async execute(
645
+ _toolCallId: string,
646
+ params: {
647
+ harness?: string;
648
+ task: string;
649
+ mode?: string;
650
+ scope?: string;
651
+ model?: string;
652
+ maxBudgetUsd?: number;
653
+ allowDangerous?: boolean;
654
+ sessionId?: string;
655
+ pr?: string;
656
+ },
657
+ signal: AbortSignal | undefined,
658
+ onUpdate: ((u: { content: { type: string; text: string }[]; details: { progress: number } }) => void) | undefined,
659
+ ctx: ExtensionContext,
660
+ ) {
661
+ const config = loadConfig();
662
+ const feed: string[] = [];
663
+ let liveTail = '';
664
+ let thinkingChars = 0;
665
+ let lastPushAt = 0;
666
+ const THROTTLE_MS = 250;
667
+ const pushFeed = () => {
668
+ const now = Date.now();
669
+ if (now - lastPushAt < THROTTLE_MS) return;
670
+ lastPushAt = now;
671
+ const lines: string[] = [...feed.slice(-6)];
672
+ if (thinkingChars > 0)
673
+ lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
674
+ if (liveTail) lines.push(`✍ ${liveTail}`);
675
+ if (lines.length === 0) return;
676
+ onUpdate?.({ content: [{ type: 'text', text: lines.join('\n') }], details: { progress: 0.5 } });
677
+ };
678
+ const { content, details, result } = await delegate(pi, ctx, {
679
+ harness: params.harness,
680
+ task: params.task,
681
+ mode: params.mode,
682
+ scope: params.scope,
683
+ model: params.model,
684
+ maxBudgetUsd: params.maxBudgetUsd,
685
+ allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
686
+ sessionId: params.sessionId,
687
+ pr: params.pr,
688
+ signal,
689
+ onStream: text => {
690
+ liveTail = (liveTail + text).slice(-400);
691
+ pushFeed();
692
+ },
693
+ onActivity: ev => {
694
+ if (ev.kind === 'tool_input') {
695
+ feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
696
+ if (feed.length > 40) feed.splice(0, feed.length - 40);
697
+ } else if (ev.kind === 'tool_result') {
698
+ const last = feed.length - 1;
699
+ if (last >= 0 && feed[last].startsWith('▶')) feed[last] += ev.isError ? ' ✗' : ' ✓';
700
+ } else if (ev.kind === 'thinking') thinkingChars += ev.chars;
701
+ pushFeed();
702
+ },
703
+ });
704
+ const summary = summarize(content);
705
+ const resumed = details.resumed ? ' · resumed' : '';
706
+ const head = result.isError
707
+ ? `⚠ ${details.harness} reported an error`
708
+ : `${details.harness} ${details.mode} (${result.numTurns} turn(s), $${result.totalCostUsd.toFixed(3)})${resumed}`;
709
+ const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
710
+ const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
711
+ (details as Record<string, unknown>).markdown = summary.text;
712
+ return {
713
+ content: [{ type: 'text', text: `${head}${body}${footer}` }],
714
+ details,
715
+ usage: result.usage ? mapClaudeUsage({ ...result.usage, totalCostUsd: result.totalCostUsd }) : undefined,
716
+ };
717
+ },
718
+ renderCall(args: unknown, theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string }) {
719
+ const params = args as { harness?: string; mode?: string; task?: string };
720
+ const harness = params.harness ?? 'delegate';
721
+ const mode = params.mode ?? 'general';
722
+ const task = params.task ?? '';
723
+ const taskStr = task ? ` — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}` : '';
724
+ return new Text(theme.fg('accent', `${harness} ${mode}`) + theme.fg('dim', taskStr), 1, 1, s =>
725
+ theme.bg('toolPendingBg', s),
726
+ );
727
+ },
728
+ renderResult(
729
+ result: { content?: { type: string; text: string }[]; details?: Record<string, unknown> },
730
+ options: { isPartial: boolean },
731
+ theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string },
732
+ ) {
733
+ if (options.isPartial) {
734
+ const text = (result.content ?? [])
735
+ .filter(c => c.type === 'text')
736
+ .map(c => c.text)
737
+ .join('\n');
738
+ return new Text(text, 1, 1, s => theme.bg('toolPendingBg', s));
739
+ }
740
+ const details = (result.details ?? {}) as Record<string, unknown>;
741
+ const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
742
+ const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
743
+ const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : 0;
744
+ const turns = typeof details.numTurns === 'number' ? details.numTurns : 0;
745
+ const isError = details.isError === true;
746
+ const resumed = details.resumed === true;
747
+ const file = typeof details.file === 'string' ? details.file : null;
748
+ const sessionId = typeof details.sessionId === 'string' ? details.sessionId : null;
749
+ const container = new Container();
750
+ container.addChild(
751
+ new Text(
752
+ theme.fg(isError ? 'error' : 'accent', `${harness} ${mode}`) +
753
+ theme.fg('dim', ` · ${turns} turn(s) · `) +
754
+ theme.fg('warning', `$${cost.toFixed(3)}`) +
755
+ (resumed ? theme.fg('dim', ' · resumed') : ''),
756
+ 1,
757
+ 1,
758
+ ),
759
+ );
760
+ const md = typeof details.markdown === 'string' && details.markdown ? details.markdown : null;
761
+ if (md) container.addChild(new Markdown(md, 1, 1, getMarkdownTheme()));
762
+ else {
763
+ const text = (result.content ?? [])
764
+ .filter(c => c.type === 'text')
765
+ .map(c => c.text)
766
+ .join('\n');
767
+ container.addChild(new Text(text, 1, 1));
768
+ }
769
+ const foot: string[] = [];
770
+ if (file) foot.push(`Transcript: ${file}`);
771
+ if (sessionId) foot.push(`Resume: /delegate --resume=${sessionId} <prompt>`);
772
+ if (foot.length > 0) container.addChild(new Text(theme.fg('dim', foot.join(' ')), 1, 1));
773
+ return container;
774
+ },
775
+ };
776
+
777
+ // SAFETY: delegateToolDef satisfies registerTool params via TypeBox, widened for alias registration
778
+ pi.registerTool(delegateToolDef as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: delegateToolDef satisfies registerTool params
779
+
780
+ // deprecated alias
781
+ pi.registerTool({
782
+ name: 'claude_delegate',
783
+ label: 'Claude Delegate (deprecated)',
784
+ description:
785
+ 'Deprecated alias for delegate{harness:claude}. Use delegate tool with harness:claude instead. ' +
786
+ (delegateToolDef as { description: string }).description,
787
+ promptSnippet: 'Delegate a subtask to Claude Code (deprecated alias)',
788
+ // SAFETY: delegateToolDef promptGuidelines is string[] from literal, safe to spread
789
+ promptGuidelines: [...(delegateToolDef as unknown as { promptGuidelines: string[] }).promptGuidelines], // SAFETY: promptGuidelines is string[]
790
+ parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
791
+ async execute(
792
+ toolCallId: string,
793
+ params: {
794
+ harness?: string;
795
+ task: string;
796
+ mode?: string;
797
+ scope?: string;
798
+ model?: string;
799
+ maxBudgetUsd?: number;
800
+ allowDangerous?: boolean;
801
+ sessionId?: string;
802
+ pr?: string;
803
+ },
804
+ signal: AbortSignal | undefined,
805
+ onUpdate: never,
806
+ ctx: ExtensionContext,
807
+ ) {
808
+ return (
809
+ // SAFETY: deprecated alias delegates to primary tool, shape identical
810
+ (
811
+ delegateToolDef as unknown as {
812
+ // SAFETY: alias shape identical
813
+ execute: (a: string, b: unknown, c: unknown, d: unknown, e: unknown) => Promise<unknown>;
814
+ }
815
+ ).execute(toolCallId, { ...params, harness: 'claude' }, signal, onUpdate, ctx)
816
+ );
817
+ },
818
+ // SAFETY: delegateToolDef renderCall matches expected signature
819
+ renderCall: (delegateToolDef as unknown as { renderCall: (a: unknown, b: unknown) => unknown }).renderCall, // SAFETY: matches signature
820
+ // SAFETY: delegateToolDef renderResult matches expected signature
821
+ renderResult: (delegateToolDef as unknown as { renderResult: (a: unknown, b: unknown, c: unknown) => unknown }) // SAFETY: matches signature
822
+ .renderResult,
823
+ // SAFETY: final alias tool matches registerTool overload
824
+ } as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: alias tool matches overload
825
+
826
+ // ── Commands ─────────────────────────────────────────────────────────────
827
+ const makeHandler = (forcedHarness?: string) => async (args: string, ctx: ExtensionContext) => {
828
+ const sub = args.trim();
829
+ if (sub === 'watch' || sub === 'show') {
830
+ if (activeOverlay) {
831
+ activeOverlay.show();
832
+ activeOverlay.focus();
833
+ } else {
834
+ ctx.ui.notify?.('No active delegate run to show — start one with /delegate <harness> <mode> <prompt>', 'info');
835
+ }
836
+ return;
837
+ }
838
+ if (sub === 'list') {
839
+ await showModes(ctx, forcedHarness);
840
+ return;
841
+ }
842
+ if (sub.startsWith('list ')) {
843
+ const h = sub.slice(5).trim();
844
+ if (isKnownHarness(h)) {
845
+ await showModes(ctx, h);
846
+ return;
847
+ }
848
+ }
849
+ if (sub === 'history' || sub === 'logs') {
850
+ await showHistory(ctx);
851
+ return;
852
+ }
853
+
854
+ // combine forced harness + args for parsing
855
+ const rawForParse = forcedHarness ? `${forcedHarness} ${args}`.trim() : args;
856
+ // gather known modes across all harnesses for parsing
857
+ const allModes = new Set<string>();
858
+ for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h).keys()) allModes.add(k);
859
+ for (const k of loadTemplates(ctx.cwd).keys()) allModes.add(k);
860
+ const knownHarnessesSet = new Set([...HARNESS_NAMES, ...Object.keys(ALIASES)]);
861
+ const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
862
+ // if forcedHarness provided, it wins
863
+ if (forcedHarness) parsed.harness = forcedHarness;
864
+ const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
865
+ const templates = loadTemplates(ctx.cwd, harnessName);
866
+ const resolved = resolveDefaults(parsed, templates);
867
+ const template = parsed.mode ? templates.get(parsed.mode) : undefined;
868
+ const isDanger =
869
+ template?.permission === 'danger' ||
870
+ (template?.nativePermission
871
+ ? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
872
+ : false);
873
+
874
+ if (!resolved) {
875
+ if (parsed.mode)
876
+ ctx.ui.notify?.(
877
+ `/delegate ${parsed.mode} <what to do> — give a prompt for the "${parsed.mode}" mode`,
878
+ 'warning',
879
+ );
880
+ else
881
+ ctx.ui.notify?.(
882
+ 'Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=…] [--model=…] [--scope=…] <prompt>',
883
+ 'warning',
884
+ );
885
+ return;
886
+ }
887
+ const modeForDisplay = parsed.mode ?? 'general';
888
+ const harnessForDisplay = harnessName;
889
+
890
+ const feed: FeedEntry[] = [];
891
+ let thinkingChars = 0;
892
+ let liveTail = '';
893
+ let requestRender: (() => void) | null = null;
894
+ const getEntries = (): FeedEntry[] => {
895
+ const entries = [...feed.slice(-12)];
896
+ if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
897
+ if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
898
+ return entries;
899
+ };
900
+ let chipActivity = '';
901
+ let chipLastPush = 0;
902
+ const pushChip = () => {
903
+ if (!ctx.hasUI) return;
904
+ const now = Date.now();
905
+ if (now - chipLastPush < 500) return;
906
+ chipLastPush = now;
907
+ const theme = ctx.ui.theme;
908
+ const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
909
+ ctx.ui.setStatus(
910
+ 'delegate',
911
+ theme.fg('accent', '●') + theme.fg('dim', ` ${harnessForDisplay} ${modeForDisplay}`) + activity,
912
+ );
913
+ };
914
+ const onActivity = (ev: ActivityEvent) => {
915
+ if (ev.kind === 'tool_input') {
916
+ chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
917
+ feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input) });
918
+ if (feed.length > 40) feed.splice(0, feed.length - 40);
919
+ } else if (ev.kind === 'tool_result') {
920
+ if (chipActivity.startsWith('▶')) chipActivity += ev.isError ? ' ✗' : ' ✓';
921
+ const last = feed.length - 1;
922
+ if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: ev.isError ? false : true };
923
+ } else if (ev.kind === 'thinking') {
924
+ chipActivity = '💭 thinking…';
925
+ thinkingChars += ev.chars;
926
+ }
927
+ pushChip();
928
+ requestRender?.();
929
+ };
930
+ const ac = new AbortController();
931
+ let cancelled = false;
932
+ const runState: { error: Error | null } = { error: null };
933
+ const runId = ++activeRunId;
934
+ const clearActive = () => {
935
+ if (activeOverlay?.runId === runId) activeOverlay = null;
936
+ };
937
+ const run = delegate(pi, ctx, {
938
+ harness: harnessName,
939
+ task: resolved.task,
940
+ mode: parsed.mode,
941
+ scope: resolved.scope,
942
+ model: parsed.model,
943
+ maxBudgetUsd: parsed.budget,
944
+ sessionId: parsed.sessionId,
945
+ pr: parsed.pr,
946
+ signal: ac.signal,
947
+ onStream: t => {
948
+ liveTail = (liveTail + t).slice(-400);
949
+ requestRender?.();
950
+ },
951
+ onActivity,
952
+ }).catch((err: unknown) => {
953
+ runState.error = err instanceof Error ? err : new Error(String(err));
954
+ return null;
955
+ });
956
+
957
+ let closeWindow: (() => void) | null = null;
958
+ let result: Awaited<ReturnType<typeof delegate>> | null = null;
959
+ if (ctx.hasUI) {
960
+ let overlayHandle: OverlayHandle | null = null;
961
+ const uiPromise = ctx.ui
962
+ .custom(
963
+ (tui, theme, _kb, done) => {
964
+ requestRender = () => tui.requestRender();
965
+ closeWindow = () => done(undefined);
966
+ return progressWindow(tui, theme, {
967
+ mode: `${harnessForDisplay} ${modeForDisplay}`,
968
+ model:
969
+ parsed.model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
970
+ startedAt: Date.now(),
971
+ getEntries,
972
+ dangerous: isDanger,
973
+ onCancel: () => {
974
+ cancelled = true;
975
+ ac.abort();
976
+ },
977
+ onMinimize: () => {
978
+ overlayHandle?.setHidden(true);
979
+ overlayHandle?.unfocus();
980
+ },
981
+ });
982
+ },
983
+ {
984
+ overlay: true,
985
+ overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
986
+ onHandle: h => {
987
+ overlayHandle = h;
988
+ activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
989
+ h.focus();
990
+ },
991
+ },
992
+ )
993
+ .catch(() => {});
994
+ result = await run;
995
+ await closeWhenMounted(() => closeWindow, 2000);
996
+ await uiPromise;
997
+ } else {
998
+ result = await run;
999
+ }
1000
+ clearActive();
1001
+ if (cancelled || !result) {
1002
+ if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
1003
+ const message = runState.error ? runState.error.message : cancelled ? 'cancelled' : 'delegation failed';
1004
+ if (ctx.hasUI)
1005
+ ctx.ui.notify(`delegate ${cancelled ? 'cancelled' : 'failed'}: ${message}`, cancelled ? 'warning' : 'error');
1006
+ else process.stderr.write(`${message}\n`);
1007
+ return;
1008
+ }
1009
+ const { content, details } = result;
1010
+ const summary = summarize(content);
1011
+ const file = (details.file as string) ?? null;
1012
+ const sessionId = (details.sessionId as string) ?? null;
1013
+ const resumeHint = sessionId ? ` · resume: /delegate --resume=${sessionId} <prompt>` : '';
1014
+ const usage = details.usage as
1015
+ | {
1016
+ inputTokens?: number;
1017
+ outputTokens?: number;
1018
+ cacheCreationInputTokens?: number;
1019
+ cacheReadInputTokens?: number;
1020
+ }
1021
+ | undefined;
1022
+ const promptTokens = usage
1023
+ ? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
1024
+ : 0;
1025
+ const metrics = formatMetrics({
1026
+ numTurns: (details.numTurns as number) ?? 0,
1027
+ totalCostUsd: (details.totalCostUsd as number) ?? 0,
1028
+ promptTokens,
1029
+ contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
1030
+ durationMs:
1031
+ typeof details.durationMs === 'number' && details.durationMs !== null ? (details.durationMs as number) : null,
1032
+ });
1033
+ injectReport(ctx, {
1034
+ harness: details.harness as string,
1035
+ mode: details.mode as string,
1036
+ metrics,
1037
+ body: summary.text,
1038
+ file: file ?? undefined,
1039
+ sessionId: sessionId ?? undefined,
1040
+ });
1041
+ if (ctx.hasUI) {
1042
+ ctx.ui.setStatus('delegate', undefined);
1043
+ ctx.ui.notify(`${details.harness} ${details.mode} done — ${metrics}${resumeHint} · transcript: ${file}`, 'info');
1044
+ } else process.stdout.write(`${summary.text}\n`);
1045
+ };
1046
+
1047
+ pi.registerCommand('delegate', {
1048
+ description:
1049
+ 'Delegate a task to any harness. Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=review|plan|implement|security-audit|docs|general] [--model=...] [--scope=diff|pr|paths] [--resume=<id>] <prompt> — or use harness as first word: /delegate codex review <prompt>',
1050
+ handler: makeHandler(),
1051
+ });
1052
+ pi.registerCommand('claude', {
1053
+ description: 'Alias for /delegate --harness=claude. Usage: /claude [--mode=...] <prompt>',
1054
+ handler: makeHandler('claude'),
1055
+ });
1056
+ pi.registerCommand('codex', {
1057
+ description: 'Alias for /delegate --harness=codex. Usage: /codex [--mode=...] <prompt>',
1058
+ handler: makeHandler('codex'),
1059
+ });
1060
+ pi.registerCommand('opencode', {
1061
+ description: 'Alias for /delegate --harness=opencode. Usage: /opencode [--mode=...] <prompt>',
1062
+ handler: makeHandler('opencode'),
1063
+ });
1064
+ pi.registerCommand('amp', {
1065
+ description: 'Alias for /delegate --harness=amp. Usage: /amp [--mode=...] <prompt>',
1066
+ handler: makeHandler('amp'),
1067
+ });
1068
+ pi.registerCommand('omp', {
1069
+ description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>',
1070
+ handler: makeHandler('amp'),
1071
+ });
1072
+
1073
+ pi.on('input', async (event, _ctx) => {
1074
+ if (event.source === 'extension') return { action: 'continue' };
1075
+ const hint = delegationHint(event.text, { autoDelegateHints: loadConfig().autoDelegateHints });
1076
+ if (!hint) return { action: 'continue' };
1077
+ return { action: 'transform', text: `${stripMarker(event.text)}\n\n${hint}` };
1078
+ });
1079
+
1080
+ pi.on('before_agent_start', async () => {
1081
+ if (!pendingReport) return;
1082
+ const report = pendingReport;
1083
+ pendingReport = null;
1084
+ return { message: { customType: 'delegate', content: report.content, display: true, details: report.details } };
1085
+ });
816
1086
  }