pi-harness-delegate 0.6.1 → 0.7.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/README.md +66 -10
- package/extensions/acp-runner.ts +98 -11
- package/extensions/activity.ts +91 -1
- package/extensions/command.ts +35 -2
- package/extensions/concurrency.ts +4 -1
- package/extensions/config.ts +114 -20
- package/extensions/engine.ts +586 -0
- package/extensions/fanout.ts +526 -0
- package/extensions/harnesses/amp.ts +7 -6
- package/extensions/harnesses/claude.ts +2 -0
- package/extensions/harnesses/codex.ts +15 -7
- package/extensions/harnesses/opencode.ts +14 -3
- package/extensions/harnesses/types.ts +8 -0
- package/extensions/history.ts +170 -0
- package/extensions/index.ts +85 -1369
- package/extensions/run-registry.ts +36 -3
- package/extensions/runner.ts +72 -10
- package/extensions/subcommands.ts +227 -0
- package/extensions/templates.ts +12 -0
- package/extensions/validate.ts +214 -0
- package/package.json +5 -4
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* `acquireRun`/`countActiveRuns` directly.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { agentDir } from './config.ts';
|
|
23
23
|
|
|
@@ -35,6 +35,23 @@ function isAlive(pid: number): boolean {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** A `.json.tmp` this old can't be an in-flight write — acquireRun renames it within microseconds. */
|
|
39
|
+
export const STALE_TMP_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Remove a leftover `<pid>-<harness>-<rand>.json.tmp` if its writer crashed between write and
|
|
43
|
+
* rename (embedded pid dead) or it's older than `STALE_TMP_MS`. A live writer's tmp is left alone.
|
|
44
|
+
*/
|
|
45
|
+
function reapTmp(full: string, name: string): void {
|
|
46
|
+
try {
|
|
47
|
+
const pid = Number(name.split('-', 1)[0]);
|
|
48
|
+
const dead = !Number.isInteger(pid) || pid <= 0 || !isAlive(pid);
|
|
49
|
+
if (dead || Date.now() - statSync(full).mtimeMs > STALE_TMP_MS) rmSync(full, { force: true });
|
|
50
|
+
} catch {
|
|
51
|
+
// best-effort (already renamed/removed by its writer, or unreadable)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
export interface RunHandle {
|
|
39
56
|
file: string;
|
|
40
57
|
}
|
|
@@ -45,7 +62,17 @@ export function acquireRun(harness: string, mode: string): RunHandle | null {
|
|
|
45
62
|
const dir = runsDir();
|
|
46
63
|
mkdirSync(dir, { recursive: true });
|
|
47
64
|
const file = join(dir, `${process.pid}-${harness}-${Math.random().toString(36).slice(2, 8)}.json`);
|
|
48
|
-
|
|
65
|
+
// Write-then-rename so a concurrent countActiveRuns() never sees a half-written entry: it
|
|
66
|
+
// treats unparseable `.json` files as corrupt and deletes them, which would silently drop a
|
|
67
|
+
// live run's registration mid-write. `.tmp` files are invisible to it (it only reads `.json`).
|
|
68
|
+
const tmp = `${file}.tmp`;
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(tmp, JSON.stringify({ pid: process.pid, harness, mode, startedAt: Date.now() }));
|
|
71
|
+
renameSync(tmp, file);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
rmSync(tmp, { force: true });
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
49
76
|
return { file };
|
|
50
77
|
} catch {
|
|
51
78
|
return null;
|
|
@@ -63,7 +90,7 @@ export function releaseRun(handle: RunHandle | null): void {
|
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
/** Count active runs across processes (optionally filtered to one harness), cleaning up
|
|
66
|
-
* entries left behind by dead processes. Returns 0 (never throws) if the registry is unreadable. */
|
|
93
|
+
* entries — and abandoned `.json.tmp` writes — left behind by dead processes. Returns 0 (never throws) if the registry is unreadable. */
|
|
67
94
|
export function countActiveRuns(harness?: string): number {
|
|
68
95
|
let files: string[];
|
|
69
96
|
try {
|
|
@@ -73,6 +100,12 @@ export function countActiveRuns(harness?: string): number {
|
|
|
73
100
|
}
|
|
74
101
|
let count = 0;
|
|
75
102
|
for (const f of files) {
|
|
103
|
+
// only complete entries count — `<entry>.json.tmp` writes (see acquireRun) are skipped, but
|
|
104
|
+
// reaped once they're demonstrably abandoned (crashed writer), so they don't pile up forever
|
|
105
|
+
if (f.endsWith('.json.tmp')) {
|
|
106
|
+
reapTmp(join(runsDir(), f), f);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
76
109
|
if (!f.endsWith('.json')) continue;
|
|
77
110
|
const full = join(runsDir(), f);
|
|
78
111
|
try {
|
package/extensions/runner.ts
CHANGED
|
@@ -21,12 +21,63 @@ export interface RunHarnessOptions {
|
|
|
21
21
|
export interface HarnessResult extends StreamedResult {
|
|
22
22
|
streamedText: string;
|
|
23
23
|
harness: string;
|
|
24
|
+
/** Set when the runner itself killed the run for exceeding `maxBudgetUsd` (host enforcement). */
|
|
25
|
+
budgetExceeded?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Host-side `maxBudgetUsd` enforcement for a harness with no native budget flag: true once the
|
|
30
|
+
* harness's own streamed running total (`totalCostUsd`) goes over the cap. A harness that reports
|
|
31
|
+
* no cost (`null`) can never trip this — `delegate()` flags that budget as unenforced instead.
|
|
32
|
+
*/
|
|
33
|
+
export function isOverBudget(
|
|
34
|
+
harness: Harness,
|
|
35
|
+
maxBudgetUsd: number | undefined,
|
|
36
|
+
r: StreamedResult | null,
|
|
37
|
+
baselineUsd = 0,
|
|
38
|
+
): boolean {
|
|
39
|
+
return r !== null && isCostOverBudget(harness, maxBudgetUsd, r.totalCostUsd, baselineUsd);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The cost-only core of `isOverBudget`. `baselineUsd` is subtracted first: a harness whose reported
|
|
44
|
+
* total is *session*-cumulative (opencode over ACP) already includes every prior turn's spend on a
|
|
45
|
+
* resume, so only the delta since this run started counts against this run's cap.
|
|
46
|
+
*/
|
|
47
|
+
export function isCostOverBudget(
|
|
48
|
+
harness: Harness,
|
|
49
|
+
maxBudgetUsd: number | undefined,
|
|
50
|
+
costUsd: number | null | undefined,
|
|
51
|
+
baselineUsd = 0,
|
|
52
|
+
): boolean {
|
|
53
|
+
return (
|
|
54
|
+
maxBudgetUsd !== undefined &&
|
|
55
|
+
harness.nativeBudget !== true &&
|
|
56
|
+
typeof costUsd === 'number' &&
|
|
57
|
+
costUsd - baselineUsd > maxBudgetUsd
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The result recorded for a run the host stopped over budget. */
|
|
62
|
+
export function budgetStoppedResult(r: StreamedResult, streamedText: string): StreamedResult {
|
|
63
|
+
return {
|
|
64
|
+
...r,
|
|
65
|
+
isError: true,
|
|
66
|
+
stopReason: 'budget_exceeded',
|
|
67
|
+
result: r.result || streamedText || '(stopped: budget exceeded)',
|
|
68
|
+
};
|
|
24
69
|
}
|
|
25
70
|
|
|
26
71
|
import { DEFAULT_TIMEOUT_MS } from './harnesses/types.ts';
|
|
27
72
|
|
|
28
73
|
export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
29
74
|
return new Promise((resolve, reject) => {
|
|
75
|
+
// Already cancelled (e.g. the user hit cancel while this run was still being set up) —
|
|
76
|
+
// never spawn a process just to kill it.
|
|
77
|
+
if (opts.signal?.aborted) {
|
|
78
|
+
reject(new Error('cancelled'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
30
81
|
const args = opts.harness.buildArgs({
|
|
31
82
|
prompt: opts.prompt,
|
|
32
83
|
cwd: opts.cwd,
|
|
@@ -48,17 +99,25 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
|
48
99
|
const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
|
|
49
100
|
const MAX_ACTIVITIES = 5000;
|
|
50
101
|
|
|
51
|
-
const finish = (r: StreamedResult) => {
|
|
102
|
+
const finish = (r: StreamedResult, budgetExceeded = false) => {
|
|
52
103
|
if (settled) return;
|
|
53
104
|
settled = true;
|
|
54
105
|
clearTimeout(timer);
|
|
106
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
55
107
|
const ttft = firstTokenAt !== null ? firstTokenAt - startAt : r.ttftMs;
|
|
56
|
-
resolve({
|
|
108
|
+
resolve({
|
|
109
|
+
...r,
|
|
110
|
+
ttftMs: ttft,
|
|
111
|
+
streamedText: state.streamedText,
|
|
112
|
+
harness: opts.harness.name,
|
|
113
|
+
...(budgetExceeded ? { budgetExceeded: true } : {}),
|
|
114
|
+
});
|
|
57
115
|
};
|
|
58
116
|
const fail = (err: Error) => {
|
|
59
117
|
if (settled) return;
|
|
60
118
|
settled = true;
|
|
61
119
|
clearTimeout(timer);
|
|
120
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
62
121
|
reject(err);
|
|
63
122
|
};
|
|
64
123
|
|
|
@@ -90,6 +149,10 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
|
90
149
|
// merge streamedText into result if empty
|
|
91
150
|
if (!outcome.result.result) outcome.result.result = state.streamedText;
|
|
92
151
|
state.result = outcome.result;
|
|
152
|
+
if (!settled && isOverBudget(opts.harness, opts.maxBudgetUsd, state.result)) {
|
|
153
|
+
proc.kill('SIGKILL');
|
|
154
|
+
finish(budgetStoppedResult(state.result, state.streamedText), true);
|
|
155
|
+
}
|
|
93
156
|
}
|
|
94
157
|
});
|
|
95
158
|
|
|
@@ -120,13 +183,12 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
|
|
|
120
183
|
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
121
184
|
timer.unref?.();
|
|
122
185
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
);
|
|
186
|
+
// Removed again in finish()/fail() — a long-lived signal (e.g. one shared by a fan-out, or the
|
|
187
|
+
// tool call's own) must not accumulate a dead listener per run.
|
|
188
|
+
function onAbort(): void {
|
|
189
|
+
proc.kill('SIGKILL');
|
|
190
|
+
fail(new Error('cancelled'));
|
|
191
|
+
}
|
|
192
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
131
193
|
});
|
|
132
194
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** `/delegate list|status|config|config init` subcommand UIs. Split out of index.ts with no behavior change. */
|
|
2
|
+
|
|
3
|
+
import { readdirSync } from 'node:fs';
|
|
4
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
|
|
6
|
+
import { aggregateSpend, formatSpend } from './activity.ts';
|
|
7
|
+
import { activeCount } from './concurrency.ts';
|
|
8
|
+
import {
|
|
9
|
+
buildConfigReport,
|
|
10
|
+
describeConfigSource,
|
|
11
|
+
getMaxConcurrent,
|
|
12
|
+
outputsDir as getOutputsDir,
|
|
13
|
+
legacyOutputsDir,
|
|
14
|
+
loadConfigWithSource,
|
|
15
|
+
nonDefaultConfig,
|
|
16
|
+
writeDelegateConfig,
|
|
17
|
+
} from './config.ts';
|
|
18
|
+
import { isProjectTrusted } from './engine.ts';
|
|
19
|
+
import { ALIASES, detectAll, getHarness, HARNESS_NAMES, isKnownHarness } from './harnesses/registry.ts';
|
|
20
|
+
import { readAllHistory } from './history.ts';
|
|
21
|
+
import { type DelegateTemplate, loadTemplates } from './templates.ts';
|
|
22
|
+
export function formatTemplateRow(t: DelegateTemplate): string {
|
|
23
|
+
const parts = [
|
|
24
|
+
t.name,
|
|
25
|
+
`[${t.permission}]`,
|
|
26
|
+
t.model ? `model=${t.model}` : '',
|
|
27
|
+
t.defaultTask ? '↳ default task' : '',
|
|
28
|
+
t.harness ? `(${t.harness})` : '',
|
|
29
|
+
];
|
|
30
|
+
return `${parts.filter(Boolean).join(' ')} — ${t.description}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function showModes(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
34
|
+
const all = new Map<string, DelegateTemplate>();
|
|
35
|
+
const trusted = isProjectTrusted(ctx);
|
|
36
|
+
// collect from all harnesses if no filter
|
|
37
|
+
if (harnessFilter) {
|
|
38
|
+
for (const [k, v] of loadTemplates(ctx.cwd, harnessFilter, trusted)) all.set(k, v);
|
|
39
|
+
} else {
|
|
40
|
+
for (const h of [...HARNESS_NAMES, 'shared']) {
|
|
41
|
+
for (const [k, v] of loadTemplates(ctx.cwd, h, trusted)) if (!all.has(k)) all.set(k, v);
|
|
42
|
+
}
|
|
43
|
+
// also load without harness param
|
|
44
|
+
for (const [k, v] of loadTemplates(ctx.cwd, undefined, trusted)) if (!all.has(k)) all.set(k, v);
|
|
45
|
+
}
|
|
46
|
+
const rows = [...all.values()].map(formatTemplateRow);
|
|
47
|
+
if (!ctx.hasUI) {
|
|
48
|
+
process.stdout.write(`${rows.join('\n')}\n`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
52
|
+
let offset = 0;
|
|
53
|
+
const height = 12;
|
|
54
|
+
return {
|
|
55
|
+
render(width: number): string[] {
|
|
56
|
+
const header = theme.fg(
|
|
57
|
+
'accent',
|
|
58
|
+
`delegate — modes${harnessFilter ? ` (${harnessFilter})` : ''} (↑↓ scroll · any key to close)`,
|
|
59
|
+
);
|
|
60
|
+
const visible = rows.slice(offset, offset + height);
|
|
61
|
+
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
62
|
+
},
|
|
63
|
+
handleInput(data: string): void {
|
|
64
|
+
if (matchesKey(data, Key.up) && offset > 0) {
|
|
65
|
+
offset--;
|
|
66
|
+
tui.requestRender();
|
|
67
|
+
} else if (matchesKey(data, Key.down) && offset < rows.length - 1) {
|
|
68
|
+
offset++;
|
|
69
|
+
tui.requestRender();
|
|
70
|
+
} else {
|
|
71
|
+
done(undefined);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
invalidate() {},
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function showStatus(ctx: ExtensionContext, harnessFilter?: string): Promise<void> {
|
|
80
|
+
const { config: cfg, source } = loadConfigWithSource();
|
|
81
|
+
const detection = await detectAll();
|
|
82
|
+
const trusted = isProjectTrusted(ctx);
|
|
83
|
+
const allHarnesses = harnessFilter ? [harnessFilter].filter(h => isKnownHarness(h)) : HARNESS_NAMES;
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
lines.push(`delegate — status${harnessFilter ? ` (${harnessFilter})` : ''}`);
|
|
86
|
+
lines.push(...describeConfigSource(source));
|
|
87
|
+
lines.push(`defaultHarness: ${cfg.defaultHarness} · defaultMode: ${cfg.defaultMode} · model: ${cfg.model ?? '—'}`);
|
|
88
|
+
lines.push(
|
|
89
|
+
`maxConcurrent: ${typeof cfg.maxConcurrent === 'number' ? cfg.maxConcurrent : JSON.stringify(cfg.maxConcurrent)} · maxTranscripts: ${cfg.maxTranscripts}`,
|
|
90
|
+
);
|
|
91
|
+
lines.push(
|
|
92
|
+
trusted
|
|
93
|
+
? 'project trust: trusted — project-local templates (.pi/delegate/templates/) are loaded'
|
|
94
|
+
: "project trust: untrusted — project-local templates skipped (trust this project via pi's trust prompt, or set defaultProjectTrust, to load them)",
|
|
95
|
+
);
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push('harness binary ok version outputs templates active');
|
|
98
|
+
lines.push('─'.repeat(78));
|
|
99
|
+
for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
|
|
100
|
+
const det = detection[h] ?? { ok: false };
|
|
101
|
+
const harness = getHarness(h);
|
|
102
|
+
const bin = harness?.binary ?? h;
|
|
103
|
+
const ver = det.version ? det.version.slice(0, 18) : det.hint ? '—' : '—';
|
|
104
|
+
const ok = det.ok ? '✓' : '✗';
|
|
105
|
+
let outputs = 0;
|
|
106
|
+
try {
|
|
107
|
+
outputs = readdirSync(getOutputsDir(h)).filter(f => f.endsWith('.md')).length;
|
|
108
|
+
} catch {}
|
|
109
|
+
let templates = 0;
|
|
110
|
+
try {
|
|
111
|
+
templates = loadTemplates(ctx.cwd, h, trusted).size;
|
|
112
|
+
} catch {}
|
|
113
|
+
// cross-process count via the file registry, combined with the in-process counter as a fallback
|
|
114
|
+
const active = activeCount(h);
|
|
115
|
+
const cap = getMaxConcurrent(cfg, h);
|
|
116
|
+
const activeCol = `${active}/${cap > 0 ? cap : '∞'}`;
|
|
117
|
+
const hint = !det.ok && det.hint ? ` ← ${det.hint}` : '';
|
|
118
|
+
lines.push(
|
|
119
|
+
`${h.padEnd(20)} ${bin.padEnd(8)} ${ok.padEnd(3)} ${ver.padEnd(20)} ${String(outputs).padEnd(8)} ${String(templates).padEnd(10)} ${activeCol}${hint}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const historyEntries = harnessFilter ? readAllHistory().filter(e => e.harness === harnessFilter) : readAllHistory();
|
|
123
|
+
const spend = aggregateSpend(historyEntries.map(e => ({ harness: e.harness, cost: e.cost })));
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push('spend:');
|
|
126
|
+
for (const h of harnessFilter ? allHarnesses : HARNESS_NAMES) {
|
|
127
|
+
const s = spend.byHarness[h];
|
|
128
|
+
lines.push(` ${h}: ${s ? formatSpend(s) : '$0.000 over 0 run(s)'}`);
|
|
129
|
+
}
|
|
130
|
+
if (!harnessFilter) lines.push(` total: ${formatSpend(spend.total)}`);
|
|
131
|
+
if (!harnessFilter) {
|
|
132
|
+
const globalCap = getMaxConcurrent(cfg);
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(
|
|
135
|
+
`global active: ${activeCount()}/${globalCap > 0 ? globalCap : '∞'} · aliases: ${
|
|
136
|
+
Object.entries(ALIASES)
|
|
137
|
+
.map(([k, v]) => `${k}→${v}`)
|
|
138
|
+
.join(', ') || '—'
|
|
139
|
+
}`,
|
|
140
|
+
);
|
|
141
|
+
lines.push(`outputs dir: ${getOutputsDir()} (plus ${legacyOutputsDir()} legacy)`);
|
|
142
|
+
}
|
|
143
|
+
if (!ctx.hasUI) {
|
|
144
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
148
|
+
let offset = 0;
|
|
149
|
+
const height = 14;
|
|
150
|
+
return {
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
const header = theme.fg(
|
|
153
|
+
'accent',
|
|
154
|
+
`delegate status${harnessFilter ? ` — ${harnessFilter}` : ''} (↑↓ scroll · any key to close)`,
|
|
155
|
+
);
|
|
156
|
+
const visible = lines.slice(offset, offset + height);
|
|
157
|
+
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
158
|
+
},
|
|
159
|
+
handleInput(data: string): void {
|
|
160
|
+
if (matchesKey(data, Key.up) && offset > 0) {
|
|
161
|
+
offset--;
|
|
162
|
+
tui.requestRender();
|
|
163
|
+
} else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
164
|
+
offset++;
|
|
165
|
+
tui.requestRender();
|
|
166
|
+
} else done(undefined);
|
|
167
|
+
},
|
|
168
|
+
invalidate() {},
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `/delegate config` — the discoverability gap `/delegate status`'s provenance line only hints at:
|
|
175
|
+
* shows exactly what was read from `settings.json` (or why it wasn't) plus the effective config
|
|
176
|
+
* with defaults filled in, formatted as a paste-ready JSON block under the `delegate` key. Print-
|
|
177
|
+
* only — writing is a separate, explicit action (`/delegate config init`, below), never triggered
|
|
178
|
+
* from this default view.
|
|
179
|
+
*/
|
|
180
|
+
export async function showConfig(ctx: ExtensionContext): Promise<void> {
|
|
181
|
+
const result = loadConfigWithSource();
|
|
182
|
+
const lines = ['delegate — config', '', ...buildConfigReport(result)];
|
|
183
|
+
if (!ctx.hasUI) {
|
|
184
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
188
|
+
let offset = 0;
|
|
189
|
+
const height = 20;
|
|
190
|
+
return {
|
|
191
|
+
render(width: number): string[] {
|
|
192
|
+
const header = theme.fg('accent', `delegate config — ${result.source.file} (↑↓ scroll · any key to close)`);
|
|
193
|
+
const visible = lines.slice(offset, offset + height);
|
|
194
|
+
return [header, ...visible.map(l => theme.fg('muted', truncateToWidth(l, width)))];
|
|
195
|
+
},
|
|
196
|
+
handleInput(data: string): void {
|
|
197
|
+
if (matchesKey(data, Key.up) && offset > 0) {
|
|
198
|
+
offset--;
|
|
199
|
+
tui.requestRender();
|
|
200
|
+
} else if (matchesKey(data, Key.down) && offset < lines.length - 1) {
|
|
201
|
+
offset++;
|
|
202
|
+
tui.requestRender();
|
|
203
|
+
} else done(undefined);
|
|
204
|
+
},
|
|
205
|
+
invalidate() {},
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `/delegate config init` — the one place this extension ever writes to `settings.json`, and only
|
|
212
|
+
* because a human explicitly typed this subcommand. Writes the current effective config (defaults
|
|
213
|
+
* merged with whatever was already on disk, minus anything still at its default — pinning defaults would
|
|
214
|
+
* stop later releases' default changes reaching the user) into the `delegate` key via `writeDelegateConfig()`
|
|
215
|
+
* (read-modify-write, atomic, refuses on an unparseable file rather than clobbering it). This is
|
|
216
|
+
* also the practical fix for the legacy-`claudeDelegate`-only gap `describeConfigSource` warns
|
|
217
|
+
* about: writing an explicit `delegate` key (with the correctly-resolved values already folded
|
|
218
|
+
* in — the legacy migration already ran before this point) makes it win from then on, without
|
|
219
|
+
* this command ever touching or deleting the old `claudeDelegate` key itself.
|
|
220
|
+
*/
|
|
221
|
+
export async function initConfig(ctx: ExtensionContext): Promise<void> {
|
|
222
|
+
const result = loadConfigWithSource();
|
|
223
|
+
const write = writeDelegateConfig(nonDefaultConfig(result.config));
|
|
224
|
+
const msg = write.ok ? `✓ ${write.message}` : `✗ ${write.message}`;
|
|
225
|
+
if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
|
|
226
|
+
else ctx.ui.notify?.(msg, write.ok ? 'info' : 'warning');
|
|
227
|
+
}
|
package/extensions/templates.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface DelegateTemplate {
|
|
|
30
30
|
defaultScope?: string;
|
|
31
31
|
/** Host-run shell command executed after the harness exits to check its claims (e.g. `bun test`). */
|
|
32
32
|
verify?: string;
|
|
33
|
+
/** Extra directories the harness may access (`addDirs: ../shared, /opt/lib` — comma-separated). */
|
|
34
|
+
addDirs?: string[];
|
|
33
35
|
prompt: string;
|
|
34
36
|
harness?: string;
|
|
35
37
|
}
|
|
@@ -65,6 +67,15 @@ export function normalizePermission(
|
|
|
65
67
|
return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** Comma-separated frontmatter list (`a, b`) — undefined when absent or empty. */
|
|
71
|
+
function parseList(raw: string | undefined): string[] | undefined {
|
|
72
|
+
const items = (raw ?? '')
|
|
73
|
+
.split(',')
|
|
74
|
+
.map(s => s.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
return items.length > 0 ? items : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
/** Parse a template file: frontmatter (---\nkey: value\n---) + markdown body. */
|
|
69
80
|
export function parseTemplate(text: string): DelegateTemplate | null {
|
|
70
81
|
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.trimStart());
|
|
@@ -98,6 +109,7 @@ export function parseTemplate(text: string): DelegateTemplate | null {
|
|
|
98
109
|
defaultTask: meta.defaultTask || undefined,
|
|
99
110
|
defaultScope: meta.defaultScope || undefined,
|
|
100
111
|
verify: meta.verify || undefined,
|
|
112
|
+
addDirs: parseList(meta.addDirs),
|
|
101
113
|
prompt: m[2].trim(),
|
|
102
114
|
harness: meta.harness || undefined,
|
|
103
115
|
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation for values that end up on a harness's (or `gh`'s) command line.
|
|
3
|
+
*
|
|
4
|
+
* `sessionId`, `model`, and `pr` all reach `spawn()`/`pi.exec()` argv — no shell, so no shell
|
|
5
|
+
* injection, but a value starting with `-` would be parsed by the target CLI as a *flag* (argument
|
|
6
|
+
* injection: e.g. a `sessionId` of `--dangerously-bypass-…` or a `pr` of `--repo=evil/x`). These
|
|
7
|
+
* values are model-settable on the `delegate` tool, and the model's context is attacker-influenceable,
|
|
8
|
+
* so they're validated once at the shared `delegate()` entry (both tool and `/delegate` paths) and
|
|
9
|
+
* up front on fan-out — never left to each harness's `buildArgs`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { realpathSync } from 'node:fs';
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
14
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
15
|
+
|
|
16
|
+
const SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
17
|
+
const PR_NUMBER_RE = /^\d{1,10}$/;
|
|
18
|
+
const PR_SHORTHAND_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}#\d{1,10}$/;
|
|
19
|
+
// http(s)://<host>/<owner>/<repo>/pull/<n>[/files|?…|#…] — any host (GitHub Enterprise), but no
|
|
20
|
+
// userinfo (`user:pass@`) and nothing that isn't actually a pull-request URL.
|
|
21
|
+
const PR_URL_RE = /^https?:\/\/[^/@\s]+\/[^/\s]+\/[^/\s]+\/pull\/\d{1,10}(?:[/?#]\S*)?$/;
|
|
22
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point
|
|
23
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/;
|
|
24
|
+
|
|
25
|
+
/** Returns an error message, or null when `id` is an acceptable session id. */
|
|
26
|
+
export function sessionIdError(id: string): string | null {
|
|
27
|
+
if (id.startsWith('-') || !SESSION_ID_RE.test(id))
|
|
28
|
+
return `invalid sessionId ${JSON.stringify(id.slice(0, 60))} — expected 1-128 chars of [A-Za-z0-9._:-], not starting with "-" (use the session id from a previous run's details)`;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Returns an error message, or null when `model` is an acceptable model name. */
|
|
33
|
+
export function modelError(model: string): string | null {
|
|
34
|
+
if (model.length === 0 || model.length > 200 || model.startsWith('-') || CONTROL_RE.test(model))
|
|
35
|
+
return `invalid model ${JSON.stringify(model.slice(0, 60))} — must be a non-empty name, not starting with "-"`;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns an error message, or null when `pr` is a PR number, an http(s) pull-request URL
|
|
40
|
+
* (`<host>/<owner>/<repo>/pull/<n>`, no userinfo), or `owner/repo#n`. */
|
|
41
|
+
export function prError(pr: string): string | null {
|
|
42
|
+
const bad = `invalid pr ${JSON.stringify(pr.slice(0, 80))} — expected a PR number, an http(s) PR URL, or owner/repo#123`;
|
|
43
|
+
if (pr.startsWith('-') || CONTROL_RE.test(pr)) return bad;
|
|
44
|
+
if (PR_NUMBER_RE.test(pr) || PR_SHORTHAND_RE.test(pr)) return null;
|
|
45
|
+
if (PR_URL_RE.test(pr)) return null;
|
|
46
|
+
return bad;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns an error message, or null when `dir` is an acceptable extra directory path. Judged on its
|
|
51
|
+
* *resolved* form, which is what actually reaches argv (`mergeAddDirs` resolves every entry against
|
|
52
|
+
* cwd): an absolute path can't be parsed as a flag, so a legitimate relative dir like `-foo`
|
|
53
|
+
* (→ `<cwd>/-foo`) is fine. Empty, oversized, and control-character entries are still rejected.
|
|
54
|
+
*/
|
|
55
|
+
export function addDirError(dir: string, cwd: string = process.cwd()): string | null {
|
|
56
|
+
const bad = `invalid addDirs entry ${JSON.stringify(dir.slice(0, 80))} — must be a non-empty path without control characters`;
|
|
57
|
+
if (dir.length === 0 || dir.length > 4096 || CONTROL_RE.test(dir)) return bad;
|
|
58
|
+
const abs = resolve(cwd, dir);
|
|
59
|
+
if (!isAbsolute(abs) || abs.startsWith('-')) return bad;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ValidatableInputs {
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
model?: string;
|
|
66
|
+
pr?: string;
|
|
67
|
+
addDirs?: string[];
|
|
68
|
+
/** What relative `addDirs` resolve against (the run's cwd). */
|
|
69
|
+
cwd?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Throws a clear Error on the first invalid argv-bound input; returns normally otherwise. */
|
|
73
|
+
export function validateDelegateInputs(inputs: ValidatableInputs): void {
|
|
74
|
+
const errors = [
|
|
75
|
+
inputs.sessionId !== undefined ? sessionIdError(inputs.sessionId) : null,
|
|
76
|
+
inputs.model !== undefined ? modelError(inputs.model) : null,
|
|
77
|
+
inputs.pr !== undefined ? prError(inputs.pr) : null,
|
|
78
|
+
...(inputs.addDirs ?? []).map(d => addDirError(d, inputs.cwd)),
|
|
79
|
+
];
|
|
80
|
+
const first = errors.find((e): e is string => e !== null);
|
|
81
|
+
if (first) throw new Error(first);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type ConfirmCtx = Pick<ExtensionContext, 'hasUI'> & { ui?: { confirm?: ExtensionContext['ui']['confirm'] } };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The one danger-confirmation primitive shared by the tool and command paths: with a UI, ask via
|
|
88
|
+
* `ctx.ui.confirm` (a decline or a throwing dialog counts as "no"); without one there is nobody to
|
|
89
|
+
* ask, so fail closed. Resolves only on an explicit approval; throws the caller's message otherwise.
|
|
90
|
+
*/
|
|
91
|
+
async function askDangerConfirmation(
|
|
92
|
+
ctx: ConfirmCtx,
|
|
93
|
+
opts: { task: string; body: string; noUiError: string; declinedError: string },
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (!ctx.hasUI || typeof ctx.ui?.confirm !== 'function') throw new Error(opts.noUiError);
|
|
96
|
+
const task = opts.task.length > 200 ? `${opts.task.slice(0, 199)}…` : opts.task;
|
|
97
|
+
let ok = false;
|
|
98
|
+
try {
|
|
99
|
+
ok = await ctx.ui.confirm('Allow dangerous delegation?', `${opts.body}\n\nTask: ${task}`);
|
|
100
|
+
} catch {
|
|
101
|
+
ok = false;
|
|
102
|
+
}
|
|
103
|
+
if (!ok) throw new Error(opts.declinedError);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Gate a model-requested `allowDangerous: true` on the `delegate` tool behind a human: a tool
|
|
108
|
+
* param is model-settable (prompt-injection reachable), and `danger` means an unrestricted
|
|
109
|
+
* harness, so the model alone must never be able to grant it. With a UI, ask via
|
|
110
|
+
* `ctx.ui.confirm`; without one there is nobody to ask, so fail closed with a clear error. The
|
|
111
|
+
* `/delegate` command path uses `confirmDangerousCommand` instead.
|
|
112
|
+
*/
|
|
113
|
+
export async function confirmDangerousToolCall(
|
|
114
|
+
ctx: ConfirmCtx,
|
|
115
|
+
summary: { harness?: string; mode?: string; task: string },
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const target = `${summary.harness ?? 'default harness'} ${summary.mode ?? 'default mode'}`;
|
|
118
|
+
await askDangerConfirmation(ctx, {
|
|
119
|
+
task: summary.task,
|
|
120
|
+
body: `The agent wants to run ${target} with DANGER permission (unrestricted: no sandbox, no approval prompts).`,
|
|
121
|
+
noUiError: `allowDangerous requested for ${target}, but there is no interactive UI to confirm it with — refusing (danger permission needs a human's explicit approval; run it from an interactive session)`,
|
|
122
|
+
declinedError: `allowDangerous for ${target} was declined by the user`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Gate a human-typed `/delegate --allow-dangerous` behind the same interactive confirm. A human
|
|
128
|
+
* typed the flag, but it's one token away from an unrestricted run (and also escalates a
|
|
129
|
+
* non-danger template), so it's confirmed once more naming exactly what will run — one prompt
|
|
130
|
+
* covering every harness of a fan-out. Headless sessions never honor it: there's no one to
|
|
131
|
+
* confirm with, so it fails closed exactly like the tool path.
|
|
132
|
+
*/
|
|
133
|
+
export async function confirmDangerousCommand(
|
|
134
|
+
ctx: ConfirmCtx,
|
|
135
|
+
summary: { harnesses: string[]; mode: string; task: string },
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const n = summary.harnesses.length;
|
|
138
|
+
const names = summary.harnesses.join(', ');
|
|
139
|
+
const target = `${names} ${summary.mode}`;
|
|
140
|
+
await askDangerConfirmation(ctx, {
|
|
141
|
+
task: summary.task,
|
|
142
|
+
body: `--allow-dangerous: run "${summary.mode}" on ${n > 1 ? `all ${n} harnesses (${names})` : names} with DANGER permission — full, unrestricted permissions (no sandbox, no approval prompts). Applies to this invocation only.`,
|
|
143
|
+
noUiError: `--allow-dangerous for ${target} needs interactive confirmation, but there is no UI — refusing (a headless /delegate never runs with danger permission)`,
|
|
144
|
+
declinedError: `--allow-dangerous for ${target} was declined — nothing was run`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** realpath of `p`, or — when `p` doesn't exist yet — realpath of its nearest existing ancestor
|
|
149
|
+
* with the missing tail re-appended, so a not-yet-created dir under a symlink still resolves
|
|
150
|
+
* through that symlink. */
|
|
151
|
+
function realpathOrAncestor(p: string): string {
|
|
152
|
+
let cur = p;
|
|
153
|
+
const tail: string[] = [];
|
|
154
|
+
for (;;) {
|
|
155
|
+
try {
|
|
156
|
+
return join(realpathSync(cur), ...tail);
|
|
157
|
+
} catch {
|
|
158
|
+
const parent = dirname(cur);
|
|
159
|
+
if (parent === cur) return p;
|
|
160
|
+
tail.unshift(basename(cur));
|
|
161
|
+
cur = parent;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The model-supplied `addDirs` entries that resolve **outside** `cwd` — after `resolve()` against
|
|
168
|
+
* `cwd` and symlink resolution on both sides, so neither `..` nor a symlink inside the repo
|
|
169
|
+
* pointing out of it escapes. Empty when every entry stays inside the working directory.
|
|
170
|
+
*/
|
|
171
|
+
export function addDirsOutsideCwd(cwd: string, addDirs: string[] | undefined): string[] {
|
|
172
|
+
if (!addDirs || addDirs.length === 0) return [];
|
|
173
|
+
const root = realpathOrAncestor(resolve(cwd));
|
|
174
|
+
const out: string[] = [];
|
|
175
|
+
for (const d of addDirs) {
|
|
176
|
+
const real = realpathOrAncestor(resolve(cwd, d));
|
|
177
|
+
const rel = relative(root, real);
|
|
178
|
+
const inside = rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
179
|
+
if (!inside) out.push(real);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Gate model-requested `addDirs` on the `delegate` tool. `addDirs` widens what the harness may
|
|
186
|
+
* touch — on `codex`/`claude`/`amp` the extra dir is writable on any non-readonly run, and even a
|
|
187
|
+
* `readonly` run can read it — so a model-set entry (prompt-injection reachable, same trust model as
|
|
188
|
+
* `allowDangerous`/`verify`) may only stay inside the working directory on its own. Anything that
|
|
189
|
+
* resolves outside it needs a human's `ctx.ui.confirm`; with no UI, fail closed. Template
|
|
190
|
+
* frontmatter (trusted on-disk config) and the human-typed `/delegate --add-dir` never call this.
|
|
191
|
+
*/
|
|
192
|
+
export async function confirmToolAddDirs(
|
|
193
|
+
ctx: Pick<ExtensionContext, 'hasUI' | 'cwd'> & { ui?: { confirm?: ExtensionContext['ui']['confirm'] } },
|
|
194
|
+
addDirs: string[] | undefined,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const outside = addDirsOutsideCwd(ctx.cwd, addDirs);
|
|
197
|
+
if (outside.length === 0) return;
|
|
198
|
+
const list = outside.join(', ');
|
|
199
|
+
if (!ctx.hasUI || typeof ctx.ui?.confirm !== 'function') {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`addDirs outside the working directory requested (${list}), but there is no interactive UI to confirm it with — refusing (extra directories outside the project need a human's explicit approval)`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
let ok = false;
|
|
205
|
+
try {
|
|
206
|
+
ok = await ctx.ui.confirm(
|
|
207
|
+
'Allow access outside the project?',
|
|
208
|
+
`The agent wants the delegated harness to access directories outside ${ctx.cwd}:\n\n${outside.map(d => ` ${d}`).join('\n')}\n\nOn non-readonly runs these may be writable.`,
|
|
209
|
+
);
|
|
210
|
+
} catch {
|
|
211
|
+
ok = false;
|
|
212
|
+
}
|
|
213
|
+
if (!ok) throw new Error(`addDirs outside the working directory (${list}) were declined by the user`);
|
|
214
|
+
}
|