pi-harness-delegate 0.6.2 → 0.7.1

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.
@@ -47,12 +47,18 @@ export function fmtElapsed(ms: number): string {
47
47
  }
48
48
 
49
49
  /**
50
- * Slice a feed down to its last `max` entries, reporting how many were dropped so the caller can
50
+ * Fit a feed into `max` rendered lines, reporting how many entries were dropped so the caller can
51
51
  * show a "+N earlier" marker instead of silently truncating with no hint older entries existed.
52
- * Pure — testable without a TUI.
52
+ * The marker itself takes one of the `max` lines: when anything is dropped only the last `max - 1`
53
+ * entries are visible, so marker + entries never exceed `max` (for `max >= 1`). `max <= 0` shows no entries (and
54
+ * reports everything hidden) — never `slice(-0)`'s "everything". Pure — testable without a TUI.
53
55
  */
54
56
  export function truncateFeed<T>(entries: T[], max: number): { visible: T[]; hiddenCount: number } {
55
- return { visible: entries.slice(-max), hiddenCount: Math.max(0, entries.length - max) };
57
+ const cap = Math.max(0, Math.floor(max));
58
+ if (entries.length <= cap) return { visible: [...entries], hiddenCount: 0 };
59
+ // one line goes to the marker; with a cap of 0 or 1 there's no room for entries at all
60
+ const keep = Math.max(0, cap - 1);
61
+ return { visible: keep > 0 ? entries.slice(-keep) : [], hiddenCount: entries.length - keep };
56
62
  }
57
63
 
58
64
  /** Style one feed entry; the returned string may contain ANSI colors. */
