pi-harness-delegate 0.6.1 → 0.6.2

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 CHANGED
@@ -184,7 +184,7 @@ In `~/.pi/agent/settings.json`:
184
184
 
185
185
  Run `/delegate config` to see exactly what was read from `settings.json` (or why nothing was — no file, no `delegate` key, or a parse error), plus the effective config with defaults filled in, as a paste-ready JSON block for the `delegate` key. `/delegate status` shows the same provenance as one summary line.
186
186
 
187
- `/delegate config init` writes that effective config into `settings.json` under the `delegate` key — the only thing this extension ever writes there, and only on this explicit command. It reads the whole file, replaces only the `delegate` key, and preserves every other key (pi's `theme`/`defaultProvider`/`packages`/…, and a leftover `claudeDelegate`, verbatim). The write is atomic (temp file + rename in the same directory — no torn file if the process dies mid-write) and refuses outright if the existing file fails to parse, rather than clobbering whatever's actually in it; `/delegate config`'s paste-ready block is the fallback in that case.
187
+ `/delegate config init` writes the parts of that effective config that differ from the built-in defaults into `settings.json` under the `delegate` key (an unmodified setup writes `{}`) — defaults are deliberately *not* written, because a value present in the file is treated as your choice and would stop later releases' default changes (e.g. `maxConcurrent` 1 → 4) from reaching you — the only thing this extension ever writes there, and only on this explicit command. It reads the whole file, replaces only the `delegate` key, and preserves every other key (pi's `theme`/`defaultProvider`/`packages`/…, and a leftover `claudeDelegate`, verbatim). The write is atomic (temp file + rename in the same directory — no torn file if the process dies mid-write) and refuses outright if the existing file fails to parse, rather than clobbering whatever's actually in it. It also keeps the file's mode, writes through a symlinked `settings.json` rather than replacing the link, matches the file's trailing-newline convention, and takes pi's own `settings.json.lock` — if another pi session holds it, the command refuses and you retry; `/delegate config`'s paste-ready block is the fallback in that case.
188
188
 
