agent-repl 0.1.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.
@@ -0,0 +1,180 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, lstat, unlink } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const exec = promisify(execFile);
7
+ export const GNUPLOT_OWNER_ENV = "PI_REPL_GNUPLOT_OWNER";
8
+ export const GNUPLOT_OWNER_OPTION = "@pi_repl_gnuplot_owner";
9
+ export const validGnuplotOwner = (value) => typeof value === "string" && value.length === 32 && /^[a-f0-9]{32}$/.test(value);
10
+
11
+ // On macOS, ps flattens argv/environment into ambiguous text. Read the native
12
+ // NUL-delimited KERN_PROCARGS2 buffer instead. Only the requested marker leaves
13
+ // this isolated stdlib-only process: never print argv or other environment data.
14
+ const macOwnerScript = String.raw`
15
+ import ctypes, errno, json, struct, sys
16
+ libc = ctypes.CDLL('/usr/lib/libSystem.B.dylib', use_errno=True)
17
+ libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p, ctypes.c_size_t]
18
+ key = b'PI_REPL_GNUPLOT_OWNER='
19
+ rows = []
20
+ for value in sys.argv[1:]:
21
+ pid = int(value)
22
+ try:
23
+ mib = (ctypes.c_int * 3)(1, 49, pid)
24
+ size = ctypes.c_size_t()
25
+ if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0):
26
+ raise OSError(ctypes.get_errno(), 'process metadata unavailable')
27
+ if not 4 <= size.value <= 1048576:
28
+ raise ValueError('invalid process metadata size')
29
+ buffer = ctypes.create_string_buffer(size.value)
30
+ if libc.sysctl(mib, 3, buffer, ctypes.byref(size), None, 0):
31
+ raise OSError(ctypes.get_errno(), 'process metadata unavailable')
32
+ data = buffer.raw[:size.value]
33
+ argc = struct.unpack_from('i', data)[0]
34
+ if not 1 <= argc <= 16384:
35
+ raise ValueError('invalid argument count')
36
+ pos = data.index(b'\0', 4) + 1
37
+ while pos < len(data) and data[pos] == 0:
38
+ pos += 1
39
+ for _ in range(argc):
40
+ pos = data.index(b'\0', pos) + 1
41
+ matches = [item[len(key):].decode('ascii') for item in data[pos:].split(b'\0') if item.startswith(key)]
42
+ if len(matches) > 1:
43
+ raise ValueError('duplicate ownership marker')
44
+ marker = matches[0] if matches else None
45
+ if marker is not None and (len(marker) != 32 or any(c not in '0123456789abcdef' for c in marker)):
46
+ marker = None
47
+ rows.append({'pid': pid, 'owner': marker})
48
+ except OSError as error:
49
+ rows.append({'pid': pid, 'gone': True} if error.errno == errno.ESRCH else {'pid': pid, 'error': 'process metadata unavailable'})
50
+ except Exception:
51
+ rows.append({'pid': pid, 'error': 'invalid process metadata'})
52
+ print(json.dumps(rows))
53
+ `;
54
+
55
+ export function parseGnuplotCandidates(text, uid) {
56
+ return text.split("\n").filter((line) => line.trim()).flatMap((line) => {
57
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
58
+ if (!match) throw new Error("Could not parse gnuplot process candidates; refusing unverified cleanup.");
59
+ const pid = Number(match[1]), ownerUid = Number(match[2]);
60
+ if (![pid, ownerUid].every(Number.isSafeInteger)) throw new Error("Invalid gnuplot process identity.");
61
+ // Include the interpreter too: QProcess's short-lived detached launcher
62
+ // can still have that image before exec'ing gnuplot_qt. Names select
63
+ // candidates only; they never authorize a signal.
64
+ return ownerUid === uid && ["gnuplot", "gnuplot_qt"].includes(basename(match[3])) ? [pid] : [];
65
+ });
66
+ }
67
+
68
+ export function ownerFromEnvironment(data) {
69
+ const values = data.toString("utf8").split("\0").filter((value) => value.startsWith(`${GNUPLOT_OWNER_ENV}=`));
70
+ if (values.length > 1) throw new Error("Duplicate gnuplot ownership marker.");
71
+ return values.length ? values[0].slice(GNUPLOT_OWNER_ENV.length + 1) : null;
72
+ }
73
+
74
+ export async function readGnuplotOwners(pids, { platform = process.platform, execute = exec, read = readFile } = {}) {
75
+ if (!pids.length) return [];
76
+ if (pids.length > 128 || pids.some((pid) => !Number.isSafeInteger(pid) || pid <= 1)) throw new Error("Invalid or excessive gnuplot process candidates.");
77
+ if (platform === "darwin") {
78
+ let result, rows;
79
+ try {
80
+ result = await execute("python3", ["-I", "-S", "-c", macOwnerScript, ...pids.map(String)], {
81
+ timeout: 3000, killSignal: "SIGKILL", maxBuffer: 64 * 1024,
82
+ });
83
+ } catch {
84
+ throw new Error("Could not inspect gnuplot process ownership on macOS; an existing python3 is required. No installation was attempted.");
85
+ }
86
+ try { rows = JSON.parse(result.stdout); }
87
+ catch { throw new Error("Invalid gnuplot ownership inspection result."); }
88
+ if (!Array.isArray(rows) || rows.length !== pids.length || rows.some((row, i) => row.pid !== pids[i] || (!row.gone && !row.error && row.owner !== null && typeof row.owner !== "string"))) {
89
+ throw new Error("Invalid gnuplot ownership inspection result.");
90
+ }
91
+ return rows;
92
+ }
93
+ if (platform === "linux") {
94
+ return Promise.all(pids.map(async (pid) => {
95
+ try {
96
+ const data = await read(`/proc/${pid}/environ`);
97
+ if (data.length > 1024 * 1024) throw new Error("Excessive process metadata.");
98
+ const owner = ownerFromEnvironment(data);
99
+ return { pid, owner: validGnuplotOwner(owner) ? owner : null };
100
+ } catch (error) {
101
+ if (error.code === "ENOENT" || error.code === "ESRCH") return { pid, gone: true };
102
+ throw new Error(`Could not inspect gnuplot ownership for PID ${pid}.`, { cause: error });
103
+ }
104
+ }));
105
+ }
106
+ throw new Error("Detached gnuplot ownership inspection requires macOS or Linux.");
107
+ }
108
+
109
+ export async function findOwnedGnuplotProcesses(table, owner, { uid = process.getuid?.(), execute = exec, inspect = readGnuplotOwners } = {}) {
110
+ if (!validGnuplotOwner(owner)) throw new Error("Invalid gnuplot ownership marker.");
111
+ const listing = await execute("ps", ["-axo", "pid=,uid=,comm="], { timeout: 3000, maxBuffer: 4 * 1024 * 1024 });
112
+ // Tty-attached interpreter jobs are already handled by pane ancestry.
113
+ // Inspect only detached candidates; a text-only session needs no Python
114
+ // metadata reader when no detached gnuplot processes exist.
115
+ const candidates = parseGnuplotCandidates(listing.stdout, uid).filter((pid) => table.some((p) => p.pid === pid && p.uid === uid && /^\?+$/.test(p.tty) && !p.state.startsWith("Z")));
116
+ const rows = await inspect(candidates);
117
+ const failed = rows.find((row) => row.error);
118
+ if (failed) throw new Error(`Could not inspect gnuplot ownership for PID ${failed.pid}; refusing unverified cleanup.`);
119
+ return rows.filter((row) => row.owner === owner && !row.gone).map((row) => table.find((p) => p.pid === row.pid));
120
+ }
121
+
122
+ // Socket cleanup is separate from process ownership. Remember only paths
123
+ // actually open in a marked process, never a guessed TMPDIR/PID filename.
124
+ export function parseUnixSocketNames(text) {
125
+ if (text && !text.includes("\0")) throw new Error("Could not parse Unix socket ownership data.");
126
+ let pid;
127
+ const entries = [];
128
+ for (let field of text.split("\0")) {
129
+ field = field.replace(/^\n+/, "");
130
+ if (!field || field.startsWith("f")) continue;
131
+ if (/^p\d+$/.test(field) && Number.isSafeInteger(Number(field.slice(1)))) { pid = Number(field.slice(1)); continue; }
132
+ if (!pid || !field.startsWith("n")) throw new Error("Invalid Unix socket ownership field.");
133
+ if (field.startsWith("n/")) {
134
+ // Linux lsof may append socket type/state even in field output.
135
+ const name = field.slice(1).replace(/ type=STREAM(?: \([^)]*\))?$/, "");
136
+ entries.push({ pid, path: name });
137
+ }
138
+ }
139
+ return entries;
140
+ }
141
+
142
+ async function unixSockets(selection, execute = exec) {
143
+ try {
144
+ const result = await execute("lsof", ["-nP", "-a", ...selection, "-U", "-F0pn"], { timeout: 3000, maxBuffer: 4 * 1024 * 1024 });
145
+ if (result.stderr?.trim()) throw new Error("Unix socket ownership inspection was incomplete.");
146
+ return parseUnixSocketNames(result.stdout);
147
+ } catch (error) {
148
+ if (error.code === 1 && !error.stdout && !error.stderr) return []; // no matching open sockets
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ export async function snapshotGnuplotSockets(p, { execute = exec, stat = lstat } = {}) {
154
+ const entries = await unixSockets(["-p", String(p.pid)], execute);
155
+ const sockets = [];
156
+ for (const entry of entries) {
157
+ if (entry.pid !== p.pid || basename(entry.path) !== `qtgnuplot${p.pid}`) continue;
158
+ const info = await stat(entry.path).catch((error) => { if (error.code === "ENOENT") return null; throw error; });
159
+ if (info?.isSocket() && info.uid === p.uid) sockets.push({ path: entry.path, uid: info.uid, dev: info.dev, ino: info.ino, ctimeMs: info.ctimeMs });
160
+ }
161
+ return sockets;
162
+ }
163
+
164
+ export async function cleanupGnuplotSockets(sockets, { uid = process.getuid?.(), execute = exec, stat = lstat, remove = unlink } = {}) {
165
+ if (!sockets.length) return [];
166
+ // Refuse to unlink paths still reported by ANY same-user process, not just
167
+ // gnuplot. This also catches replacement listeners and shared sockets.
168
+ const references = await unixSockets(["-u", String(uid)], execute);
169
+ const warnings = [];
170
+ for (const socket of sockets) {
171
+ const current = await stat(socket.path).catch((error) => { if (error.code === "ENOENT") return null; throw error; });
172
+ if (!current) continue;
173
+ if (references.some((entry) => entry.path === socket.path) || !current.isSocket() || current.uid !== uid || current.uid !== socket.uid || current.dev !== socket.dev || current.ino !== socket.ino || current.ctimeMs !== socket.ctimeMs) {
174
+ warnings.push(`Qt socket changed or is still in use; left untouched: ${socket.path}`);
175
+ continue;
176
+ }
177
+ await remove(socket.path).catch((error) => { if (error.code !== "ENOENT") throw error; });
178
+ }
179
+ return warnings;
180
+ }
@@ -0,0 +1,58 @@
1
+ const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
2
+
3
+ export function gnuplotStringLiteral(value) {
4
+ if (/[\r\n\0]/.test(value)) throw new Error("gnuplot control paths cannot contain line breaks or NUL bytes.");
5
+ // Single quotes suppress both backslash escapes and backquote shell
6
+ // substitution. Concatenate literal apostrophes: repeated doubled quotes
7
+ // are misparsed by gnuplot 6.0. Only the fixed apostrophe uses double quotes.
8
+ return value.includes("'") ? `(${value.split("'").map((part) => `'${part}'`).join(' . "\'" . ')})` : `'${value}'`;
9
+ }
10
+
11
+ export function buildGnuplotSubmissionLine(driverFile) {
12
+ // Ignore SIGINT in the pipe producer, not in gnuplot. Gnuplot unwinds a
13
+ // failed/interrupted load and waits for pclose(); the producer must survive
14
+ // long enough to observe that closure and release the completion/lease.
15
+ return `load ${gnuplotStringLiteral(`<trap "" INT; exec ${shellQuote(process.execPath)} ${shellQuote(driverFile)}`)}`;
16
+ }
17
+
18
+ export function buildGnuplotGuardSource(ackFile) {
19
+ // Unlike `system`, native load-from-pipe leaves GPVAL_SYSTEM_ERRNO alone.
20
+ // No gnuplot helper variables, print destination or terminal changes.
21
+ return `load ${gnuplotStringLiteral(`<umask 077; set -C; : > ${shellQuote(ackFile)}`)}\n`;
22
+ }
23
+
24
+ export function buildGnuplotDriverSource(sourceFile, guardFile, ackFile, doneFile, display) {
25
+ const commands = `load ${gnuplotStringLiteral(sourceFile)}\nload ${gnuplotStringLiteral(guardFile)}\n`;
26
+ return `const fs = require("node:fs");
27
+ let ending = false;
28
+ let writable = true;
29
+ process.on("SIGINT", () => {});
30
+ function finish() {
31
+ if (ending) return;
32
+ ending = true;
33
+ ${display.enabled ? ` let column = "";
34
+ try {
35
+ column = require("node:child_process").execFileSync("tmux", ["-N", "display-message", "-p", "-t", process.env.TMUX_PANE, "#{cursor_x}"], {
36
+ encoding: "utf8", timeout: 500, killSignal: "SIGKILL", maxBuffer: 128, stdio: ["ignore", "pipe", "ignore"]
37
+ }).trim();
38
+ } catch {}
39
+ fs.writeSync(2, (column === "0" ? "" : "\\n") + ${JSON.stringify(display.suffixLines.join("\n") + "\n")});
40
+ ` : ""} fs.writeFileSync(${JSON.stringify(doneFile)}, "done\\n", { mode: 0o600, flag: "wx" });
41
+ process.exit(0);
42
+ }
43
+ process.stdout.on("error", error => {
44
+ if (error.code === "EPIPE") finish();
45
+ else throw error;
46
+ });
47
+ process.stdout.on("drain", () => { writable = true; });
48
+ ${display.enabled ? `fs.writeSync(2, ${JSON.stringify(display.prefixLines.join("\n") + "\n")});\n` : ""}writable = process.stdout.write(${JSON.stringify(commands)});
49
+ // ACK means the nested source returned. On errors/Ctrl-C, gnuplot closes
50
+ // this pipe instead. Bounded comment writes witness EPIPE without queuing
51
+ // terminal input that could answer pause or an unfinished expression.
52
+ // Respect backpressure: a long-running source cannot grow an output queue.
53
+ setInterval(() => {
54
+ if (fs.existsSync(${JSON.stringify(ackFile)})) return finish();
55
+ if (writable) writable = process.stdout.write("#\\n");
56
+ }, 50);
57
+ `;
58
+ }
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, lstatSync, mkdirSync, openSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export function getPrivateReplHistoryRoot() {
7
+ const uid = typeof process.getuid === "function" ? process.getuid() : "user";
8
+ return join(tmpdir(), `pi-repl-history-${uid}`);
9
+ }
10
+
11
+ /** Allocate a private raw log without reusing or truncating a prior session's log. */
12
+ export function createPrivateReplHistoryFile(sessionName, root = getPrivateReplHistoryRoot()) {
13
+ if (typeof sessionName !== "string" || !/^[a-z0-9-]{1,100}$/.test(sessionName)) {
14
+ throw new Error("Invalid REPL history session name.");
15
+ }
16
+ try {
17
+ mkdirSync(root, { mode: 0o700 });
18
+ } catch (error) {
19
+ if (error?.code !== "EEXIST") throw error;
20
+ }
21
+ const info = lstatSync(root);
22
+ if (!info.isDirectory() || info.isSymbolicLink()) {
23
+ throw new Error(`REPL history root is not a real directory: ${root}`);
24
+ }
25
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
26
+ throw new Error(`REPL history root is not owned by the current user: ${root}`);
27
+ }
28
+ if (process.platform !== "win32" && (info.mode & 0o777) !== 0o700) {
29
+ throw new Error(`REPL history root must have mode 0700: ${root}`);
30
+ }
31
+
32
+ // The random, exclusively created filename isolates session lifetimes and
33
+ // tmux servers, even when their session names/IDs/creation seconds coincide.
34
+ const path = join(root, `${sessionName}-${randomUUID()}.history.log`);
35
+ closeSync(openSync(path, "wx", 0o600));
36
+ return path;
37
+ }
@@ -0,0 +1,47 @@
1
+ // MATLAB and Octave share the base-workspace protocol, not a process/session.
2
+ const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
3
+
4
+ export function mStringLiteral(value) {
5
+ // Single-quoted ASCII is compact in the loader. Encode everything else as
6
+ // UTF-8 bytes: MATLAB chars are UTF-16, Octave chars are bytes, and their
7
+ // backslash rules differ. Neither runtime can interpolate the submitted text.
8
+ if (/^[\x20-\x7e]*$/.test(value) && !value.includes("\\")) return `'${value.replace(/'/g, "''")}'`;
9
+ return `native2unicode(uint8([${[...Buffer.from(value, "utf8")].join(" ")}]),'UTF-8')`;
10
+ }
11
+
12
+ export function buildMLanguageControlSource(code, display) {
13
+ return [
14
+ ...(display.enabled ? display.prefixLines.map((line) => `fprintf(1,'%s\\n',${mStringLiteral(line)});`) : []),
15
+ "try",
16
+ ` evalin('base',${mStringLiteral(code)});`,
17
+ "catch pi_repl_error",
18
+ " fprintf(2,'Error: %s\\n',pi_repl_error.message);",
19
+ "end",
20
+ "",
21
+ ].join("\n");
22
+ }
23
+
24
+ export function buildMLanguageDriverSource(runtime, sourceFile, doneFile, display) {
25
+ const query = [
26
+ 'try { process.stdout.write(require("node:child_process").execFileSync("tmux", ["-N", "display-message", "-p", "-t", process.env.TMUX_PANE, "#{cursor_x}"], { encoding: "utf8", timeout: 500, killSignal: "SIGKILL", maxBuffer: 128, stdio: ["ignore", "pipe", "ignore"] }).trim()); } catch { process.exitCode = 1; }',
27
+ ].join(" ");
28
+ const finish = [
29
+ ...(display.enabled ? [
30
+ ...(runtime === "octave" ? ["fflush(1);"] : []),
31
+ "try",
32
+ ` [pi_repl_status,pi_repl_column]=system(${mStringLiteral(`${shellQuote(process.execPath)} -e ${shellQuote(query)} 2>/dev/null`)});`,
33
+ "catch",
34
+ " pi_repl_status=-1; pi_repl_column='';",
35
+ "end",
36
+ "if pi_repl_status~=0 || ~strcmp(strtrim(pi_repl_column),'0'); fprintf(1,'\\n'); end",
37
+ ...display.suffixLines.map((line) => `fprintf(1,'%s\\n',${mStringLiteral(line)});`),
38
+ ] : []),
39
+ `fclose(fopen(${mStringLiteral(doneFile)},'w'));`,
40
+ ].join("\n");
41
+ // Parameters give eval its own slots in MATLAB's static anonymous-function
42
+ // workspace. No helper variables/ans, cwd or path changes enter the base
43
+ // workspace. onCleanup runs on normal completion, return and interruption;
44
+ // even base-workspace clear all cannot remove this function-local guard.
45
+ const cleanup = `onCleanup(@() feval(@(pi_repl_status,pi_repl_column) eval(${mStringLiteral(finish)}),-1,''))`;
46
+ return `feval(@(pi_repl_error,pi_repl_guard) eval(fileread(${mStringLiteral(sourceFile)})),[],${cleanup});\n`;
47
+ }