pi-harness-delegate 0.1.0 → 0.2.0

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,1188 @@
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, detectAll, 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
- 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
- }
271
+ function saveOutput(harness: string, mode: string, text: string): string {
272
+ const dir = outputsDirFor(harness);
273
+ mkdirSync(dir, { recursive: true });
274
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
275
+ const file = join(dir, `${stamp}-${safeSegmentName(mode)}.md`);
276
+ writeFileSync(file, text, 'utf8');
277
+ return file;
264
278
  }
265
279
 
266
- 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;
280
+ async function showHistory(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
281
+ const entries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
282
+ if (entries.length === 0) {
283
+ const msg = harnessFilter
284
+ ? `No transcripts yet for ${harnessFilter} — run /delegate ${harnessFilter} <mode> <prompt> first`
285
+ : 'No transcripts yet — run /delegate <harness> <mode> <prompt> first';
286
+ if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
287
+ else ctx.ui.notify?.(msg, 'info');
288
+ return;
289
+ }
290
+ if (!ctx.hasUI) {
291
+ for (const e of entries)
292
+ process.stdout.write(`${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${e.sessionId ?? '-'}\n`);
293
+ return;
294
+ }
295
+ const entry = await ctx.ui.custom((tui, theme, _kb, done) => {
296
+ const items: SelectItem[] = entries.map(e => ({
297
+ value: e.file,
298
+ label: `${e.harness} ${e.mode} · $${e.cost.toFixed(3)} · ${new Date(e.mtime).toISOString().slice(0, 16)}`,
299
+ description: e.sessionId ? `session ${e.sessionId.slice(0, 8)}…` : undefined,
300
+ }));
301
+ const list = new SelectList(items, Math.min(items.length, 10), {
302
+ selectedPrefix: (s: string) => theme.fg('accent', s),
303
+ selectedText: (s: string) => theme.fg('accent', s),
304
+ description: (s: string) => theme.fg('dim', s),
305
+ scrollInfo: (s: string) => theme.fg('dim', s),
306
+ noMatch: (s: string) => theme.fg('warning', s),
307
+ });
308
+ list.onSelect = item => done(item.value);
309
+ list.onCancel = () => done(undefined);
310
+ return {
311
+ render: (w: number) => list.render(w),
312
+ invalidate: () => list.invalidate(),
313
+ handleInput: (data: string) => {
314
+ list.handleInput(data);
315
+ tui.requestRender();
316
+ },
317
+ };
318
+ });
319
+ if (entry) {
320
+ const chosen = entries.find(e => e.file === entry);
321
+ if (chosen) await viewTranscript(ctx, chosen);
322
+ }
323
+ }
324
+
325
+ async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
326
+ const cfg = loadConfig();
327
+ const detection = await detectAll();
328
+ const allHarnesses = harnessFilter ? [harnessFilter].filter(h => isKnownHarness(h)) : HARNESS_NAMES;
329
+ const lines: string[] = [];
330
+ lines.push(`delegate — status${harnessFilter ? ` (${harnessFilter})` : ''}`);
331
+ lines.push(`defaultHarness: ${cfg.defaultHarness} · defaultMode: ${cfg.defaultMode} · model: ${cfg.model ?? '—'}`);
332
+ lines.push(
333
+ `maxConcurrent: ${typeof cfg.maxConcurrent === 'number' ? cfg.maxConcurrent : JSON.stringify(cfg.maxConcurrent)} · maxTranscripts: ${cfg.maxTranscripts}`,
334
+ );
335
+ lines.push('');
336
+ lines.push('harness binary ok version outputs templates active');
337
+ lines.push('─'.repeat(78));
338
+ for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
339
+ const det = detection[h] ?? { ok: false };
340
+ const harness = getHarness(h);
341
+ const bin = harness?.binary ?? h;
342
+ const ver = det.version ? det.version.slice(0, 18) : det.hint ? '—' : '—';
343
+ const ok = det.ok ? '✓' : '✗';
344
+ let outputs = 0;
345
+ try {
346
+ outputs = readdirSync(getOutputsDir(h)).filter(f => f.endsWith('.md')).length;
347
+ } catch {}
348
+ let templates = 0;
349
+ try {
350
+ templates = loadTemplates(ctx.cwd, h).size;
351
+ } catch {}
352
+ const active = activeRuns.get(h) ?? 0;
353
+ const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
354
+ lines.push(
355
+ `${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${active}${hint}`,
356
+ );
357
+ }
358
+ if (!harnessFilter) {
359
+ lines.push('');
360
+ lines.push(
361
+ `global active: ${globalActiveRuns} · aliases: ${
362
+ Object.entries(ALIASES)
363
+ .map(([k, v]) => `${k}→${v}`)
364
+ .join(', ') || '—'
365
+ }`,
366
+ );
367
+ lines.push(`outputs dir: ${getOutputsDir()} (plus ${legacyOutputsDir()} legacy)`);
368
+ }
369
+ if (!ctx.hasUI) {
370
+ process.stdout.write(`${lines.join('\n')}\n`);
371
+ return;
372
+ }
373
+ await ctx.ui.custom((tui, theme, _kb, done) => {
374
+ let offset = 0;
375
+ const height = 14;
376
+ return {
377
+ render(width: number): string[] {
378
+ const header = theme.fg(
379
+ 'accent',
380
+ `delegate status${harnessFilter ? ` — ${harnessFilter}` : ''} (↑↓ scroll · any key to close)`,
381
+ );
382
+ const visible = lines.slice(offset, offset + height);
383
+ return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
384
+ },
385
+ handleInput(data: string): void {
386
+ if (matchesKey(data, Key.up) && offset > 0) {
387
+ offset--;
388
+ tui.requestRender();
389
+ } else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
390
+ offset++;
391
+ tui.requestRender();
392
+ } else done(undefined);
393
+ },
394
+ invalidate() {},
395
+ };
396
+ });
273
397
  }
