pi-better-subagents 0.15.0 → 0.16.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 +9 -1
- package/agent-commands.ts +13 -11
- package/agent-operations.ts +6 -1
- package/config.ts +53 -35
- package/docs/usage.md +13 -2
- package/index.ts +5 -1
- package/package.json +2 -1
- package/shared-callback-batcher.ts +23 -30
- package/shared-harness-settings.ts +114 -0
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
|
|
|
35
35
|
|
|
36
36
|
## Delegation Modes
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
The user config sets `delegationMode` to `manual`, `adaptive` (default), or
|
|
39
39
|
`coordinator`. `/subagents settings` (or `/subagents`) opens a settings page
|
|
40
40
|
for delegation mode and the concurrent-subagent cap (default 4). Edits apply to
|
|
41
41
|
this session and persist across reloads; Ctrl+S saves both as defaults, and
|
|
@@ -51,6 +51,14 @@ uses `agents_catalog` to discover current role descriptions and delegates every
|
|
|
51
51
|
nontrivial role-owned task, while the foreground coordinates, integrates, and
|
|
52
52
|
verifies. See [usage notes](docs/usage.md#delegation-mode).
|
|
53
53
|
|
|
54
|
+
Saved defaults live in global `~/.pi/agent/settings.json` under
|
|
55
|
+
`piBetterHarness.subagents` (or under `PI_CODING_AGENT_DIR`) and survive package
|
|
56
|
+
reinstalls and upgrades. The old user config migrates on first use. Global values
|
|
57
|
+
override the package's `config.json`, which remains a compatibility fallback.
|
|
58
|
+
Save existing mode and cap choices with `/subagents save` or Ctrl+S. Put other
|
|
59
|
+
custom config keys in `piBetterHarness.subagents` as well; edits made only inside
|
|
60
|
+
the installed package can be lost on upgrade.
|
|
61
|
+
|
|
54
62
|
`agents_catalog` shows each role's and named agent's default model and effort,
|
|
55
63
|
such as `role developer "Developer" … default openai/gpt-6.1-sol@high`. Roles are
|
|
56
64
|
listed and passed by short name (`role: "developer"`); the stored id
|
package/agent-commands.ts
CHANGED
|
@@ -137,21 +137,23 @@ export function registerAgentCommands(pi: Pick<ExtensionAPI, "registerCommand">,
|
|
|
137
137
|
return items.length > 0 ? items : null;
|
|
138
138
|
},
|
|
139
139
|
async handler(args: string, ctx: ExtensionCommandContext) {
|
|
140
|
-
|
|
141
|
-
// context argument is the current session, not the one captured
|
|
142
|
-
// when the command was registered.
|
|
143
|
-
deps.propagateCommandContext?.(ctx);
|
|
144
|
-
return executeAgentsCommand(args, {
|
|
145
|
-
cwd: ctx.cwd,
|
|
146
|
-
hasUI: ctx.hasUI,
|
|
147
|
-
mode: ctx.mode,
|
|
148
|
-
isProjectTrusted: () => ctx.isProjectTrusted(),
|
|
149
|
-
ui: ctx.ui,
|
|
150
|
-
}, deps);
|
|
140
|
+
return executeAgentsCommandContext(args, ctx, deps);
|
|
151
141
|
},
|
|
152
142
|
});
|
|
153
143
|
}
|
|
154
144
|
|
|
145
|
+
/** Slash commands and settings shortcuts must resolve against the same live context. */
|
|
146
|
+
export function executeAgentsCommandContext(args: string, ctx: ExtensionCommandContext, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
|
|
147
|
+
deps.propagateCommandContext?.(ctx);
|
|
148
|
+
return executeAgentsCommand(args, {
|
|
149
|
+
cwd: ctx.cwd,
|
|
150
|
+
hasUI: ctx.hasUI,
|
|
151
|
+
mode: ctx.mode,
|
|
152
|
+
isProjectTrusted: () => ctx.isProjectTrusted(),
|
|
153
|
+
ui: ctx.ui,
|
|
154
|
+
}, deps);
|
|
155
|
+
}
|
|
156
|
+
|
|
155
157
|
export async function executeAgentsCommand(args: string, host: AgentCommandHost, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
|
|
156
158
|
const parsed = parseCommandArgs(args);
|
|
157
159
|
if (parsed.positionals.length === 0 && parsed.flags.size === 0 && host.mode === "tui" && host.hasUI && host.ui.custom) parsed.positionals.push("list");
|
package/agent-operations.ts
CHANGED
|
@@ -18,17 +18,19 @@
|
|
|
18
18
|
import { agentsCatalogTool, type DiscoveryDeps } from "./agents-catalog-tool.ts";
|
|
19
19
|
import {
|
|
20
20
|
executeAgentsCommand,
|
|
21
|
+
executeAgentsCommandContext,
|
|
21
22
|
registerAgentCommands,
|
|
22
23
|
type AgentCommandDeps,
|
|
23
24
|
type AgentCommandHost,
|
|
24
25
|
type AgentCommandResult,
|
|
25
26
|
} from "./agent-commands.ts";
|
|
26
27
|
import { resolveRoleAssignment, type RoleAssignment, type RoleAssignmentDecision } from "./role-assignment.ts";
|
|
27
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
28
29
|
|
|
29
30
|
export interface AgentOperationsDeps extends AgentCommandDeps, DiscoveryDeps {}
|
|
30
31
|
|
|
31
32
|
export interface AgentOperationsHandle {
|
|
33
|
+
openCatalog(ctx: ExtensionCommandContext): Promise<void>;
|
|
32
34
|
registerCommands(pi: Pick<ExtensionAPI, "registerCommand">): void;
|
|
33
35
|
createDiscoveryTool(Type: Parameters<typeof agentsCatalogTool>[0]): ReturnType<typeof agentsCatalogTool>;
|
|
34
36
|
executeCommand(args: string, host: AgentCommandHost): Promise<AgentCommandResult>;
|
|
@@ -40,6 +42,9 @@ export interface AgentOperationsHandle {
|
|
|
40
42
|
|
|
41
43
|
export function createAgentOperations(deps: AgentOperationsDeps = {}): AgentOperationsHandle {
|
|
42
44
|
return {
|
|
45
|
+
async openCatalog(ctx) {
|
|
46
|
+
await executeAgentsCommandContext("", ctx, deps);
|
|
47
|
+
},
|
|
43
48
|
registerCommands(pi) {
|
|
44
49
|
registerAgentCommands(pi, deps);
|
|
45
50
|
},
|
package/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* User config overrides the shipped `config.json` defaults for every subagent,
|
|
3
|
+
* each overridable per `subagent_spawn` call. Saves never edit installed files.
|
|
4
4
|
*
|
|
5
5
|
* { "defaultModel": "xai/grok-4.5", "defaultTools": "read, bash, web_fetch" }
|
|
6
6
|
*
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
* `defaultTools` absent → the built-in SAFE_DEFAULT_TOOLS.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, readFileSync
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { SELF_SPEC } from "./extensions.ts";
|
|
16
16
|
import { isDelegationMode, type DelegationMode } from "./delegation.ts";
|
|
17
17
|
import type { TimingSettings } from "./timing.ts";
|
|
18
|
+
import { harnessSettingsPath, readHarnessSetting, updateHarnessSetting } from "./shared-harness-settings.ts";
|
|
18
19
|
|
|
19
20
|
export interface SubagentConfig extends TimingSettings {
|
|
20
21
|
/** Foreground delegation policy; /subagents mode overrides this for the current session. */
|
|
@@ -89,65 +90,82 @@ export const SAFE_CLEAN_TOOLS = "read, bash";
|
|
|
89
90
|
|
|
90
91
|
let cached: SubagentConfig | undefined;
|
|
91
92
|
let configPathForTests: string | undefined;
|
|
93
|
+
let bundledConfigPathForTests: string | undefined;
|
|
92
94
|
|
|
93
95
|
/** Test seam. Pass undefined to reload config.json on the next loadConfig call. */
|
|
94
96
|
export function setConfigForTests(next: SubagentConfig | undefined): void {
|
|
95
97
|
cached = next;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
/** Test seam.
|
|
99
|
-
export function setConfigPathForTests(path: string | undefined): void {
|
|
100
|
+
/** Test seam. Isolate user config, optionally supplying a package fallback. */
|
|
101
|
+
export function setConfigPathForTests(path: string | undefined, bundledPath?: string): void {
|
|
100
102
|
configPathForTests = path;
|
|
103
|
+
bundledConfigPathForTests = bundledPath;
|
|
101
104
|
cached = undefined;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
export function configPath(): string {
|
|
105
|
-
return
|
|
108
|
+
return harnessSettingsPath(configSeams());
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
function configSeams() {
|
|
112
|
+
return { agentDir: () => configPathForTests ? dirname(configPathForTests) : piAgentDir() };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readConfig(path: string): SubagentConfig {
|
|
116
|
+
return parseConfig(JSON.parse(readFileSync(path, "utf-8")));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseConfig(parsed: unknown): SubagentConfig {
|
|
120
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Config must be a JSON object.");
|
|
121
|
+
return parsed as SubagentConfig;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function savedConfig(): SubagentConfig {
|
|
125
|
+
const user = readHarnessSetting("subagents", configSeams(), configPathForTests ? undefined : {
|
|
126
|
+
path: join(piAgentDir(), "extensions", "pi-better-subagents-config.json"), parse: parseConfig,
|
|
127
|
+
});
|
|
128
|
+
return user === undefined ? {} : parseConfig(user);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function bundledConfig(): SubagentConfig {
|
|
132
|
+
const path = bundledConfigPathForTests ?? (configPathForTests ? undefined : join(selfDir(), "config.json"));
|
|
133
|
+
if (!path) return {};
|
|
111
134
|
try {
|
|
112
|
-
|
|
135
|
+
return readConfig(path);
|
|
113
136
|
} catch {
|
|
114
|
-
|
|
137
|
+
return {};
|
|
115
138
|
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Saved user choices take precedence; missing/invalid files contribute no keys. */
|
|
142
|
+
export function loadConfig(): SubagentConfig {
|
|
143
|
+
if (cached) return cached;
|
|
144
|
+
let user: SubagentConfig = {};
|
|
145
|
+
try { user = savedConfig(); } catch { /* retain package fallback */ }
|
|
146
|
+
cached = { ...bundledConfig(), ...user };
|
|
116
147
|
return cached;
|
|
117
148
|
}
|
|
118
149
|
|
|
119
|
-
/**
|
|
150
|
+
/** Save the navigator's mode-only default without replacing the saved cap. */
|
|
120
151
|
export function writeDelegationMode(mode: DelegationMode, path = configPath()): void {
|
|
121
152
|
if (!isDelegationMode(mode)) throw new Error(`Invalid delegation mode: ${String(mode)}`);
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
125
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) current = parsed as SubagentConfig;
|
|
126
|
-
} catch { /* missing file starts as an empty config */ }
|
|
127
|
-
current.delegationMode = mode;
|
|
128
|
-
const pending = `${path}.${process.pid}.tmp`;
|
|
129
|
-
writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
|
|
130
|
-
renameSync(pending, path);
|
|
131
|
-
if (path === configPath()) cached = current;
|
|
153
|
+
writeConfigSettings({ delegationMode: mode }, path);
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
/** Save human-facing defaults together, preserving unrelated configuration. */
|
|
135
157
|
export function writeSubagentSettings(settings: { delegationMode: DelegationMode; maxConcurrent: number }, path = configPath()): void {
|
|
136
158
|
if (!isDelegationMode(settings.delegationMode)) throw new Error("Invalid delegation mode.");
|
|
137
159
|
if (!isConcurrencyCap(settings.maxConcurrent)) throw new Error("Concurrent subagents must be a positive whole number.");
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const pending = `${path}.${process.pid}.tmp`;
|
|
148
|
-
writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
|
|
149
|
-
renameSync(pending, path);
|
|
150
|
-
if (path === configPath()) cached = current as SubagentConfig;
|
|
160
|
+
writeConfigSettings(settings, path);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function writeConfigSettings(settings: Partial<SubagentConfig>, path: string): void {
|
|
164
|
+
if (path === configPath()) savedConfig();
|
|
165
|
+
const current = updateHarnessSetting("subagents", previous => ({
|
|
166
|
+
...(previous === undefined ? {} : parseConfig(previous)), ...settings,
|
|
167
|
+
}), { agentDir: () => dirname(path) });
|
|
168
|
+
if (path === configPath()) cached = { ...bundledConfig(), ...current };
|
|
151
169
|
}
|
|
152
170
|
|
|
153
171
|
/** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
|
package/docs/usage.md
CHANGED
|
@@ -14,7 +14,8 @@ launch is the result · completion triggers fetch · the foreground never blocks
|
|
|
14
14
|
|
|
15
15
|
## Delegation Mode
|
|
16
16
|
|
|
17
|
-
Set `delegationMode` in
|
|
17
|
+
Set `delegationMode` in the user config described under
|
|
18
|
+
[Tool scoping](#tool-scoping-allowlist) to `manual`, `adaptive`, or
|
|
18
19
|
`coordinator` (default `adaptive`). This controls foreground delegation guidance,
|
|
19
20
|
not child permissions or catalog definitions. `/subagents settings` (or simply
|
|
20
21
|
`/subagents`) opens the terminal settings page with mode and concurrent-subagent
|
|
@@ -438,7 +439,17 @@ Precedence, highest first: the per-call `tools` param → `config.json`
|
|
|
438
439
|
web_fetch`; just `read, bash` in a `clean` child). `exclude_tools` subtracts on
|
|
439
440
|
top.
|
|
440
441
|
|
|
441
|
-
|
|
442
|
+
User configuration lives in
|
|
443
|
+
`~/.pi/agent/settings.json` under `piBetterHarness.subagents` (under
|
|
444
|
+
`PI_CODING_AGENT_DIR` when set). The old user config migrates on first use.
|
|
445
|
+
Its keys override the package's `config.json`,
|
|
446
|
+
which is still read as a compatibility fallback. Settings saves write only the
|
|
447
|
+
user file, so saved mode and cap survive reinstalls and upgrades without freezing
|
|
448
|
+
other shipped defaults. To retain custom keys from an older package-local config,
|
|
449
|
+
copy those keys into the user file before upgrading. Already-overwritten values
|
|
450
|
+
cannot be recovered automatically.
|
|
451
|
+
|
|
452
|
+
The effective config also sets:
|
|
442
453
|
|
|
443
454
|
- `defaultModel` — model for spawns that don't specify one (`null` = inherit the
|
|
444
455
|
foreground model).
|
package/index.ts
CHANGED
|
@@ -2300,7 +2300,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
2300
2300
|
}
|
|
2301
2301
|
};
|
|
2302
2302
|
const contribution = { id: "subagents", label: "Subagents", command: "/subagents settings", open: openSettings };
|
|
2303
|
-
const
|
|
2303
|
+
const agentsContribution = { id: "agents", label: "Agents", command: "/agents", open: agentOperations.openCatalog };
|
|
2304
|
+
const registerSettings = () => {
|
|
2305
|
+
pi.events?.emit("harness-settings:register", contribution);
|
|
2306
|
+
pi.events?.emit("harness-settings:register", agentsContribution);
|
|
2307
|
+
};
|
|
2304
2308
|
const unsubscribeSettingsRequest = pi.events?.on?.("harness-settings:request", registerSettings);
|
|
2305
2309
|
registerSettings();
|
|
2306
2310
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"@earendil-works/pi-tui": "*"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
+
"proper-lockfile": "^4.1.2",
|
|
64
65
|
"smol-toml": "1.9.0",
|
|
65
66
|
"yaml": "^2.9.1"
|
|
66
67
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
-
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
2
|
import { join } from "node:path";
|
|
5
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
harnessSettingsPath,
|
|
6
|
+
readHarnessSetting,
|
|
7
|
+
updateHarnessSetting,
|
|
8
|
+
type HarnessSettingsSeams,
|
|
9
|
+
} from "./shared-harness-settings.ts";
|
|
6
10
|
|
|
7
11
|
export type CallbackSource = "subagent" | "background-task";
|
|
8
12
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
@@ -13,9 +17,7 @@ export interface CallbackSettingsContext {
|
|
|
13
17
|
ui?: { notify(message: string, type: "error"): void };
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
export
|
|
17
|
-
agentDir?: () => string;
|
|
18
|
-
}
|
|
20
|
+
export type CallbackSettingsSeams = HarnessSettingsSeams;
|
|
19
21
|
|
|
20
22
|
export interface CallbackSettings {
|
|
21
23
|
mode: CallbackDeliveryMode;
|
|
@@ -36,7 +38,12 @@ function validCallbackPreferences(value: unknown): value is { version: 1; mode:
|
|
|
36
38
|
&& data.version === 1 && isDeliveryMode(data.mode);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
function
|
|
41
|
+
function parseCallbackPreferences(value: unknown): { version: 1; mode: CallbackDeliveryMode } {
|
|
42
|
+
if (!validCallbackPreferences(value)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function legacyCallbackPreferencesPath(seams: CallbackSettingsSeams): string {
|
|
40
47
|
return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -64,41 +71,27 @@ function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettings
|
|
|
64
71
|
if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
|
|
65
72
|
return { mode, source: "default" };
|
|
66
73
|
};
|
|
67
|
-
const path =
|
|
68
|
-
let text: string;
|
|
74
|
+
const path = harnessSettingsPath(seams);
|
|
69
75
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const data: unknown = JSON.parse(text);
|
|
77
|
-
if (!validCallbackPreferences(data)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
|
|
78
|
-
return snapshot(data.mode);
|
|
76
|
+
const data = readHarnessSetting<{ version: 1; mode: CallbackDeliveryMode }>("callbacks", seams, {
|
|
77
|
+
path: legacyCallbackPreferencesPath(seams),
|
|
78
|
+
parse: parseCallbackPreferences,
|
|
79
|
+
});
|
|
80
|
+
return snapshot(data === undefined ? "hold" : parseCallbackPreferences(data).mode);
|
|
79
81
|
} catch (error) {
|
|
80
|
-
|
|
82
|
+
const prefix = (error as NodeJS.ErrnoException).code ? "Cannot read" : "Invalid";
|
|
83
|
+
throw new Error(`${prefix} callback default at ${path}: ${String(error)}`, { cause: error });
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/** Saves only the future-session default; never changes the active branch or batcher. */
|
|
85
88
|
export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
|
|
86
89
|
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
|
|
87
|
-
const
|
|
88
|
-
const path = join(dir, CALLBACK_PREFERENCES_FILE);
|
|
89
|
-
const tmp = join(dir, `.${CALLBACK_PREFERENCES_FILE}.${randomUUID()}.tmp`);
|
|
90
|
-
let created = false;
|
|
90
|
+
const path = harnessSettingsPath(seams);
|
|
91
91
|
try {
|
|
92
|
-
|
|
93
|
-
const fd = openSync(tmp, "wx", 0o600);
|
|
94
|
-
created = true;
|
|
95
|
-
try { writeFileSync(fd, `${JSON.stringify({ version: 1, mode })}\n`); }
|
|
96
|
-
finally { closeSync(fd); }
|
|
97
|
-
renameSync(tmp, path);
|
|
92
|
+
updateHarnessSetting("callbacks", () => ({ version: 1, mode }), seams);
|
|
98
93
|
} catch (error) {
|
|
99
94
|
throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
|
|
100
|
-
} finally {
|
|
101
|
-
if (created) rmSync(tmp, { force: true });
|
|
102
95
|
}
|
|
103
96
|
}
|
|
104
97
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Generated from packages/harness-settings/index.ts. Do not edit directly.
|
|
2
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
// Match Pi's settings lock without depending on a private SDK import.
|
|
9
|
+
const lockfile = createRequire(import.meta.url)("proper-lockfile") as {
|
|
10
|
+
lockSync(path: string, options: { realpath: boolean }): () => void;
|
|
11
|
+
};
|
|
12
|
+
const NAMESPACE = "piBetterHarness";
|
|
13
|
+
export interface HarnessSettingsSeams { agentDir?: () => string }
|
|
14
|
+
|
|
15
|
+
export function harnessSettingsPath(seams: HarnessSettingsSeams = {}): string {
|
|
16
|
+
return join((seams.agentDir ?? getAgentDir)(), "settings.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object.`);
|
|
21
|
+
return value as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readSettings(path: string): Record<string, unknown> {
|
|
25
|
+
try { return object(JSON.parse(readFileSync(path, "utf8")), `Settings at ${path}`); }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function section(settings: Record<string, unknown>): Record<string, unknown> {
|
|
33
|
+
return Object.hasOwn(settings, NAMESPACE) ? object(settings[NAMESPACE], NAMESPACE) : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function acquire(path: string): () => void {
|
|
37
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
38
|
+
try { return lockfile.lockSync(path, { realpath: false }); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code !== "ELOCKED" || attempt === 9) throw error;
|
|
41
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Cannot lock settings at ${path}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function lockedRead(path: string): Record<string, unknown> {
|
|
48
|
+
if (!existsSync(path)) return {};
|
|
49
|
+
const release = acquire(path);
|
|
50
|
+
try { return readSettings(path); } finally { release(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeDestination(path: string): string {
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = lstatSync(path); }
|
|
56
|
+
catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return path;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
// Keep managed dotfiles links intact. A dangling link fails rather than being replaced.
|
|
61
|
+
return stat.isSymbolicLink() ? realpathSync(path) : path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mutate<T>(seams: HarnessSettingsSeams, change: (values: Record<string, unknown>) => T): T {
|
|
65
|
+
const path = harnessSettingsPath(seams);
|
|
66
|
+
mkdirSync((seams.agentDir ?? getAgentDir)(), { recursive: true });
|
|
67
|
+
const release = acquire(path);
|
|
68
|
+
let pending: string | undefined;
|
|
69
|
+
try {
|
|
70
|
+
// Read inside the lock so another session's settings are never replaced by a stale snapshot.
|
|
71
|
+
const settings = readSettings(path);
|
|
72
|
+
const values = section(settings);
|
|
73
|
+
const result = change(values);
|
|
74
|
+
settings[NAMESPACE] = values;
|
|
75
|
+
const destination = writeDestination(path);
|
|
76
|
+
pending = `${destination}.${randomUUID()}.tmp`;
|
|
77
|
+
writeFileSync(pending, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
78
|
+
renameSync(pending, destination);
|
|
79
|
+
return result;
|
|
80
|
+
} finally {
|
|
81
|
+
try { if (pending) rmSync(pending, { force: true }); } finally { release(); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readHarnessSetting<T = unknown>(
|
|
86
|
+
key: string,
|
|
87
|
+
seams: HarnessSettingsSeams = {},
|
|
88
|
+
legacy?: { path: string; parse: (value: unknown) => T },
|
|
89
|
+
): T | undefined {
|
|
90
|
+
const values = section(lockedRead(harnessSettingsPath(seams)));
|
|
91
|
+
if (Object.hasOwn(values, key)) return values[key] as T;
|
|
92
|
+
if (!legacy) return undefined;
|
|
93
|
+
let raw: string;
|
|
94
|
+
try { raw = readFileSync(legacy.path, "utf8"); }
|
|
95
|
+
catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const migrated = legacy.parse(JSON.parse(raw));
|
|
100
|
+
return mutate(seams, current => {
|
|
101
|
+
if (!Object.hasOwn(current, key)) current[key] = migrated;
|
|
102
|
+
return current[key] as T;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function updateHarnessSetting<T>(
|
|
107
|
+
key: string, update: (current: unknown) => T, seams: HarnessSettingsSeams = {},
|
|
108
|
+
): T {
|
|
109
|
+
return mutate(seams, values => {
|
|
110
|
+
const next = update(values[key]);
|
|
111
|
+
values[key] = next;
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
}
|