@xynogen/pix-runtime 0.10.2 → 0.13.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/LICENSE +21 -0
- package/README.md +209 -12
- package/package.json +12 -1
- package/src/audio.ts +298 -0
- package/src/binaries/catalog.ts +279 -0
- package/src/binaries/ensure.ts +294 -0
- package/src/binaries/index.ts +43 -0
- package/src/binaries/resolve.ts +199 -0
- package/src/binaries/store.ts +149 -0
- package/src/binaries-tab.ts +364 -0
- package/src/exec.ts +365 -0
- package/src/extension.ts +35 -3
- package/src/hashline.check.mjs +11 -0
- package/src/hashline.ts +71 -0
- package/src/icon-catalog.ts +1 -0
- package/src/migrations.ts +41 -10
- package/src/os.ts +250 -0
- package/src/paths.ts +87 -0
- package/src/persistence.ts +122 -28
- package/src/pix-command.ts +132 -17
- package/src/platform.ts +75 -0
- package/src/runtime.ts +126 -80
- package/src/safe-path.ts +101 -0
- package/src/sections/index.ts +16 -0
- package/src/sections/pretty.ts +37 -2
- package/src/sections/services.ts +71 -0
- package/src/testing.ts +19 -3
- package/src/user-shell.ts +51 -0
- package/src/which.ts +21 -4
package/src/os.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* os.ts — jobs that need a different program on each OS, behind one call.
|
|
3
|
+
*
|
|
4
|
+
* Packages ask for the job ("open this URL", "read the clipboard image"); this
|
|
5
|
+
* module picks the program for the host and runs it through `exec.ts`, so the
|
|
6
|
+
* binary still comes from binary.json / `<agentDir>/bin` / known dirs / PATH.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { resolveTool } from "./binaries/resolve.ts";
|
|
13
|
+
import { quoteForCmd, runTool, runToolSync } from "./exec.ts";
|
|
14
|
+
import { tempDir } from "./paths.ts";
|
|
15
|
+
import { currentPlatform, type HostPlatform } from "./platform.ts";
|
|
16
|
+
|
|
17
|
+
export interface OsOptions {
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
host?: HostPlatform;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Open a URL or path ──────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface OpenOptions extends OsOptions {
|
|
25
|
+
/** Browser/app name or path. Default: the OS default handler. */
|
|
26
|
+
app?: string;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Program + args that open `target` on `host` (exported for tests). */
|
|
31
|
+
export function openCommand(
|
|
32
|
+
target: string,
|
|
33
|
+
host: HostPlatform,
|
|
34
|
+
app?: string,
|
|
35
|
+
): { name: string; args: string[]; verbatim?: boolean } {
|
|
36
|
+
if (host.os === "darwin") return { name: "open", args: app ? ["-a", app, target] : [target] };
|
|
37
|
+
if (host.os === "win32") {
|
|
38
|
+
// `start` is a cmd.exe builtin; "" is its window-title argument.
|
|
39
|
+
const parts = ["start", '""', ...(app ? [quoteForCmd(app)] : []), quoteForCmd(target)];
|
|
40
|
+
return { name: "cmd", args: ["/d", "/s", "/c", `"${parts.join(" ")}"`], verbatim: true };
|
|
41
|
+
}
|
|
42
|
+
if (host.wsl && !app) {
|
|
43
|
+
// WSL: hand the target to Windows so the user's default browser opens.
|
|
44
|
+
return { name: "wslview", args: [target] };
|
|
45
|
+
}
|
|
46
|
+
return { name: app ?? "xdg-open", args: [target] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Open a URL or file with the OS default handler (or `app`). Throws a
|
|
51
|
+
* BinaryMissingError with an install hint when the opener is missing, or an
|
|
52
|
+
* Error with the opener's stderr when it exits non-zero.
|
|
53
|
+
*/
|
|
54
|
+
export async function openTarget(target: string, opts: OpenOptions = {}): Promise<void> {
|
|
55
|
+
const host = opts.host ?? currentPlatform();
|
|
56
|
+
let cmd = openCommand(target, host, opts.app);
|
|
57
|
+
if (cmd.name === "wslview" && !resolveTool("wslview", opts))
|
|
58
|
+
cmd = openCommand(target, { ...host, wsl: false });
|
|
59
|
+
const result = await runTool(cmd.name, cmd.args, {
|
|
60
|
+
env: opts.env,
|
|
61
|
+
host,
|
|
62
|
+
timeoutMs: opts.timeoutMs ?? 15_000,
|
|
63
|
+
verbatim: cmd.verbatim,
|
|
64
|
+
});
|
|
65
|
+
if (result.code !== 0)
|
|
66
|
+
throw new Error(result.stderr.trim() || `${cmd.name} exited with code ${result.code}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Git ─────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface GitOptions extends OsOptions {
|
|
72
|
+
cwd: string;
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
maxBuffer?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `git args…` in `cwd`: stdout on exit 0, null on any git failure (not a
|
|
80
|
+
* repo, timeout, abort, or output cut at `maxBuffer`). A missing git binary rejects with BinaryMissingError
|
|
81
|
+
* so the caller can surface the install hint once (pix-pretty warnBinaryMissing).
|
|
82
|
+
*/
|
|
83
|
+
export async function runGit(args: readonly string[], opts: GitOptions): Promise<string | null> {
|
|
84
|
+
const r = await runTool("git", args, {
|
|
85
|
+
cwd: opts.cwd,
|
|
86
|
+
env: opts.env,
|
|
87
|
+
host: opts.host,
|
|
88
|
+
timeoutMs: opts.timeoutMs ?? 5_000,
|
|
89
|
+
signal: opts.signal,
|
|
90
|
+
maxBuffer: opts.maxBuffer,
|
|
91
|
+
}).catch((err: unknown) => {
|
|
92
|
+
if (err instanceof Error && err.name === "BinaryMissingError") throw err;
|
|
93
|
+
return null;
|
|
94
|
+
});
|
|
95
|
+
return r && r.code === 0 && !r.truncated ? r.stdout : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Clipboard image ─────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const LIST_TIMEOUT_MS = 1000;
|
|
101
|
+
const READ_TIMEOUT_MS = 3000;
|
|
102
|
+
const POWERSHELL_TIMEOUT_MS = 5000;
|
|
103
|
+
|
|
104
|
+
/** Preference order mirrors Pi: PNG first, then other lossless/animated formats. */
|
|
105
|
+
const SUPPORTED_MIME = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
106
|
+
|
|
107
|
+
export interface ClipboardImage {
|
|
108
|
+
bytes: Buffer;
|
|
109
|
+
mimeType: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function baseMime(mimeType: string): string {
|
|
113
|
+
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function pickPreferredMime(types: readonly string[]): string | null {
|
|
117
|
+
const normalized = types
|
|
118
|
+
.map((t) => t.trim())
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.map((t) => ({ raw: t, base: baseMime(t) }));
|
|
121
|
+
for (const preferred of SUPPORTED_MIME) {
|
|
122
|
+
const match = normalized.find((t) => t.base === preferred);
|
|
123
|
+
if (match) return match.raw;
|
|
124
|
+
}
|
|
125
|
+
return normalized.find((t) => t.base.startsWith("image/"))?.raw ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Output bytes when the tool exists and exits 0; null otherwise (probing, never throws). */
|
|
129
|
+
function probe(name: string, args: string[], timeoutMs: number, opts: OsOptions): Buffer | null {
|
|
130
|
+
if (!resolveTool(name, opts)) return null;
|
|
131
|
+
try {
|
|
132
|
+
const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs });
|
|
133
|
+
return r.code === 0 ? r.stdoutBytes : null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function lines(buf: Buffer | null): string[] {
|
|
140
|
+
return buf
|
|
141
|
+
? buf
|
|
142
|
+
.toString("utf-8")
|
|
143
|
+
.split(/\r?\n/)
|
|
144
|
+
.map((t) => t.trim())
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
: [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function viaWlPaste(opts: OsOptions): ClipboardImage | null {
|
|
150
|
+
const selected = pickPreferredMime(
|
|
151
|
+
lines(probe("wl-paste", ["--list-types"], LIST_TIMEOUT_MS, opts)),
|
|
152
|
+
);
|
|
153
|
+
if (!selected) return null;
|
|
154
|
+
const data = probe("wl-paste", ["--type", selected, "--no-newline"], READ_TIMEOUT_MS, opts);
|
|
155
|
+
return data?.length ? { bytes: data, mimeType: baseMime(selected) } : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function viaXclip(opts: OsOptions): ClipboardImage | null {
|
|
159
|
+
const targets = lines(
|
|
160
|
+
probe("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], LIST_TIMEOUT_MS, opts),
|
|
161
|
+
);
|
|
162
|
+
const preferred = targets.length > 0 ? pickPreferredMime(targets) : null;
|
|
163
|
+
for (const mimeType of preferred ? [preferred, ...SUPPORTED_MIME] : SUPPORTED_MIME) {
|
|
164
|
+
const data = probe(
|
|
165
|
+
"xclip",
|
|
166
|
+
["-selection", "clipboard", "-t", mimeType, "-o"],
|
|
167
|
+
READ_TIMEOUT_MS,
|
|
168
|
+
opts,
|
|
169
|
+
);
|
|
170
|
+
if (data?.length) return { bytes: data, mimeType: baseMime(mimeType) };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Windows clipboard via PowerShell — native Windows, or from WSL via powershell.exe. */
|
|
176
|
+
function viaPowerShell(host: HostPlatform, opts: OsOptions): ClipboardImage | null {
|
|
177
|
+
const tmpFile = join(tempDir(), `pix-clip-${randomUUID()}.png`);
|
|
178
|
+
try {
|
|
179
|
+
const winPath =
|
|
180
|
+
host.os === "win32"
|
|
181
|
+
? tmpFile
|
|
182
|
+
: lines(probe("wslpath", ["-w", tmpFile], LIST_TIMEOUT_MS, opts))[0];
|
|
183
|
+
if (!winPath) return null;
|
|
184
|
+
const script = [
|
|
185
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
186
|
+
"Add-Type -AssemblyName System.Drawing",
|
|
187
|
+
`$path = '${winPath.split("'").join("''")}'`,
|
|
188
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
189
|
+
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
|
|
190
|
+
].join("; ");
|
|
191
|
+
const args = ["-NoProfile", "-STA", "-Command", script];
|
|
192
|
+
const out = probe("powershell", args, POWERSHELL_TIMEOUT_MS, opts);
|
|
193
|
+
if (out?.toString("utf-8").trim() !== "ok") return null;
|
|
194
|
+
const bytes = readFileSync(tmpFile);
|
|
195
|
+
return bytes.length ? { bytes, mimeType: "image/png" } : null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
} finally {
|
|
199
|
+
try {
|
|
200
|
+
unlinkSync(tmpFile);
|
|
201
|
+
} catch {
|
|
202
|
+
// best-effort cleanup
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read an image from the system clipboard, or null when there is none (or no
|
|
209
|
+
* clipboard tool). Windows and WSL use PowerShell; Linux uses wl-paste/xclip.
|
|
210
|
+
* macOS and Termux return null (no dependency-free bridge).
|
|
211
|
+
*/
|
|
212
|
+
export function readClipboardImage(opts: OsOptions = {}): ClipboardImage | null {
|
|
213
|
+
const host = opts.host ?? currentPlatform();
|
|
214
|
+
const env = opts.env ?? process.env;
|
|
215
|
+
if (host.os === "win32") return viaPowerShell(host, opts);
|
|
216
|
+
if (host.os !== "linux") return null;
|
|
217
|
+
const wayland = Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
|
|
218
|
+
let image: ClipboardImage | null = null;
|
|
219
|
+
if (wayland || host.wsl) image = viaWlPaste(opts) ?? viaXclip(opts);
|
|
220
|
+
if (!image && host.wsl) image = viaPowerShell(host, opts);
|
|
221
|
+
if (!image && !wayland) image = viaXclip(opts);
|
|
222
|
+
return image;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** {@link readClipboardImage}, spilled to a temp file. Returns the path or null. */
|
|
226
|
+
export function readClipboardImageToFile(opts: OsOptions = {}): string | null {
|
|
227
|
+
const image = readClipboardImage(opts);
|
|
228
|
+
if (!image) return null;
|
|
229
|
+
const ext = extForMime(image.mimeType);
|
|
230
|
+
const filePath = join(tempDir(), `pix-clipboard-${randomUUID()}.${ext}`);
|
|
231
|
+
try {
|
|
232
|
+
writeFileSync(filePath, image.bytes);
|
|
233
|
+
return filePath;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function extForMime(mimeType: string): string {
|
|
240
|
+
switch (baseMime(mimeType)) {
|
|
241
|
+
case "image/jpeg":
|
|
242
|
+
return "jpg";
|
|
243
|
+
case "image/webp":
|
|
244
|
+
return "webp";
|
|
245
|
+
case "image/gif":
|
|
246
|
+
return "gif";
|
|
247
|
+
default:
|
|
248
|
+
return "png";
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paths.ts — canonical Pi/pix directories, pure and env-injectable.
|
|
3
|
+
*
|
|
4
|
+
* `agentDir()` mirrors Pi's `getAgentDir()` (PI_CODING_AGENT_DIR, `~`-expanded,
|
|
5
|
+
* else `~/.pi/agent`) so code that runs outside a Pi process (CLIs, tests) and
|
|
6
|
+
* code inside it agree. `binDir()` is the same folder Pi downloads fd/rg into.
|
|
7
|
+
*
|
|
8
|
+
* Home resolution never trusts `HOME` alone: it is unset in a Windows Pi process,
|
|
9
|
+
* which silently turned `HOME ?? ""` paths into cwd-relative ones.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* User home. For the live process this is exactly `os.homedir()` (what Pi's
|
|
18
|
+
* `getAgentDir` uses). An injected env (tests) is read instead: USERPROFILE on
|
|
19
|
+
* Windows, HOME elsewhere, falling back to `os.homedir()`.
|
|
20
|
+
*/
|
|
21
|
+
export function homeDir(
|
|
22
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
23
|
+
os: NodeJS.Platform = process.platform,
|
|
24
|
+
): string {
|
|
25
|
+
if (env === process.env) return homedir();
|
|
26
|
+
const fromEnv = os === "win32" ? env.USERPROFILE : env.HOME;
|
|
27
|
+
return fromEnv || homedir();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Expand a leading `~` / `~/` / `~\` against {@link homeDir}. */
|
|
31
|
+
export function expandHome(path: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
if (path === "~") return homeDir(env);
|
|
33
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homeDir(env), path.slice(2));
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pi agent dir: `PI_CODING_AGENT_DIR` (tilde-expanded) or `~/.pi/agent`. */
|
|
38
|
+
export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
39
|
+
const override = env.PI_CODING_AGENT_DIR;
|
|
40
|
+
if (override) return expandHome(override, env);
|
|
41
|
+
return join(homeDir(env), ".pi", "agent");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pi's managed binary dir (`<agentDir>/bin`), shared with Pi's fd/rg downloads. */
|
|
45
|
+
export function binDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
46
|
+
return join(agentDir(env), "bin");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Project config dir: `<cwd>/.pi` (Pi's project `settings.json`, `mcp.json`,
|
|
51
|
+
* `agents/`, `plans/`, `lsp.json`). With no `cwd` it returns the relative `.pi`,
|
|
52
|
+
* for paths that resolve against the process cwd or show in text.
|
|
53
|
+
*/
|
|
54
|
+
export function projectDir(cwd = "."): string {
|
|
55
|
+
return join(cwd, ".pi");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* OS temp dir, shared with every other program. Never delete it whole, only
|
|
60
|
+
* your own entries in it. For the live process this is exactly `os.tmpdir()`.
|
|
61
|
+
* An injected env (tests) is read instead: TEMP/TMP on Windows, TMPDIR
|
|
62
|
+
* elsewhere, falling back to `os.tmpdir()`.
|
|
63
|
+
*/
|
|
64
|
+
export function tempDir(
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
os: NodeJS.Platform = process.platform,
|
|
67
|
+
): string {
|
|
68
|
+
if (env === process.env) return tmpdir();
|
|
69
|
+
const fromEnv = os === "win32" ? env.TEMP || env.TMP : env.TMPDIR;
|
|
70
|
+
return fromEnv || tmpdir();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Absolute path to a file shipped next to a module: `moduleFile(import.meta.url, "..", "SOP.md")`.
|
|
75
|
+
* Use it for package assets (SOP.md, skills/). It does not use `URL.pathname`, which gives
|
|
76
|
+
* `/C:/...` on Windows. It does not use `require.resolve("<pkg>/package.json")`, which Node
|
|
77
|
+
* rejects when `exports` omits it.
|
|
78
|
+
*/
|
|
79
|
+
export function moduleFile(moduleUrl: string, ...segments: string[]): string {
|
|
80
|
+
return resolve(dirname(fileURLToPath(moduleUrl)), ...segments);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Pi cache root: `$XDG_CACHE_HOME/pi` or `~/.cache/pi`. */
|
|
84
|
+
export function cacheDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
85
|
+
const xdg = env.XDG_CACHE_HOME;
|
|
86
|
+
return join(xdg || join(homeDir(env), ".cache"), "pi");
|
|
87
|
+
}
|
package/src/persistence.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import {
|
|
10
10
|
closeSync,
|
|
11
11
|
existsSync,
|
|
12
|
+
linkSync,
|
|
12
13
|
mkdirSync,
|
|
13
14
|
openSync,
|
|
14
15
|
readFileSync,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
writeFileSync,
|
|
19
20
|
writeSync,
|
|
20
21
|
} from "node:fs";
|
|
22
|
+
import { hostname } from "node:os";
|
|
21
23
|
import { dirname, join } from "node:path";
|
|
22
24
|
import type { RawDocument } from "./schema.ts";
|
|
23
25
|
|
|
@@ -38,19 +40,62 @@ export class ConfigLockError extends ConfigWriteError {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** The config file is not valid JSON or not a JSON object. Never overwrite it. */
|
|
44
|
+
export class ConfigParseError extends Error {
|
|
45
|
+
constructor(message: string) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "ConfigParseError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
/** Filesystem adapter — tests inject an in-memory or temp-dir implementation. */
|
|
42
52
|
export interface StorageAdapter {
|
|
43
53
|
readonly path: string;
|
|
44
54
|
readRaw(): string | undefined;
|
|
45
|
-
/**
|
|
46
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Read-modify-write under the cross-process lock: `fn` gets the current file
|
|
57
|
+
* text and returns the new contents (atomically written) or undefined (no
|
|
58
|
+
* write). Another process cannot write between the read and the write.
|
|
59
|
+
*/
|
|
60
|
+
transact(fn: (raw: string | undefined) => string | undefined): void;
|
|
47
61
|
ensureDir(): void;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
|
-
|
|
64
|
+
// A healthy holder keeps the lock for milliseconds (read + write + rename).
|
|
65
|
+
// Stale must be shorter than the retry budget, or a crashed holder makes every
|
|
66
|
+
// writer fail until it expires.
|
|
67
|
+
const LOCK_STALE_MS = 4_000;
|
|
51
68
|
const LOCK_RETRY_MS = 25;
|
|
52
69
|
const LOCK_MAX_RETRIES = 200; // ~5s budget
|
|
53
70
|
|
|
71
|
+
interface LockOwner {
|
|
72
|
+
pid: number;
|
|
73
|
+
host: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseOwner(text: string): LockOwner | undefined {
|
|
77
|
+
try {
|
|
78
|
+
const o = JSON.parse(text) as Partial<LockOwner>;
|
|
79
|
+
return typeof o.pid === "number" && typeof o.host === "string"
|
|
80
|
+
? { pid: o.pid, host: o.host }
|
|
81
|
+
: undefined;
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True only when the owner ran on this host and its process is gone. */
|
|
88
|
+
function ownerIsDead(owner: LockOwner | undefined): boolean {
|
|
89
|
+
if (!owner || owner.host !== hostname() || owner.pid === process.pid) return false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(owner.pid, 0);
|
|
92
|
+
return false;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// EPERM: the process exists under another user.
|
|
95
|
+
return (err as NodeJS.ErrnoException).code === "ESRCH";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
54
99
|
/** Node/Bun filesystem storage rooted at `<agentDir>/pix.json`. */
|
|
55
100
|
export class FileStorage implements StorageAdapter {
|
|
56
101
|
readonly path: string;
|
|
@@ -75,28 +120,63 @@ export class FileStorage implements StorageAdapter {
|
|
|
75
120
|
}
|
|
76
121
|
|
|
77
122
|
private acquireLock(): void {
|
|
123
|
+
const me = JSON.stringify({ pid: process.pid, host: hostname() } satisfies LockOwner);
|
|
78
124
|
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
79
125
|
try {
|
|
80
126
|
const fd = openSync(this.lockPath, "wx", 0o600);
|
|
81
|
-
writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
82
|
-
closeSync(fd);
|
|
83
|
-
return;
|
|
84
|
-
} catch {
|
|
85
|
-
// Reclaim a stale lock only when older than the threshold.
|
|
86
127
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
// Lock vanished between open and stat — retry immediately.
|
|
94
|
-
continue;
|
|
128
|
+
writeSync(fd, me);
|
|
129
|
+
} finally {
|
|
130
|
+
closeSync(fd);
|
|
95
131
|
}
|
|
96
|
-
|
|
132
|
+
return;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Only EEXIST means "another writer holds it". EACCES/EROFS/ENOSPC will
|
|
135
|
+
// not clear by waiting, so fail at once with the real cause.
|
|
136
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST")
|
|
137
|
+
throw new ConfigLockError(`could not create ${this.lockPath}`, err);
|
|
97
138
|
}
|
|
139
|
+
if (this.reclaimIfStale()) continue;
|
|
140
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
|
|
141
|
+
}
|
|
142
|
+
throw new ConfigLockError(`could not acquire ${this.lockPath} (held by another process)`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove the lock when its owner process is dead (same host) or it is older
|
|
147
|
+
* than {@link LOCK_STALE_MS}. Returns true when the caller should retry now.
|
|
148
|
+
*
|
|
149
|
+
* The lock moves away by rename, which is atomic, so only one waiter takes a
|
|
150
|
+
* given lock file. If that file is not the one judged stale (another waiter
|
|
151
|
+
* already reclaimed it and a new holder wrote a fresh lock), it goes back with
|
|
152
|
+
* linkSync, which never overwrites.
|
|
153
|
+
* ponytail: a third writer can still lock in the few microseconds between the
|
|
154
|
+
* rename and the link. Then the restored holder loses mutual exclusion once.
|
|
155
|
+
* Upgrade path: an OS file lock (flock / LockFileEx) through a native addon.
|
|
156
|
+
*/
|
|
157
|
+
private reclaimIfStale(): boolean {
|
|
158
|
+
let text: string;
|
|
159
|
+
let mtimeMs: number;
|
|
160
|
+
try {
|
|
161
|
+
text = readFileSync(this.lockPath, "utf-8");
|
|
162
|
+
mtimeMs = statSync(this.lockPath).mtimeMs;
|
|
163
|
+
} catch {
|
|
164
|
+
return true; // The lock vanished between open and read: retry at once.
|
|
165
|
+
}
|
|
166
|
+
if (!ownerIsDead(parseOwner(text)) && Date.now() - mtimeMs <= LOCK_STALE_MS) return false;
|
|
167
|
+
const moved = `${this.lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
168
|
+
try {
|
|
169
|
+
renameSync(this.lockPath, moved);
|
|
170
|
+
} catch {
|
|
171
|
+
return true; // Another waiter took it first.
|
|
98
172
|
}
|
|
99
|
-
|
|
173
|
+
try {
|
|
174
|
+
if (readFileSync(moved, "utf-8") !== text) linkSync(moved, this.lockPath);
|
|
175
|
+
} catch {
|
|
176
|
+
// EEXIST: a newer lock exists, which is correct. Unreadable: treat as stale.
|
|
177
|
+
}
|
|
178
|
+
rmSync(moved, { force: true });
|
|
179
|
+
return true;
|
|
100
180
|
}
|
|
101
181
|
|
|
102
182
|
private releaseLock(): void {
|
|
@@ -107,9 +187,19 @@ export class FileStorage implements StorageAdapter {
|
|
|
107
187
|
}
|
|
108
188
|
}
|
|
109
189
|
|
|
110
|
-
|
|
190
|
+
transact(fn: (raw: string | undefined) => string | undefined): void {
|
|
111
191
|
this.ensureDir();
|
|
112
192
|
this.acquireLock();
|
|
193
|
+
try {
|
|
194
|
+
const contents = fn(this.readRaw());
|
|
195
|
+
if (contents !== undefined) this.writeAtomic(contents);
|
|
196
|
+
} finally {
|
|
197
|
+
this.releaseLock();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Temp file + rename. The caller holds the lock. */
|
|
202
|
+
private writeAtomic(contents: string): void {
|
|
113
203
|
const tmp = `${this.path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
114
204
|
try {
|
|
115
205
|
writeFileSync(tmp, contents, { mode: 0o600 });
|
|
@@ -121,8 +211,6 @@ export class FileStorage implements StorageAdapter {
|
|
|
121
211
|
/* ignore */
|
|
122
212
|
}
|
|
123
213
|
throw new ConfigWriteError(`write failed: ${this.path}`, err);
|
|
124
|
-
} finally {
|
|
125
|
-
this.releaseLock();
|
|
126
214
|
}
|
|
127
215
|
}
|
|
128
216
|
}
|
|
@@ -149,16 +237,22 @@ export class WriteQueue {
|
|
|
149
237
|
|
|
150
238
|
// ── Raw document read/parse ──────────────────────────────────────────────────
|
|
151
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Parse the config file. Missing or empty means `{}`. Invalid JSON or a
|
|
242
|
+
* non-object throws {@link ConfigParseError}: a hand-edit typo must never be
|
|
243
|
+
* read as "all defaults" and then written back over the user's file.
|
|
244
|
+
*/
|
|
152
245
|
export function parseRawDocument(text: string | undefined): RawDocument {
|
|
153
|
-
if (!text) return {};
|
|
246
|
+
if (!text?.trim()) return {};
|
|
247
|
+
let parsed: unknown;
|
|
154
248
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
: {};
|
|
159
|
-
} catch {
|
|
160
|
-
return {};
|
|
249
|
+
parsed = JSON.parse(text);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
throw new ConfigParseError(`invalid JSON: ${(err as Error).message}`);
|
|
161
252
|
}
|
|
253
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
254
|
+
throw new ConfigParseError("top level is not a JSON object");
|
|
255
|
+
return parsed as RawDocument;
|
|
162
256
|
}
|
|
163
257
|
|
|
164
258
|
export function serializeRawDocument(doc: RawDocument): string {
|