274
398
 
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;
399
+ function buildPrompt(
400
+ template: DelegateTemplate,
401
+ task: string,
402
+ scopeText: string | null,
403
+ cwd: string,
404
+ harness: string,
405
+ ): string {
406
+ let prompt = [
407
+ `You are being delegated a subtask by the pi coding agent.`,
408
+ `Working directory: ${cwd}`,
409
+ `Harness: ${harness}`,
410
+ `Mode: ${template.name}`,
411
+ ``,
412
+ template.prompt,
413
+ ].join('\n');
414
+ prompt += `\n\n# Task\n${task}`;
415
+ if (scopeText) prompt += `\n\n# Scope\n${scopeText}`;
416
+ if (template.skill) prompt += `\n\nUse the "${template.skill}" skill.`;
417
+ return prompt;
281
418
  }
282
419
 
283
420
  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
- };
421
+ pi: ExtensionAPI,
422
+ ctx: ExtensionContext,
423
+ opts: DelegateOptions,
424
+ ): Promise<{
425
+ content: string;
426
+ details: Record<string, unknown>;
427
+ result: import('./harnesses/types.ts').StreamedResult & { streamedText: string; harness: string };
428
+ activityLog: string[];
429
+ }> {
430
+ const config = loadConfig();
431
+ const harnessName = opts.harness ?? config.defaultHarness ?? 'claude';
432
+ const harness = getHarness(harnessName);
433
+ if (!harness)
434
+ throw new Error(
435
+ `unknown harness "${harnessName}". Available: ${HARNESS_NAMES.join(', ')} (aliases: ${Object.keys(ALIASES).join(', ')})`,
436
+ );
437
+ const templates = loadTemplates(ctx.cwd, harnessName);
438
+ const mode = opts.mode || config.defaultMode;
439
+ const template = templates.get(mode);
440
+ if (!template)
441
+ throw new Error(
442
+ `unknown delegate mode "${mode}" for harness "${harnessName}". Available: ${[...templates.keys()].sort().join(', ')}`,
443
+ );
444
+ const task = opts.task || template.defaultTask;
445
+ if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
446
+
447
+ // concurrency guard
448
+ const maxGlobal = getMaxConcurrentGlobal();
449
+ const perHarnessCount = activeRuns.get(harnessName) ?? 0;
450
+ if (maxGlobal > 0 && globalActiveRuns >= maxGlobal)
451
+ throw new Error('another delegate run is already in progress (global limit)');
452
+ // per-harness limit if configured as object
453
+ const perHarnessLimit = (() => {
454
+ // SAFETY: maxConcurrent shape checked for perHarness record before access
455
+ const mc = config.maxConcurrent as unknown as { perHarness?: Record<string, number> }; // SAFETY: shape checked before access
456
+ if (mc && typeof mc === 'object' && mc.perHarness && typeof mc.perHarness[harnessName] === 'number')
457
+ return mc.perHarness[harnessName]!;
458
+ return maxGlobal;
459
+ })();
460
+ if (perHarnessLimit > 0 && perHarnessCount >= perHarnessLimit)
461
+ throw new Error(`another ${harnessName} run is already in progress`);
462
+ activeRuns.set(harnessName, perHarnessCount + 1);
463
+ globalActiveRuns++;
464
+ const release = () => {
465
+ activeRuns.set(harnessName, (activeRuns.get(harnessName) ?? 1) - 1);
466
+ globalActiveRuns = Math.max(0, globalActiveRuns - 1);
467
+ };
468
+
469
+ let scopeText: string | null = opts.scope ?? null;
470
+ if (opts.scope === 'diff') {
471
+ const diff = await pi.exec('git', ['diff', 'HEAD'], { cwd: ctx.cwd });
472
+ scopeText = diff.stdout
473
+ ? `Current git diff (working tree vs HEAD):\n${diff.stdout}`
474
+ : 'No git diff vs HEAD (working tree clean).';
475
+ } else if (opts.scope === 'pr' || opts.pr) {
476
+ const target = opts.pr ?? '';
477
+ const pr = await pi.exec('gh', target ? ['pr', 'diff', target] : ['pr', 'diff'], { cwd: ctx.cwd });
478
+ scopeText = pr.stdout
479
+ ? `Pull request diff (${target || 'current branch'}):\n${pr.stdout}`
480
+ : `Could not resolve the PR diff${pr.stderr ? ` — ${pr.stderr.trim().slice(0, 300)}` : ''}.`;
481
+ }
482
+
483
+ // permission: normalized, danger requires explicit per-call allowDangerous:true
484
+ let permission: NormalizedPermission = template.permission;
485
+ const nativePerm = template.nativePermission;
486
+ const isNativeDanger = !!nativePerm && ['bypassPermissions', 'danger-full-access', 'danger'].includes(nativePerm);
487
+ if (template.permission === 'danger' || isNativeDanger) {
488
+ if (opts.allowDangerous !== true) {
489
+ throw new Error(
490
+ `template "${mode}" requires danger permission — pass allowDangerous:true to run it (never a default)`,
491
+ );
492
+ }
493
+ permission = 'danger';
494
+ } else if (opts.allowDangerous === true) {
495
+ // explicit per-call escalation for any template
496
+ permission = 'danger';
497
+ }
498
+ const permissionForDisplay = nativePerm ?? permission;
499
+
500
+ const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
501
+ const prompt = buildPrompt(template, task, scopeText, ctx.cwd, harnessName);
502
+
503
+ const activityEvents: ActivityEvent[] = [];
504
+ let streamedFull = '';
505
+ let result: import('./runner.ts').HarnessResult;
506
+ try {
507
+ result = await runHarness({
508
+ harness,
509
+ prompt,
510
+ cwd: ctx.cwd,
511
+ permission,
512
+ model,
513
+ maxBudgetUsd:
514
+ opts.maxBudgetUsd ??
515
+ template.maxBudgetUsd ??
516
+ config.maxBudgetUsd ??
517
+ config.harnesses[harnessName]?.maxBudgetUsd,
518
+ signal: opts.signal,
519
+ timeoutMs: config.harnesses[harnessName]?.timeoutMs ?? config.timeoutMs,
520
+ resumeSessionId: opts.sessionId,
521
+ onStream: t => {
522
+ streamedFull += t;
523
+ opts.onStream?.(t);
524
+ },
525
+ onActivity: ev => {
526
+ activityEvents.push(ev);
527
+ opts.onActivity?.(ev);
528
+ },
529
+ });
530
+ } catch (err) {
531
+ release();
532
+ if (streamedFull.length > 0) {
533
+ try {
534
+ saveOutput(
535
+ harnessName,
536
+ `${mode}-partial`,
537
+ buildTranscript({
538
+ harness: harnessName,
539
+ mode: `${mode} (partial)`,
540
+ permission: permission,
541
+ nativePermission: nativePerm ?? undefined,
542
+ model: model ?? null,
543
+ cwd: ctx.cwd,
544
+ sessionId: null,
545
+ resumed: Boolean(opts.sessionId),
546
+ numTurns: 0,
547
+ totalCostUsd: 0,
548
+ isError: true,
549
+ stopReason: null,
550
+ durationMs: null,
551
+ usage: null,
552
+ contextPercent: null,
553
+ contextWindow: null,
554
+ activityLog: collectActivityLog(activityEvents),
555
+ output: streamedFull,
556
+ }),
557
+ );
558
+ } catch (_e) {
559
+ void _e;
560
+ }
561
+ }
562
+ throw err;
563
+ }
564
+ release();
565
+
566
+ if (result.isError && !result.result && !result.streamedText)
567
+ throw new Error(`${harnessName} reported an error and produced no output`);
568
+
569
+ const actualModel = result.model ?? model ?? null;
570
+ const promptTokens =
571
+ result.usage === null
572
+ ? null
573
+ : result.usage.inputTokens + result.usage.cacheCreationInputTokens + result.usage.cacheReadInputTokens;
574
+ const contextPercent =
575
+ promptTokens !== null && result.contextWindow ? (promptTokens / result.contextWindow) * 100 : null;
576
+
577
+ const file = saveOutput(
578
+ harnessName,
579
+ mode,
580
+ buildTranscript({
581
+ harness: harnessName,
582
+ mode: mode,
583
+ permission: permission,
584
+ nativePermission: nativePerm ?? undefined,
585
+ model: actualModel,
586
+ cwd: ctx.cwd,
587
+ sessionId: result.sessionId,
588
+ resumed: Boolean(opts.sessionId),
589
+ numTurns: result.numTurns,
590
+ totalCostUsd: result.totalCostUsd,
591
+ isError: result.isError,
592
+ stopReason: result.stopReason,
593
+ durationMs: result.durationMs,
594
+ usage: result.usage,
595
+ contextPercent,
596
+ contextWindow: result.contextWindow,
597
+ activityLog: collectActivityLog(activityEvents),
598
+ output: result.result || result.streamedText,
599
+ }),
600
+ );
601
+ pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
602
+ // also prune legacy if claude
603
+ if (harnessName === 'claude') pruneOutputs(legacyOutputsDir(), config.maxTranscripts);
604
+
605
+ return {
606
+ content: result.result || result.streamedText || '(empty result)',
607
+ details: {
608
+ harness: harnessName,
609
+ mode,
610
+ permission,
611
+ nativePermission: nativePerm ?? null,
612
+ permissionMode: String(permissionForDisplay),
613
+ model: actualModel,
614
+ numTurns: result.numTurns,
615
+ totalCostUsd: result.totalCostUsd,
616
+ sessionId: result.sessionId,
617
+ stopReason: result.stopReason,
618
+ permissionDenials: result.permissionDenials,
619
+ isError: result.isError,
620
+ resumed: Boolean(opts.sessionId),
621
+ file,
622
+ durationMs: result.durationMs,
623
+ ttftMs: result.ttftMs,
624
+ contextWindow: result.contextWindow,
625
+ contextPercent,
626
+ promptTokens,
627
+ usage: result.usage,
628
+ },
629
+ result,
630
+ activityLog: collectActivityLog(activityEvents),
631
+ };
463
632
  }
