@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/exec.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec.ts — run a catalogued binary by name, on any OS.
|
|
3
|
+
*
|
|
4
|
+
* Every pix package that starts an external program goes through here, so:
|
|
5
|
+
* - the binary comes from the one resolver (binary.json → `<agentDir>/bin` →
|
|
6
|
+
* known install dirs → PATH), never a bare-name OS lookup;
|
|
7
|
+
* - a missing binary is a {@link BinaryMissingError} with an install hint, and
|
|
8
|
+
* the async runners download it first when the catalog has a trusted release;
|
|
9
|
+
* - Windows `.cmd`/`.bat` shims (npm, npx, pi) run through `cmd.exe` with
|
|
10
|
+
* strict quoting, since Node refuses to spawn them without a shell.
|
|
11
|
+
*
|
|
12
|
+
* Callers pass the tool's arguments unchanged; this layer never adds a shell
|
|
13
|
+
* for real executables.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type ChildProcess,
|
|
18
|
+
type ChildProcessWithoutNullStreams,
|
|
19
|
+
type SpawnOptions,
|
|
20
|
+
type SpawnOptionsWithStdioTuple,
|
|
21
|
+
type SpawnSyncOptions,
|
|
22
|
+
type StdioNull,
|
|
23
|
+
type StdioPipe,
|
|
24
|
+
spawn,
|
|
25
|
+
spawnSync,
|
|
26
|
+
} from "node:child_process";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import type { Readable, Writable } from "node:stream";
|
|
29
|
+
import { type EnsureOptions, ensureTool, type ToolStatus } from "./binaries/ensure.ts";
|
|
30
|
+
import { type LookupOptions, type ResolvedTool, requireTool } from "./binaries/resolve.ts";
|
|
31
|
+
import { currentPlatform, type HostPlatform } from "./platform.ts";
|
|
32
|
+
|
|
33
|
+
// ── Windows shim quoting ────────────────────────────────────────────────────
|
|
34
|
+
// Ported from cross-spawn (MIT): arguments are quoted for the program's
|
|
35
|
+
// CommandLineToArgvW parsing, then cmd.exe metacharacters are caret-escaped.
|
|
36
|
+
// npm-style shims under node_modules/.bin re-parse their arguments, so they get
|
|
37
|
+
// a second round of caret escaping.
|
|
38
|
+
|
|
39
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
|
|
40
|
+
const CMD_SHIM_IN_BIN = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i;
|
|
41
|
+
|
|
42
|
+
/** True when `path` is a batch file that needs `cmd.exe` to run. */
|
|
43
|
+
export function isBatchFile(path: string): boolean {
|
|
44
|
+
return /\.(cmd|bat)$/i.test(path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Quote one argument for a `cmd.exe /d /s /c "…"` line. */
|
|
48
|
+
export function quoteForCmd(arg: string, doubleEscape = false): string {
|
|
49
|
+
let out = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
|
|
50
|
+
out = `"${out}"`.replace(CMD_META, "^$1");
|
|
51
|
+
return doubleEscape ? out.replace(CMD_META, "^$1") : out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CommandLine {
|
|
55
|
+
command: string;
|
|
56
|
+
args: string[];
|
|
57
|
+
/** Must be passed to spawn so Node does not re-quote the cmd.exe line. */
|
|
58
|
+
windowsVerbatimArguments?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Translate `path args…` into what spawn needs on this host. Real executables
|
|
63
|
+
* pass through untouched; Windows batch files become one quoted `cmd.exe` line.
|
|
64
|
+
*/
|
|
65
|
+
export function commandLine(
|
|
66
|
+
path: string,
|
|
67
|
+
args: readonly string[],
|
|
68
|
+
host: HostPlatform = currentPlatform(),
|
|
69
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
70
|
+
): CommandLine {
|
|
71
|
+
if (host.os !== "win32" || !isBatchFile(path)) return { command: path, args: [...args] };
|
|
72
|
+
const doubleEscape = CMD_SHIM_IN_BIN.test(path);
|
|
73
|
+
const line = [
|
|
74
|
+
path.replace(CMD_META, "^$1"),
|
|
75
|
+
...args.map((a) => quoteForCmd(a, doubleEscape)),
|
|
76
|
+
].join(" ");
|
|
77
|
+
const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
|
|
78
|
+
return {
|
|
79
|
+
command: env.ComSpec ?? env.COMSPEC ?? join(root, "System32", "cmd.exe"),
|
|
80
|
+
args: ["/d", "/s", "/c", `"${line}"`],
|
|
81
|
+
windowsVerbatimArguments: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Runners ─────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export interface ToolExecOptions {
|
|
88
|
+
cwd?: string;
|
|
89
|
+
/** Child environment; also where PATH is read for the lookup. Default process.env. */
|
|
90
|
+
env?: NodeJS.ProcessEnv;
|
|
91
|
+
/** Kill the child after this many ms (result has `timedOut: true`). */
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
/** Written to stdin, then stdin is closed. */
|
|
95
|
+
input?: string | Buffer;
|
|
96
|
+
/** Download progress when the binary is fetched first (async runners only). */
|
|
97
|
+
onStatus?: (status: ToolStatus) => void;
|
|
98
|
+
/** Test seam for the host OS. */
|
|
99
|
+
host?: HostPlatform;
|
|
100
|
+
/** Cap on captured stdout/stderr bytes each (default 50 MB). */
|
|
101
|
+
maxBuffer?: number;
|
|
102
|
+
/** Args are already quoted for the target (cmd.exe `/c` lines); Node must not re-quote. */
|
|
103
|
+
verbatim?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ToolRunResult {
|
|
107
|
+
/** Exit code, or null when killed by a signal/timeout. */
|
|
108
|
+
code: number | null;
|
|
109
|
+
stdout: string;
|
|
110
|
+
stderr: string;
|
|
111
|
+
/** Raw stdout for binary output (clipboard images, archives). */
|
|
112
|
+
stdoutBytes: Buffer;
|
|
113
|
+
timedOut: boolean;
|
|
114
|
+
/** Output passed `maxBuffer` and was cut. A zero exit code is then not a complete result. */
|
|
115
|
+
truncated: boolean;
|
|
116
|
+
/** The binary that ran and where it was found. */
|
|
117
|
+
tool: ResolvedTool;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const MAX_BUFFER = 50 * 1024 * 1024;
|
|
121
|
+
/** Time between SIGTERM and SIGKILL, and then before open pipes are dropped. */
|
|
122
|
+
const KILL_GRACE_MS = 1500;
|
|
123
|
+
|
|
124
|
+
function lookupOpts(opts: ToolExecOptions): LookupOptions {
|
|
125
|
+
return { env: opts.env, host: opts.host };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Kill a child and its descendants. Windows: `taskkill /T /F` (a cmd.exe wrapper
|
|
130
|
+
* leaves the shim running otherwise). POSIX: signal the child's process group
|
|
131
|
+
* (runTool starts it as a group leader), because a descendant that keeps
|
|
132
|
+
* stdout open would otherwise delay "close" past the timeout.
|
|
133
|
+
*/
|
|
134
|
+
function killTree(
|
|
135
|
+
child: ChildProcess,
|
|
136
|
+
host: HostPlatform,
|
|
137
|
+
env: NodeJS.ProcessEnv,
|
|
138
|
+
signal: NodeJS.Signals = "SIGTERM",
|
|
139
|
+
): void {
|
|
140
|
+
if (!child.pid) return;
|
|
141
|
+
if (host.os === "win32") {
|
|
142
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
143
|
+
const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
|
|
144
|
+
spawnSync(join(root, "System32", "taskkill.exe"), ["/pid", String(child.pid), "/T", "/F"], {
|
|
145
|
+
stdio: "ignore",
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// The group can outlive the leader, so do not skip on child exit.
|
|
151
|
+
try {
|
|
152
|
+
process.kill(-child.pid, signal);
|
|
153
|
+
} catch {
|
|
154
|
+
// ESRCH: the group is gone. EPERM: not a group we own; fall back to the child.
|
|
155
|
+
if (child.exitCode === null && child.signalCode === null) child.kill(signal);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type HostOpt = { host?: HostPlatform };
|
|
160
|
+
type Pipe = StdioPipe;
|
|
161
|
+
type Null = StdioNull;
|
|
162
|
+
/** Child whose piped streams are typed non-null, mirroring Node's spawn overloads. */
|
|
163
|
+
type Piped<I, O, E> = ChildProcess & {
|
|
164
|
+
stdin: I extends Pipe ? Writable : null;
|
|
165
|
+
stdout: O extends Pipe ? Readable : null;
|
|
166
|
+
stderr: E extends Pipe ? Readable : null;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Start a resolved binary with Node's spawn options (stdio, detached, …).
|
|
171
|
+
* Sync lookup, no download; throws {@link BinaryMissingError} when missing.
|
|
172
|
+
*/
|
|
173
|
+
export function spawnTool<I extends Pipe | Null, O extends Pipe | Null, E extends Pipe | Null>(
|
|
174
|
+
name: string,
|
|
175
|
+
args: readonly string[],
|
|
176
|
+
options: SpawnOptionsWithStdioTuple<I, O, E> & HostOpt,
|
|
177
|
+
): Piped<I, O, E>;
|
|
178
|
+
export function spawnTool(
|
|
179
|
+
name: string,
|
|
180
|
+
args?: readonly string[],
|
|
181
|
+
options?: Omit<SpawnOptions, "stdio"> & HostOpt,
|
|
182
|
+
): ChildProcessWithoutNullStreams;
|
|
183
|
+
export function spawnTool(
|
|
184
|
+
name: string,
|
|
185
|
+
args: readonly string[],
|
|
186
|
+
options: SpawnOptions & HostOpt,
|
|
187
|
+
): ChildProcess;
|
|
188
|
+
export function spawnTool(
|
|
189
|
+
name: string,
|
|
190
|
+
args: readonly string[] = [],
|
|
191
|
+
options: SpawnOptions & HostOpt = {},
|
|
192
|
+
): ChildProcess {
|
|
193
|
+
const { host: hostOpt, ...spawnOpts } = options;
|
|
194
|
+
const host = hostOpt ?? currentPlatform();
|
|
195
|
+
const env = spawnOpts.env ?? process.env;
|
|
196
|
+
const tool = requireTool(name, { env, host });
|
|
197
|
+
const line = commandLine(tool.path, args, host, env);
|
|
198
|
+
return spawn(line.command, line.args, {
|
|
199
|
+
windowsHide: true,
|
|
200
|
+
...spawnOpts,
|
|
201
|
+
windowsVerbatimArguments: line.windowsVerbatimArguments ?? spawnOpts.windowsVerbatimArguments,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Run a resolved child to completion, collecting output. */
|
|
206
|
+
function collect(
|
|
207
|
+
child: ChildProcess,
|
|
208
|
+
tool: ResolvedTool,
|
|
209
|
+
opts: ToolExecOptions,
|
|
210
|
+
host: HostPlatform,
|
|
211
|
+
) {
|
|
212
|
+
const env = opts.env ?? process.env;
|
|
213
|
+
const cap = opts.maxBuffer ?? MAX_BUFFER;
|
|
214
|
+
return new Promise<ToolRunResult>((resolve, reject) => {
|
|
215
|
+
const out: Buffer[] = [];
|
|
216
|
+
const err: Buffer[] = [];
|
|
217
|
+
let outLen = 0;
|
|
218
|
+
let errLen = 0;
|
|
219
|
+
let timedOut = false;
|
|
220
|
+
let truncated = false;
|
|
221
|
+
const push = (list: Buffer[], chunk: Buffer, len: number): number => {
|
|
222
|
+
const room = cap - len;
|
|
223
|
+
if (chunk.length > room) truncated = true;
|
|
224
|
+
if (room <= 0) return len;
|
|
225
|
+
list.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
|
|
226
|
+
return len + Math.min(chunk.length, room);
|
|
227
|
+
};
|
|
228
|
+
child.stdout?.on("data", (c: Buffer) => {
|
|
229
|
+
outLen = push(out, c, outLen);
|
|
230
|
+
});
|
|
231
|
+
child.stderr?.on("data", (c: Buffer) => {
|
|
232
|
+
errLen = push(err, c, errLen);
|
|
233
|
+
});
|
|
234
|
+
const escalation: NodeJS.Timeout[] = [];
|
|
235
|
+
const stop = () => {
|
|
236
|
+
if (escalation.length > 0) return;
|
|
237
|
+
killTree(child, host, env);
|
|
238
|
+
// A child may ignore SIGTERM. A descendant may leave the group and keep a
|
|
239
|
+
// pipe open. Both would hold "close" forever, so escalate, then drop pipes.
|
|
240
|
+
escalation.push(
|
|
241
|
+
setTimeout(() => killTree(child, host, env, "SIGKILL"), KILL_GRACE_MS),
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
child.stdout?.destroy();
|
|
244
|
+
child.stderr?.destroy();
|
|
245
|
+
}, KILL_GRACE_MS * 2),
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
const timer =
|
|
249
|
+
opts.timeoutMs && opts.timeoutMs > 0
|
|
250
|
+
? setTimeout(() => {
|
|
251
|
+
timedOut = true;
|
|
252
|
+
stop();
|
|
253
|
+
}, opts.timeoutMs)
|
|
254
|
+
: undefined;
|
|
255
|
+
opts.signal?.addEventListener("abort", stop, { once: true });
|
|
256
|
+
const done = () => {
|
|
257
|
+
if (timer) clearTimeout(timer);
|
|
258
|
+
for (const t of escalation) clearTimeout(t);
|
|
259
|
+
opts.signal?.removeEventListener("abort", stop);
|
|
260
|
+
};
|
|
261
|
+
child.on("error", (e) => {
|
|
262
|
+
done();
|
|
263
|
+
reject(e);
|
|
264
|
+
});
|
|
265
|
+
child.on("close", (code) => {
|
|
266
|
+
done();
|
|
267
|
+
const stdoutBytes = Buffer.concat(out);
|
|
268
|
+
resolve({
|
|
269
|
+
code: timedOut ? null : code,
|
|
270
|
+
stdout: stdoutBytes.toString("utf-8"),
|
|
271
|
+
stderr: Buffer.concat(err).toString("utf-8"),
|
|
272
|
+
stdoutBytes,
|
|
273
|
+
timedOut,
|
|
274
|
+
truncated,
|
|
275
|
+
tool,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
// A child may exit without reading stdin (EPIPE). With no listener that is an
|
|
279
|
+
// uncaught stream error that kills the host. The exit code still reports the
|
|
280
|
+
// outcome. Other stdin errors come back through child "error" or "close".
|
|
281
|
+
child.stdin?.on("error", () => {});
|
|
282
|
+
if (opts.input !== undefined) child.stdin?.end(opts.input);
|
|
283
|
+
else child.stdin?.end();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Run `name args…` and capture its output. Downloads the binary first when it
|
|
289
|
+
* is missing and the catalog has a release for this host (progress via
|
|
290
|
+
* `onStatus`). Rejects with {@link BinaryMissingError} when it cannot be found.
|
|
291
|
+
* A non-zero exit resolves normally — check `code`.
|
|
292
|
+
*/
|
|
293
|
+
export async function runTool(
|
|
294
|
+
name: string,
|
|
295
|
+
args: readonly string[],
|
|
296
|
+
opts: ToolExecOptions = {},
|
|
297
|
+
): Promise<ToolRunResult> {
|
|
298
|
+
const host = opts.host ?? currentPlatform();
|
|
299
|
+
const ensure: EnsureOptions = {
|
|
300
|
+
env: opts.env,
|
|
301
|
+
host,
|
|
302
|
+
onStatus: opts.onStatus,
|
|
303
|
+
signal: opts.signal,
|
|
304
|
+
};
|
|
305
|
+
const tool = await ensureTool(name, ensure);
|
|
306
|
+
if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("aborted");
|
|
307
|
+
const env = opts.env ?? process.env;
|
|
308
|
+
const line = opts.verbatim
|
|
309
|
+
? { command: tool.path, args: [...args], windowsVerbatimArguments: true }
|
|
310
|
+
: commandLine(tool.path, args, host, env);
|
|
311
|
+
const child = spawn(line.command, line.args, {
|
|
312
|
+
cwd: opts.cwd,
|
|
313
|
+
env,
|
|
314
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
315
|
+
// POSIX: own process group, so killTree reaches descendants. On Windows
|
|
316
|
+
// `detached` opens a new console, and taskkill /T covers the tree.
|
|
317
|
+
// ponytail: the group also has no controlling terminal, so a tool cannot
|
|
318
|
+
// prompt on /dev/tty. runTool is for captured, non-interactive runs.
|
|
319
|
+
// Interactive tools use spawnTool.
|
|
320
|
+
detached: host.os !== "win32",
|
|
321
|
+
windowsHide: true,
|
|
322
|
+
windowsVerbatimArguments: line.windowsVerbatimArguments,
|
|
323
|
+
});
|
|
324
|
+
return collect(child, tool, opts, host);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Blocking {@link runTool} for startup probes and sync call sites. Never
|
|
329
|
+
* downloads; throws {@link BinaryMissingError} when missing.
|
|
330
|
+
*/
|
|
331
|
+
export function runToolSync(
|
|
332
|
+
name: string,
|
|
333
|
+
args: readonly string[],
|
|
334
|
+
opts: Omit<ToolExecOptions, "signal" | "onStatus"> = {},
|
|
335
|
+
): ToolRunResult {
|
|
336
|
+
const host = opts.host ?? currentPlatform();
|
|
337
|
+
const env = opts.env ?? process.env;
|
|
338
|
+
const tool = requireTool(name, lookupOpts({ ...opts, host }));
|
|
339
|
+
const line = commandLine(tool.path, args, host, env);
|
|
340
|
+
const sync: SpawnSyncOptions = {
|
|
341
|
+
cwd: opts.cwd,
|
|
342
|
+
env,
|
|
343
|
+
input: opts.input,
|
|
344
|
+
timeout: opts.timeoutMs,
|
|
345
|
+
maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
|
|
346
|
+
windowsHide: true,
|
|
347
|
+
windowsVerbatimArguments: line.windowsVerbatimArguments,
|
|
348
|
+
};
|
|
349
|
+
const r = spawnSync(line.command, line.args, sync);
|
|
350
|
+
// Over maxBuffer, spawnSync kills the child and sets ENOBUFS. That throws below,
|
|
351
|
+
// so a sync result is never truncated.
|
|
352
|
+
if (r.error && (r.error as NodeJS.ErrnoException).code !== "ETIMEDOUT") throw r.error;
|
|
353
|
+
const stdoutBytes = Buffer.isBuffer(r.stdout) ? r.stdout : Buffer.from(r.stdout ?? "");
|
|
354
|
+
const stderr = Buffer.isBuffer(r.stderr) ? r.stderr.toString("utf-8") : String(r.stderr ?? "");
|
|
355
|
+
const timedOut = (r.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
|
|
356
|
+
return {
|
|
357
|
+
code: timedOut ? null : r.status,
|
|
358
|
+
stdout: stdoutBytes.toString("utf-8"),
|
|
359
|
+
stderr,
|
|
360
|
+
stdoutBytes,
|
|
361
|
+
timedOut,
|
|
362
|
+
truncated: false,
|
|
363
|
+
tool,
|
|
364
|
+
};
|
|
365
|
+
}
|
package/src/extension.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type BashOperations,
|
|
3
|
+
createLocalBashOperations,
|
|
4
|
+
createLocalPowerShellOperations,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { syncBinaryStore } from "./binaries/store.ts";
|
|
2
8
|
import { bindHerdrNotify } from "./herdr-notify.ts";
|
|
3
9
|
import { bindAgentStateEvents, resetAgentState, resetUnattendedState } from "./herdr-state.ts";
|
|
4
10
|
import { once } from "./once.ts";
|
|
5
11
|
import { registerPixCommand } from "./pix-command.ts";
|
|
6
12
|
import { pixRuntime } from "./runtime.ts";
|
|
13
|
+
import { userShell } from "./user-shell.ts";
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Runtime extension entry. Idempotent per `pi` instance: registers `/pix` and
|
|
@@ -21,16 +28,38 @@ export default function registerRuntime(pi: ExtensionAPI): void {
|
|
|
21
28
|
const unbindAgentState = bindAgentStateEvents(pi.events);
|
|
22
29
|
const unbindHerdrNotify = bindHerdrNotify(pi.events);
|
|
23
30
|
|
|
31
|
+
// User `!` commands: PowerShell on Windows, $SHELL on POSIX (zsh gets rc + aliases).
|
|
32
|
+
const shell = userShell();
|
|
33
|
+
// Older Pi has no PowerShell backend. Keep Pi's default shell then.
|
|
34
|
+
const inner =
|
|
35
|
+
shell?.kind === "powershell"
|
|
36
|
+
? createLocalPowerShellOperations?.()
|
|
37
|
+
: shell && createLocalBashOperations({ shellPath: shell.shellPath });
|
|
38
|
+
if (shell && inner) {
|
|
39
|
+
const operations: BashOperations = {
|
|
40
|
+
exec: (command, cwd, options) => inner.exec(shell.wrap(command), cwd, options),
|
|
41
|
+
};
|
|
42
|
+
pi.on("user_bash", () => ({ operations }));
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
let initialized = false;
|
|
25
46
|
pi.on("session_start", async () => {
|
|
26
47
|
resetUnattendedState(pi.events);
|
|
27
48
|
if (initialized) {
|
|
28
49
|
await runtime.reload({ origin: "reload", source: "session_start" });
|
|
29
50
|
} else {
|
|
30
|
-
initialized = true;
|
|
31
51
|
await runtime.init({ origin: "init", source: "session_start" });
|
|
52
|
+
// Set only on success: a failed init retries init (with migrations) next session.
|
|
53
|
+
initialized = true;
|
|
32
54
|
}
|
|
33
55
|
surfaceDiagnostics(pi, runtime);
|
|
56
|
+
// binary.json holds overrides only; drop legacy `null` padding, report bad JSON.
|
|
57
|
+
try {
|
|
58
|
+
const store = syncBinaryStore();
|
|
59
|
+
if (store.error) notifyWarning(pi, `pix: ${store.path} is invalid (${store.error})`);
|
|
60
|
+
} catch {
|
|
61
|
+
/* read-only agent dir: the Binaries tab still resolves without the file */
|
|
62
|
+
}
|
|
34
63
|
});
|
|
35
64
|
|
|
36
65
|
pi.on("session_shutdown", async () => {
|
|
@@ -47,7 +76,10 @@ export default function registerRuntime(pi: ExtensionAPI): void {
|
|
|
47
76
|
function surfaceDiagnostics(pi: ExtensionAPI, runtime: ReturnType<typeof pixRuntime>): void {
|
|
48
77
|
const errors = runtime.diagnostics().filter((d) => d.severity === "error");
|
|
49
78
|
if (errors.length === 0) return;
|
|
79
|
+
notifyWarning(pi, `pix config: ${errors.length} issue(s) — see ${runtime.path}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function notifyWarning(pi: ExtensionAPI, msg: string): void {
|
|
50
83
|
const ui = (pi as unknown as { ui?: { notify?(m: string, t?: string): void } }).ui;
|
|
51
|
-
const msg = `pix config: ${errors.length} issue(s) — see ${runtime.path}`;
|
|
52
84
|
ui?.notify?.(msg, "warning");
|
|
53
85
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import xxhash from "xxhash-wasm";
|
|
3
|
+
|
|
4
|
+
const { h32, h32Raw } = await xxhash();
|
|
5
|
+
// Official xxHash v0.8.3 tests/sanity_test_vectors.h, empty-input vectors.
|
|
6
|
+
assert.equal(h32("", 0), 0x02cc5d05);
|
|
7
|
+
assert.equal(h32("", 0x9e3779b1), 0x36b78ae7);
|
|
8
|
+
for (const text of ["", "hello", "中文🙂", " a ", "\tconst x = 1; "]) {
|
|
9
|
+
assert.equal(h32(text, 0), h32Raw(new TextEncoder().encode(text), 0));
|
|
10
|
+
console.log(`${(h32(text, 0) & 0xfff).toString(16).toUpperCase().padStart(3, "0")}|${text}`);
|
|
11
|
+
}
|
package/src/hashline.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import xxhash from "xxhash-wasm";
|
|
2
|
+
|
|
3
|
+
// Pi reevaluates shared modules with Jiti. Both tools must share one WASM instance.
|
|
4
|
+
const global = globalThis as typeof globalThis & {
|
|
5
|
+
__pixHashline?: {
|
|
6
|
+
initialization?: Promise<void>;
|
|
7
|
+
h32?: (input: string, seed?: number) => number;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
const state = global.__pixHashline ?? {};
|
|
11
|
+
global.__pixHashline = state;
|
|
12
|
+
|
|
13
|
+
export function initHashline(): Promise<void> {
|
|
14
|
+
state.initialization ??= xxhash()
|
|
15
|
+
.then((hash) => {
|
|
16
|
+
state.h32 = hash.h32;
|
|
17
|
+
})
|
|
18
|
+
.catch((error) => {
|
|
19
|
+
throw new Error("Hashline WASM initialization failed", { cause: error });
|
|
20
|
+
});
|
|
21
|
+
return state.initialization;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hashLine(text: string): string {
|
|
25
|
+
if (!state.h32) throw new Error("Hashline WASM is not initialized");
|
|
26
|
+
return (state.h32(text, 0) & 0xfff).toString(16).toUpperCase().padStart(3, "0");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function anchor(line: number, text: string): string {
|
|
30
|
+
return `${line}#${hashLine(text)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseAnchor(value: unknown): { line: number; hash: string } {
|
|
34
|
+
const match = typeof value === "string" ? /^([1-9]\d*)#([0-9A-F]{3})$/.exec(value) : null;
|
|
35
|
+
if (!match || !Number.isSafeInteger(Number(match[1])))
|
|
36
|
+
throw new Error(`Invalid anchor: ${String(value)}`);
|
|
37
|
+
return { line: Number(match[1]), hash: match[2]! };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SourceLine = { text: string; ending: string };
|
|
41
|
+
|
|
42
|
+
export function decodeLines(bytes: Uint8Array): {
|
|
43
|
+
bom: string;
|
|
44
|
+
lines: SourceLine[];
|
|
45
|
+
delimiter: string;
|
|
46
|
+
finalNewline: boolean;
|
|
47
|
+
} {
|
|
48
|
+
const decoded = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
49
|
+
if (decoded.includes("\0")) throw new Error("Unsupported binary text: NUL byte");
|
|
50
|
+
const bom = decoded.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
51
|
+
const text = decoded.slice(bom.length);
|
|
52
|
+
const lines: SourceLine[] = [];
|
|
53
|
+
const counts = new Map<string, number>();
|
|
54
|
+
let start = 0;
|
|
55
|
+
for (const match of text.matchAll(/\r\n|\r|\n/g)) {
|
|
56
|
+
const ending = match[0];
|
|
57
|
+
lines.push({ text: text.slice(start, match.index), ending });
|
|
58
|
+
counts.set(ending, (counts.get(ending) ?? 0) + 1);
|
|
59
|
+
start = match.index + ending.length;
|
|
60
|
+
}
|
|
61
|
+
if (start < text.length) lines.push({ text: text.slice(start), ending: "" });
|
|
62
|
+
let delimiter = "\n";
|
|
63
|
+
let highest = 0;
|
|
64
|
+
for (const [ending, count] of counts) {
|
|
65
|
+
if (count > highest) {
|
|
66
|
+
delimiter = ending;
|
|
67
|
+
highest = count;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { bom, lines, delimiter, finalNewline: lines.length > 0 && lines.at(-1)!.ending !== "" };
|
|
71
|
+
}
|
package/src/icon-catalog.ts
CHANGED
|
@@ -99,6 +99,7 @@ const CATALOG = {
|
|
|
99
99
|
// ── paste chips (pix-display) ─────────────────────────────────────────
|
|
100
100
|
"paste.image": { nerd: "\u{F02E9}", unicode: `\u25A3${VS}`, ascii: "img" },
|
|
101
101
|
"paste.text": { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "txt" },
|
|
102
|
+
"paste.prompt": { nerd: "\u{F0D0}", unicode: `\u2726${VS}`, ascii: "pr" },
|
|
102
103
|
|
|
103
104
|
// ── model picker (pix-models) ─────────────────────────────────────────
|
|
104
105
|
"picker.model": { nerd: "\u{F0229}", unicode: `\u25C8${VS}`, ascii: "M" },
|
package/src/migrations.ts
CHANGED
|
@@ -106,7 +106,31 @@ export function importOptimizerSidecar(
|
|
|
106
106
|
agentDir: string,
|
|
107
107
|
ctx: ParseContext,
|
|
108
108
|
): { changed: boolean; archive?: () => void } {
|
|
109
|
-
const
|
|
109
|
+
const imports = ["optimizer", "voice", "fetch", "search", "toolbox"];
|
|
110
|
+
let changed = false;
|
|
111
|
+
const archives: (() => void)[] = [];
|
|
112
|
+
for (const section of imports) {
|
|
113
|
+
const result = importSidecar(doc, agentDir, ctx, section);
|
|
114
|
+
changed ||= result.changed;
|
|
115
|
+
if (result.archive) archives.push(result.archive);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
changed,
|
|
119
|
+
archive: archives.length
|
|
120
|
+
? () => {
|
|
121
|
+
for (const archive of archives) archive();
|
|
122
|
+
}
|
|
123
|
+
: undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function importSidecar(
|
|
128
|
+
doc: RawDocument,
|
|
129
|
+
agentDir: string,
|
|
130
|
+
ctx: ParseContext,
|
|
131
|
+
section: string,
|
|
132
|
+
): { changed: boolean; archive?: () => void } {
|
|
133
|
+
const sidecarPath = join(agentDir, `${section}.json`);
|
|
110
134
|
if (!existsSync(sidecarPath)) return { changed: false };
|
|
111
135
|
|
|
112
136
|
let raw: Record<string, unknown>;
|
|
@@ -118,32 +142,39 @@ export function importOptimizerSidecar(
|
|
|
118
142
|
ctx.diagnostic({
|
|
119
143
|
code: "MIGRATION_FAILED",
|
|
120
144
|
severity: "warning",
|
|
121
|
-
path:
|
|
122
|
-
message:
|
|
145
|
+
path: `${section}.json`,
|
|
146
|
+
message: `malformed ${section} config left unchanged`,
|
|
123
147
|
cause: err,
|
|
124
148
|
});
|
|
125
149
|
return { changed: false };
|
|
126
150
|
}
|
|
127
151
|
|
|
128
|
-
const existing = isObj(doc
|
|
152
|
+
const existing = isObj(doc[section]) ? { ...doc[section] } : {};
|
|
129
153
|
let changed = false;
|
|
130
|
-
for (const key of OPTIMIZER_KEYS) {
|
|
154
|
+
for (const key of section === "optimizer" ? OPTIMIZER_KEYS : Object.keys(raw)) {
|
|
131
155
|
const value = raw[key];
|
|
132
|
-
if (typeof value === "string" && !(key in existing)) {
|
|
156
|
+
if ((section !== "optimizer" || typeof value === "string") && !(key in existing)) {
|
|
133
157
|
existing[key] = value;
|
|
134
158
|
changed = true;
|
|
135
159
|
}
|
|
136
160
|
}
|
|
137
|
-
if (changed) doc
|
|
161
|
+
if (changed) doc[section] = existing;
|
|
138
162
|
|
|
139
|
-
const hasLegacyToon = typeof raw.toon === "string";
|
|
163
|
+
const hasLegacyToon = section === "optimizer" && typeof raw.toon === "string";
|
|
140
164
|
const archive = () => {
|
|
141
165
|
let target = `${sidecarPath}.migrated-v1`;
|
|
142
166
|
if (existsSync(target)) target = `${target}.${Date.now()}`;
|
|
143
167
|
try {
|
|
144
168
|
renameSync(sidecarPath, target);
|
|
145
|
-
} catch {
|
|
146
|
-
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT")
|
|
171
|
+
ctx.diagnostic({
|
|
172
|
+
code: "MIGRATION_FAILED",
|
|
173
|
+
severity: "warning",
|
|
174
|
+
path: sidecarPath,
|
|
175
|
+
message: "config backup failed; original file remains",
|
|
176
|
+
cause: err,
|
|
177
|
+
});
|
|
147
178
|
}
|
|
148
179
|
};
|
|
149
180
|
|