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
package/extensions/config.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
88
|
-
|
|
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) —
|
|
277
|
-
lines.push('
|
|
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
|
|
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(
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|