189
189
  Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (deprecated) — most fields migrate, including per-harness settings like `harnesses.<name>.transport`. Two things never migrate, though, and stay silently unreachable as long as `claudeDelegate` is your *only* key (no `delegate` key at all): `defaultHarness` (stays pinned to `claude`) and a top-level default `model` (only `claudeDelegate.model` → `harnesses.claude.model` migrates — there's no global fallback). Both `/delegate status` and `/delegate config` call this out when it's happening; fix it by renaming `claudeDelegate` to `delegate`, or by running `/delegate config init`, which writes an explicit `delegate` key (with the legacy values already correctly migrated) without touching `claudeDelegate` itself.
190
190
 
@@ -1,4 +1,14 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
2
12
  import { homedir } from 'node:os';
3
13
  import { dirname, join } from 'node:path';
4
14
  import { DEFAULT_TIMEOUT_MS, type Harness, type Transport } from './harnesses/types.ts';
@@ -83,17 +93,9 @@ function sanitizeHarnessConfig(v: HarnessConfig): HarnessConfig {
83
93
  return out;
84
94
  }
85
95
 
86
- /**
87
- * Loads `delegate` config from `~/.pi/agent/settings.json` alongside `ConfigSource`, describing
88
- * how that happened (file present? which key won? did it fail to parse?) — see `ConfigSource`'s
89
- * doc comment. `loadConfig()` below is a thin wrapper for the existing call sites that only want
90
- * the resolved values; this is the one place that actually reads/parses the file, so a caller
91
- * needing both never pays for a second parse. Never throws — any failure (missing file, bad JSON,
92
- * a non-object root) is recorded on `source` and falls back to the same defaults `loadConfig()`
93
- * has always returned.
94
- */
95
- export function loadConfigWithSource(): ConfigLoadResult {
96
- const cfg: DelegateConfig = {
96
+ /** Built-in defaults — a fresh object each call, since callers mutate the result. */
97
+ export function defaultDelegateConfig(): DelegateConfig {
98
+ return {
97
99
  timeoutMs: DEFAULT_TIMEOUT_MS,
98
100
  defaultMode: 'general',
99
101
  defaultHarness: 'claude',
@@ -105,6 +107,44 @@ export function loadConfigWithSource(): ConfigLoadResult {
105
107
  maxTranscripts: 100,
106
108
  harnesses: {},
107
109
  };
110
+ }
111
+
112
+ /**
113
+ * Only the top-level settings whose effective value differs from the built-in default.
114
+ *
115
+ * What `/delegate config init` writes. Writing the full effective config would pin every default
116
+ * into settings.json, and the loader treats a present value as user intent — so a later release
117
+ * that changes a default (maxConcurrent 1 -> 4 already did) would never reach that user.
118
+ */
119
+ export function nonDefaultConfig(cfg: DelegateConfig): Partial<DelegateConfig> {
120
+ const defaults = defaultDelegateConfig() as unknown as Record<string, unknown>;
121
+ const out: Record<string, unknown> = {};
122
+ for (const [k, v] of Object.entries(cfg as unknown as Record<string, unknown>)) {
123
+ if (v === undefined) continue;
124
+ if (k === 'modelAliases') {
125
+ // The loader merges aliases additively onto the defaults, so only the keys that differ need
126
+ // writing — whole-object diffing would pin the untouched default aliases next to a custom one.
127
+ const d = defaults.modelAliases as Record<string, string>;
128
+ const diff = Object.fromEntries(Object.entries(v as Record<string, string>).filter(([a, t]) => d[a] !== t));
129
+ if (Object.keys(diff).length > 0) out[k] = diff;
130
+ continue;
131
+ }
132
+ if (JSON.stringify(v) !== JSON.stringify(defaults[k])) out[k] = v;
133
+ }
134
+ return out as Partial<DelegateConfig>;
135
+ }
136
+
137
+ /**
138
+ * Loads `delegate` config from `~/.pi/agent/settings.json` alongside `ConfigSource`, describing
139
+ * how that happened (file present? which key won? did it fail to parse?) — see `ConfigSource`'s
140
+ * doc comment. `loadConfig()` below is a thin wrapper for the existing call sites that only want
141
+ * the resolved values; this is the one place that actually reads/parses the file, so a caller
142
+ * needing both never pays for a second parse. Never throws — any failure (missing file, bad JSON,
143
+ * a non-object root) is recorded on `source` and falls back to the same defaults `loadConfig()`
144
+ * has always returned.
145
+ */
146
+ export function loadConfigWithSource(): ConfigLoadResult {
147
+ const cfg = defaultDelegateConfig();
108
148
  const file = join(agentDir(), 'settings.json');
109
149
  const source: ConfigSource = { file, fileExists: false, usedKey: 'none', legacyKeyPresent: false };
110
150
  try {
@@ -273,8 +313,8 @@ export function buildConfigReport(result: ConfigLoadResult): string[] {
273
313
  lines.push('from file (as written, before defaults are applied):');
274
314
  lines.push(JSON.stringify(result.source.raw ?? {}, null, 2));
275
315
  lines.push('');
276
- lines.push('effective config (file merged with defaults) — paste under "delegate" in settings.json,');
277
- lines.push('or run `/delegate config init` to write it there directly:');
316
+ lines.push('effective config (file merged with defaults) — for reference; you only need to put the values');
317
+ lines.push('you want to change under "delegate" (`/delegate config init` writes just those, not defaults):');
278
318
  lines.push(JSON.stringify({ delegate: result.config }, null, 2));
279
319
  return lines;
280
320
  }
@@ -298,13 +338,27 @@ export interface WriteConfigResult {
298
338
  * on the strength of a read like `loadConfig()`/`showStatus`.
299
339
  */
300
340
  export function writeDelegateConfig(delegateSubtree: unknown): WriteConfigResult {
301
- const file = join(agentDir(), 'settings.json');
341
+ const configured = join(agentDir(), 'settings.json');
342
+ // Write through a symlink to its real target rather than replacing the link with a regular file.
343
+ let file = configured;
344
+ try {
345
+ file = realpathSync(configured);
346
+ } catch {
347
+ // absent — created below
348
+ }
349
+ // pi locks the path as configured (proper-lockfile realpath:false), not a symlink's target.
350
+ const lock = `${configured}.lock`;
351
+ const tmp = join(dirname(file), `settings.json.${process.pid}.tmp`);
352
+ let locked = false;
302
353
  try {
303
354
  let root: Record<string, unknown> = {};
355
+ let mode: number | undefined;
356
+ let trailingNewline = false;
304
357
  if (existsSync(file)) {
358
+ const raw = readFileSync(file, 'utf8');
305
359
  let parsed: unknown;
306
360
  try {
307
- parsed = JSON.parse(readFileSync(file, 'utf8'));
361
+ parsed = JSON.parse(raw);
308
362
  } catch (err) {
309
363
  return {
310
364
  ok: false,
@@ -316,16 +370,56 @@ export function writeDelegateConfig(delegateSubtree: unknown): WriteConfigResult
316
370
  return { ok: false, file, message: `refusing to write: ${file} exists but its root isn't a JSON object` };
317
371
  }
318
372
  root = parsed as Record<string, unknown>;
373
+ mode = statSync(file).mode & 0o777;
374
+ trailingNewline = raw.endsWith('\n');
375
+ }
376
+ mkdirSync(dirname(file), { recursive: true });
377
+ // pi saves settings.json under proper-lockfile (`<file>.lock`, a directory taken with an atomic
378
+ // mkdir). Take the same lock, without waiting, so we neither lose a concurrent pi write nor have
379
+ // ours overwritten mid-read-modify-write. The read above is repeated under the lock.
380
+ try {
381
+ mkdirSync(lock);
382
+ locked = true;
383
+ } catch (err) {
384
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
385
+ return {
386
+ ok: false,
387
+ file,
388
+ message: `refusing to write: ${file} is locked (another pi session is saving settings, or a crashed one left a stale lock that pi clears after ~10s) — retry in a moment`,
389
+ };
390
+ }
391
+ throw err;
392
+ }
393
+ if (existsSync(file)) {
394
+ const again: unknown = JSON.parse(readFileSync(file, 'utf8'));
395
+ if (again === null || typeof again !== 'object' || Array.isArray(again)) {
396
+ return { ok: false, file, message: `refusing to write: ${file} changed and its root isn't a JSON object` };
397
+ }
398
+ root = again as Record<string, unknown>;
319
399
  }
320
400
  root.delegate = delegateSubtree;
321
- const dir = dirname(file);
322
- mkdirSync(dir, { recursive: true });
323
- const tmp = join(dir, `settings.json.${process.pid}.tmp`);
324
- writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`, 'utf8');
401
+ // Match the file's own format: pi writes JSON.stringify(x, null, 2) with no trailing newline.
402
+ writeFileSync(tmp, `${JSON.stringify(root, null, 2)}${trailingNewline ? '\n' : ''}`, { encoding: 'utf8' });
403
+ if (mode !== undefined) chmodSync(tmp, mode);
325
404
  renameSync(tmp, file);
326
405
  return { ok: true, file, message: `wrote "delegate" key to ${file}` };
327
406
  } catch (err) {
328
407
  return { ok: false, file, message: `failed to write ${file}: ${err instanceof Error ? err.message : String(err)}` };
408
+ } finally {
409
+ // Cleanup is best-effort: this function must never throw.
410
+ try {
411
+ rmSync(tmp, { force: true });
412
+ } catch {
413
+ // nothing useful to do
414
+ }
415
+ // Separate from the tmp cleanup so a failure there can never leave pi's lock behind.
416
+ if (locked) {
417
+ try {
418
+ rmSync(lock, { recursive: true, force: true });
419
+ } catch {
420
+ // nothing useful to do
421
+ }
422
+ }
329
423
  }
330
424
  }
331
425
 
@@ -67,6 +67,7 @@ import {
67
67
  legacyOutputsDir,
68
68
  loadConfig,
69
69
  loadConfigWithSource,
70
+ nonDefaultConfig,
70
71
  resolveModelForHarness,
71
72
  resolveTransport,
72
73
  writeDelegateConfig,
@@ -585,7 +586,8 @@ async function showConfig(ctx: ExtensionContext): Promise<void> {
585
586
  /**
586
587
  * `/delegate config init` — the one place this extension ever writes to `settings.json`, and only
587
588
  * because a human explicitly typed this subcommand. Writes the current effective config (defaults
588
- * merged with whatever was already on disk) into the `delegate` key via `writeDelegateConfig()`
589
+ * merged with whatever was already on disk, minus anything still at its default — pinning defaults would
590
+ * stop later releases' default changes reaching the user) into the `delegate` key via `writeDelegateConfig()`
589
591
  * (read-modify-write, atomic, refuses on an unparseable file rather than clobbering it). This is
590
592
  * also the practical fix for the legacy-`claudeDelegate`-only gap `describeConfigSource` warns
591
593
  * about: writing an explicit `delegate` key (with the correctly-resolved values already folded
@@ -594,7 +596,7 @@ async function showConfig(ctx: ExtensionContext): Promise<void> {
594
596
  */
595
597
  async function initConfig(ctx: ExtensionContext): Promise<void> {
596
598
  const result = loadConfigWithSource();
597
- const write = writeDelegateConfig(result.config);
599
+ const write = writeDelegateConfig(nonDefaultConfig(result.config));
598
600
  const msg = write.ok ? `✓ ${write.message}` : `✗ ${write.message}`;
599
601
  if (!ctx.hasUI) process.stdout.write(`${msg}\n`);
600
602
  else ctx.ui.notify?.(msg, write.ok ? 'info' : 'warning');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.14",
@@ -65,7 +65,6 @@
65
65
  "pi": {
66
66
  "extensions": [
67
67
  "./extensions/index.ts"
68
- ],
69
- "image": "https://raw.githubusercontent.com/yorch/pi-harness-delegate/main/docs/assets/claude-delegate-preview.png"
68
+ ]
70
69
  }
71
70
  }