pi-studio 0.10.1 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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,285 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { GNUPLOT_OWNER_OPTION, validGnuplotOwner, findOwnedGnuplotProcesses, snapshotGnuplotSockets, cleanupGnuplotSockets } from "./repl-gnuplot-processes.js";
4
+
5
+ const exec = promisify(execFile);
6
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+ const processKey = (p) => `${p.pid}:${p.uid}:${p.startedAt}`;
8
+ const live = (p) => Boolean(p && !p.state.startsWith("Z"));
9
+ const fingerprintFormat = "#{pid}|#{session_id}|#{session_name}|#{session_created}|#{W:#{window_id}:#{window_linked}:#{P:#{pane_id}:#{pane_pid}:#{pane_dead};};}";
10
+ const paneFormat = "#{pid}\t#{session_id}\t#{session_name}\t#{session_created}\t#{pane_id}\t#{pane_pid}\t#{pane_tty}\t#{pane_dead}\t#{window_id}\t#{window_index}\t#{pane_index}\t#{window_linked}\t#{@pi_repl_gnuplot_owner}\t#{@pi_repl_record_id}";
11
+
12
+ export function parseStopProcessTable(text) {
13
+ return text.split("\n").filter((line) => line.trim()).map((line) => {
14
+ const f = line.trim().split(/\s+/);
15
+ if (f.length !== 11 || !f.slice(0, 4).every((v) => /^\d+$/.test(v) && Number.isSafeInteger(Number(v)))) throw new Error("Could not parse process ownership data; refusing unsafe shutdown.");
16
+ return { pid: Number(f[0]), ppid: Number(f[1]), pgid: Number(f[2]), uid: Number(f[3]), tty: f[4], startedAt: f.slice(5, 10).join(" "), state: f[10] };
17
+ });
18
+ }
19
+
20
+ export async function readStopProcessTable() {
21
+ const result = await exec("ps", ["-axo", "pid=,ppid=,pgid=,uid=,tty=,lstart=,stat="], {
22
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 3000, maxBuffer: 4 * 1024 * 1024,
23
+ });
24
+ return parseStopProcessTable(result.stdout);
25
+ }
26
+
27
+ function parsePanes(text) {
28
+ return text.split("\n").filter((line) => line.trim()).map((line) => {
29
+ const f = line.split("\t");
30
+ if (f.length !== 14 || !/^\d+$/.test(f[0]) || !/^\$\d+$/.test(f[1]) || !/^\d+$/.test(f[3]) ||
31
+ !/^%\d+$/.test(f[4]) || !/^\d+$/.test(f[5]) || !/^\/dev\//.test(f[6]) || !/^[01]$/.test(f[7]) ||
32
+ !/^@\d+$/.test(f[8]) || !/^\d+$/.test(f[9]) || !/^\d+$/.test(f[10]) || !/^[01]$/.test(f[11])) {
33
+ throw new Error("Could not identify tmux pane ownership; refusing unsafe shutdown.");
34
+ }
35
+ return { serverPid: Number(f[0]), sessionId: f[1], sessionName: f[2], createdAt: f[3], paneId: f[4], pid: Number(f[5]), tty: f[6].slice(5), dead: f[7], windowId: f[8], windowIndex: Number(f[9]), paneIndex: Number(f[10]), linked: f[11], recordId: f[13], gnuplotOwner: f[12] };
36
+ });
37
+ }
38
+
39
+ async function readPanes(tmux) {
40
+ const result = await tmux(["list-panes", "-a", "-F", paneFormat]);
41
+ if (result.code !== 0) {
42
+ if (/no server running|(?:error connecting|failed to connect).*?(?:No such file|Connection refused)/i.test(result.stderr)) return [];
43
+ throw new Error(`Could not inspect tmux while verifying shutdown: ${result.stderr.trim() || `exit ${result.code}`}`);
44
+ }
45
+ return parsePanes(result.stdout);
46
+ }
47
+
48
+ function fingerprint(panes) {
49
+ const first = panes[0];
50
+ let windows = "", previousWindow;
51
+ for (const p of [...panes].sort((a, b) => a.windowIndex - b.windowIndex || a.paneIndex - b.paneIndex)) {
52
+ if (p.windowId !== previousWindow) {
53
+ if (previousWindow) windows += ";";
54
+ windows += `${p.windowId}:${p.linked}:`;
55
+ previousWindow = p.windowId;
56
+ }
57
+ windows += `${p.paneId}:${p.pid}:${p.dead};`;
58
+ }
59
+ return `${first.serverPid}|${first.sessionId}|${first.sessionName}|${first.createdAt}|${windows};`;
60
+ }
61
+
62
+ function closure(table, roots) {
63
+ const ids = new Set(roots);
64
+ let changed;
65
+ do {
66
+ changed = false;
67
+ for (const p of table) if (ids.has(p.ppid) && !ids.has(p.pid)) { ids.add(p.pid); changed = true; }
68
+ } while (changed);
69
+ return ids;
70
+ }
71
+
72
+ function protectedProcesses(table, panes, currentPid, serverPid) {
73
+ // Never include the server as an ownership root: its children include
74
+ // other REPLs, attached clients and unrelated history loggers.
75
+ const ids = closure(table, [currentPid, ...panes.filter((p) => p.dead === "0").map((p) => p.pid)]);
76
+ const byPid = new Map(table.map((p) => [p.pid, p]));
77
+ let p = byPid.get(currentPid);
78
+ const visited = new Set();
79
+ while (p && !visited.has(p.pid)) { visited.add(p.pid); ids.add(p.pid); p = byPid.get(p.ppid); }
80
+ ids.add(0); ids.add(1); ids.add(serverPid);
81
+ for (const pane of panes) ids.add(pane.serverPid);
82
+ return ids;
83
+ }
84
+
85
+ /** Snapshot-based, session-scoped counterpart to the independent test tracker. */
86
+ export class SessionStopProcesses {
87
+ constructor({ table, panes, selected, currentPid, uid }) {
88
+ this.owned = new Map();
89
+ this.groups = new Map();
90
+ this.uid = uid;
91
+ this.currentPid = currentPid;
92
+ this.serverPid = selected[0].serverPid;
93
+ this.issues = new Set();
94
+ const otherPanes = panes.filter((p) => p.sessionId !== selected[0].sessionId);
95
+ const protectedIds = protectedProcesses(table, otherPanes, currentPid, this.serverPid);
96
+ const roots = [];
97
+ for (const pane of selected) {
98
+ if (pane.linked !== "0" || otherPanes.some((p) => p.paneId === pane.paneId)) throw new Error("A REPL window is linked to another session. Unlink it before stopping; nothing was stopped.");
99
+ const p = table.find((candidate) => candidate.pid === pane.pid);
100
+ if (pane.dead !== "0" || !live(p) || p.tty !== pane.tty || p.pgid !== p.pid) {
101
+ throw new Error(`Cannot verify the runtime owner of pane ${pane.paneId}; nothing was stopped. Inspect dead or detached panes manually.`);
102
+ }
103
+ if (protectedIds.has(p.pid) || p.uid !== uid) throw new Error("REPL ownership overlaps Pi, another session or another user; nothing was stopped.");
104
+ roots.push(p.pid);
105
+ this.groups.set(p.pgid, p);
106
+ }
107
+ const ids = closure(table, roots);
108
+ for (const p of table) {
109
+ if (ids.has(p.pid) || (this.groups.has(p.pgid) && p.tty === this.groups.get(p.pgid).tty)) {
110
+ if (protectedIds.has(p.pid) || p.uid !== uid) throw new Error("Runtime descendants overlap protected processes; nothing was stopped.");
111
+ this.owned.set(processKey(p), p);
112
+ }
113
+ }
114
+ }
115
+
116
+ rememberMarked(processes, table, panes) {
117
+ const protectedIds = protectedProcesses(table, panes, this.currentPid, this.serverPid);
118
+ for (const p of processes) {
119
+ if (!live(p) || p.uid !== this.uid || protectedIds.has(p.pid)) {
120
+ throw new Error(`Marked gnuplot PID ${p.pid} overlaps a protected process; no signal was sent to it.`);
121
+ }
122
+ this.owned.set(processKey(p), p);
123
+ }
124
+ }
125
+
126
+ refresh(table, panes) {
127
+ const protectedIds = protectedProcesses(table, panes, this.currentPid, this.serverPid);
128
+ const byPid = new Map(table.map((p) => [p.pid, p]));
129
+ const known = table.filter((p) => this.owned.has(processKey(p)));
130
+ const ids = closure(table, known.filter((p) => !protectedIds.has(p.pid)).map((p) => p.pid));
131
+ for (const [pgid, leader] of this.groups) {
132
+ const currentLeader = byPid.get(leader.pid);
133
+ const members = table.filter((p) => p.pgid === pgid && live(p));
134
+ if (currentLeader && processKey(currentLeader) !== processKey(leader)) continue; // recycled leader
135
+ // Do not adopt new processes solely because they share a stale
136
+ // numeric PGID. Require continuity through a known live member.
137
+ if (known.some((p) => live(p) && p.pgid === pgid && !protectedIds.has(p.pid))) {
138
+ for (const p of members) ids.add(p.pid);
139
+ } else if (members.some((p) => !this.owned.has(processKey(p)))) {
140
+ this.issues.add(`Unconfirmed survivors in former pane group ${pgid}; manual inspection is required.`);
141
+ }
142
+ }
143
+ for (const p of table) {
144
+ if (!ids.has(p.pid) && !this.owned.has(processKey(p))) continue;
145
+ if (protectedIds.has(p.pid) || p.uid !== this.uid) {
146
+ if (live(p)) this.issues.add(`PID ${p.pid} now overlaps a protected process/session; it was not signalled.`);
147
+ continue;
148
+ }
149
+ this.owned.set(processKey(p), p);
150
+ }
151
+ return table.filter((p) => this.owned.has(processKey(p)) && live(p) && !protectedIds.has(p.pid) && p.uid === this.uid);
152
+ }
153
+ }
154
+
155
+ export const PI_REPL_STOP_SESSION_NAME = /^pi-repl-(python|julia|r|ghci|clojure|ruby|java|octave|matlab|gnuplot|cpp)$/;
156
+
157
+ /**
158
+ * Explicit stop only. Never used by send, startup, reload, or shutdown hooks.
159
+ * sessionNamePattern names the sessions this host owns; pi-studio passes its own.
160
+ */
161
+ export async function stopVerifiedReplSession({ tmux, sessionName, sessionNamePattern = PI_REPL_STOP_SESSION_NAME, snapshot = readStopProcessTable, signal = (pid, value) => process.kill(pid, value), currentPid = process.pid, uid = process.getuid?.(), graceMs = 500, termMs = 1000, killMs = 2000, findMarked = findOwnedGnuplotProcesses, snapshotSockets = snapshotGnuplotSockets, cleanupSockets = cleanupGnuplotSockets }) {
162
+ // A global or sticky pattern keeps state between tests; refuse it rather than misjudge a name.
163
+ if (!(sessionNamePattern instanceof RegExp) || sessionNamePattern.global || sessionNamePattern.sticky) throw new Error("Verified REPL shutdown needs a plain session-name pattern.");
164
+ if (typeof uid !== "number" || typeof sessionName !== "string" || !sessionNamePattern.test(sessionName)) throw new Error("Verified REPL shutdown requires a supported local Unix session.");
165
+ const panes = await readPanes(tmux);
166
+ const selected = panes.filter((p) => p.sessionName === sessionName);
167
+ if (!selected.length) throw new Error("The selected session ended or changed before shutdown; no processes were signalled.");
168
+ const original = selected[0];
169
+ const gnuplot = sessionName === "pi-repl-gnuplot";
170
+ const owner = gnuplot && validGnuplotOwner(original.gnuplotOwner) ? original.gnuplotOwner : undefined;
171
+ const warnings = gnuplot && !owner ? ["Detached gnuplot/Qt helpers cannot be verified: this session has no valid ownership marker. Unattributed detached helpers were left untouched."] : [];
172
+ function guardOwner(currentPanes) {
173
+ if (owner && currentPanes.some((p) => (p.serverPid !== original.serverPid || p.sessionId !== original.sessionId || p.createdAt !== original.createdAt) && p.gnuplotOwner === owner)) {
174
+ throw new Error("Gnuplot ownership marker is shared with another session; refusing ambiguous cleanup.");
175
+ }
176
+ }
177
+ guardOwner(panes);
178
+ const table = await snapshot();
179
+ const server = table.find((p) => p.pid === original.serverPid);
180
+ if (!live(server) || server.uid !== uid) throw new Error("Cannot verify the local tmux server owner; nothing was stopped.");
181
+ const tracker = new SessionStopProcesses({ table, panes, selected, currentPid, uid });
182
+ const sockets = new Map(), capturedSockets = new Set();
183
+ let socketInspectionFailed = false;
184
+ async function rememberMarked(currentTable, currentPanes) {
185
+ const marked = await findMarked(currentTable, owner, { uid });
186
+ tracker.rememberMarked(marked, currentTable, currentPanes);
187
+ for (const p of marked) {
188
+ // Native Qt detaches from the tty. Avoid repeatedly inspecting the
189
+ // interpreter's unrelated open files; this is NOT signal authority.
190
+ if (!/^\?+$/.test(p.tty) || capturedSockets.has(processKey(p))) continue;
191
+ try {
192
+ const paths = await snapshotSockets(p);
193
+ if (paths.length) {
194
+ const fresh = (await snapshot()).find((candidate) => candidate.pid === p.pid);
195
+ if (!live(fresh) || processKey(fresh) !== processKey(p)) { socketInspectionFailed = true; continue; }
196
+ capturedSockets.add(processKey(p));
197
+ }
198
+ for (const entry of paths) sockets.set(entry.path, entry);
199
+ } catch { socketInspectionFailed = true; }
200
+ }
201
+ }
202
+ if (owner) await rememberMarked(table, panes.filter((p) => p.sessionId !== original.sessionId));
203
+ const originalFingerprint = fingerprint(selected);
204
+ const repeatPanes = await readPanes(tmux);
205
+ const repeatSelected = repeatPanes.filter((p) => p.sessionId === original.sessionId);
206
+ const repeatTable = await snapshot();
207
+ guardOwner(repeatPanes);
208
+ if ((owner && repeatSelected.some((p) => p.gnuplotOwner !== owner)) || !repeatSelected.length || fingerprint(repeatSelected) !== originalFingerprint ||
209
+ !repeatTable.some((p) => processKey(p) === processKey(server)) ||
210
+ selected.some((pane) => !repeatTable.some((p) => p.pid === pane.pid && tracker.owned.has(processKey(p))))) {
211
+ throw new Error("Session ownership changed while preparing shutdown; nothing was stopped.");
212
+ }
213
+ if (owner) {
214
+ await rememberMarked(repeatTable, repeatPanes.filter((p) => p.sessionId !== original.sessionId));
215
+ // The native metadata/socket readers can take time. Revalidate pane
216
+ // and server birth identities once more after them, before tmux acts.
217
+ const fresh = await snapshot();
218
+ if (!fresh.some((p) => live(p) && processKey(p) === processKey(server)) || selected.some((pane) => !fresh.some((p) => live(p) && p.pid === pane.pid && tracker.owned.has(processKey(p))))) {
219
+ throw new Error("Session ownership changed during helper inspection; nothing was stopped.");
220
+ }
221
+ }
222
+ // This comparison and kill execute in the same tmux command queue: a
223
+ // changed/replaced session or newly linked/added pane cannot slip between
224
+ // the final topology check and kill-session. No runtime input is injected.
225
+ let condition = `#{==:${fingerprintFormat},${originalFingerprint}}`;
226
+ if (/^[a-f0-9]{32}$/.test(original.recordId)) condition = `#{&&:${condition},#{==:#{@pi_repl_record_id},${original.recordId}}}`;
227
+ if (owner) condition = `#{&&:${condition},#{==:#{${GNUPLOT_OWNER_OPTION}},${owner}}}`;
228
+ const stopped = await tmux(["if-shell", "-F", "-t", original.sessionId, condition, `kill-session -t '${original.sessionId}'`, "display-message -p PI_REPL_STOP_CHANGED"]);
229
+ if (stopped.code !== 0 || stopped.stdout.includes("PI_REPL_STOP_CHANGED")) throw new Error(`tmux refused or could not complete the stop; no follow-up signals were sent. ${stopped.stderr.trim() || stopped.stdout.trim()}`);
230
+
231
+ const signals = [];
232
+ async function inspect() {
233
+ const currentPanes = await readPanes(tmux);
234
+ const currentTable = await snapshot();
235
+ guardOwner(currentPanes);
236
+ const sameServer = currentTable.some((p) => processKey(p) === processKey(server));
237
+ if (sameServer && currentPanes.some((p) => p.serverPid === original.serverPid && p.sessionId === original.sessionId && p.createdAt === original.createdAt)) throw new Error("The original tmux session still exists; no follow-up signals were sent.");
238
+ if (owner) await rememberMarked(currentTable, currentPanes);
239
+ return tracker.refresh(currentTable, currentPanes);
240
+ }
241
+ async function wait(ms) {
242
+ const deadline = Date.now() + ms;
243
+ while (true) {
244
+ const remaining = await inspect();
245
+ // Allow a bounded quiet grace for startDetached's launcher to exec;
246
+ // an empty ancestry snapshot alone does not establish settlement.
247
+ if ((!remaining.length && !owner) || Date.now() >= deadline) return remaining;
248
+ await sleep(100);
249
+ }
250
+ }
251
+ async function sendVerified(p, value) {
252
+ // Fresh pane protection + fresh process birth identity before every
253
+ // individual signal, including KILL escalation. No pkill/group kills.
254
+ const remaining = await inspect();
255
+ if (!remaining.some((candidate) => processKey(candidate) === processKey(p))) return;
256
+ // Marker inspection can involve a subprocess. Refresh ordinary birth
257
+ // identities and pane protections again immediately before signalling.
258
+ const freshPanes = await readPanes(tmux);
259
+ guardOwner(freshPanes);
260
+ const fresh = tracker.refresh(await snapshot(), freshPanes);
261
+ if (!fresh.some((candidate) => processKey(candidate) === processKey(p))) return;
262
+ try { signal(p.pid, value); signals.push({ pid: p.pid, signal: value }); }
263
+ catch (error) {
264
+ if (error.code !== "ESRCH") tracker.issues.add(`Could not send ${value} to verified PID ${p.pid}: ${error.message}`);
265
+ }
266
+ }
267
+ try {
268
+ let remaining = await wait(graceMs);
269
+ for (const p of remaining) await sendVerified(p, "SIGTERM");
270
+ remaining = await wait(termMs);
271
+ const deadline = Date.now() + killMs;
272
+ while (remaining.length && Date.now() < deadline) {
273
+ for (const p of remaining) await sendVerified(p, "SIGKILL");
274
+ remaining = await wait(100);
275
+ }
276
+ if (remaining.length) tracker.issues.add(`Surviving verified PIDs: ${remaining.map((p) => p.pid).join(", ")}.`);
277
+ if (tracker.issues.size) throw new Error([...tracker.issues].join(" "));
278
+ if (socketInspectionFailed) warnings.push("Qt processes exited, but their socket files could not be fully inspected; unverified files were left untouched (lsof may be unavailable).");
279
+ try { warnings.push(...await cleanupSockets([...sockets.values()], { uid })); }
280
+ catch { warnings.push("Qt processes exited, but socket cleanup could not be fully verified; unverified files were left untouched."); }
281
+ return { sessionName, processCount: tracker.owned.size, signals, warnings };
282
+ } catch (error) {
283
+ throw new Error(`Stop requested for ${sessionName}, but runtime cleanup could not be fully verified: ${error.message} Inspect remaining processes manually; logs and clean records were preserved.`, { cause: error });
284
+ }
285
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { win32 } from "node:path";
2
3
 
3
4
  export const STUDIO_TERMINAL_BROWSER_OPEN_TIMEOUT_MS = 5_000;
4
5
  export const STUDIO_MUXY_CLI_TIMEOUT_SECONDS = 4;
@@ -78,22 +79,48 @@ export function getStudioCmuxBrowserOpenCommand(target, env = process.env) {
78
79
  */
79
80
  export function getStudioDefaultBrowserOpenCommand(target, platform = process.platform) {
80
81
  if (platform === "darwin") return { command: "open", args: [target] };
81
- if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", target] };
82
+ // Not cmd /c start, which cuts the URL at each & and expands %NAME% (#5).
83
+ // url.dll's handler takes the URL as one argument, with no shell.
84
+ if (platform === "win32") return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", target] };
82
85
  return { command: "xdg-open", args: [target] };
83
86
  }
84
87
 
85
88
  /**
86
- * @param {{ command: string, args: string[] }} openCommand
89
+ * Build the platform-native invocation that opens a local file in its default app,
90
+ * or a folder in the file manager.
91
+ *
92
+ * @param {string} path
93
+ * @param {NodeJS.Platform} [platform]
94
+ * @returns {{ command: string, args: string[], verbatim?: boolean }}
95
+ */
96
+ export function getStudioDefaultViewerOpenCommand(path, platform = process.platform) {
97
+ if (platform === "darwin") return { command: "open", args: [path] };
98
+ // Not cmd /c start, which cuts the path at & and expands %NAME%, even inside quotes;
99
+ // nor url.dll, which loses non-ASCII names. Explorer reads an unquoted comma as a
100
+ // switch separator and opens Documents for / separators, so the path is resolved and
101
+ // passed quoted as is. Windows paths can't contain ", and one that does is refused,
102
+ // so the quotes can't be closed early (Sol).
103
+ if (platform === "win32") {
104
+ if (path.includes("\"")) throw new Error("A Windows path can't contain \".");
105
+ return { command: "explorer.exe", args: [`"${win32.resolve(path)}"`], verbatim: true };
106
+ }
107
+ return { command: "xdg-open", args: [path] };
108
+ }
109
+
110
+ /**
111
+ * @param {{ command: string, args: string[], verbatim?: boolean }} openCommand
87
112
  * @param {typeof spawn} spawnProcess
88
113
  * @returns {Promise<void>}
89
114
  */
90
- function spawnDetachedBrowser(openCommand, spawnProcess) {
115
+ function spawnDetached(openCommand, spawnProcess) {
91
116
  return new Promise((resolve, reject) => {
92
117
  let child;
93
118
  try {
94
119
  child = spawnProcess(openCommand.command, openCommand.args, {
95
120
  stdio: "ignore",
96
121
  detached: true,
122
+ shell: false,
123
+ ...(openCommand.verbatim ? { windowsVerbatimArguments: true } : {}),
97
124
  });
98
125
  } catch (error) {
99
126
  reject(error);
@@ -216,6 +243,18 @@ export async function openStudioUrlInBrowser(target, options = {}) {
216
243
  }
217
244
 
218
245
  const openCommand = getStudioDefaultBrowserOpenCommand(target, options.platform ?? process.platform);
219
- await spawnDetachedBrowser(openCommand, options.spawnProcess ?? spawn);
246
+ await spawnDetached(openCommand, options.spawnProcess ?? spawn);
220
247
  return "system";
221
248
  }
249
+
250
+ /**
251
+ * Open a local file in its default app, or a folder in the file manager.
252
+ *
253
+ * @param {string} path
254
+ * @param {{ platform?: NodeJS.Platform, spawnProcess?: typeof spawn }} [options]
255
+ * @returns {Promise<void>}
256
+ */
257
+ export async function openStudioPathInDefaultViewer(path, options = {}) {
258
+ const openCommand = getStudioDefaultViewerOpenCommand(path, options.platform ?? process.platform);
259
+ await spawnDetached(openCommand, options.spawnProcess ?? spawn);
260
+ }
@@ -306,7 +306,9 @@ export function createStudioBufferClient(options) {
306
306
  if (owned && same(owned, store.get(owned.id))) escrow.resolve(backup.moveId);
307
307
  }
308
308
  const selected = store.get(state.selectedBufferId);
309
- return { ok: true, restored: !fromInitial, editor: projectStudioBufferEditor(state), baselineText: selected.baselineText };
309
+ // restored also covers a launch the server registered before this tab opened;
310
+ // fromBrowser says this tab had its own copy, as after a reload.
311
+ return { ok: true, restored: !fromInitial, fromBrowser: Boolean(localState), editor: projectStudioBufferEditor(state), baselineText: selected.baselineText };
310
312
  },
311
313
  capture,
312
314
  select(id) {
@@ -9,6 +9,8 @@ export function createStudioDocumentOpenController(options) {
9
9
  const notify = (message, severity = "warning") => { if (!closed) options.status?.(message, severity); };
10
10
  const changed = () => { if (!closed) options.changed?.(); };
11
11
  const fail = message => ({ ok: false, message });
12
+ // Optional narration; it never changes a committed open's outcome (Sol).
13
+ const opened = bufferId => { try { return options.opened?.(bufferId); } catch { return ""; } };
12
14
  // Refusals the server makes before recording anything: the open certainly didn't happen, so it
13
15
  // is closed through the ordinary Cancel route and its reason shown, not kept as unknown (Oliver, 9 Oct).
14
16
  const finalOpenRefusals = new Set(["too-large", "invalid-document"]);
@@ -34,7 +36,7 @@ export function createStudioDocumentOpenController(options) {
34
36
  changed();
35
37
  if (!settings.quiet) notify(result.status === "cancelled" ? "File opening cancelled. Current work was kept."
36
38
  : result.status === "reused" ? "The file already has an editor. Current work was kept."
37
- : "Opened the file in its existing buffer. Other work was kept; nothing was sent.", "success");
39
+ : opened(record.request.bufferId) || "Opened the file.", "success");
38
40
  await owner.settled();
39
41
  // Failure to release a server receipt cannot undo verified local adoption.
40
42
  // Send no acknowledgements from an obsolete connection or disposed page.