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.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/extensions/activity.ts +254 -0
- package/extensions/command.ts +91 -0
- package/extensions/config.ts +158 -0
- package/extensions/harnesses/amp.ts +137 -0
- package/extensions/harnesses/claude.ts +129 -0
- package/extensions/harnesses/codex.ts +172 -0
- package/extensions/harnesses/opencode.ts +138 -0
- package/extensions/harnesses/registry.ts +51 -0
- package/extensions/harnesses/types.ts +93 -0
- package/extensions/hint.ts +57 -0
- package/extensions/index.ts +816 -0
- package/extensions/progress.ts +142 -0
- package/extensions/run-claude.ts +52 -0
- package/extensions/runner.ts +120 -0
- package/extensions/stream-parse.ts +29 -0
- package/extensions/templates.ts +166 -0
- package/extensions/usage.ts +40 -0
- package/package.json +58 -0
- package/templates/amp/docs.md +11 -0
- package/templates/amp/general.md +9 -0
- package/templates/amp/implement.md +13 -0
- package/templates/amp/plan.md +18 -0
- package/templates/amp/review.md +19 -0
- package/templates/amp/security-audit.md +18 -0
- package/templates/claude/docs.md +11 -0
- package/templates/claude/general.md +8 -0
- package/templates/claude/implement.md +13 -0
- package/templates/claude/plan.md +18 -0
- package/templates/claude/review.md +19 -0
- package/templates/claude/security-audit.md +18 -0
- package/templates/codex/docs.md +11 -0
- package/templates/codex/general.md +9 -0
- package/templates/codex/implement.md +13 -0
- package/templates/codex/plan.md +18 -0
- package/templates/codex/review.md +19 -0
- package/templates/codex/security-audit.md +18 -0
- package/templates/docs.md +11 -0
- package/templates/general.md +8 -0
- package/templates/implement.md +13 -0
- package/templates/opencode/docs.md +11 -0
- package/templates/opencode/general.md +9 -0
- package/templates/opencode/implement.md +13 -0
- package/templates/opencode/plan.md +18 -0
- package/templates/opencode/review.md +19 -0
- package/templates/opencode/security-audit.md +18 -0
- package/templates/plan.md +18 -0
- package/templates/review.md +19 -0
- package/templates/security-audit.md +18 -0
- package/templates/shared/docs.md +11 -0
- package/templates/shared/general.md +8 -0
- package/templates/shared/implement.md +13 -0
- package/templates/shared/plan.md +18 -0
- package/templates/shared/review.md +19 -0
- 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.
|