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,142 @@
1
+ /**
2
+ * Live progress window for /delegate runs — a framed overlay showing the
3
+ * activity feed, thinking indicator and text tail while the delegation runs.
4
+ *
5
+ * Controls:
6
+ * - ESC twice: cancel (first press arms, second confirms within 1.5s)
7
+ * - `m`: minimize (hide the window, run continues in the background;
8
+ * re-show with /delegate watch)
9
+ */
10
+
11
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from '@earendil-works/pi-tui';
12
+ import type { Theme } from '@earendil-works/pi-coding-agent';
13
+
14
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
15
+ const SPIN_INTERVAL_MS = 100;
16
+ const MAX_VISIBLE_ENTRIES = 12;
17
+
18
+ export type FeedEntry =
19
+ | { kind: 'tool'; text: string; ok?: boolean }
20
+ | { kind: 'thinking'; text: string }
21
+ | { kind: 'text'; text: string };
22
+
23
+ export interface ProgressWindowOptions {
24
+ /** Harness name shown in title. */
25
+ harness?: string;
26
+ /** Mode name, shown in the title bar (e.g. "review"). */
27
+ mode: string;
28
+ /** Model (or alias) shown in the title bar. */
29
+ model?: string | null;
30
+ /** Epoch ms when the run started — drives the live elapsed timer. */
31
+ startedAt: number;
32
+ /** Live feed entries (tool calls, thinking, text tail). */
33
+ getEntries: () => FeedEntry[];
34
+ /** Show an "unrestricted permissions" warning banner. */
35
+ dangerous?: boolean;
36
+ /** Called when the user confirms cancel. */
37
+ onCancel: () => void;
38
+ /** Called when the user presses `m` (minimize — keep the run going). */
39
+ onMinimize: () => void;
40
+ }
41
+
42
+ export function fmtElapsed(ms: number): string {
43
+ const total = Math.max(0, Math.floor(ms / 1000));
44
+ const m = Math.floor(total / 60);
45
+ const s = total % 60;
46
+ return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`;
47
+ }
48
+
49
+ /** Style one feed entry; the returned string may contain ANSI colors. */
50
+ export function renderEntry(entry: FeedEntry, theme: Theme): string {
51
+ switch (entry.kind) {
52
+ case 'tool': {
53
+ const mark = entry.ok === undefined ? '' : entry.ok ? theme.fg('success', ' ✓') : theme.fg('error', ' ✗');
54
+ return theme.fg('accent', '▶ ') + theme.fg('muted', entry.text) + mark;
55
+ }
56
+ case 'thinking':
57
+ return theme.fg('dim', entry.text);
58
+ case 'text':
59
+ return theme.fg('text', entry.text);
60
+ }
61
+ }
62
+
63
+ /** Create the overlay component; disposes the spinner timer. */
64
+ export function progressWindow(tui: TUI, theme: Theme, opts: ProgressWindowOptions): Component & { dispose(): void } {
65
+ let frame = 0;
66
+ let armed = false;
67
+ let armTimer: ReturnType<typeof setTimeout> | null = null;
68
+ const timer = setInterval(() => {
69
+ frame++;
70
+ tui.requestRender();
71
+ }, SPIN_INTERVAL_MS);
72
+
73
+ const disarm = () => {
74
+ armed = false;
75
+ if (armTimer) {
76
+ clearTimeout(armTimer);
77
+ armTimer = null;
78
+ }
79
+ };
80
+
81
+ return {
82
+ render(width: number): string[] {
83
+ const inner = Math.max(10, width - 4);
84
+ const padTo = (s: string, w: number) => `${s}${' '.repeat(Math.max(1, w - visibleWidth(s)))}`;
85
+ const out: string[] = [];
86
+
87
+ // title bar: ⠋ <harness> <mode> · <model> · ⏱ elapsed
88
+ const harness = opts.harness ?? 'delegate';
89
+ const title = `${SPINNER[frame % SPINNER.length]} ${harness} ${opts.mode}`;
90
+ const status = [opts.model ?? '', `⏱ ${fmtElapsed(Date.now() - opts.startedAt)}`].filter(Boolean).join(' · ');
91
+ const titleStr = status ? `${title} · ${status}` : title;
92
+ const dash = '─'.repeat(Math.max(1, inner - visibleWidth(titleStr) - 2));
93
+ out.push(theme.fg('accent', `╭─ ${titleStr} ${dash}─╮`));
94
+
95
+ // danger banner
96
+ if (opts.dangerous) {
97
+ const banner = theme.fg('error', '⚠ danger — unrestricted access');
98
+ out.push(`│ ${padTo(banner, inner)} │`);
99
+ }
100
+
101
+ // feed
102
+ for (const entry of opts.getEntries().slice(-MAX_VISIBLE_ENTRIES)) {
103
+ out.push(`│ ${padTo(truncateToWidth(renderEntry(entry, theme), inner), inner)} │`);
104
+ }
105
+
106
+ // hint row (double-ESC guard + minimize)
107
+ const hint = armed
108
+ ? theme.fg('warning', 'press esc again to cancel') + theme.fg('dim', ' · m minimize')
109
+ : theme.fg('dim', 'esc cancel') + theme.fg('dim', ' · m minimize');
110
+ out.push(`│ ${padTo(hint, inner)} │`);
111
+
112
+ out.push(theme.fg('accent', `╰${'─'.repeat(Math.max(1, width - 2))}╯`));
113
+ return out;
114
+ },
115
+ handleInput(data: string): void {
116
+ if (matchesKey(data, Key.escape)) {
117
+ if (armed) {
118
+ disarm();
119
+ opts.onCancel();
120
+ } else {
121
+ armed = true;
122
+ armTimer = setTimeout(() => {
123
+ armed = false;
124
+ armTimer = null;
125
+ tui.requestRender();
126
+ }, 1500);
127
+ tui.requestRender();
128
+ }
129
+ } else if (data === 'm') {
130
+ disarm();
131
+ opts.onMinimize();
132
+ }
133
+ },
134
+ invalidate(): void {
135
+ // stateless render — nothing to clear
136
+ },
137
+ dispose(): void {
138
+ clearInterval(timer);
139
+ disarm();
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,52 @@
1
+ /** @deprecated use extensions/runner.ts + harnesses/claude.ts directly */
2
+ import type { ActivityEvent, StreamedResult } from './harnesses/types.ts';
3
+ import { claudeHarness } from './harnesses/claude.ts';
4
+ import { runHarness } from './runner.ts';
5
+
6
+ export interface RunClaudeOptions {
7
+ prompt: string;
8
+ cwd: string;
9
+ permissionMode: string;
10
+ model?: string;
11
+ maxBudgetUsd?: number;
12
+ addDirs?: string[];
13
+ signal?: AbortSignal;
14
+ timeoutMs?: number;
15
+ resumeSessionId?: string;
16
+ onStream?: (text: string) => void;
17
+ onActivity?: (ev: ActivityEvent) => void;
18
+ }
19
+
20
+ export interface ClaudeResult extends StreamedResult {
21
+ streamedText: string;
22
+ }
23
+
24
+ export const DEFAULT_TIMEOUT_MS = 600_000;
25
+
26
+ function mapPerm(mode: string): 'readonly' | 'edit' | 'danger' {
27
+ if (mode === 'plan') return 'readonly';
28
+ if (mode === 'bypassPermissions') return 'danger';
29
+ return 'edit';
30
+ }
31
+
32
+ export function runClaude(opts: RunClaudeOptions): Promise<ClaudeResult> {
33
+ const perm = mapPerm(opts.permissionMode);
34
+ const nativePerm = perm === 'readonly' && opts.permissionMode !== 'plan' ? opts.permissionMode : perm === 'danger' && opts.permissionMode !== 'bypassPermissions' ? opts.permissionMode : undefined;
35
+ // normalize: if caller passed a non-standard mode, preserve as native
36
+ const isStandard = opts.permissionMode === 'plan' || opts.permissionMode === 'acceptEdits' || opts.permissionMode === 'bypassPermissions';
37
+ return runHarness({
38
+ harness: claudeHarness,
39
+ prompt: opts.prompt,
40
+ cwd: opts.cwd,
41
+ permission: perm,
42
+ nativePermission: isStandard ? undefined : opts.permissionMode,
43
+ model: opts.model,
44
+ maxBudgetUsd: opts.maxBudgetUsd,
45
+ addDirs: opts.addDirs,
46
+ signal: opts.signal,
47
+ timeoutMs: opts.timeoutMs,
48
+ resumeSessionId: opts.resumeSessionId,
49
+ onStream: opts.onStream,
50
+ onActivity: opts.onActivity,
51
+ }).then((r) => ({ ...r, streamedText: r.streamedText }));
52
+ }
@@ -0,0 +1,120 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import type { Harness, ParseState, StreamedResult } from './harnesses/types.ts';
4
+
5
+ export interface RunHarnessOptions {
6
+ harness: Harness;
7
+ prompt: string;
8
+ cwd: string;
9
+ permission: import('./harnesses/types.ts').NormalizedPermission;
10
+ nativePermission?: string;
11
+ model?: string;
12
+ maxBudgetUsd?: number;
13
+ addDirs?: string[];
14
+ signal?: AbortSignal;
15
+ timeoutMs?: number;
16
+ resumeSessionId?: string;
17
+ onStream?: (text: string) => void;
18
+ onActivity?: (ev: import('./harnesses/types.ts').ActivityEvent) => void;
19
+ }
20
+
21
+ export interface HarnessResult extends StreamedResult {
22
+ streamedText: string;
23
+ harness: string;
24
+ }
25
+
26
+ import { DEFAULT_TIMEOUT_MS } from './harnesses/types.ts';
27
+
28
+ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
29
+ return new Promise((resolve, reject) => {
30
+ const args = opts.harness.buildArgs({
31
+ prompt: opts.prompt,
32
+ cwd: opts.cwd,
33
+ permission: opts.permission,
34
+ nativePermission: opts.nativePermission,
35
+ model: opts.model,
36
+ maxBudgetUsd: opts.maxBudgetUsd,
37
+ addDirs: opts.addDirs,
38
+ resumeSessionId: opts.resumeSessionId,
39
+ });
40
+
41
+ const proc = spawn(opts.harness.binary, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
42
+
43
+ const state: ParseState = { streamedText: '', activities: [], result: null, _harness: {} };
44
+ let stderr = '';
45
+ let settled = false;
46
+ let firstTokenAt: number | null = null;
47
+ const startAt = Date.now();
48
+
49
+ const finish = (r: StreamedResult) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ clearTimeout(timer);
53
+ const ttft = firstTokenAt !== null ? firstTokenAt - startAt : r.ttftMs;
54
+ resolve({ ...r, ttftMs: ttft, streamedText: state.streamedText, harness: opts.harness.name });
55
+ };
56
+ const fail = (err: Error) => {
57
+ if (settled) return;
58
+ settled = true;
59
+ clearTimeout(timer);
60
+ reject(err);
61
+ };
62
+
63
+ const rl = createInterface({ input: proc.stdout });
64
+ rl.on('line', (line) => {
65
+ const outcome = opts.harness.parseLine(line, state);
66
+ if (outcome.streamedText) {
67
+ if (firstTokenAt === null) firstTokenAt = Date.now();
68
+ state.streamedText += outcome.streamedText;
69
+ opts.onStream?.(outcome.streamedText);
70
+ }
71
+ if (outcome.activities) {
72
+ for (const a of outcome.activities) {
73
+ state.activities.push(a);
74
+ opts.onActivity?.(a);
75
+ }
76
+ }
77
+ if (outcome.result) {
78
+ // merge streamedText into result if empty
79
+ if (!outcome.result.result) outcome.result.result = state.streamedText;
80
+ state.result = outcome.result;
81
+ }
82
+ });
83
+
84
+ proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
85
+ proc.on('close', (code) => {
86
+ // Don't synthesize fallback on non-zero exit without explicit result — surface the error
87
+ if (code !== 0 && !state.result) {
88
+ fail(new Error(stderr.trim() || `${opts.harness.binary} exited with code ${code}`));
89
+ return;
90
+ }
91
+ const final = opts.harness.extractResult(state);
92
+ if (final) {
93
+ if (!final.result) final.result = state.streamedText;
94
+ finish(final);
95
+ } else if (code !== 0) {
96
+ fail(new Error(stderr.trim() || `${opts.harness.binary} exited with code ${code}`));
97
+ } else {
98
+ fail(new Error(`${opts.harness.binary} finished without emitting a result`));
99
+ }
100
+ });
101
+ proc.on('error', (err) => {
102
+ fail(new Error(`failed to start ${opts.harness.binary}: ${err.message}`));
103
+ });
104
+
105
+ const timer = setTimeout(() => {
106
+ proc.kill('SIGKILL');
107
+ fail(new Error(`${opts.harness.binary} timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
108
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
109
+ timer.unref?.();
110
+
111
+ opts.signal?.addEventListener(
112
+ 'abort',
113
+ () => {
114
+ proc.kill('SIGKILL');
115
+ fail(new Error('cancelled'));
116
+ },
117
+ { once: true },
118
+ );
119
+ });
120
+ }
@@ -0,0 +1,29 @@
1
+ /** @deprecated use extensions/harnesses/claude.ts parse directly */
2
+ export type { StreamedUsage, StreamedResult, ActivityEvent, StreamParseOutcome, ParseState, ParseOutcome } from './harnesses/types.ts';
3
+ import type { ParseState, StreamParseOutcome } from './harnesses/types.ts';
4
+ import { parseClaudeLine } from './harnesses/claude.ts';
5
+
6
+ export function parseStreamLines(lines: Iterable<string>): StreamParseOutcome {
7
+ const state: ParseState = { streamedText: '', activities: [], result: null };
8
+ let streamedText = '';
9
+ const activities: StreamParseOutcome['activities'] = [];
10
+ let result: StreamParseOutcome['result'] = null;
11
+ for (const line of lines) {
12
+ const out = parseClaudeLine(line, state);
13
+ if (out.streamedText) {
14
+ streamedText += out.streamedText;
15
+ state.streamedText += out.streamedText;
16
+ }
17
+ if (out.activities) {
18
+ for (const a of out.activities) {
19
+ activities.push(a);
20
+ state.activities.push(a);
21
+ }
22
+ }
23
+ if (out.result) {
24
+ result = out.result;
25
+ state.result = out.result;
26
+ }
27
+ }
28
+ return { streamedText, result, activities };
29
+ }
@@ -0,0 +1,166 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { NormalizedPermission } from './harnesses/types.ts';
6
+
7
+ export type PermissionMode =
8
+ | 'plan'
9
+ | 'acceptEdits'
10
+ | 'bypassPermissions'
11
+ | 'dontAsk'
12
+ | 'auto'
13
+ | 'manual';
14
+
15
+ const PERMISSION_MODES = new Set<PermissionMode>([
16
+ 'plan',
17
+ 'acceptEdits',
18
+ 'bypassPermissions',
19
+ 'dontAsk',
20
+ 'auto',
21
+ 'manual',
22
+ ]);
23
+
24
+ export interface DelegateTemplate {
25
+ name: string;
26
+ description: string;
27
+ permission: NormalizedPermission;
28
+ /** Native harness permission string if user used escape hatch. */
29
+ nativePermission?: string;
30
+ /** Legacy raw permissionMode for transcript compat. */
31
+ permissionMode: PermissionMode;
32
+ model?: string;
33
+ maxBudgetUsd?: number;
34
+ skill?: string;
35
+ defaultTask?: string;
36
+ defaultScope?: string;
37
+ prompt: string;
38
+ harness?: string;
39
+ }
40
+
41
+ export function normalizePermission(raw: string | undefined, fallbackMode: string | undefined): { permission: NormalizedPermission; nativePermission?: string; permissionMode: PermissionMode } {
42
+ // Prefer normalized permission
43
+ if (raw) {
44
+ const lower = raw.trim().toLowerCase();
45
+ if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only') return { permission: 'readonly', permissionMode: 'plan' };
46
+ if (lower === 'edit' || lower === 'acceptEdits' || lower === 'accept-edits') return { permission: 'edit', permissionMode: 'acceptEdits' };
47
+ if (lower === 'danger' || lower === 'bypassPermissions' || lower === 'danger-full-access' || lower === 'danger_full_access') return { permission: 'danger', permissionMode: 'bypassPermissions' };
48
+ // Unknown native — treat as native escape hatch
49
+ return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
50
+ }
51
+ // Legacy permissionMode mapping
52
+ if (fallbackMode && PERMISSION_MODES.has(fallbackMode as PermissionMode)) {
53
+ const m = fallbackMode as PermissionMode;
54
+ if (m === 'plan') return { permission: 'readonly', permissionMode: m };
55
+ if (m === 'bypassPermissions') return { permission: 'danger', permissionMode: m };
56
+ return { permission: 'edit', permissionMode: m };
57
+ }
58
+ return { permission: 'edit', permissionMode: 'acceptEdits' };
59
+ }
60
+
61
+ /** Parse a template file: frontmatter (---\nkey: value\n---) + markdown body. */
62
+ export function parseTemplate(text: string): DelegateTemplate | null {
63
+ const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.trimStart());
64
+ if (!m) return null;
65
+
66
+ const meta: Record<string, string> = {};
67
+ for (const line of m[1].split('\n')) {
68
+ const i = line.indexOf(':');
69
+ if (i <= 0) continue;
70
+ meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
71
+ }
72
+
73
+ const name = meta.name?.trim();
74
+ if (!name) return null;
75
+
76
+ const permRaw = meta.permission?.trim();
77
+ const permModeRaw = meta.permissionMode?.trim() ?? meta.sandbox?.trim();
78
+ const norm = normalizePermission(permRaw, permModeRaw);
79
+
80
+ const budget = meta.maxBudgetUsd ? Number(meta.maxBudgetUsd) : NaN;
81
+
82
+ return {
83
+ name,
84
+ description: meta.description ?? '',
85
+ permission: norm.permission,
86
+ nativePermission: norm.nativePermission,
87
+ permissionMode: norm.permissionMode,
88
+ model: meta.model || undefined,
89
+ maxBudgetUsd: Number.isFinite(budget) && budget > 0 ? budget : undefined,
90
+ skill: meta.skill || undefined,
91
+ defaultTask: meta.defaultTask || undefined,
92
+ defaultScope: meta.defaultScope || undefined,
93
+ prompt: m[2].trim(),
94
+ harness: meta.harness || undefined,
95
+ };
96
+ }
97
+
98
+ function loadDir(dir: string, out: Map<string, DelegateTemplate>): void {
99
+ if (!existsSync(dir)) return;
100
+ for (const f of readdirSync(dir)) {
101
+ if (!f.endsWith('.md')) continue;
102
+ try {
103
+ const t = parseTemplate(readFileSync(join(dir, f), 'utf8'));
104
+ if (t) out.set(t.name, t);
105
+ } catch {
106
+ // skip unreadable files
107
+ }
108
+ }
109
+ }
110
+
111
+ export function builtinTemplatesDir(): string {
112
+ return fileURLToPath(new URL('../templates/', import.meta.url));
113
+ }
114
+
115
+ export function builtinHarnessTemplatesDir(harness: string): string {
116
+ return fileURLToPath(new URL(`../templates/${harness}/`, import.meta.url));
117
+ }
118
+
119
+ export function sharedTemplatesDir(): string {
120
+ return fileURLToPath(new URL('../templates/shared/', import.meta.url));
121
+ }
122
+
123
+ export function userTemplatesDir(harness?: string): string {
124
+ const dir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
125
+ if (harness) return join(dir, 'delegate', 'templates', harness);
126
+ return join(dir, 'delegate', 'templates');
127
+ }
128
+
129
+ export function projectTemplatesDir(cwd: string, harness?: string): string {
130
+ if (harness) return join(cwd, '.pi', 'delegate', 'templates', harness);
131
+ return join(cwd, '.pi', 'delegate', 'templates');
132
+ }
133
+
134
+ /** Legacy dirs for compat */
135
+ function legacyUserTemplatesDir(): string {
136
+ const dir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
137
+ return join(dir, 'claude-delegate', 'templates');
138
+ }
139
+ function legacyProjectTemplatesDir(cwd: string): string {
140
+ return join(cwd, '.pi', 'claude-delegate', 'templates');
141
+ }
142
+
143
+ /** Legacy root < shared < harness builtins < legacyUser < user < user/harness < legacyProject < project < project/harness (later wins). */
144
+ export function loadTemplates(cwd: string, harnessName?: string): Map<string, DelegateTemplate> {
145
+ const out = new Map<string, DelegateTemplate>();
146
+ const harness = harnessName ?? 'claude';
147
+ // legacy root builtins (templates/*.md) lowest — for migration from pi-claude-delegate
148
+ loadDir(builtinTemplatesDir(), out);
149
+ // shared canonical bodies
150
+ loadDir(sharedTemplatesDir(), out);
151
+ // harness-specific builtins override shared
152
+ loadDir(builtinHarnessTemplatesDir(harness), out);
153
+ // user globals: legacy before new so new wins
154
+ loadDir(legacyUserTemplatesDir(), out);
155
+ loadDir(userTemplatesDir(), out);
156
+ loadDir(userTemplatesDir(harness), out);
157
+ // project locals: legacy before new so new wins
158
+ loadDir(legacyProjectTemplatesDir(cwd), out);
159
+ loadDir(projectTemplatesDir(cwd), out);
160
+ loadDir(projectTemplatesDir(cwd, harness), out);
161
+ return out;
162
+ }
163
+
164
+ export function loadAllTemplates(cwd: string): Map<string, DelegateTemplate> {
165
+ return loadTemplates(cwd);
166
+ }
@@ -0,0 +1,40 @@
1
+ import type { Usage } from '@earendil-works/pi-ai';
2
+
3
+ export interface HarnessUsage {
4
+ inputTokens: number;
5
+ outputTokens: number;
6
+ cacheCreationInputTokens: number;
7
+ cacheReadInputTokens: number;
8
+ totalCostUsd: number;
9
+ }
10
+
11
+ export type ClaudeUsage = HarnessUsage;
12
+
13
+ /**
14
+ * Map harness usage/cost into pi's `Usage` shape so delegated runs appear
15
+ * in the pi footer token/cost stats and /session totals.
16
+ */
17
+ export function mapHarnessUsage(u: HarnessUsage): Usage {
18
+ const input = u.inputTokens + u.cacheCreationInputTokens;
19
+ const cacheRead = u.cacheReadInputTokens;
20
+ const output = u.outputTokens;
21
+ const totalTokens = input + output + cacheRead;
22
+ return {
23
+ input,
24
+ output,
25
+ cacheRead,
26
+ cacheWrite: 0,
27
+ totalTokens,
28
+ cost: {
29
+ input: 0,
30
+ output: 0,
31
+ cacheRead: 0,
32
+ cacheWrite: 0,
33
+ total: u.totalCostUsd,
34
+ },
35
+ };
36
+ }
37
+
38
+ export function mapClaudeUsage(u: ClaudeUsage): Usage {
39
+ return mapHarnessUsage(u);
40
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "pi-harness-delegate",
3
+ "version": "0.1.0",
4
+ "description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent — code reviews, plans, implementation, security audits, docs, or your own custom templates.",
5
+ "type": "module",
6
+ "files": [
7
+ "extensions",
8
+ "templates",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi",
15
+ "coding-agent",
16
+ "claude",
17
+ "codex",
18
+ "opencode",
19
+ "amp",
20
+ "delegate",
21
+ "subagent",
22
+ "code-review",
23
+ "harness"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "Jorge Barnaby",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/yorch/pi-harness-delegate.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/yorch/pi-harness-delegate/issues"
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "node --experimental-strip-types --test tests/**/*.test.ts"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-ai": "*",
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*",
42
+ "typebox": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-ai": "^0.84.1",
46
+ "@earendil-works/pi-coding-agent": "^0.84.1",
47
+ "@earendil-works/pi-tui": "^0.84.1",
48
+ "@types/node": "24.x",
49
+ "typebox": "^1.3.7",
50
+ "typescript": "^5.9.0"
51
+ },
52
+ "pi": {
53
+ "extensions": [
54
+ "./extensions/index.ts"
55
+ ],
56
+ "image": "https://raw.githubusercontent.com/yorch/pi-harness-delegate/main/docs/assets/claude-delegate-preview.png"
57
+ }
58
+ }
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: docs
3
+ description: Generate or update documentation. Writes files.
4
+ permission: edit
5
+ model: amp-default
6
+ ---
7
+ You are a technical writer delegated by the pi coding agent.
8
+
9
+ Write clear, accurate documentation matching the project's existing doc style
10
+ (check for README/ADRs/docs conventions first). Cover usage, gotchas, and
11
+ worked examples. Report which files you created or changed.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: general
3
+ description: General delegation — any task. Bounded to file edits unless allowDangerous is set.
4
+ permission: edit
5
+ model: amp-default
6
+ ---
7
+ You are a capable engineer delegated by the pi coding agent to handle the
8
+ following task. Work in the current repository. Follow its conventions, run
9
+ available checks, and report what you did and why.
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: implement
3
+ description: Implement a task with file edits (auto-accepted). Runs checks.
4
+ permission: edit
5
+ model: amp-default
6
+ ---
7
+ You are a senior engineer delegated by the pi coding agent to implement a
8
+ task.
9
+
10
+ Implement the task described below. Follow the repo's existing conventions.
11
+ Run the relevant checks when present (tests, typecheck, lint) and fix what
12
+ breaks. Keep changes minimal and focused on the task. Report what you changed
13
+ and the verification you ran.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: plan
3
+ description: Produce a detailed implementation plan from an intent. Read-only.
4
+ permission: readonly
5
+ model: amp-default
6
+ ---
7
+ You are a staff engineer delegated by the pi coding agent to produce a
8
+ detailed implementation plan.
9
+
10
+ Understand the current codebase first (read the relevant files). Then produce:
11
+ 1. Goal and non-goals
12
+ 2. Proposed approach, with alternatives considered and why rejected
13
+ 3. Step-by-step implementation plan: ordered steps, each naming the files to
14
+ touch and what changes in them
15
+ 4. Risks, edge cases, and testing strategy
16
+
17
+ Be concrete and reference actual files/functions in the repo. Do not edit
18
+ files — this is a plan.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: review
3
+ description: Code review of a scope (git diff, files, or the whole repo). Read-only.
4
+ permission: readonly
5
+ model: amp-default
6
+ defaultTask: Review the current git diff (staged + unstaged)
7
+ defaultScope: diff
8
+ ---
9
+ You are a senior code reviewer delegated by the pi coding agent.
10
+
11
+ Review the provided scope for:
12
+ - Correctness bugs and edge cases
13
+ - Security issues (injection, auth, secrets, unsafe deserialization)
14
+ - Performance problems
15
+ - Code style and maintainability
16
+
17
+ Be specific: cite `file:line` for every finding. Classify each finding as
18
+ Critical / Major / Minor / Nit. End with a prioritized list of the top
19
+ actions. Do not edit files — this is a review.