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,105 @@
1
+ # Shared REPL session record protocol v1
2
+
3
+ This protocol lets independent `pi-repl` and `pi-studio` installations exchange a clean code/output record while using the same tmux REPL session. Neither package imports or requires the other. If the protocol is unavailable or invalid, each package keeps its existing standalone behavior and the raw tmux pane/history remains usable.
4
+
5
+ ## Discovery and identity
6
+
7
+ Compatible clients inspect two tmux session options:
8
+
9
+ - `@pi_repl_record_id`: 32 lowercase hexadecimal characters
10
+ - `@pi_repl_record_version`: `1`
11
+
12
+ A client creating metadata uses tmux `set-option -o`, so concurrent first writers cannot replace an ID already chosen by another client. The option contains an opaque ID, never a filesystem path.
13
+
14
+ The record is bound to:
15
+
16
+ - the tmux session name
17
+ - `#{session_id}`
18
+ - `#{session_created}`
19
+
20
+ A client refuses a record whose stored identity does not match the live tmux session. Runtime metadata continues to use `@pi_repl_runtime`.
21
+
22
+ ## Storage and permissions
23
+
24
+ The opaque ID maps to:
25
+
26
+ ```text
27
+ <os temporary directory>/pi-repl-session-records-<uid>/<record-id>.json
28
+ ```
29
+
30
+ The root is a real, current-user-owned mode-`0700` directory. Record files are single-link regular, current-user-owned mode-`0600` files. Symlinked roots, records, lock paths, and hard-linked records are refused. Writes use a same-directory exclusive temporary file, file fsync, atomic rename, and best-effort directory fsync.
31
+
32
+ The record is a bounded JSON snapshot:
33
+
34
+ ```json
35
+ {
36
+ "protocol": "pi-repl-session-record",
37
+ "version": 1,
38
+ "recordId": "…",
39
+ "session": {
40
+ "sessionName": "pi-repl-python",
41
+ "tmuxSessionId": "$1",
42
+ "tmuxSessionCreatedAt": 1700000000,
43
+ "runtime": "ipython"
44
+ },
45
+ "revision": 4,
46
+ "createdAt": 1700000000000,
47
+ "updatedAt": 1700000005000,
48
+ "clearedAt": null,
49
+ "droppedEntries": 0,
50
+ "entries": []
51
+ }
52
+ ```
53
+
54
+ Each entry has a stable `id`, optional `requestId`, timestamps, session/runtime identity, `origin` (`pi-repl` or `pi-studio`), a presentation `label`, `mode` (`raw`, `literate`, or `agent`), prose, code, cleaned output, status, and skipped/truncation metadata.
55
+
56
+ ## Concurrency and bounds
57
+
58
+ Record updates take a short cross-process directory lock, read the latest snapshot, upsert by stable entry ID, and replace the snapshot atomically. A stale lock can be recovered. The implementation retains at most 300 entries, bounds individual prose/code/output fields, and caps the serialized record at 16 MiB by dropping the oldest entries first.
59
+
60
+ Attribution-sensitive sends also take a separate cross-client send lease. A compatible client holds it from the pre-send pane capture through the completion/output capture. The lease has an owner token, heartbeat, bounded wait, and stale recovery. If a caller times out or aborts after submission, the live client continues heartbeating the lease until the runtime completion signal appears or that exact tmux session lifetime ends; a caller timeout does not imply that submitted code stopped. This prevents `pi-repl` and `pi-studio` from concurrently claiming each other's output; it cannot prevent a person typing directly into an attached tmux pane.
61
+
62
+ ## Clean record versus raw history
63
+
64
+ The clean record includes submissions whose semantic boundaries are known to a compatible client, plus explicit literate notes. Code typed directly into tmux is retained only in the raw pane/history mirror unless future runtime-specific instrumentation can establish reliable boundaries. Clients must not present raw `pipe-pane` output as reliably parsed code/output.
65
+
66
+ Canonical Markdown exports identify origin, mode, status, runtime, and timestamp and include this direct-input limitation.
67
+
68
+ ## Optional raw-history display and alignment anchors
69
+
70
+ Compatible clients may add protocol-independent submission displays to the raw pane while retaining the same clean record. Display version 1 derives a non-secret 12-hex-character anchor as the first 12 characters of SHA-256 over `pi-repl-submission-display-v1`, a NUL byte, and the stable clean-record entry ID. The entry ID itself is not written to the pane. The short hash is labelled `id:` in both the input and completion markers; it is a correlation aid, not an execution signal. The hash derivation and record protocol remain unchanged.
71
+
72
+ Echo defaults and preview limits are client preferences, not protocol requirements. `pi-repl` defaults to **Summary**, which shows a short submission in full, truncating after 20 source lines or 2,000 source characters. **Off** writes no optional display or alignment anchors. Explicit opt-in **Full** raises the bounds to 40 lines or 4,000 characters. Both Summary and Full persist their source preview in raw terminal history; Off does not remove code from the clean record. Other clients, including older installations, may use different echo defaults or preview limits without affecting interoperability. Display text normalizes newlines and tabs, removes trailing display whitespace, and escapes terminal, line-separator, and bidirectional control characters.
73
+
74
+ ```text
75
+ <runtime loader command>
76
+
77
+ ── pi-repl · input · 2 lines · id: a1b2c3d4e5f6 ──
78
+ x = 1
79
+ x + 1
80
+ ── output ──
81
+ 2
82
+ ── done · id: a1b2c3d4e5f6 ──
83
+
84
+ <runtime prompt>
85
+ ```
86
+
87
+ The compact submitted and completion anchors remain in raw tmux history for human readability and deterministic future alignment. A plain unanchored `── output ──` divider separates the source preview from runtime output without repeating the ID or other metadata. `pi-repl` inserts one display-only blank line before the begin anchor and after the completion anchor in Summary and Full modes, with no internal padding. The trailing gap is removed from clean output along with the exact final anchor; additional user whitespace is preserved. Native prompt spacing is not changed. Input previews are plain code without an added vertical bar. Cleanup matches the known preview before its output divider, including retained line wraps, so marker-looking lines inside source are not mistaken for real boundaries. Existing histories with different spacing, bar-prefixed source, unlabelled compact IDs or earlier three-marker displays remain readable. Clients remove the exact request-specific header, source preview, divider, and footer from captured tool output and clean-record output. These markers are presentation metadata, not clean-record authority: missing, malformed, duplicated, or user-produced marker-like text must never cause inferred raw activity to be promoted silently into protocol-v1 entries.
88
+
89
+ ## Runtime control files (outside protocol v1)
90
+
91
+ Runtime-specific source wrappers and completion files are client implementation details, not shared-record state or authority. Compatible clients use compact request-unique names under a current-user-owned mode-`0700` `/tmp/pi-rc-<user-key>` root on POSIX systems, with mode-`0600` source files created exclusively. This avoids both verbose per-session paths and fixed global filenames that can collide across clients, processes, tmux servers, or runtimes.
92
+
93
+ `pi-repl` prefixes submission control filenames with the display anchor: `<12-hex-anchor>-<16-hex-random-token>.<extension>`. Related source, guard and driver files share the anchor but retain independently allocated random tokens; each completion file uses its source file's full stem with `.done`. The prefix is present even in Off mode, without adding optional display markers. Transient paste-buffer files and callers that do not supply an anchor retain the older `<16-hex-random-token>.<extension>` form. Cleanup and stale-file pruning recognize both forms. The anchor derivation, record IDs and protocol version do not change; the prefix is only a human correlation aid, not a uniqueness guarantee or completion signal. Other clients need not adopt these names to remain compatible.
94
+
95
+ A client removes the source and completion files after output capture. If a send times out or is aborted after submission, the same watcher that retains any shared lease also retains those files until the wrapper signals completion or the exact session lifetime disappears. Orphans left by a process crash are pruned after 24 hours on a later send. These files remain protocol-independent: their names and presence never turn raw pane activity into a clean-record entry.
96
+
97
+ gnuplot's implementation additionally uses its guard's `.done` path as a private acknowledgment that the nested source returned. The pipe producer writes the source's actual completion file only after acknowledgment or native pipe closure on error/interruption. Neither a display marker, a comment written to the pipe, nor SIGINT alone establishes completion. This does not change the shared record or its lease protocol; source, guard and producer files are all retained until settlement.
98
+
99
+ New gnuplot sessions also have independent launch-ownership metadata: the inherited `PI_REPL_GNUPLOT_OWNER` environment marker and matching tmux `@pi_repl_gnuplot_owner` option (32 lowercase hexadecimal characters). These are allocated only for a newly created session, not derived from a record ID, copied to another session, or retrofitted into a running interpreter. They support explicit-stop attribution of detached helpers together with UID/birth identities and pane protections. They are not record identity, send-lease authority or completion signals, and do not change protocol version 1. Clients without that attribution must report detached-helper cleanup as unverified rather than infer ownership from a process name or PID.
100
+
101
+ C++ (`cpp`) likewise leaves record/display/lease version 1 unchanged. A private `.cpp` source, length-framed `.req` and ordinary C++ `.hxx` driver share the display anchor but have independent allocation tokens. The single terminal include loads an in-session Cling helper and a unique scalar initializer for this request; no completion command is queued into terminal stdin. The helper validates the private directory and request/source descriptor identities and publishes a `pi-repl-cpp-v1 <source-stem> <result>\n` reply only after native evaluation and helper-local destruction. Only a bounded, complete, single-link, owned mode-0600 reply in the original mode-0700 directory settles the send. Empty, malformed or mismatched replies remain pending. This is a native-return signal, not proof of successful library calls or rollback of earlier groups. Captured entries retain native diagnostics; tool details expose the evaluator result separately. The private controls are not a security sandbox against arbitrary code executing with the same user's privileges.
102
+
103
+ ## Compatibility
104
+
105
+ A client that sees an unsupported version leaves it untouched. Existing tmux sessions gain v1 metadata lazily when inspected or used. Studio may import legacy browser-local entries as `pi-studio` entries using their stable IDs, making retries idempotent.
@@ -0,0 +1,170 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fchmodSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readdirSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { basename, dirname, join } from "node:path";
16
+
17
+ const REPL_CONTROL_TOKEN_BYTES = 8;
18
+ const REPL_CONTROL_FILENAME_PATTERN = /^((?:[a-f0-9]{12}-)?[a-f0-9]{16})\.[A-Za-z0-9]{1,8}$/;
19
+ const REPL_CONTROL_STALE_MS = 24 * 60 * 60 * 1_000;
20
+ const prunedRoots = new Set();
21
+
22
+ function currentUid() {
23
+ return typeof process.getuid === "function" ? process.getuid() : null;
24
+ }
25
+
26
+ export function getPrivateReplControlRoot() {
27
+ const uid = currentUid();
28
+ const base = process.platform === "win32" ? tmpdir() : "/tmp";
29
+ const userKey = uid === null ? "user" : uid.toString(36);
30
+ return join(base, `pi-rc-${userKey}`);
31
+ }
32
+
33
+ function assertPrivateReplControlRoot(root) {
34
+ const stats = lstatSync(root);
35
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
36
+ throw new Error(`REPL control root is not a real directory: ${root}`);
37
+ }
38
+ const uid = currentUid();
39
+ if (uid !== null && stats.uid !== uid) {
40
+ throw new Error(`REPL control root is not owned by the current user: ${root}`);
41
+ }
42
+ if (process.platform !== "win32" && (stats.mode & 0o777) !== 0o700) {
43
+ throw new Error(`REPL control root must have mode 0700: ${root}`);
44
+ }
45
+ }
46
+
47
+ function getReplControlFileStem(name) {
48
+ const match = name.match(REPL_CONTROL_FILENAME_PATTERN);
49
+ return match?.[0] === name ? match[1] : undefined;
50
+ }
51
+
52
+ function pruneStaleReplControlFiles(root, now = Date.now()) {
53
+ if (prunedRoots.has(root)) return;
54
+ prunedRoots.add(root);
55
+ let entries = [];
56
+ try {
57
+ entries = readdirSync(root);
58
+ } catch {
59
+ return;
60
+ }
61
+ const uid = currentUid();
62
+ for (const entry of entries) {
63
+ if (!getReplControlFileStem(entry)) continue;
64
+ const file = join(root, entry);
65
+ try {
66
+ const stats = lstatSync(file);
67
+ if (!stats.isFile() || stats.isSymbolicLink()) continue;
68
+ if (uid !== null && stats.uid !== uid) continue;
69
+ if (now - stats.mtimeMs < REPL_CONTROL_STALE_MS) continue;
70
+ unlinkSync(file);
71
+ } catch {
72
+ // Another process may have removed the same stale file.
73
+ }
74
+ }
75
+ }
76
+
77
+ export function ensurePrivateReplControlRoot(root = getPrivateReplControlRoot()) {
78
+ let created = false;
79
+ try {
80
+ mkdirSync(root, { mode: 0o700 });
81
+ created = true;
82
+ } catch (error) {
83
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
84
+ }
85
+ if (created && process.platform !== "win32") chmodSync(root, 0o700);
86
+ assertPrivateReplControlRoot(root);
87
+ pruneStaleReplControlFiles(root);
88
+ return root;
89
+ }
90
+
91
+ function normalizeExtension(extension) {
92
+ const normalized = String(extension || "").replace(/^\.+/, "");
93
+ if (!normalized || normalized.length > 8 || /[^A-Za-z0-9]/.test(normalized)) {
94
+ throw new Error(`Invalid REPL control-file extension: ${extension}`);
95
+ }
96
+ return normalized;
97
+ }
98
+
99
+ /**
100
+ * Create and populate one private, collision-resistant REPL control file.
101
+ * An optional display anchor prefixes the independent random allocation token.
102
+ * The builder receives the final paths so it can embed the matching done-file
103
+ * path in the runtime-specific wrapper.
104
+ */
105
+ export function createPrivateReplControlFiles(options) {
106
+ const extension = normalizeExtension(options?.extension);
107
+ const anchorId = options?.anchorId;
108
+ if (anchorId !== undefined && (typeof anchorId !== "string" || anchorId.length !== 12 || !/^[a-f0-9]{12}$/.test(anchorId))) {
109
+ throw new Error("Invalid REPL submission anchor ID: expected 12 lowercase hex characters.");
110
+ }
111
+ const prefix = anchorId === undefined ? "" : `${anchorId}-`;
112
+ const root = ensurePrivateReplControlRoot(options?.root || getPrivateReplControlRoot());
113
+ if (typeof options?.buildSource !== "function") {
114
+ throw new Error("REPL control-file source builder is required.");
115
+ }
116
+
117
+ for (let attempt = 0; attempt < 20; attempt += 1) {
118
+ const stem = prefix + randomBytes(REPL_CONTROL_TOKEN_BYTES).toString("hex");
119
+ const paths = {
120
+ dir: root,
121
+ sourceFile: join(root, `${stem}.${extension}`),
122
+ doneFile: join(root, `${stem}.done`),
123
+ };
124
+ if (existsSync(paths.doneFile)) continue;
125
+
126
+ let descriptor;
127
+ try {
128
+ descriptor = openSync(paths.sourceFile, "wx", 0o600);
129
+ } catch (error) {
130
+ if (error && typeof error === "object" && error.code === "EEXIST") continue;
131
+ throw error;
132
+ }
133
+
134
+ let complete = false;
135
+ try {
136
+ const source = String(options.buildSource(paths));
137
+ writeFileSync(descriptor, source, "utf8");
138
+ if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
139
+ complete = true;
140
+ return paths;
141
+ } finally {
142
+ closeSync(descriptor);
143
+ if (!complete) {
144
+ try {
145
+ unlinkSync(paths.sourceFile);
146
+ } catch {
147
+ // Preserve the source-builder error.
148
+ }
149
+ }
150
+ }
151
+ }
152
+ throw new Error("Could not allocate a unique REPL control file.");
153
+ }
154
+
155
+ export function cleanupPrivateReplControlFiles(paths) {
156
+ if (!paths || typeof paths !== "object") return;
157
+ const root = String(paths.dir || "");
158
+ const sourceFile = String(paths.sourceFile || "");
159
+ const doneFile = String(paths.doneFile || "");
160
+ const sourceName = basename(sourceFile);
161
+ const stem = getReplControlFileStem(sourceName);
162
+ if (!stem || dirname(sourceFile) !== root || dirname(doneFile) !== root || basename(doneFile) !== `${stem}.done`) return;
163
+ for (const file of [sourceFile, doneFile]) {
164
+ try {
165
+ unlinkSync(file);
166
+ } catch {
167
+ // Cleanup is idempotent and best effort.
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,164 @@
1
+ // Cling-only, in-session driver. No evaluator process, source rewriting or replay.
2
+ // Included by the first private driver; this reserved namespace is protocol v1.
3
+ #ifndef PI_REPL_CPP_DRIVER_V1
4
+ #define PI_REPL_CPP_DRIVER_V1
5
+ #include <cling/Interpreter/Interpreter.h>
6
+ #include <cling/Interpreter/RuntimeUniverse.h>
7
+ #include <cling/MetaProcessor/InputValidator.h>
8
+ #include <cstdio>
9
+ #include <cstdlib>
10
+ #include <cstring>
11
+ #include <string>
12
+ #include <exception>
13
+ #include <stdexcept>
14
+ #include <cerrno>
15
+ #include <climits>
16
+ #include <fcntl.h>
17
+ #include <sys/stat.h>
18
+ #include <unistd.h>
19
+
20
+ namespace pi_repl_cpp_v1 {
21
+ using number = unsigned long long;
22
+ struct file {
23
+ int fd;
24
+ explicit file(int value) : fd(value) {}
25
+ void close_now() { if (fd >= 0) { close(fd); fd=-1; } }
26
+ ~file() { close_now(); }
27
+ file(const file&) = delete;
28
+ };
29
+ inline bool decimal(const std::string& text, number& value) {
30
+ if (text.empty() || text.size() > 20) return false;
31
+ value = 0;
32
+ for (char c : text) {
33
+ if (c < '0' || c > '9' || value > (ULLONG_MAX - (c-'0')) / 10) return false;
34
+ value = value * 10 + (c-'0');
35
+ }
36
+ return true;
37
+ }
38
+ inline bool stem_ok(const std::string& text) {
39
+ if (text.size() != 16 && text.size() != 29) return false;
40
+ for (size_t i=0; i<text.size(); ++i) {
41
+ char c=text[i];
42
+ if (text.size()==29 && i==12) { if (c!='-') return false; }
43
+ else if (!((c>='0' && c<='9') || (c>='a' && c<='f'))) return false;
44
+ }
45
+ return true;
46
+ }
47
+ inline bool private_file(int fd, number dev, number ino, number size) {
48
+ struct stat s;
49
+ return fd>=0 && fstat(fd,&s)==0 && S_ISREG(s.st_mode)
50
+ && s.st_uid==getuid() && (s.st_mode&0777)==0600 && s.st_nlink==1
51
+ && number(s.st_dev)==dev && number(s.st_ino)==ino && number(s.st_size)==size;
52
+ }
53
+ inline bool read_exact(int fd, number size, std::string& result) {
54
+ char buffer[4096];
55
+ while (size) {
56
+ ssize_t n=read(fd,buffer,size<sizeof buffer ? size : sizeof buffer);
57
+ if (n<0 && errno==EINTR) continue;
58
+ if (n<=0) return false;
59
+ result.append(buffer,size_t(n)); size-=number(n);
60
+ }
61
+ // Reject growth as well as truncation; never read unbounded user input.
62
+ char extra; return read(fd,&extra,1)==0;
63
+ }
64
+ inline bool field(const std::string& request, size_t& position, std::string& result) {
65
+ size_t end=request.find('\n',position); number size;
66
+ if (end==std::string::npos || !decimal(request.substr(position,end-position),size)
67
+ || size>request.size()-end-1) return false;
68
+ position=end+1; result=request.substr(position,size); position+=size; return true;
69
+ }
70
+ inline int evaluate(const std::string& source) {
71
+ cling::InputValidator validator;
72
+ int result=0; size_t position=0;
73
+ while (position<source.size()) {
74
+ size_t end=source.find('\n',position);
75
+ std::string line=source.substr(position,end==std::string::npos ? end : end-position);
76
+ position=end==std::string::npos ? source.size() : end+1;
77
+ if (validator.validate(line)==cling::InputValidator::kIncomplete) { result=2; continue; }
78
+ std::string input; validator.reset(&input);
79
+ result=static_cast<int>(cling::runtime::gCling->process(input));
80
+ if (result!=0) break;
81
+ }
82
+ if (result==2) fputs("pi-repl: incomplete C++ prompt group; no continuation is queued\n",stderr);
83
+ return result;
84
+ }
85
+ inline void submit(const char* request_path, number root_dev, number root_ino,
86
+ number request_dev, number request_ino, number request_size) {
87
+ static const pid_t owner_pid = getpid();
88
+ int response_fd=-1;
89
+ char reply[128]; size_t reply_size=0;
90
+ {
91
+ // All descriptors and source/evaluator locals die before publication.
92
+ // Source/request failure before a valid family is decoded fails closed.
93
+ std::string root_path, stem, footer;
94
+ int result=5;
95
+ try {
96
+ std::string path(request_path);
97
+ size_t slash=path.rfind('/');
98
+ if (slash==std::string::npos || request_size>65536) throw std::runtime_error("invalid request path or size");
99
+ root_path=path.substr(0,slash);
100
+ std::string name=path.substr(slash+1);
101
+ if (name.size()<5 || name.substr(name.size()-4)!=".req" || !stem_ok(name.substr(0,name.size()-4))) throw std::runtime_error("invalid request name");
102
+ file root(open(root_path.c_str(),O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC));
103
+ struct stat rs;
104
+ if (root.fd<0 || fstat(root.fd,&rs)!=0 || rs.st_uid!=getuid() || (rs.st_mode&0777)!=0700
105
+ || number(rs.st_dev)!=root_dev || number(rs.st_ino)!=root_ino) throw std::runtime_error("private directory changed");
106
+ file request(openat(root.fd,name.c_str(),O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC));
107
+ std::string body;
108
+ if (!private_file(request.fd,request_dev,request_ino,request_size) || !read_exact(request.fd,request_size,body)) throw std::runtime_error("request identity or read failed");
109
+ const std::string magic="pi-repl-cpp-v1\n";
110
+ if (body.compare(0,magic.size(),magic)!=0) throw std::runtime_error("unknown request protocol");
111
+ size_t position=magic.size(); std::string fields[6];
112
+ for (auto& value : fields) if (!field(body,position,value)) throw std::runtime_error("invalid request frame");
113
+ number dev,ino,size;
114
+ if (position!=body.size() || !stem_ok(fields[0]) || !decimal(fields[1],dev)
115
+ || !decimal(fields[2],ino) || !decimal(fields[3],size) || size>16*1024*1024
116
+ || fields[5].find('\0')!=std::string::npos) throw std::runtime_error("invalid request fields");
117
+ stem=fields[0]; footer=fields[5];
118
+ file source_file(openat(root.fd,(stem+".cpp").c_str(),O_RDONLY|O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC));
119
+ std::string source;
120
+ if (!private_file(source_file.fd,dev,ino,size) || !read_exact(source_file.fd,size,source)
121
+ || source.find('\0')!=std::string::npos) throw std::runtime_error("source identity or read failed");
122
+ // Do not keep control descriptors in the user's running workspace. A user
123
+ // closing/reusing a descriptor must not make our later destructor close it.
124
+ source_file.close_now(); request.close_now(); root.close_now();
125
+ fwrite(fields[4].data(),1,fields[4].size(),stderr); fflush(stderr);
126
+ try { result=evaluate(source); }
127
+ catch (const std::exception& error) {
128
+ fprintf(stderr,"pi-repl caught std::exception: %s\n",error.what()); result=3;
129
+ } catch (...) { fputs("pi-repl caught non-standard C++ exception\n",stderr); result=4; }
130
+ } catch (const std::exception& error) {
131
+ fprintf(stderr,"pi-repl C++ control error: %s\n",error.what());
132
+ } catch (...) { fputs("pi-repl C++ control error\n",stderr); }
133
+ fflush(stdout); fflush(stderr);
134
+ if (getpid()!=owner_pid || stem.empty()) return;
135
+ // Like the other native adapters, the optional display uses existing Node
136
+ // and a bounded read-only cursor query. It is NOT completion authority.
137
+ if (!footer.empty() && std::system(footer.c_str())!=0) fputs("\n",stderr);
138
+ fflush(stdout); fflush(stderr);
139
+ file root(open(root_path.c_str(),O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC));
140
+ struct stat rs;
141
+ if (root.fd<0 || fstat(root.fd,&rs)!=0 || rs.st_uid!=getuid() || (rs.st_mode&0777)!=0700
142
+ || number(rs.st_dev)!=root_dev || number(rs.st_ino)!=root_ino) return;
143
+ response_fd=openat(root.fd,(stem+".done").c_str(),O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0600);
144
+ if (response_fd<0 || fchmod(response_fd,0600)!=0) {
145
+ perror("pi-repl C++ response"); if (response_fd>=0) close(response_fd); return;
146
+ }
147
+ int count=snprintf(reply,sizeof reply,"pi-repl-cpp-v1 %s %d\n",stem.c_str(),result);
148
+ if (count<0 || size_t(count)>=sizeof reply) { close(response_fd); return; }
149
+ reply_size=size_t(count);
150
+ } // Every helper-owned C++ object is destroyed before the reply is written.
151
+ // Empty/partial frames are pending. No destructor or user source follows this
152
+ // publication, only POSIX write/close and the driver's scalar initialization.
153
+ if (getpid()!=owner_pid) { close(response_fd); return; }
154
+ size_t written=0;
155
+ while (written<reply_size) {
156
+ ssize_t n=write(response_fd,reply+written,reply_size-written);
157
+ if (n<0 && errno==EINTR) continue;
158
+ if (n<=0) break;
159
+ written+=size_t(n);
160
+ }
161
+ close(response_fd);
162
+ }
163
+ }
164
+ #endif
@@ -0,0 +1,107 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, unlinkSync } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export const CPP_DRIVER_HEADER = fileURLToPath(new URL("./repl-cpp-driver.hxx", import.meta.url));
6
+ export const CPP_MAX_SOURCE_BYTES = 16 * 1024 * 1024;
7
+ const stemPattern = /^(?:[a-f0-9]{12}-)?[a-f0-9]{16}$/;
8
+
9
+ // Fixed-width octal encodes UTF-8 without C++ hex-escape run-on or terminal input.
10
+ export function cppStringLiteral(value) {
11
+ return '"' + [...Buffer.from(String(value), "utf8")].map((b) => `\\${b.toString(8).padStart(3, "0")}`).join("") + '"';
12
+ }
13
+ function quote(value) { return `'${value.replace(/'/g, `'"'"'`)}'`; }
14
+ function privateStat(path, directory = false) {
15
+ const s = lstatSync(path, { bigint: true });
16
+ if (!(directory ? s.isDirectory() : s.isFile()) || s.isSymbolicLink()
17
+ || s.uid !== BigInt(process.getuid()) || (s.mode & 0o777n) !== (directory ? 0o700n : 0o600n)
18
+ || (!directory && s.nlink !== 1n)) throw new Error(`Unsafe C++ control path: ${path}`);
19
+ return s;
20
+ }
21
+ export function buildCppRequest(sourceFile, doneFile, display) {
22
+ const stem = basename(sourceFile, ".cpp");
23
+ if (!stemPattern.test(stem) || stem.length !== stem.match(stemPattern)?.[0].length
24
+ || basename(doneFile) !== `${stem}.done` || dirname(sourceFile) !== dirname(doneFile)) throw new Error("Invalid C++ control family.");
25
+ const s = privateStat(sourceFile);
26
+ if (s.size > BigInt(CPP_MAX_SOURCE_BYTES)) throw new Error("C++ submission exceeds the 16 MiB source limit; no code was sent.");
27
+ const footer = [
28
+ 'let column = "";',
29
+ 'try { column = 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 {}',
30
+ `process.stderr.write((column === "0" ? "" : "\\n") + ${JSON.stringify(display.enabled ? display.suffixLines.join("\n") + "\n" : "")});`,
31
+ ].join(" ");
32
+ // Length framing, not executable C++/JSON interpolation. Names are relative
33
+ // to a pinned private directory, and source inode/size are checked natively.
34
+ const fields = [stem, String(s.dev), String(s.ino), String(s.size), display.enabled ? display.prefixLines.join("\n") + "\n" : "", `${quote(process.execPath)} -e ${quote(footer)}`];
35
+ return "pi-repl-cpp-v1\n" + fields.map((s) => `${Buffer.byteLength(s)}\n${s}`).join("");
36
+ }
37
+ export function cppInclude(path) {
38
+ if (/[\x00-\x1f\x7f\u2028\u2029]/.test(path)) throw new Error("C++ include paths cannot contain control characters, line breaks or NUL bytes.");
39
+ if (!path.includes(">")) return `#include <${path}>`;
40
+ if (!path.includes('"')) return `#include "${path}"`;
41
+ throw new Error("C++ include paths cannot contain both double quotes and >.");
42
+ }
43
+ export function buildCppDriverSource(requestFile, doneFile) {
44
+ const { submissionLine } = prepareCppSubmission(requestFile, doneFile);
45
+ const name = basename(requestFile, ".req").replaceAll("-", "_");
46
+ if (!/^[a-f0-9_]+$/.test(name)) throw new Error("Invalid C++ driver name.");
47
+ // A unique, ordinary C++ initializer, NOT Cling's top-level-statement
48
+ // extension. Each include runs this request once; no stdin command follows.
49
+ return `${cppInclude(CPP_DRIVER_HEADER)}\nnamespace pi_repl_cpp_v1 { const int request_${name} = (${submissionLine.slice(0, -1)}, 0); }\n`;
50
+ }
51
+ export function prepareCppSubmission(requestFile, doneFile) {
52
+ if (dirname(requestFile) !== dirname(doneFile)) throw new Error("C++ controls must share one private directory.");
53
+ const root = privateStat(dirname(requestFile), true);
54
+ const request = privateStat(requestFile);
55
+ return {
56
+ submissionLine: `pi_repl_cpp_v1::submit(${cppStringLiteral(requestFile)}, ${root.dev}ULL, ${root.ino}ULL, ${request.dev}ULL, ${request.ino}ULL, ${request.size}ULL);`,
57
+ completion: { doneFile, rootDev: String(root.dev), rootIno: String(root.ino), stem: basename(doneFile, ".done") },
58
+ };
59
+ }
60
+
61
+ export function createCppControlCleanup(completion, paths) {
62
+ const files = paths.map((path) => ({ path, stat: privateStat(path) }));
63
+ return () => {
64
+ try {
65
+ const root = privateStat(dirname(completion.doneFile), true);
66
+ if (String(root.dev) !== completion.rootDev || String(root.ino) !== completion.rootIno) return;
67
+ } catch { return; }
68
+ for (const { path, stat } of files) {
69
+ try {
70
+ const current = privateStat(path);
71
+ if (current.dev === stat.dev && current.ino === stat.ino && current.size === stat.size) unlinkSync(path);
72
+ } catch { /* Absent or changed controls are never adopted for cleanup. */ }
73
+ }
74
+ if (readCppCompletion(completion) !== undefined) {
75
+ try { unlinkSync(completion.doneFile); } catch {}
76
+ }
77
+ };
78
+ }
79
+
80
+ // Presence, prompts and display IDs are not settlement. Partial/malformed,
81
+ // symlinked, multiply-linked or replaced-root replies remain pending.
82
+ export function readCppCompletion(completion) {
83
+ let fd;
84
+ try {
85
+ const { doneFile, rootDev, rootIno, stem } = completion;
86
+ const root = privateStat(dirname(doneFile), true);
87
+ if (String(root.dev) !== rootDev || String(root.ino) !== rootIno) return undefined;
88
+ const before = privateStat(doneFile);
89
+ if (before.size > 128n) return undefined;
90
+ fd = openSync(doneFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
91
+ const opened = fstatSync(fd, { bigint: true });
92
+ if (opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size
93
+ || !opened.isFile() || opened.uid !== BigInt(process.getuid()) || opened.nlink !== 1n || (opened.mode & 0o777n) !== 0o600n) return undefined;
94
+ const buffer = Buffer.alloc(129);
95
+ const count = readSync(fd, buffer, 0, buffer.length, 0);
96
+ if (BigInt(count) !== opened.size) return undefined;
97
+ const text = buffer.subarray(0, count).toString("utf8");
98
+ const after = privateStat(doneFile);
99
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) return undefined;
100
+ const finalRoot = privateStat(dirname(doneFile), true);
101
+ if (String(finalRoot.dev) !== rootDev || String(finalRoot.ino) !== rootIno) return undefined;
102
+ const match = text.match(/^pi-repl-cpp-v1 ([a-f0-9-]+) ([0-5])\n$/);
103
+ if (!match || match[0] !== text || match[1] !== stem) return undefined;
104
+ return Number(match[2]);
105
+ } catch { return undefined; }
106
+ finally { if (fd !== undefined) closeSync(fd); }
107
+ }