pi-harness-delegate 0.1.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/extensions/activity.ts +254 -0
  4. package/extensions/command.ts +91 -0
  5. package/extensions/config.ts +158 -0
  6. package/extensions/harnesses/amp.ts +137 -0
  7. package/extensions/harnesses/claude.ts +129 -0
  8. package/extensions/harnesses/codex.ts +172 -0
  9. package/extensions/harnesses/opencode.ts +138 -0
  10. package/extensions/harnesses/registry.ts +51 -0
  11. package/extensions/harnesses/types.ts +93 -0
  12. package/extensions/hint.ts +57 -0
  13. package/extensions/index.ts +816 -0
  14. package/extensions/progress.ts +142 -0
  15. package/extensions/run-claude.ts +52 -0
  16. package/extensions/runner.ts +120 -0
  17. package/extensions/stream-parse.ts +29 -0
  18. package/extensions/templates.ts +166 -0
  19. package/extensions/usage.ts +40 -0
  20. package/package.json +58 -0
  21. package/templates/amp/docs.md +11 -0
  22. package/templates/amp/general.md +9 -0
  23. package/templates/amp/implement.md +13 -0
  24. package/templates/amp/plan.md +18 -0
  25. package/templates/amp/review.md +19 -0
  26. package/templates/amp/security-audit.md +18 -0
  27. package/templates/claude/docs.md +11 -0
  28. package/templates/claude/general.md +8 -0
  29. package/templates/claude/implement.md +13 -0
  30. package/templates/claude/plan.md +18 -0
  31. package/templates/claude/review.md +19 -0
  32. package/templates/claude/security-audit.md +18 -0
  33. package/templates/codex/docs.md +11 -0
  34. package/templates/codex/general.md +9 -0
  35. package/templates/codex/implement.md +13 -0
  36. package/templates/codex/plan.md +18 -0
  37. package/templates/codex/review.md +19 -0
  38. package/templates/codex/security-audit.md +18 -0
  39. package/templates/docs.md +11 -0
  40. package/templates/general.md +8 -0
  41. package/templates/implement.md +13 -0
  42. package/templates/opencode/docs.md +11 -0
  43. package/templates/opencode/general.md +9 -0
  44. package/templates/opencode/implement.md +13 -0
  45. package/templates/opencode/plan.md +18 -0
  46. package/templates/opencode/review.md +19 -0
  47. package/templates/opencode/security-audit.md +18 -0
  48. package/templates/plan.md +18 -0
  49. package/templates/review.md +19 -0
  50. package/templates/security-audit.md +18 -0
  51. package/templates/shared/docs.md +11 -0
  52. package/templates/shared/general.md +8 -0
  53. package/templates/shared/implement.md +13 -0
  54. package/templates/shared/plan.md +18 -0
  55. package/templates/shared/review.md +19 -0
  56. package/templates/shared/security-audit.md +18 -0
@@ -0,0 +1,816 @@
1
+ /**
2
+ * pi-harness-delegate — delegate work to any harness from the pi coding agent.
3
+ *
4
+ * Registers:
5
+ * - `delegate` tool (primary) + `claude_delegate` alias
6
+ * - `/delegate` command (primary) + `/claude`, `/codex`, `/opencode`, `/amp`, `/omp` aliases
7
+ *
8
+ * Templates ship in ../templates/shared + ../templates/<harness>; users add custom ones in
9
+ * ~/.pi/agent/delegate/templates/<harness>/ (global)
10
+ * .pi/delegate/templates/<harness>/ (project)
11
+ * Legacy: ~/.pi/agent/claude-delegate/templates/, .pi/claude-delegate/templates/
12
+ *
13
+ * Config in ~/.pi/agent/settings.json: { delegate: { defaultHarness, defaultMode, ... } }
14
+ * Legacy: { claudeDelegate: {...} } is auto-migrated.
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { basename, join } from 'node:path';
20
+ 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';
26
+ import { delegationHint, stripMarker } from './hint.ts';
27
+ import { progressWindow, type FeedEntry } from './progress.ts';
28
+ import { loadTemplates, type DelegateTemplate } from './templates.ts';
29
+ 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
+
36
+ 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;
49
+ }
50
+
51
+ const activeRuns = new Map<string, number>();
52
+ let globalActiveRuns = 0;
53
+
54
+ 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;
60
+ }
61
+
62
+ 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
+ });
79
+ }
80
+
81
+ function outputsDirFor(harness: string): string {
82
+ return getOutputsDir(harness);
83
+ }
84
+
85
+ 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}`;
88
+ }
89
+
90
+ 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
+ });
130
+ }
131
+
132
+ interface HistoryEntry {
133
+ file: string;
134
+ mode: string;
135
+ harness: string;
136
+ cost: number;
137
+ sessionId: string | null;
138
+ mtime: number;
139
+ }
140
+
141
+ 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
+ }
162
+ }
163
+
164
+ 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);
188
+ }
189
+
190
+ 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
+ });
222
+ }
223
+
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
+ }
264
+ }
265
+
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;
273
+ }
274
+
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;
281
+ }
282
+
283
+ 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
+ };
463
+ }
464
+
465
+ 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 };
468
+ }
469
+
470
+ interface PendingReport {
471
+ content: string;
472
+ details: Record<string, unknown>;
473
+ }
474
+ 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
+ };
480
+ }
481
+
482
+ 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
+ });
816
+ }