@xynogen/pix-runtime 0.10.2 → 0.12.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/LICENSE +21 -0
- package/README.md +206 -11
- package/package.json +8 -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/icon-catalog.ts +1 -0
- package/src/os.ts +250 -0
- package/src/paths.ts +87 -0
- package/src/persistence.ts +122 -28
- package/src/pix-command.ts +110 -10
- package/src/platform.ts +75 -0
- package/src/runtime.ts +126 -80
- package/src/safe-path.ts +101 -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
|
}
|
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/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
|
+
}
|