@@ -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
- writeFileSync(file, JSON.stringify({ pid: process.pid, harness, mode, startedAt: Date.now() }));
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 {
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
3
  import type { Harness, ParseState, StreamedResult } from './harnesses/types.ts';
4
+ import { appendActivities, appendStreamed } from './stream-caps.ts';
4
5
 
5
6
  export interface RunHarnessOptions {
6
7
  harness: Harness;
@@ -21,12 +22,63 @@ export interface RunHarnessOptions {
21
22
  export interface HarnessResult extends StreamedResult {
22
23
  streamedText: string;
23
24
  harness: string;
25
+ /** Set when the runner itself killed the run for exceeding `maxBudgetUsd` (host enforcement). */
26
+ budgetExceeded?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Host-side `maxBudgetUsd` enforcement for a harness with no native budget flag: true once the
31
+ * harness's own streamed running total (`totalCostUsd`) goes over the cap. A harness that reports
32
+ * no cost (`null`) can never trip this — `delegate()` flags that budget as unenforced instead.
33
+ */
34
+ export function isOverBudget(
35
+ harness: Harness,
36
+ maxBudgetUsd: number | undefined,
37
+ r: StreamedResult | null,
38
+ baselineUsd = 0,
39
+ ): boolean {
40
+ return r !== null && isCostOverBudget(harness, maxBudgetUsd, r.totalCostUsd, baselineUsd);
41
+ }
42
+
43
+ /**
44
+ * The cost-only core of `isOverBudget`. `baselineUsd` is subtracted first: a harness whose reported
45
+ * total is *session*-cumulative (opencode over ACP) already includes every prior turn's spend on a
46
+ * resume, so only the delta since this run started counts against this run's cap.
47
+ */
48
+ export function isCostOverBudget(
49
+ harness: Harness,
50
+ maxBudgetUsd: number | undefined,
51
+ costUsd: number | null | undefined,
52
+ baselineUsd = 0,
53
+ ): boolean {
54
+ return (
55
+ maxBudgetUsd !== undefined &&
56
+ harness.nativeBudget !== true &&
57
+ typeof costUsd === 'number' &&
58
+ costUsd - baselineUsd > maxBudgetUsd
59
+ );
60
+ }
61
+
62
+ /** The result recorded for a run the host stopped over budget. */
63
+ export function budgetStoppedResult(r: StreamedResult, streamedText: string): StreamedResult {
64
+ return {
65
+ ...r,
66
+ isError: true,
67
+ stopReason: 'budget_exceeded',
68
+ result: r.result || streamedText || '(stopped: budget exceeded)',
69
+ };
24
70
  }
25
71
 
26
72
  import { DEFAULT_TIMEOUT_MS } from './harnesses/types.ts';
27
73
 
28
74
  export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
29
75
  return new Promise((resolve, reject) => {
76
+ // Already cancelled (e.g. the user hit cancel while this run was still being set up) —
77
+ // never spawn a process just to kill it.
78
+ if (opts.signal?.aborted) {
79
+ reject(new Error('cancelled'));
80
+ return;
81
+ }
30
82
  const args = opts.harness.buildArgs({
31
83
  prompt: opts.prompt,
32
84
  cwd: opts.cwd,
@@ -45,20 +97,26 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
45
97
  let settled = false;
46
98
  let firstTokenAt: number | null = null;
47
99
  const startAt = Date.now();
48
- const MAX_STREAMED = 5 * 1024 * 1024; // 5MB cap to prevent OOM on compromised harness
49
- const MAX_ACTIVITIES = 5000;
50
100
 
51
- const finish = (r: StreamedResult) => {
101
+ const finish = (r: StreamedResult, budgetExceeded = false) => {
52
102
  if (settled) return;
53
103
  settled = true;
54
104
  clearTimeout(timer);
105
+ opts.signal?.removeEventListener('abort', onAbort);
55
106
  const ttft = firstTokenAt !== null ? firstTokenAt - startAt : r.ttftMs;
56
- resolve({ ...r, ttftMs: ttft, streamedText: state.streamedText, harness: opts.harness.name });
107
+ resolve({
108
+ ...r,
109
+ ttftMs: ttft,
110
+ streamedText: state.streamedText,
111
+ harness: opts.harness.name,
112
+ ...(budgetExceeded ? { budgetExceeded: true } : {}),
113
+ });
57
114
  };
58
115
  const fail = (err: Error) => {
59
116
  if (settled) return;
60
117
  settled = true;
61
118
  clearTimeout(timer);
119
+ opts.signal?.removeEventListener('abort', onAbort);
62
120
  reject(err);
63
121
  };
64
122
 
@@ -67,29 +125,17 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
67
125
  const outcome = opts.harness.parseLine(line, state);
68
126
  if (outcome.streamedText) {
69
127
  if (firstTokenAt === null) firstTokenAt = Date.now();
70
- // Cap streamedText to prevent OOM on compromised harness
71
- if (state.streamedText.length < MAX_STREAMED) {
72
- const remaining = MAX_STREAMED - state.streamedText.length;
73
- const chunk =
74
- outcome.streamedText.length > remaining
75
- ? `${outcome.streamedText.slice(0, remaining)} [truncated ${outcome.streamedText.length - remaining} chars]`
76
- : outcome.streamedText;
77
- state.streamedText += chunk;
78
- opts.onStream?.(chunk);
79
- }
80
- }
81
- if (outcome.activities) {
82
- for (const a of outcome.activities) {
83
- if (state.activities.length < MAX_ACTIVITIES) {
84
- state.activities.push(a);
85
- opts.onActivity?.(a);
86
- }
87
- }
128
+ appendStreamed(state, outcome.streamedText, opts.onStream); // capped — see stream-caps.ts
88
129
  }
130
+ if (outcome.activities) appendActivities(state, outcome.activities, opts.onActivity);
89
131
  if (outcome.result) {
90
132
  // merge streamedText into result if empty
91
133
  if (!outcome.result.result) outcome.result.result = state.streamedText;
92
134
  state.result = outcome.result;
135
+ if (!settled && isOverBudget(opts.harness, opts.maxBudgetUsd, state.result)) {
136
+ proc.kill('SIGKILL');
137
+ finish(budgetStoppedResult(state.result, state.streamedText), true);
138
+ }
93
139
  }
94
140
  });
95
141
 
@@ -120,13 +166,12 @@ export function runHarness(opts: RunHarnessOptions): Promise<HarnessResult> {
120
166
  }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
121
167
  timer.unref?.();
122
168
 
123
- opts.signal?.addEventListener(
124
- 'abort',
125
- () => {
126
- proc.kill('SIGKILL');
127
- fail(new Error('cancelled'));
128
- },
129
- { once: true },
130
- );
169
+ // Removed again in finish()/fail() — a long-lived signal (e.g. one shared by a fan-out, or the
170
+ // tool call's own) must not accumulate a dead listener per run.
171
+ function onAbort(): void {
172
+ proc.kill('SIGKILL');
173
+ fail(new Error('cancelled'));
174
+ }
175
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
131
176
  });
132
177
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Memory caps shared by both runners (`runner.ts` for stdout harnesses, `acp-runner.ts` for ACP):
3
+ * a misbehaving or compromised harness can stream without bound, so accumulated text and activity
4
+ * events are capped here, once, instead of in two hand-copied blocks that could drift.
5
+ */
6
+ import type { ActivityEvent, ParseState } from './harnesses/types.ts';
7
+
8
+ /** Max characters of streamed text kept per run (5MB) — beyond this the stream is truncated. */
9
+ export const MAX_STREAMED_CHARS = 5 * 1024 * 1024;
10
+ /** Max activity events kept (and forwarded to `onActivity`) per run. */
11
+ export const MAX_ACTIVITIES = 5000;
12
+
13
+ /**
14
+ * Append a streamed chunk to `state.streamedText`, truncating at `max` with a
15
+ * `[truncated N chars]` marker, and forward exactly what was kept to `onStream`. Once the cap has
16
+ * been reached, further chunks are dropped (and not forwarded). Returns the appended chunk, or
17
+ * `null` when nothing was appended.
18
+ */
19
+ export function appendStreamed(
20
+ state: ParseState,
21
+ text: string,
22
+ onStream?: (chunk: string) => void,
23
+ max: number = MAX_STREAMED_CHARS,
24
+ ): string | null {
25
+ if (!text || state.streamedText.length >= max) return null;
26
+ const remaining = max - state.streamedText.length;
27
+ const chunk =
28
+ text.length > remaining ? `${text.slice(0, remaining)} [truncated ${text.length - remaining} chars]` : text;
29
+ state.streamedText += chunk;
30
+ onStream?.(chunk);
31
+ return chunk;
32
+ }
33
+
34
+ /** Append activity events to `state.activities` up to `max`, forwarding each kept one to `onActivity`. */
35
+ export function appendActivities(
36
+ state: ParseState,
37
+ activities: readonly ActivityEvent[],
38
+ onActivity?: (ev: ActivityEvent) => void,
39
+ max: number = MAX_ACTIVITIES,
40
+ ): void {
41
+ for (const a of activities) {
42
+ if (state.activities.length >= max) return;
43
+ state.activities.push(a);
44
+ onActivity?.(a);
45
+ }
46
+ }
@@ -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
+ }
@@ -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
  };
@@ -212,13 +224,32 @@ export function resolveNativePermission(
212
224
  *
213
225
  * Pure filesystem inspection — no trust logic. The caller supplies the trust decision.
214
226
  */
215
- export function projectTemplatePresence(cwd: string): {
227
+ export function projectTemplatePresence(
228
+ cwd: string,
229
+ /** Canonical harness names — the only partitions `loadTemplates` ever reads (callers pass the
230
+ * registry's `HARNESS_NAMES`; injected so this module stays free of the harness registry). */
231
+ harnessNames: readonly string[],
232
+ ): {
216
233
  /** Template dirs that exist and would load if the project were trusted. */
217
234
  dirs: string[];
218
235
  /** A leftover `.pi/trusted` file — strong evidence the user relied on the removed mechanism. */
219
236
  staleTrustFile: boolean;
220
237
  } {
221
- const candidates = [projectTemplatesDir(cwd), legacyProjectTemplatesDir(cwd)];
238
+ // the per-harness partitions (`.pi/delegate/templates/<harness>/`) load too — see loadTemplates —
239
+ // so they count as skipped content just like the shared root does. Only real harness partitions:
240
+ // loadTemplates is always called with a canonical name, so `omp/` (an alias), `archive/`,
241
+ // `shared/` etc. are never read and must not trigger the warning either.
242
+ const known = new Set(harnessNames);
243
+ let partitions: string[] = [];
244
+ try {
245
+ partitions = readdirSync(projectTemplatesDir(cwd), { withFileTypes: true })
246
+ .filter(e => e.isDirectory() && known.has(e.name))
247
+ .map(e => projectTemplatesDir(cwd, e.name))
248
+ .sort();
249
+ } catch {
250
+ // absent or unreadable
251
+ }
252
+ const candidates = [projectTemplatesDir(cwd), ...partitions, legacyProjectTemplatesDir(cwd)];
222
253
  const dirs: string[] = [];
223
254
  for (const dir of candidates) {
224
255
  try {