@xynogen/pix-runtime 0.10.1 → 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/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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
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
  }
@@ -44,6 +44,10 @@ const CATALOG = {
44
44
  mcp: { nerd: "\u{F048D}", unicode: `\u25D0${VS}`, ascii: "MCP" },
45
45
  cwd: { nerd: "\u{F024B}", unicode: `\u2302${VS}`, ascii: "~" },
46
46
  process: { nerd: "\u{F018D}", unicode: `\u25B8${VS}`, ascii: ">_" },
47
+ "audio.play": { nerd: "\u{F04B}", unicode: `\u25B6${VS}`, ascii: ">" },
48
+ "audio.pause": { nerd: "\u{F04C}", unicode: `\u23F8${VS}`, ascii: "||" },
49
+ "audio.stop": { nerd: "\u{F04D}", unicode: `\u25A0${VS}`, ascii: "[]" },
50
+ "audio.file": { nerd: "\u{F1C7}", unicode: `\u266B${VS}`, ascii: "audio" },
47
51
  folder: { nerd: "\u{F024B}", unicode: `\u2302${VS}`, ascii: "/" },
48
52
  afk: { nerd: "\u{F0310}", unicode: `\u2328${VS}`, ascii: "kbd" },
49
53
 
@@ -95,6 +99,7 @@ const CATALOG = {
95
99
  // ── paste chips (pix-display) ─────────────────────────────────────────
96
100
  "paste.image": { nerd: "\u{F02E9}", unicode: `\u25A3${VS}`, ascii: "img" },
97
101
  "paste.text": { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "txt" },
102
+ "paste.prompt": { nerd: "\u{F0D0}", unicode: `\u2726${VS}`, ascii: "pr" },
98
103
 
99
104
  // ── model picker (pix-models) ─────────────────────────────────────────
100
105
  "picker.model": { nerd: "\u{F0229}", unicode: `\u25C8${VS}`, ascii: "M" },