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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/cli.js +122 -0
- package/dist/host.js +24 -0
- package/dist/mcp.js +109 -0
- package/dist/pi-compat/LICENSE +21 -0
- package/dist/pi-compat/child-process.js +105 -0
- package/dist/pi-compat/exec.js +76 -0
- package/dist/pi-compat/index.js +3 -0
- package/dist/pi-compat/string-enum.js +22 -0
- package/dist/pi-compat/truncate.js +216 -0
- package/dist/pi-repl.js +2583 -0
- package/dist/shared/REPL_SESSION_RECORD_PROTOCOL.md +105 -0
- package/dist/shared/repl-control-files.js +170 -0
- package/dist/shared/repl-cpp-driver.hxx +164 -0
- package/dist/shared/repl-cpp.js +107 -0
- package/dist/shared/repl-gnuplot-processes.js +180 -0
- package/dist/shared/repl-gnuplot.js +58 -0
- package/dist/shared/repl-history.js +37 -0
- package/dist/shared/repl-m-language.js +47 -0
- package/dist/shared/repl-session-record.js +623 -0
- package/dist/shared/repl-session-stop.js +278 -0
- package/dist/shared/repl-submission-display.js +249 -0
- package/dist/text.js +20 -0
- package/package.json +59 -0
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
/** Explicit stop only. Never used by send, startup, reload, or shutdown hooks. */
|
|
156
|
+
export async function stopVerifiedReplSession({ tmux, sessionName, 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 }) {
|
|
157
|
+
if (typeof uid !== "number" || !/^pi-repl-(python|julia|r|ghci|clojure|ruby|java|octave|matlab|gnuplot|cpp)$/.test(sessionName)) throw new Error("Verified REPL shutdown requires a supported local Unix session.");
|
|
158
|
+
const panes = await readPanes(tmux);
|
|
159
|
+
const selected = panes.filter((p) => p.sessionName === sessionName);
|
|
160
|
+
if (!selected.length) throw new Error("The selected session ended or changed before shutdown; no processes were signalled.");
|
|
161
|
+
const original = selected[0];
|
|
162
|
+
const gnuplot = sessionName === "pi-repl-gnuplot";
|
|
163
|
+
const owner = gnuplot && validGnuplotOwner(original.gnuplotOwner) ? original.gnuplotOwner : undefined;
|
|
164
|
+
const warnings = gnuplot && !owner ? ["Detached gnuplot/Qt helpers cannot be verified: this session has no valid ownership marker. Unattributed detached helpers were left untouched."] : [];
|
|
165
|
+
function guardOwner(currentPanes) {
|
|
166
|
+
if (owner && currentPanes.some((p) => (p.serverPid !== original.serverPid || p.sessionId !== original.sessionId || p.createdAt !== original.createdAt) && p.gnuplotOwner === owner)) {
|
|
167
|
+
throw new Error("Gnuplot ownership marker is shared with another session; refusing ambiguous cleanup.");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
guardOwner(panes);
|
|
171
|
+
const table = await snapshot();
|
|
172
|
+
const server = table.find((p) => p.pid === original.serverPid);
|
|
173
|
+
if (!live(server) || server.uid !== uid) throw new Error("Cannot verify the local tmux server owner; nothing was stopped.");
|
|
174
|
+
const tracker = new SessionStopProcesses({ table, panes, selected, currentPid, uid });
|
|
175
|
+
const sockets = new Map(), capturedSockets = new Set();
|
|
176
|
+
let socketInspectionFailed = false;
|
|
177
|
+
async function rememberMarked(currentTable, currentPanes) {
|
|
178
|
+
const marked = await findMarked(currentTable, owner, { uid });
|
|
179
|
+
tracker.rememberMarked(marked, currentTable, currentPanes);
|
|
180
|
+
for (const p of marked) {
|
|
181
|
+
// Native Qt detaches from the tty. Avoid repeatedly inspecting the
|
|
182
|
+
// interpreter's unrelated open files; this is NOT signal authority.
|
|
183
|
+
if (!/^\?+$/.test(p.tty) || capturedSockets.has(processKey(p))) continue;
|
|
184
|
+
try {
|
|
185
|
+
const paths = await snapshotSockets(p);
|
|
186
|
+
if (paths.length) {
|
|
187
|
+
const fresh = (await snapshot()).find((candidate) => candidate.pid === p.pid);
|
|
188
|
+
if (!live(fresh) || processKey(fresh) !== processKey(p)) { socketInspectionFailed = true; continue; }
|
|
189
|
+
capturedSockets.add(processKey(p));
|
|
190
|
+
}
|
|
191
|
+
for (const entry of paths) sockets.set(entry.path, entry);
|
|
192
|
+
} catch { socketInspectionFailed = true; }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (owner) await rememberMarked(table, panes.filter((p) => p.sessionId !== original.sessionId));
|
|
196
|
+
const originalFingerprint = fingerprint(selected);
|
|
197
|
+
const repeatPanes = await readPanes(tmux);
|
|
198
|
+
const repeatSelected = repeatPanes.filter((p) => p.sessionId === original.sessionId);
|
|
199
|
+
const repeatTable = await snapshot();
|
|
200
|
+
guardOwner(repeatPanes);
|
|
201
|
+
if ((owner && repeatSelected.some((p) => p.gnuplotOwner !== owner)) || !repeatSelected.length || fingerprint(repeatSelected) !== originalFingerprint ||
|
|
202
|
+
!repeatTable.some((p) => processKey(p) === processKey(server)) ||
|
|
203
|
+
selected.some((pane) => !repeatTable.some((p) => p.pid === pane.pid && tracker.owned.has(processKey(p))))) {
|
|
204
|
+
throw new Error("Session ownership changed while preparing shutdown; nothing was stopped.");
|
|
205
|
+
}
|
|
206
|
+
if (owner) {
|
|
207
|
+
await rememberMarked(repeatTable, repeatPanes.filter((p) => p.sessionId !== original.sessionId));
|
|
208
|
+
// The native metadata/socket readers can take time. Revalidate pane
|
|
209
|
+
// and server birth identities once more after them, before tmux acts.
|
|
210
|
+
const fresh = await snapshot();
|
|
211
|
+
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))))) {
|
|
212
|
+
throw new Error("Session ownership changed during helper inspection; nothing was stopped.");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// This comparison and kill execute in the same tmux command queue: a
|
|
216
|
+
// changed/replaced session or newly linked/added pane cannot slip between
|
|
217
|
+
// the final topology check and kill-session. No runtime input is injected.
|
|
218
|
+
let condition = `#{==:${fingerprintFormat},${originalFingerprint}}`;
|
|
219
|
+
if (/^[a-f0-9]{32}$/.test(original.recordId)) condition = `#{&&:${condition},#{==:#{@pi_repl_record_id},${original.recordId}}}`;
|
|
220
|
+
if (owner) condition = `#{&&:${condition},#{==:#{${GNUPLOT_OWNER_OPTION}},${owner}}}`;
|
|
221
|
+
const stopped = await tmux(["if-shell", "-F", "-t", original.sessionId, condition, `kill-session -t '${original.sessionId}'`, "display-message -p PI_REPL_STOP_CHANGED"]);
|
|
222
|
+
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()}`);
|
|
223
|
+
|
|
224
|
+
const signals = [];
|
|
225
|
+
async function inspect() {
|
|
226
|
+
const currentPanes = await readPanes(tmux);
|
|
227
|
+
const currentTable = await snapshot();
|
|
228
|
+
guardOwner(currentPanes);
|
|
229
|
+
const sameServer = currentTable.some((p) => processKey(p) === processKey(server));
|
|
230
|
+
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.");
|
|
231
|
+
if (owner) await rememberMarked(currentTable, currentPanes);
|
|
232
|
+
return tracker.refresh(currentTable, currentPanes);
|
|
233
|
+
}
|
|
234
|
+
async function wait(ms) {
|
|
235
|
+
const deadline = Date.now() + ms;
|
|
236
|
+
while (true) {
|
|
237
|
+
const remaining = await inspect();
|
|
238
|
+
// Allow a bounded quiet grace for startDetached's launcher to exec;
|
|
239
|
+
// an empty ancestry snapshot alone does not establish settlement.
|
|
240
|
+
if ((!remaining.length && !owner) || Date.now() >= deadline) return remaining;
|
|
241
|
+
await sleep(100);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function sendVerified(p, value) {
|
|
245
|
+
// Fresh pane protection + fresh process birth identity before every
|
|
246
|
+
// individual signal, including KILL escalation. No pkill/group kills.
|
|
247
|
+
const remaining = await inspect();
|
|
248
|
+
if (!remaining.some((candidate) => processKey(candidate) === processKey(p))) return;
|
|
249
|
+
// Marker inspection can involve a subprocess. Refresh ordinary birth
|
|
250
|
+
// identities and pane protections again immediately before signalling.
|
|
251
|
+
const freshPanes = await readPanes(tmux);
|
|
252
|
+
guardOwner(freshPanes);
|
|
253
|
+
const fresh = tracker.refresh(await snapshot(), freshPanes);
|
|
254
|
+
if (!fresh.some((candidate) => processKey(candidate) === processKey(p))) return;
|
|
255
|
+
try { signal(p.pid, value); signals.push({ pid: p.pid, signal: value }); }
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (error.code !== "ESRCH") tracker.issues.add(`Could not send ${value} to verified PID ${p.pid}: ${error.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
let remaining = await wait(graceMs);
|
|
262
|
+
for (const p of remaining) await sendVerified(p, "SIGTERM");
|
|
263
|
+
remaining = await wait(termMs);
|
|
264
|
+
const deadline = Date.now() + killMs;
|
|
265
|
+
while (remaining.length && Date.now() < deadline) {
|
|
266
|
+
for (const p of remaining) await sendVerified(p, "SIGKILL");
|
|
267
|
+
remaining = await wait(100);
|
|
268
|
+
}
|
|
269
|
+
if (remaining.length) tracker.issues.add(`Surviving verified PIDs: ${remaining.map((p) => p.pid).join(", ")}.`);
|
|
270
|
+
if (tracker.issues.size) throw new Error([...tracker.issues].join(" "));
|
|
271
|
+
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).");
|
|
272
|
+
try { warnings.push(...await cleanupSockets([...sockets.values()], { uid })); }
|
|
273
|
+
catch { warnings.push("Qt processes exited, but socket cleanup could not be fully verified; unverified files were left untouched."); }
|
|
274
|
+
return { sessionName, processCount: tracker.owned.size, signals, warnings };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
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 });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const REPL_SUBMISSION_DISPLAY_VERSION = 1;
|
|
4
|
+
export const DEFAULT_REPL_SUBMISSION_ECHO_MODE = "summary";
|
|
5
|
+
export const REPL_SUBMISSION_ECHO_MODES = Object.freeze(["off", "summary", "full"]);
|
|
6
|
+
export const REPL_SUBMISSION_SUMMARY_MAX_CHARS = 2_000;
|
|
7
|
+
export const REPL_SUBMISSION_SUMMARY_MAX_LINES = 20;
|
|
8
|
+
export const REPL_SUBMISSION_FULL_MAX_CHARS = 4_000;
|
|
9
|
+
export const REPL_SUBMISSION_FULL_MAX_LINES = 40;
|
|
10
|
+
|
|
11
|
+
const UNSAFE_UNICODE_PATTERN = /[\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
12
|
+
const OTHER_CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
|
|
13
|
+
const LABELLED_BEGIN_MARKER_PATTERN = /^── ([a-z0-9][a-z0-9-]{0,31}) · input · ([1-9]\d*) (line|lines) · id: ([a-f0-9]{12}) ──$/;
|
|
14
|
+
const LABELLED_END_MARKER_PATTERN = /^── done · id: ([a-f0-9]{12}) ──$/;
|
|
15
|
+
const COMPACT_BEGIN_MARKER_PATTERN = /^── ([a-z0-9][a-z0-9-]{0,31}) · ([a-f0-9]{12}) · ([1-9]\d*) (line|lines) ──$/;
|
|
16
|
+
const COMPACT_OUTPUT_MARKER = "── output ──";
|
|
17
|
+
const COMPACT_END_MARKER_PATTERN = /^── done · ([a-f0-9]{12}) ──$/;
|
|
18
|
+
const LEGACY_MARKER_PATTERN = /^── ([a-z0-9][a-z0-9-]{0,31}) (submitted|output|complete)(?: · ([1-9]\d*) (line|lines))? · ([a-f0-9]{12}) ──$/;
|
|
19
|
+
|
|
20
|
+
export function normalizeReplSubmissionEchoMode(value, fallback = DEFAULT_REPL_SUBMISSION_ECHO_MODE) {
|
|
21
|
+
const normalizedFallback = REPL_SUBMISSION_ECHO_MODES.includes(String(fallback || "").trim().toLowerCase())
|
|
22
|
+
? String(fallback).trim().toLowerCase()
|
|
23
|
+
: DEFAULT_REPL_SUBMISSION_ECHO_MODE;
|
|
24
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
25
|
+
return REPL_SUBMISSION_ECHO_MODES.includes(normalized) ? normalized : normalizedFallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeCodePoint(char) {
|
|
29
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
30
|
+
return codePoint <= 0xff
|
|
31
|
+
? `\\x${codePoint.toString(16).padStart(2, "0")}`
|
|
32
|
+
: `\\u{${codePoint.toString(16)}}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sanitizeReplSubmissionDisplayText(value) {
|
|
36
|
+
return String(value || "")
|
|
37
|
+
.replace(/\r\n?/g, "\n")
|
|
38
|
+
.replace(/\t/g, " ")
|
|
39
|
+
.replace(OTHER_CONTROL_PATTERN, escapeCodePoint)
|
|
40
|
+
.replace(UNSAFE_UNICODE_PATTERN, escapeCodePoint);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function truncateCodePoints(value, maxChars) {
|
|
44
|
+
const chars = Array.from(String(value || ""));
|
|
45
|
+
if (chars.length <= maxChars) return { text: chars.join(""), truncated: false };
|
|
46
|
+
return { text: `${chars.slice(0, Math.max(0, maxChars - 1)).join("")}…`, truncated: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeDisplayCode(code) {
|
|
50
|
+
const sanitized = sanitizeReplSubmissionDisplayText(code)
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => line.replace(/ +$/g, ""))
|
|
53
|
+
.join("\n")
|
|
54
|
+
.replace(/\n+$/, "");
|
|
55
|
+
return sanitized || "(empty submission)";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildBoundedPreviewLines(codeLines, maxLines, maxChars) {
|
|
59
|
+
const shown = [];
|
|
60
|
+
let usedChars = 0;
|
|
61
|
+
let truncated = false;
|
|
62
|
+
for (let index = 0; index < codeLines.length; index += 1) {
|
|
63
|
+
if (shown.length >= maxLines) {
|
|
64
|
+
truncated = true;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
const line = codeLines[index];
|
|
68
|
+
const remaining = maxChars - usedChars;
|
|
69
|
+
if (remaining <= 0) {
|
|
70
|
+
truncated = true;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const clipped = truncateCodePoints(line, remaining);
|
|
74
|
+
shown.push(clipped.text);
|
|
75
|
+
usedChars += Array.from(clipped.text).length + 1;
|
|
76
|
+
if (clipped.truncated) {
|
|
77
|
+
truncated = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (shown.length < codeLines.length) truncated = true;
|
|
82
|
+
if (truncated) shown.push(`… preview truncated; ${codeLines.length} ${codeLines.length === 1 ? "line" : "lines"} total`);
|
|
83
|
+
return shown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createReplSubmissionDisplay(details = {}) {
|
|
87
|
+
const mode = normalizeReplSubmissionEchoMode(details.mode);
|
|
88
|
+
const origin = String(details.origin || "pi")
|
|
89
|
+
.trim()
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
92
|
+
.replace(/^-+|-+$/g, "")
|
|
93
|
+
.slice(0, 32) || "pi";
|
|
94
|
+
const entryId = String(details.entryId || "");
|
|
95
|
+
const anchorId = createHash("sha256")
|
|
96
|
+
.update(`pi-repl-submission-display-v${REPL_SUBMISSION_DISPLAY_VERSION}\0${entryId}`, "utf8")
|
|
97
|
+
.digest("hex")
|
|
98
|
+
.slice(0, 12);
|
|
99
|
+
const displayCode = normalizeDisplayCode(details.code);
|
|
100
|
+
const codeLines = displayCode.split("\n");
|
|
101
|
+
const lineLabel = `${codeLines.length} ${codeLines.length === 1 ? "line" : "lines"}`;
|
|
102
|
+
const beginMarker = `── ${origin} · input · ${lineLabel} · id: ${anchorId} ──`;
|
|
103
|
+
const outputMarker = COMPACT_OUTPUT_MARKER;
|
|
104
|
+
const endMarker = `── done · id: ${anchorId} ──`;
|
|
105
|
+
const enabled = mode !== "off";
|
|
106
|
+
const previewLines = !enabled
|
|
107
|
+
? []
|
|
108
|
+
: mode === "full"
|
|
109
|
+
? buildBoundedPreviewLines(codeLines, REPL_SUBMISSION_FULL_MAX_LINES, REPL_SUBMISSION_FULL_MAX_CHARS)
|
|
110
|
+
: buildBoundedPreviewLines(codeLines, REPL_SUBMISSION_SUMMARY_MAX_LINES, REPL_SUBMISSION_SUMMARY_MAX_CHARS);
|
|
111
|
+
return {
|
|
112
|
+
version: REPL_SUBMISSION_DISPLAY_VERSION,
|
|
113
|
+
mode,
|
|
114
|
+
enabled,
|
|
115
|
+
origin,
|
|
116
|
+
entryId,
|
|
117
|
+
anchorId,
|
|
118
|
+
beginMarker,
|
|
119
|
+
outputMarker,
|
|
120
|
+
endMarker,
|
|
121
|
+
previewLines,
|
|
122
|
+
prefixLines: enabled ? ["", beginMarker, ...previewLines, outputMarker] : [],
|
|
123
|
+
suffixLines: enabled ? [endMarker, ""] : [],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseLineCount(countText, countLabel) {
|
|
128
|
+
const lineCount = Number(countText);
|
|
129
|
+
if (!Number.isSafeInteger(lineCount) || lineCount < 1) return null;
|
|
130
|
+
if ((lineCount === 1) !== (countLabel === "line")) return null;
|
|
131
|
+
return lineCount;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parseReplSubmissionDisplayMarker(line) {
|
|
135
|
+
const normalized = String(line || "").replace(/\r$/, "");
|
|
136
|
+
const labelledBegin = normalized.match(LABELLED_BEGIN_MARKER_PATTERN);
|
|
137
|
+
if (labelledBegin) {
|
|
138
|
+
const [, origin, countText, countLabel, anchorId] = labelledBegin;
|
|
139
|
+
const lineCount = parseLineCount(countText, countLabel);
|
|
140
|
+
return lineCount === null
|
|
141
|
+
? null
|
|
142
|
+
: { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase: "submitted", anchorId, lineCount };
|
|
143
|
+
}
|
|
144
|
+
// Keep the earlier compact form readable without changing its parse result.
|
|
145
|
+
const compactBegin = normalized.match(COMPACT_BEGIN_MARKER_PATTERN);
|
|
146
|
+
if (compactBegin) {
|
|
147
|
+
const [, origin, anchorId, countText, countLabel] = compactBegin;
|
|
148
|
+
const lineCount = parseLineCount(countText, countLabel);
|
|
149
|
+
return lineCount === null
|
|
150
|
+
? null
|
|
151
|
+
: { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase: "submitted", anchorId, lineCount };
|
|
152
|
+
}
|
|
153
|
+
if (normalized === COMPACT_OUTPUT_MARKER) {
|
|
154
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, phase: "output" };
|
|
155
|
+
}
|
|
156
|
+
const compactEnd = normalized.match(LABELLED_END_MARKER_PATTERN) ?? normalized.match(COMPACT_END_MARKER_PATTERN);
|
|
157
|
+
if (compactEnd) {
|
|
158
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, phase: "complete", anchorId: compactEnd[1] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Accept the earlier three-marker development format so raw histories made
|
|
162
|
+
// while testing it remain interpretable; new displays do not emit this form.
|
|
163
|
+
const legacy = normalized.match(LEGACY_MARKER_PATTERN);
|
|
164
|
+
if (!legacy) return null;
|
|
165
|
+
const [, origin, phase, countText, countLabel, anchorId] = legacy;
|
|
166
|
+
if (phase === "submitted") {
|
|
167
|
+
if (!countText) return null;
|
|
168
|
+
const lineCount = parseLineCount(countText, countLabel);
|
|
169
|
+
return lineCount === null
|
|
170
|
+
? null
|
|
171
|
+
: { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase, anchorId, lineCount, legacy: true };
|
|
172
|
+
}
|
|
173
|
+
if (countText) return null;
|
|
174
|
+
return { version: REPL_SUBMISSION_DISPLAY_VERSION, origin, phase, anchorId, legacy: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function consumeExactDisplayLine(value, offset, line) {
|
|
178
|
+
if (!value.startsWith(line, offset)) return null;
|
|
179
|
+
let end = offset + line.length;
|
|
180
|
+
while (end < value.length && (value[end] === " " || value[end] === "\t")) end += 1;
|
|
181
|
+
if (end < value.length && value[end] !== "\n") return null;
|
|
182
|
+
return value[end] === "\n" ? end + 1 : end;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function consumeWrappedDisplayLine(value, offset, line) {
|
|
186
|
+
const exact = consumeExactDisplayLine(value, offset, line);
|
|
187
|
+
if (exact !== null) return exact;
|
|
188
|
+
// Some captures retain hard wraps inside a printed preview line. Match
|
|
189
|
+
// its known text, allowing only inserted line breaks, not arbitrary output.
|
|
190
|
+
let end = offset;
|
|
191
|
+
for (const character of line) {
|
|
192
|
+
while (end > offset && value[end] === "\n") end += 1;
|
|
193
|
+
if (!value.startsWith(character, end)) return null;
|
|
194
|
+
end += character.length;
|
|
195
|
+
}
|
|
196
|
+
return consumeExactDisplayLine(value, end, "");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function findExactDisplayLine(value, line, useLast = false) {
|
|
200
|
+
let index = useLast ? value.lastIndexOf(line) : value.indexOf(line);
|
|
201
|
+
while (index >= 0) {
|
|
202
|
+
const startsLine = index === 0 || value[index - 1] === "\n";
|
|
203
|
+
const end = consumeExactDisplayLine(value, index, line);
|
|
204
|
+
if (startsLine && end !== null) return { index, end };
|
|
205
|
+
if (useLast) {
|
|
206
|
+
if (index === 0) break;
|
|
207
|
+
index = value.lastIndexOf(line, index - 1);
|
|
208
|
+
} else {
|
|
209
|
+
index = value.indexOf(line, index + line.length);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function removeMarkerLine(value, marker, useLast = false, trailingGap = false) {
|
|
216
|
+
const found = findExactDisplayLine(value, marker, useLast);
|
|
217
|
+
if (!found) return value;
|
|
218
|
+
// Consume at most our one following blank line. Preserve user whitespace
|
|
219
|
+
// before the anchor and any additional blank lines after it; older captures
|
|
220
|
+
// without a separator (or without the new display field) still work.
|
|
221
|
+
const end = found.end + (trailingGap && value[found.end] === "\n" ? 1 : 0);
|
|
222
|
+
return value.slice(0, found.index) + value.slice(end);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function stripReplSubmissionDisplay(output, display) {
|
|
226
|
+
let value = String(output || "").replace(/\r\n?/g, "\n");
|
|
227
|
+
if (!display || display.enabled !== true) return value;
|
|
228
|
+
const begin = findExactDisplayLine(value, display.beginMarker);
|
|
229
|
+
if (begin) {
|
|
230
|
+
// Remove only our single leading blank line, not the loader's newline
|
|
231
|
+
// or any additional output whitespace. Older unpadded captures still work.
|
|
232
|
+
const leadingGap = display.prefixLines[0] === "" && begin.index > 0 &&
|
|
233
|
+
value[begin.index - 1] === "\n" && (begin.index === 1 || value[begin.index - 2] === "\n");
|
|
234
|
+
const beginIndex = begin.index - (leadingGap ? 1 : 0);
|
|
235
|
+
// Unprefixed source can itself contain a line reading "── output ──".
|
|
236
|
+
// Consume the known preview before its divider rather than treating the
|
|
237
|
+
// first marker-looking source line as the start of runtime output. If a
|
|
238
|
+
// prefix is incomplete, preserve everything after the matched portion.
|
|
239
|
+
let suffixStart = begin.index;
|
|
240
|
+
const prefixLines = display.prefixLines[0] === "" ? display.prefixLines.slice(1) : display.prefixLines;
|
|
241
|
+
for (const line of prefixLines) {
|
|
242
|
+
const next = consumeWrappedDisplayLine(value, suffixStart, line);
|
|
243
|
+
if (next === null) break;
|
|
244
|
+
suffixStart = next;
|
|
245
|
+
}
|
|
246
|
+
value = value.slice(0, beginIndex) + value.slice(suffixStart);
|
|
247
|
+
}
|
|
248
|
+
return removeMarkerLine(value, display.endMarker, true, display.suffixLines?.at(-1) === "");
|
|
249
|
+
}
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// pi-repl's messages and tool guidance refer to its Pi commands, /repl and its
|
|
2
|
+
// alias /lab. Outside Pi the same actions are `agent-repl ...` in a terminal.
|
|
3
|
+
// This rewrites that wording in tool descriptions and command output. It is
|
|
4
|
+
// never applied to REPL output.
|
|
5
|
+
const PHRASES = [
|
|
6
|
+
["current /repl echo setting", "server's echo setting"],
|
|
7
|
+
["repl_start, /repl, or /lab", "repl_start or agent-repl"],
|
|
8
|
+
["the same startup path as /repl and /lab", "the same startup path as agent-repl"],
|
|
9
|
+
["The same applies to /lab, ", "The same applies to "],
|
|
10
|
+
["pi-repl requires tmux", "agent-repl requires tmux"],
|
|
11
|
+
];
|
|
12
|
+
// "/repl julia or /lab julia", "/repl R or /repl r, or /lab R or /lab r",
|
|
13
|
+
// "/repl python, /repl ipython, /lab python, or /lab ipython": drop the /lab forms.
|
|
14
|
+
const LAB_ALTERNATIVES = /(?:,? or |, | and )\/lab(?: (?!and\b|or\b)[\w${}]+)?(?:,? or \/lab(?: [\w${}]+)?)*/g;
|
|
15
|
+
const COMMAND = /(^|[\s"'`(])\/(?:repl|lab)(?=[\s.,;:)"'`]|$)/gm;
|
|
16
|
+
export function forAgentRepl(text) {
|
|
17
|
+
for (const [from, to] of PHRASES)
|
|
18
|
+
text = text.split(from).join(to);
|
|
19
|
+
return text.replace(LAB_ALTERNATIVES, "").replace(COMMAND, "$1agent-repl");
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-repl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi-repl's shared tmux REPLs (Python, Julia, R and more) for Claude Code, Codex, OpenCode and other agents, over MCP and a CLI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"repl",
|
|
7
|
+
"tmux",
|
|
8
|
+
"mcp",
|
|
9
|
+
"mcp-server",
|
|
10
|
+
"claude-code",
|
|
11
|
+
"codex",
|
|
12
|
+
"opencode",
|
|
13
|
+
"coding-agent",
|
|
14
|
+
"python",
|
|
15
|
+
"julia",
|
|
16
|
+
"pi-repl",
|
|
17
|
+
"cli"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/omaclaren/agent-repl#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/omaclaren/agent-repl/issues"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/omaclaren/agent-repl.git"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"bin": {
|
|
30
|
+
"agent-repl": "dist/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "node scripts/build.mjs",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"sync": "node scripts/sync-from-pi-repl.mjs",
|
|
42
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
43
|
+
"test:upstream": "node --import tsx --test test/upstream/*.test.js",
|
|
44
|
+
"test:all": "npm test && npm run test:upstream",
|
|
45
|
+
"prepublishOnly": "npm test"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
52
|
+
"typebox": "^1.3.34"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22.20.4",
|
|
56
|
+
"tsx": "^4.23.15",
|
|
57
|
+
"typescript": "5.9.3"
|
|
58
|
+
}
|
|
59
|
+
}
|