464
633
 
465
634
  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 };
635
+ if (content.length <= max) return { text: content, truncated: false };
636
+ return { text: `${content.slice(0, max)}\n…[truncated — full output saved to file]`, truncated: true };
468
637
  }
469
638
 
470
639
  interface PendingReport {
471
- content: string;
472
- details: Record<string, unknown>;
640
+ content: string;
641
+ details: Record<string, unknown>;
473
642
  }
474
643
  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
- };
644
+ function injectReport(
645
+ _ctx: ExtensionContext,
646
+ opts: { harness: string; mode: string; metrics: string; body: string; file?: string; sessionId?: string },
647
+ ): void {
648
+ pendingReport = {
649
+ content: buildReportContent({
650
+ harness: opts.harness,
651
+ mode: opts.mode,
652
+ metrics: opts.metrics,
653
+ body: opts.body,
654
+ file: opts.file,
655
+ sessionId: opts.sessionId,
656
+ }),
657
+ details: {
658
+ harness: opts.harness,
659
+ mode: opts.mode,
660
+ file: opts.file,
661
+ sessionId: opts.sessionId,
662
+ metrics: opts.metrics,
663
+ },
664
+ };
480
665
  }
481
666
 
482
667
  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
- });
668
+ let activeRunId = 0;
669
+ let activeOverlay: { show(): void; focus(): void; runId: number } | null = null;
670
+
671
+ // ── Tools ────────────────────────────────────────────────────────────────
672
+ const delegateToolDef = {
673
+ name: 'delegate',
674
+ label: 'Delegate',
675
+ description:
676
+ '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.',
677
+ promptSnippet: 'Delegate a subtask to a harness and return its report',
678
+ promptGuidelines: [
679
+ '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.',
680
+ '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.',
681
+ 'mode selects the template and its permission level: review/plan/security-audit are readonly; implement/docs/general are edit. Custom template names also work.',
682
+ 'sessionId resumes a previous delegated session instead of starting fresh.',
683
+ 'Do not set allowDangerous unless the user explicitly asks for unrestricted access (danger permission).',
684
+ ],
685
+ parameters: Type.Object({
686
+ harness: Type.Optional(
687
+ Type.String({
688
+ description:
689
+ 'Harness to use: claude, codex, opencode, amp (aliases: omp). Defaults to config defaultHarness.',
690
+ }),
691
+ ),
692
+ task: Type.String({ description: 'The task/intent to delegate. Be specific.' }),
693
+ mode: Type.Optional(
694
+ Type.String({
695
+ description:
696
+ 'Template/mode to run: review, plan, implement, security-audit, docs, general, or custom. Defaults to config defaultMode.',
697
+ }),
698
+ ),
699
+ scope: Type.Optional(
700
+ Type.String({
701
+ description:
702
+ 'Restrict the work: diff (git diff), pr (PR diff), comma/space-separated path list, or omit for whole repo.',
703
+ }),
704
+ ),
705
+ model: Type.Optional(
706
+ Type.String({ description: 'Model (e.g. sonnet, opus, gpt-5). Defaults to template/config.' }),
707
+ ),
708
+ maxBudgetUsd: Type.Optional(Type.Number({ description: 'Hard spend cap in USD for the run.' })),
709
+ sessionId: Type.Optional(
710
+ Type.String({
711
+ description: 'Resume an existing delegated session (pass its session id from a previous run details).',
712
+ }),
713
+ ),
714
+ allowDangerous: Type.Optional(
715
+ Type.Boolean({
716
+ description: 'Escalate to danger permission (unrestricted). Only with explicit user approval.',
717
+ }),
718
+ ),
719
+ pr: Type.Optional(Type.String({ description: 'GitHub PR number/URL (alternative to scope pr).' })),
720
+ }),
721
+ async execute(
722
+ _toolCallId: string,
723
+ params: {
724
+ harness?: string;
725
+ task: string;
726
+ mode?: string;
727
+ scope?: string;
728
+ model?: string;
729
+ maxBudgetUsd?: number;
730
+ allowDangerous?: boolean;
731
+ sessionId?: string;
732
+ pr?: string;
733
+ },
734
+ signal: AbortSignal | undefined,
735
+ onUpdate: ((u: { content: { type: string; text: string }[]; details: { progress: number } }) => void) | undefined,
736
+ ctx: ExtensionContext,
737
+ ) {
738
+ const config = loadConfig();
739
+ const feed: string[] = [];
740
+ let liveTail = '';
741
+ let thinkingChars = 0;
742
+ let lastPushAt = 0;
743
+ const THROTTLE_MS = 250;
744
+ const pushFeed = () => {
745
+ const now = Date.now();
746
+ if (now - lastPushAt < THROTTLE_MS) return;
747
+ lastPushAt = now;
748
+ const lines: string[] = [...feed.slice(-6)];
749
+ if (thinkingChars > 0)
750
+ lines.push(config.inspectThinking ? `💭 thinking… (${thinkingChars} chars)` : '💭 thinking…');
751
+ if (liveTail) lines.push(`✍ ${liveTail}`);
752
+ if (lines.length === 0) return;
753
+ onUpdate?.({ content: [{ type: 'text', text: lines.join('\n') }], details: { progress: 0.5 } });
754
+ };
755
+ const { content, details, result } = await delegate(pi, ctx, {
756
+ harness: params.harness,
757
+ task: params.task,
758
+ mode: params.mode,
759
+ scope: params.scope,
760
+ model: params.model,
761
+ maxBudgetUsd: params.maxBudgetUsd,
762
+ allowDangerous: params.allowDangerous === true, // invariant: never inherit from config.allowDangerous — danger requires explicit per-call approval
763
+ sessionId: params.sessionId,
764
+ pr: params.pr,
765
+ signal,
766
+ onStream: text => {
767
+ liveTail = (liveTail + text).slice(-400);
768
+ pushFeed();
769
+ },
770
+ onActivity: ev => {
771
+ if (ev.kind === 'tool_input') {
772
+ feed.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
773
+ if (feed.length > 40) feed.splice(0, feed.length - 40);
774
+ } else if (ev.kind === 'tool_result') {
775
+ const last = feed.length - 1;
776
+ if (last >= 0 && feed[last].startsWith('▶')) feed[last] += ev.isError ? ' ✗' : ' ✓';
777
+ } else if (ev.kind === 'thinking') thinkingChars += ev.chars;
778
+ pushFeed();
779
+ },
780
+ });
781
+ const summary = summarize(content);
782
+ const resumed = details.resumed ? ' · resumed' : '';
783
+ const head = result.isError
784
+ ? `⚠ ${details.harness} reported an error`
785
+ : `${details.harness} ${details.mode} (${result.numTurns} turn(s), $${result.totalCostUsd.toFixed(3)})${resumed}`;
786
+ const body = result.isError ? `\n${summary.text}` : `\n\n${summary.text}`;
787
+ const footer = summary.truncated ? `\nFull output: ${details.file}` : `\nTranscript: ${details.file}`;
788
+ (details as Record<string, unknown>).markdown = summary.text;
789
+ return {
790
+ content: [{ type: 'text', text: `${head}${body}${footer}` }],
791
+ details,
792
+ usage: result.usage ? mapClaudeUsage({ ...result.usage, totalCostUsd: result.totalCostUsd }) : undefined,
793
+ };
794
+ },
795
+ renderCall(args: unknown, theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string }) {
796
+ const params = args as { harness?: string; mode?: string; task?: string };
797
+ const harness = params.harness ?? 'delegate';
798
+ const mode = params.mode ?? 'general';
799
+ const task = params.task ?? '';
800
+ const taskStr = task ? ` — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}` : '';
801
+ return new Text(theme.fg('accent', `${harness} ${mode}`) + theme.fg('dim', taskStr), 1, 1, s =>
802
+ theme.bg('toolPendingBg', s),
803
+ );
804
+ },
805
+ renderResult(
806
+ result: { content?: { type: string; text: string }[]; details?: Record<string, unknown> },
807
+ options: { isPartial: boolean },
808
+ theme: { fg: (c: string, s: string) => string; bg: (c: string, s: string) => string },
809
+ ) {
810
+ if (options.isPartial) {
811
+ const text = (result.content ?? [])
812
+ .filter(c => c.type === 'text')
813
+ .map(c => c.text)
814
+ .join('\n');
815
+ return new Text(text, 1, 1, s => theme.bg('toolPendingBg', s));
816
+ }
817
+ const details = (result.details ?? {}) as Record<string, unknown>;
818
+ const harness = typeof details.harness === 'string' ? details.harness : 'delegate';
819
+ const mode = typeof details.mode === 'string' ? details.mode : 'delegate';
820
+ const cost = typeof details.totalCostUsd === 'number' ? details.totalCostUsd : 0;
821
+ const turns = typeof details.numTurns === 'number' ? details.numTurns : 0;
822
+ const isError = details.isError === true;
823
+ const resumed = details.resumed === true;
824
+ const file = typeof details.file === 'string' ? details.file : null;
825
+ const sessionId = typeof details.sessionId === 'string' ? details.sessionId : null;
826
+ const container = new Container();
827
+ container.addChild(
828
+ new Text(
829
+ theme.fg(isError ? 'error' : 'accent', `${harness} ${mode}`) +
830
+ theme.fg('dim', ` · ${turns} turn(s) · `) +
831
+ theme.fg('warning', `$${cost.toFixed(3)}`) +
832
+ (resumed ? theme.fg('dim', ' · resumed') : ''),
833
+ 1,
834
+ 1,
835
+ ),
836
+ );
837
+ const md = typeof details.markdown === 'string' && details.markdown ? details.markdown : null;
838
+ if (md) container.addChild(new Markdown(md, 1, 1, getMarkdownTheme()));
839
+ else {
840
+ const text = (result.content ?? [])
841
+ .filter(c => c.type === 'text')
842
+ .map(c => c.text)
843
+ .join('\n');
844
+ container.addChild(new Text(text, 1, 1));
845
+ }
846
+ const foot: string[] = [];
847
+ if (file) foot.push(`Transcript: ${file}`);
848
+ if (sessionId) foot.push(`Resume: /delegate --resume=${sessionId} <prompt>`);
849
+ if (foot.length > 0) container.addChild(new Text(theme.fg('dim', foot.join(' ')), 1, 1));
850
+ return container;
851
+ },
852
+ };
853
+
854
+ // SAFETY: delegateToolDef satisfies registerTool params via TypeBox, widened for alias registration
855
+ pi.registerTool(delegateToolDef as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: delegateToolDef satisfies registerTool params
856
+
857
+ // deprecated alias
858
+ pi.registerTool({
859
+ name: 'claude_delegate',
860
+ label: 'Claude Delegate (deprecated)',
861
+ description:
862
+ 'Deprecated alias for delegate{harness:claude}. Use delegate tool with harness:claude instead. ' +
863
+ (delegateToolDef as { description: string }).description,
864
+ promptSnippet: 'Delegate a subtask to Claude Code (deprecated alias)',
865
+ // SAFETY: delegateToolDef promptGuidelines is string[] from literal, safe to spread
866
+ promptGuidelines: [...(delegateToolDef as unknown as { promptGuidelines: string[] }).promptGuidelines], // SAFETY: promptGuidelines is string[]
867
+ parameters: (delegateToolDef as { parameters: unknown }).parameters as never,
868
+ async execute(
869
+ toolCallId: string,
870
+ params: {
871
+ harness?: string;
872
+ task: string;
873
+ mode?: string;
874
+ scope?: string;
875
+ model?: string;
876
+ maxBudgetUsd?: number;
877
+ allowDangerous?: boolean;
878
+ sessionId?: string;
879
+ pr?: string;
880
+ },
881
+ signal: AbortSignal | undefined,
882
+ onUpdate: never,
883
+ ctx: ExtensionContext,
884
+ ) {
885
+ return (
886
+ // SAFETY: deprecated alias delegates to primary tool, shape identical
887
+ (
888
+ delegateToolDef as unknown as {
889
+ // SAFETY: alias shape identical
890
+ execute: (a: string, b: unknown, c: unknown, d: unknown, e: unknown) => Promise<unknown>;
891
+ }
892
+ ).execute(toolCallId, { ...params, harness: 'claude' }, signal, onUpdate, ctx)
893
+ );
894
+ },
895
+ // SAFETY: delegateToolDef renderCall matches expected signature
896
+ renderCall: (delegateToolDef as unknown as { renderCall: (a: unknown, b: unknown) => unknown }).renderCall, // SAFETY: matches signature
897
+ // SAFETY: delegateToolDef renderResult matches expected signature
898
+ renderResult: (delegateToolDef as unknown as { renderResult: (a: unknown, b: unknown, c: unknown) => unknown }) // SAFETY: matches signature
899
+ .renderResult,
900
+ // SAFETY: final alias tool matches registerTool overload
901
+ } as unknown as Parameters<typeof pi.registerTool>[0]); // SAFETY: alias tool matches overload
902
+
903
+ // ── Commands ─────────────────────────────────────────────────────────────
904
+ const makeHandler = (forcedHarness?: string) => async (args: string, ctx: ExtensionContext) => {
905
+ const sub = args.trim();
906
+ const subLower = sub.toLowerCase();
907
+ // status / health / doctor — harness health check
908
+ if (subLower === 'status' || subLower === 'health' || subLower === 'doctor' || subLower === 'check') {
909
+ await showStatus(ctx, forcedHarness);
910
+ return;
911
+ }
912
+ if (
913
+ subLower.startsWith('status ') ||
914
+ subLower.startsWith('health ') ||
915
+ subLower.startsWith('doctor ') ||
916
+ subLower.startsWith('check ')
917
+ ) {
918
+ const maybeH = sub.split(/\s+/)[1]?.toLowerCase();
919
+ const flagMatch = sub.match(/--harness=([^\s]+)/);
920
+ const h =
921
+ forcedHarness ??
922
+ (flagMatch ? flagMatch[1].toLowerCase() : maybeH && isKnownHarness(maybeH) ? maybeH : undefined);
923
+ await showStatus(ctx, h);
924
+ return;
925
+ }
926
+ // extract --harness flag for list/history subcommands
927
+ const harnessFlag = sub.match(/--harness=([^\s]+)/)?.[1]?.toLowerCase();
928
+ if (sub === 'watch' || sub === 'show') {
929
+ if (activeOverlay) {
930
+ activeOverlay.show();
931
+ activeOverlay.focus();
932
+ } else {
933
+ ctx.ui.notify?.('No active delegate run to show — start one with /delegate <harness> <mode> <prompt>', 'info');
934
+ }
935
+ return;
936
+ }
937
+ if (sub === 'list' || subLower.startsWith('list ')) {
938
+ const h =
939
+ forcedHarness ??
940
+ harnessFlag ??
941
+ (subLower.startsWith('list ') ? sub.slice(5).trim().split(/\s+/)[0]?.toLowerCase() : undefined);
942
+ if (h && isKnownHarness(h)) {
943
+ await showModes(ctx, h);
944
+ return;
945
+ }
946
+ if (sub === 'list' || subLower === `list --harness=${h}`) {
947
+ await showModes(ctx, forcedHarness ?? h);
948
+ return;
949
+ }
950
+ // fallback: list without filter or with unknown word — show filtered if known, otherwise all
951
+ await showModes(ctx, forcedHarness);
952
+ return;
953
+ }
954
+ if (sub === 'history' || sub === 'logs' || subLower.startsWith('history ') || subLower.startsWith('logs ')) {
955
+ const h =
956
+ forcedHarness ??
957
+ harnessFlag ??
958
+ (subLower.startsWith('history ') || subLower.startsWith('logs ')
959
+ ? sub.split(/\s+/)[1]?.toLowerCase()
960
+ : undefined);
961
+ if (h && isKnownHarness(h)) {
962
+ await showHistory(ctx, h);
963
+ return;
964
+ }
965
+ await showHistory(ctx, forcedHarness);
966
+ return;
967
+ }
968
+
969
+ // combine forced harness + args for parsing
970
+ const rawForParse = forcedHarness ? `${forcedHarness} ${args}`.trim() : args;
971
+ // gather known modes across all harnesses for parsing
972
+ const allModes = new Set<string>();
973
+ for (const h of HARNESS_NAMES) for (const k of loadTemplates(ctx.cwd, h).keys()) allModes.add(k);
974
+ for (const k of loadTemplates(ctx.cwd).keys()) allModes.add(k);
975
+ const knownHarnessesSet = new Set([...HARNESS_NAMES, ...Object.keys(ALIASES)]);
976
+ const parsed = parseDelegateCommand(rawForParse, allModes, knownHarnessesSet);
977
+ // if forcedHarness provided, it wins
978
+ if (forcedHarness) parsed.harness = forcedHarness;
979
+ const harnessName = parsed.harness ?? loadConfig().defaultHarness ?? 'claude';
980
+ const templates = loadTemplates(ctx.cwd, harnessName);
981
+ const resolved = resolveDefaults(parsed, templates);
982
+ const template = parsed.mode ? templates.get(parsed.mode) : undefined;
983
+ const isDanger =
984
+ template?.permission === 'danger' ||
985
+ (template?.nativePermission
986
+ ? ['bypassPermissions', 'danger-full-access', 'danger'].includes(template.nativePermission)
987
+ : false);
988
+
989
+ if (!resolved) {
990
+ if (parsed.mode)
991
+ ctx.ui.notify?.(
992
+ `/delegate ${parsed.mode} <what to do> — give a prompt for the "${parsed.mode}" mode`,
993
+ 'warning',
994
+ );
995
+ else
996
+ ctx.ui.notify?.(
997
+ 'Usage: /delegate [--harness=claude|codex|opencode|amp] [--mode=…] [--model=…] [--scope=…] <prompt>',
998
+ 'warning',
999
+ );
1000
+ return;
1001
+ }
1002
+ const modeForDisplay = parsed.mode ?? 'general';
1003
+ const harnessForDisplay = harnessName;
1004
+
1005
+ const feed: FeedEntry[] = [];
1006
+ let thinkingChars = 0;
1007
+ let liveTail = '';
1008
+ let requestRender: (() => void) | null = null;
1009
+ const getEntries = (): FeedEntry[] => {
1010
+ const entries = [...feed.slice(-12)];
1011
+ if (thinkingChars > 0) entries.push({ kind: 'thinking', text: '💭 thinking…' });
1012
+ if (liveTail) entries.push({ kind: 'text', text: liveTail.slice(-200) });
1013
+ return entries;
1014
+ };
1015
+ let chipActivity = '';
1016
+ let chipLastPush = 0;
1017
+ const pushChip = () => {
1018
+ if (!ctx.hasUI) return;
1019
+ const now = Date.now();
1020
+ if (now - chipLastPush < 500) return;
1021
+ chipLastPush = now;
1022
+ const theme = ctx.ui.theme;
1023
+ const activity = chipActivity ? ` ${chipActivity}` : theme.fg('dim', ' running…');
1024
+ ctx.ui.setStatus(
1025
+ 'delegate',
1026
+ theme.fg('accent', '●') + theme.fg('dim', ` ${harnessForDisplay} ${modeForDisplay}`) + activity,
1027
+ );
1028
+ };
1029
+ const onActivity = (ev: ActivityEvent) => {
1030
+ if (ev.kind === 'tool_input') {
1031
+ chipActivity = `▶ ${formatToolUse(ev.name, ev.input)}`;
1032
+ feed.push({ kind: 'tool', text: formatToolUse(ev.name, ev.input) });
1033
+ if (feed.length > 40) feed.splice(0, feed.length - 40);
1034
+ } else if (ev.kind === 'tool_result') {
1035
+ if (chipActivity.startsWith('▶')) chipActivity += ev.isError ? ' ✗' : ' ✓';
1036
+ const last = feed.length - 1;
1037
+ if (last >= 0 && feed[last].kind === 'tool') feed[last] = { ...feed[last], ok: ev.isError ? false : true };
1038
+ } else if (ev.kind === 'thinking') {
1039
+ chipActivity = '💭 thinking…';
1040
+ thinkingChars += ev.chars;
1041
+ }
1042
+ pushChip();
1043
+ requestRender?.();
1044
+ };
1045
+ const ac = new AbortController();
1046
+ let cancelled = false;
1047
+ const runState: { error: Error | null } = { error: null };
1048
+ const runId = ++activeRunId;
1049
+ const clearActive = () => {
1050
+ if (activeOverlay?.runId === runId) activeOverlay = null;
1051
+ };
1052
+ const run = delegate(pi, ctx, {
1053
+ harness: harnessName,
1054
+ task: resolved.task,
1055
+ mode: parsed.mode,
1056
+ scope: resolved.scope,
1057
+ model: parsed.model,
1058
+ maxBudgetUsd: parsed.budget,
1059
+ sessionId: parsed.sessionId,
1060
+ pr: parsed.pr,
1061
+ signal: ac.signal,
1062
+ onStream: t => {
1063
+ liveTail = (liveTail + t).slice(-400);
1064
+ requestRender?.();
1065
+ },
1066
+ onActivity,
1067
+ }).catch((err: unknown) => {
1068
+ runState.error = err instanceof Error ? err : new Error(String(err));
1069
+ return null;
1070
+ });
1071
+
1072
+ let closeWindow: (() => void) | null = null;
1073
+ let result: Awaited<ReturnType<typeof delegate>> | null = null;
1074
+ if (ctx.hasUI) {
1075
+ let overlayHandle: OverlayHandle | null = null;
1076
+ const uiPromise = ctx.ui
1077
+ .custom(
1078
+ (tui, theme, _kb, done) => {
1079
+ requestRender = () => tui.requestRender();
1080
+ closeWindow = () => done(undefined);
1081
+ return progressWindow(tui, theme, {
1082
+ mode: `${harnessForDisplay} ${modeForDisplay}`,
1083
+ model:
1084
+ parsed.model ?? template?.model ?? loadConfig().harnesses[harnessName]?.model ?? loadConfig().model,
1085
+ startedAt: Date.now(),
1086
+ getEntries,
1087
+ dangerous: isDanger,
1088
+ onCancel: () => {
1089
+ cancelled = true;
1090
+ ac.abort();
1091
+ },
1092
+ onMinimize: () => {
1093
+ overlayHandle?.setHidden(true);
1094
+ overlayHandle?.unfocus();
1095
+ },
1096
+ });
1097
+ },
1098
+ {
1099
+ overlay: true,
1100
+ overlayOptions: { width: '70%', maxHeight: '60%', anchor: 'top-center' },
1101
+ onHandle: h => {
1102
+ overlayHandle = h;
1103
+ activeOverlay = { show: () => h.setHidden(false), focus: () => h.focus(), runId };
1104
+ h.focus();
1105
+ },
1106
+ },
1107
+ )
1108
+ .catch(() => {});
1109
+ result = await run;
1110
+ await closeWhenMounted(() => closeWindow, 2000);
1111
+ await uiPromise;
1112
+ } else {
1113
+ result = await run;
1114
+ }
1115
+ clearActive();
1116
+ if (cancelled || !result) {
1117
+ if (ctx.hasUI) ctx.ui.setStatus('delegate', undefined);
1118
+ const message = runState.error ? runState.error.message : cancelled ? 'cancelled' : 'delegation failed';
1119
+ if (ctx.hasUI)
1120
+ ctx.ui.notify(`delegate ${cancelled ? 'cancelled' : 'failed'}: ${message}`, cancelled ? 'warning' : 'error');
1121
+ else process.stderr.write(`${message}\n`);
1122
+ return;
1123
+ }
1124
+ const { content, details } = result;
1125
+ const summary = summarize(content);
1126
+ const file = (details.file as string) ?? null;
1127
+ const sessionId = (details.sessionId as string) ?? null;
1128
+ const resumeHint = sessionId ? ` · resume: /delegate --resume=${sessionId} <prompt>` : '';
1129
+ const usage = details.usage as
1130
+ | {
1131
+ inputTokens?: number;
1132
+ outputTokens?: number;
1133
+ cacheCreationInputTokens?: number;
1134
+ cacheReadInputTokens?: number;
1135
+ }
1136
+ | undefined;
1137
+ const promptTokens = usage
1138
+ ? (usage.inputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0)
1139
+ : 0;
1140
+ const metrics = formatMetrics({
1141
+ numTurns: (details.numTurns as number) ?? 0,
1142
+ totalCostUsd: (details.totalCostUsd as number) ?? 0,
1143
+ promptTokens,
1144
+ contextPercent: typeof details.contextPercent === 'number' ? (details.contextPercent as number) : null,
1145
+ durationMs:
1146
+ typeof details.durationMs === 'number' && details.durationMs !== null ? (details.durationMs as number) : null,
1147
+ });
1148
+ injectReport(ctx, {
1149
+ harness: details.harness as string,
1150
+ mode: details.mode as string,
1151
+ metrics,
1152
+ body: summary.text,
1153
+ file: file ?? undefined,
1154
+ sessionId: sessionId ?? undefined,
1155
+ });
1156
+ if (ctx.hasUI) {
1157
+ ctx.ui.setStatus('delegate', undefined);
1158
+ ctx.ui.notify(`${details.harness} ${details.mode} done — ${metrics}${resumeHint} · transcript: ${file}`, 'info');
1159
+ } else process.stdout.write(`${summary.text}\n`);
1160
+ };
1161
+
1162
+ pi.registerCommand('delegate', {
1163
+ description:
1164
+ '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>',
1165
+ handler: makeHandler(),
1166
+ });
1167
+ pi.registerCommand('claude', {
1168
+ description: 'Alias for /delegate --harness=claude. Usage: /claude [--mode=...] <prompt>',
1169
+ handler: makeHandler('claude'),
1170
+ });
1171
+ pi.registerCommand('codex', {
1172
+ description: 'Alias for /delegate --harness=codex. Usage: /codex [--mode=...] <prompt>',
1173
+ handler: makeHandler('codex'),
1174
+ });
1175
+ pi.registerCommand('opencode', {
1176
+ description: 'Alias for /delegate --harness=opencode. Usage: /opencode [--mode=...] <prompt>',
1177
+ handler: makeHandler('opencode'),
1178
+ });
1179
+ pi.registerCommand('amp', {
1180
+ description: 'Alias for /delegate --harness=amp. Usage: /amp [--mode=...] <prompt>',
1181
+ handler: makeHandler('amp'),
1182
+ });
1183
+ pi.registerCommand('omp', {
1184
+ description: 'Alias for /delegate --harness=amp (omp compat). Usage: /omp [--mode=...] <prompt>',
1185
+ handler: makeHandler('amp'),
1186
+ });
1187
+
1188
+ pi.on('input', async (event, _ctx) => {
1189
+ if (event.source === 'extension') return { action: 'continue' };
1190
+ const hint = delegationHint(event.text, { autoDelegateHints: loadConfig().autoDelegateHints });
1191
+ if (!hint) return { action: 'continue' };
1192
+ return { action: 'transform', text: `${stripMarker(event.text)}\n\n${hint}` };
1193
+ });
1194
+
1195
+ pi.on('before_agent_start', async () => {
1196
+ if (!pendingReport) return;
1197
+ const report = pendingReport;
1198
+ pendingReport = null;
1199
+ return { message: { customType: 'delegate', content: report.content, display: true, details: report.details } };
1200
+ });
816
1201
  }