pi-studio 0.9.55 → 0.9.56
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 +13 -0
- package/README.md +14 -2
- package/ROADMAP.md +16 -1
- package/client/studio-client.js +163 -43
- package/index.ts +734 -163
- package/package.json +1 -1
- package/shared/REPL_SESSION_RECORD_PROTOCOL.md +70 -0
- package/shared/repl-session-record.js +623 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { lstat, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const REPL_SESSION_RECORD_PROTOCOL = "pi-repl-session-record";
|
|
20
|
+
export const REPL_SESSION_RECORD_VERSION = 1;
|
|
21
|
+
export const REPL_SESSION_RECORD_ID_OPTION = "@pi_repl_record_id";
|
|
22
|
+
export const REPL_SESSION_RECORD_VERSION_OPTION = "@pi_repl_record_version";
|
|
23
|
+
export const REPL_SESSION_RECORD_MAX_ENTRIES = 300;
|
|
24
|
+
export const REPL_SESSION_RECORD_MAX_CODE_CHARS = 200_000;
|
|
25
|
+
export const REPL_SESSION_RECORD_MAX_PROSE_CHARS = 80_000;
|
|
26
|
+
export const REPL_SESSION_RECORD_MAX_OUTPUT_CHARS = 200_000;
|
|
27
|
+
export const REPL_SESSION_RECORD_MAX_BYTES = 16 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
const RECORD_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
30
|
+
const RECORD_LOCK_WAIT_MS = 5_000;
|
|
31
|
+
const RECORD_LOCK_STALE_MS = 30_000;
|
|
32
|
+
const SEND_LEASE_STALE_MS = 30_000;
|
|
33
|
+
const SEND_LEASE_HEARTBEAT_MS = 5_000;
|
|
34
|
+
const MAX_JAVASCRIPT_TIMESTAMP = 8_640_000_000_000_000;
|
|
35
|
+
const WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
|
|
36
|
+
|
|
37
|
+
function currentUid() {
|
|
38
|
+
return typeof process.getuid === "function" ? process.getuid() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getRootSuffix() {
|
|
42
|
+
const uid = currentUid();
|
|
43
|
+
return uid == null ? "user" : String(uid);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return the private, user-scoped root used by protocol-v1 session records.
|
|
48
|
+
* Tests may pass { root } to every public operation to isolate their files.
|
|
49
|
+
*/
|
|
50
|
+
export function getReplSessionRecordRoot(options = {}) {
|
|
51
|
+
return resolve(options.root || join(tmpdir(), `pi-repl-session-records-${getRootSuffix()}`));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createReplSessionRecordId() {
|
|
55
|
+
return randomUUID().replace(/-/g, "").toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isValidReplSessionRecordId(value) {
|
|
59
|
+
return typeof value === "string" && RECORD_ID_PATTERN.test(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertRecordId(recordId) {
|
|
63
|
+
if (!isValidReplSessionRecordId(recordId)) {
|
|
64
|
+
throw new Error("Invalid shared REPL record ID.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getReplSessionRecordPath(recordId, options = {}) {
|
|
69
|
+
assertRecordId(recordId);
|
|
70
|
+
return join(getReplSessionRecordRoot(options), `${recordId}.json`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getRecordLockPath(recordId, options = {}) {
|
|
74
|
+
return join(getReplSessionRecordRoot(options), `${recordId}.record.lock`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getSendLeasePath(recordId, options = {}) {
|
|
78
|
+
return join(getReplSessionRecordRoot(options), `${recordId}.send.lock`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertOwnedByCurrentUser(path, info) {
|
|
82
|
+
const uid = currentUid();
|
|
83
|
+
if (uid != null && typeof info.uid === "number" && info.uid !== uid) {
|
|
84
|
+
throw new Error(`Refusing shared REPL state not owned by the current user: ${path}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ensurePrivateRoot(options = {}) {
|
|
89
|
+
const root = getReplSessionRecordRoot(options);
|
|
90
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
91
|
+
const info = lstatSync(root);
|
|
92
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
93
|
+
throw new Error(`Shared REPL record root is not a real directory: ${root}`);
|
|
94
|
+
}
|
|
95
|
+
assertOwnedByCurrentUser(root, info);
|
|
96
|
+
if (process.platform !== "win32") chmodSync(root, 0o700);
|
|
97
|
+
return root;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertSafeRecordFile(path) {
|
|
101
|
+
const info = lstatSync(path);
|
|
102
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
103
|
+
throw new Error(`Shared REPL record is not a regular file: ${path}`);
|
|
104
|
+
}
|
|
105
|
+
if (typeof info.nlink === "number" && info.nlink !== 1) {
|
|
106
|
+
throw new Error(`Shared REPL record has an unsafe hard-link count: ${path}`);
|
|
107
|
+
}
|
|
108
|
+
assertOwnedByCurrentUser(path, info);
|
|
109
|
+
if (info.size > REPL_SESSION_RECORD_MAX_BYTES * 2) {
|
|
110
|
+
throw new Error(`Shared REPL record exceeds the safe read limit: ${path}`);
|
|
111
|
+
}
|
|
112
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
113
|
+
return info;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeBoundedString(value, maxChars) {
|
|
117
|
+
const text = typeof value === "string" ? value.replace(/\r\n/g, "\n").replace(/\r/g, "\n") : "";
|
|
118
|
+
if (text.length <= maxChars) return { text, omittedChars: 0 };
|
|
119
|
+
const markerBudget = 96;
|
|
120
|
+
const usable = Math.max(2, maxChars - markerBudget);
|
|
121
|
+
const head = Math.floor(usable * 0.6);
|
|
122
|
+
const tail = usable - head;
|
|
123
|
+
const omittedChars = text.length - head - tail;
|
|
124
|
+
return {
|
|
125
|
+
text: `${text.slice(0, head)}\n\n… ${omittedChars} characters omitted from shared REPL record …\n\n${text.slice(text.length - tail)}`,
|
|
126
|
+
omittedChars,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeTimestamp(value, fallback) {
|
|
131
|
+
return typeof value === "number"
|
|
132
|
+
&& Number.isFinite(value)
|
|
133
|
+
&& value >= 0
|
|
134
|
+
&& value <= MAX_JAVASCRIPT_TIMESTAMP
|
|
135
|
+
? Math.floor(value)
|
|
136
|
+
: fallback;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeNonNegativeInteger(value, maximum = Number.MAX_SAFE_INTEGER) {
|
|
140
|
+
const numeric = Number(value);
|
|
141
|
+
return Number.isFinite(numeric) && numeric > 0
|
|
142
|
+
? Math.min(maximum, Math.floor(numeric))
|
|
143
|
+
: 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeRecordIdentity(identity) {
|
|
147
|
+
if (!identity || typeof identity !== "object") throw new Error("Shared REPL record identity is required.");
|
|
148
|
+
const sessionName = typeof identity.sessionName === "string" ? identity.sessionName.trim() : "";
|
|
149
|
+
if (!sessionName || sessionName.length > 240 || /[\r\n\0]/.test(sessionName)) {
|
|
150
|
+
throw new Error("Invalid tmux session name for shared REPL record.");
|
|
151
|
+
}
|
|
152
|
+
const tmuxSessionId = typeof identity.tmuxSessionId === "string" ? identity.tmuxSessionId.trim() : "";
|
|
153
|
+
if (!/^\$[0-9]+$/.test(tmuxSessionId)) {
|
|
154
|
+
throw new Error("A valid tmux session ID is required for the shared REPL record.");
|
|
155
|
+
}
|
|
156
|
+
const tmuxSessionCreatedAt = normalizeTimestamp(identity.tmuxSessionCreatedAt, 0);
|
|
157
|
+
if (tmuxSessionCreatedAt <= 0) {
|
|
158
|
+
throw new Error("A valid tmux session creation time is required for the shared REPL record.");
|
|
159
|
+
}
|
|
160
|
+
const runtimeCandidate = typeof identity.runtime === "string"
|
|
161
|
+
? identity.runtime.trim().toLowerCase().slice(0, 40)
|
|
162
|
+
: "";
|
|
163
|
+
const runtime = /^[a-z0-9_.+-]{1,40}$/.test(runtimeCandidate) ? runtimeCandidate : "unknown";
|
|
164
|
+
return { sessionName, tmuxSessionId, tmuxSessionCreatedAt, runtime };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeOrigin(value, fallback = "unknown") {
|
|
168
|
+
return value === "pi-repl" || value === "pi-studio" ? value : fallback;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeMode(value) {
|
|
172
|
+
return value === "literate" || value === "agent" ? value : "raw";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeStatus(value) {
|
|
176
|
+
return value === "sending"
|
|
177
|
+
|| value === "sent"
|
|
178
|
+
|| value === "captured"
|
|
179
|
+
|| value === "timeout"
|
|
180
|
+
|| value === "error"
|
|
181
|
+
|| value === "note"
|
|
182
|
+
? value
|
|
183
|
+
: "sent";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Normalize and bound one interoperable clean-record entry. */
|
|
187
|
+
export function normalizeReplSessionRecordEntry(input, defaults = {}) {
|
|
188
|
+
if (!input || typeof input !== "object") throw new Error("Shared REPL record entry must be an object.");
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const code = normalizeBoundedString(input.code, REPL_SESSION_RECORD_MAX_CODE_CHARS);
|
|
191
|
+
const prose = normalizeBoundedString(input.prose, REPL_SESSION_RECORD_MAX_PROSE_CHARS);
|
|
192
|
+
const output = normalizeBoundedString(input.output, REPL_SESSION_RECORD_MAX_OUTPUT_CHARS);
|
|
193
|
+
const id = typeof input.id === "string" && /^[A-Za-z0-9_.:-]{1,240}$/.test(input.id)
|
|
194
|
+
? input.id
|
|
195
|
+
: `entry-${now.toString(36)}-${randomUUID().slice(0, 12)}`;
|
|
196
|
+
const createdAt = normalizeTimestamp(input.createdAt, now);
|
|
197
|
+
const updatedAt = Math.max(createdAt, normalizeTimestamp(input.updatedAt, now));
|
|
198
|
+
const completedAt = input.completedAt == null ? null : Math.max(createdAt, normalizeTimestamp(input.completedAt, updatedAt));
|
|
199
|
+
const sessionName = typeof input.sessionName === "string" ? input.sessionName.trim().slice(0, 240) : "";
|
|
200
|
+
const runtime = typeof input.runtime === "string" && input.runtime.trim()
|
|
201
|
+
? input.runtime.trim().toLowerCase().slice(0, 40)
|
|
202
|
+
: "unknown";
|
|
203
|
+
const labelRaw = typeof input.label === "string" ? input.label.replace(/[\r\n\0]+/g, " ").trim() : "";
|
|
204
|
+
const requestIdRaw = typeof input.requestId === "string" ? input.requestId.replace(/[\r\n\0]+/g, "").trim() : "";
|
|
205
|
+
return {
|
|
206
|
+
id,
|
|
207
|
+
requestId: requestIdRaw.slice(0, 300),
|
|
208
|
+
createdAt,
|
|
209
|
+
updatedAt,
|
|
210
|
+
completedAt,
|
|
211
|
+
sessionName,
|
|
212
|
+
runtime,
|
|
213
|
+
origin: normalizeOrigin(input.origin, normalizeOrigin(defaults.origin)),
|
|
214
|
+
label: (labelRaw || "REPL send").slice(0, 240),
|
|
215
|
+
mode: normalizeMode(input.mode),
|
|
216
|
+
prose: prose.text,
|
|
217
|
+
code: code.text,
|
|
218
|
+
output: output.text,
|
|
219
|
+
status: normalizeStatus(input.status),
|
|
220
|
+
skippedChunks: normalizeNonNegativeInteger(input.skippedChunks, 100_000),
|
|
221
|
+
codeOmittedChars: normalizeNonNegativeInteger(input.codeOmittedChars) + code.omittedChars,
|
|
222
|
+
proseOmittedChars: normalizeNonNegativeInteger(input.proseOmittedChars) + prose.omittedChars,
|
|
223
|
+
outputOmittedChars: normalizeNonNegativeInteger(input.outputOmittedChars) + output.omittedChars,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function makeEmptyRecord(recordId, identity) {
|
|
228
|
+
const normalizedIdentity = normalizeRecordIdentity(identity);
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
return {
|
|
231
|
+
protocol: REPL_SESSION_RECORD_PROTOCOL,
|
|
232
|
+
version: REPL_SESSION_RECORD_VERSION,
|
|
233
|
+
recordId,
|
|
234
|
+
session: normalizedIdentity,
|
|
235
|
+
revision: 0,
|
|
236
|
+
createdAt: now,
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
clearedAt: null,
|
|
239
|
+
droppedEntries: 0,
|
|
240
|
+
entries: [],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function assertRecordIdentity(record, expectedIdentity) {
|
|
245
|
+
if (!expectedIdentity) return;
|
|
246
|
+
const expected = normalizeRecordIdentity(expectedIdentity);
|
|
247
|
+
const actual = normalizeRecordIdentity(record.session);
|
|
248
|
+
if (actual.sessionName !== expected.sessionName) {
|
|
249
|
+
throw new Error(`Shared REPL record belongs to tmux session ${actual.sessionName}, not ${expected.sessionName}.`);
|
|
250
|
+
}
|
|
251
|
+
if (actual.tmuxSessionId && expected.tmuxSessionId && actual.tmuxSessionId !== expected.tmuxSessionId) {
|
|
252
|
+
throw new Error("Shared REPL record belongs to a different tmux session ID.");
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
actual.tmuxSessionCreatedAt
|
|
256
|
+
&& expected.tmuxSessionCreatedAt
|
|
257
|
+
&& actual.tmuxSessionCreatedAt !== expected.tmuxSessionCreatedAt
|
|
258
|
+
) {
|
|
259
|
+
throw new Error("Shared REPL record belongs to a different tmux session lifetime.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizeRecord(parsed, recordId, expectedIdentity) {
|
|
264
|
+
if (!parsed || typeof parsed !== "object") throw new Error("Shared REPL record is not a JSON object.");
|
|
265
|
+
if (parsed.protocol !== REPL_SESSION_RECORD_PROTOCOL || parsed.version !== REPL_SESSION_RECORD_VERSION) {
|
|
266
|
+
throw new Error("Unsupported shared REPL record protocol or version.");
|
|
267
|
+
}
|
|
268
|
+
if (parsed.recordId !== recordId) throw new Error("Shared REPL record ID does not match its file name.");
|
|
269
|
+
const session = normalizeRecordIdentity(parsed.session);
|
|
270
|
+
const createdAt = normalizeTimestamp(parsed.createdAt, Date.now());
|
|
271
|
+
const entries = Array.isArray(parsed.entries)
|
|
272
|
+
? parsed.entries.map((entry) => normalizeReplSessionRecordEntry(entry)).slice(-REPL_SESSION_RECORD_MAX_ENTRIES)
|
|
273
|
+
: [];
|
|
274
|
+
const record = {
|
|
275
|
+
protocol: REPL_SESSION_RECORD_PROTOCOL,
|
|
276
|
+
version: REPL_SESSION_RECORD_VERSION,
|
|
277
|
+
recordId,
|
|
278
|
+
session,
|
|
279
|
+
revision: normalizeNonNegativeInteger(parsed.revision),
|
|
280
|
+
createdAt,
|
|
281
|
+
updatedAt: Math.max(createdAt, normalizeTimestamp(parsed.updatedAt, createdAt)),
|
|
282
|
+
clearedAt: parsed.clearedAt == null ? null : normalizeTimestamp(parsed.clearedAt, createdAt),
|
|
283
|
+
droppedEntries: normalizeNonNegativeInteger(parsed.droppedEntries),
|
|
284
|
+
entries,
|
|
285
|
+
};
|
|
286
|
+
assertRecordIdentity(record, expectedIdentity);
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function readRecordUnlocked(recordId, expectedIdentity, options = {}) {
|
|
291
|
+
const path = getReplSessionRecordPath(recordId, options);
|
|
292
|
+
if (!existsSync(path)) return null;
|
|
293
|
+
assertSafeRecordFile(path);
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
297
|
+
} catch (error) {
|
|
298
|
+
throw new Error(`Could not parse shared REPL record ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
299
|
+
}
|
|
300
|
+
return normalizeRecord(parsed, recordId, expectedIdentity);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function serializeBoundedRecord(record) {
|
|
304
|
+
let droppedNow = 0;
|
|
305
|
+
let entries = [...record.entries]
|
|
306
|
+
.sort((a, b) => (a.createdAt - b.createdAt) || a.id.localeCompare(b.id))
|
|
307
|
+
.slice(-REPL_SESSION_RECORD_MAX_ENTRIES);
|
|
308
|
+
if (record.entries.length > entries.length) droppedNow += record.entries.length - entries.length;
|
|
309
|
+
let candidate = { ...record, droppedEntries: record.droppedEntries + droppedNow, entries };
|
|
310
|
+
let json = `${JSON.stringify(candidate, null, 2)}\n`;
|
|
311
|
+
while (Buffer.byteLength(json, "utf8") > REPL_SESSION_RECORD_MAX_BYTES && entries.length > 1) {
|
|
312
|
+
entries = entries.slice(1);
|
|
313
|
+
droppedNow += 1;
|
|
314
|
+
candidate = { ...record, droppedEntries: record.droppedEntries + droppedNow, entries };
|
|
315
|
+
json = `${JSON.stringify(candidate, null, 2)}\n`;
|
|
316
|
+
}
|
|
317
|
+
if (Buffer.byteLength(json, "utf8") > REPL_SESSION_RECORD_MAX_BYTES) {
|
|
318
|
+
throw new Error("One shared REPL record entry exceeds the bounded record size.");
|
|
319
|
+
}
|
|
320
|
+
return { record: candidate, json };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function writeRecordAtomically(record, options = {}) {
|
|
324
|
+
const root = ensurePrivateRoot(options);
|
|
325
|
+
const path = getReplSessionRecordPath(record.recordId, options);
|
|
326
|
+
if (existsSync(path)) assertSafeRecordFile(path);
|
|
327
|
+
const bounded = serializeBoundedRecord(record);
|
|
328
|
+
const tempPath = join(root, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
329
|
+
let fd = -1;
|
|
330
|
+
try {
|
|
331
|
+
fd = openSync(tempPath, "wx", 0o600);
|
|
332
|
+
writeFileSync(fd, bounded.json, "utf8");
|
|
333
|
+
fsyncSync(fd);
|
|
334
|
+
closeSync(fd);
|
|
335
|
+
fd = -1;
|
|
336
|
+
renameSync(tempPath, path);
|
|
337
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
338
|
+
try {
|
|
339
|
+
const dirFd = openSync(dirname(path), "r");
|
|
340
|
+
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
|
|
341
|
+
} catch {
|
|
342
|
+
// Directory fsync is best effort on filesystems/platforms that permit it.
|
|
343
|
+
}
|
|
344
|
+
return bounded.record;
|
|
345
|
+
} finally {
|
|
346
|
+
if (fd >= 0) {
|
|
347
|
+
try { closeSync(fd); } catch { /* ignore cleanup error */ }
|
|
348
|
+
}
|
|
349
|
+
if (existsSync(tempPath)) {
|
|
350
|
+
try { rmSync(tempPath, { force: true }); } catch { /* ignore cleanup error */ }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function sleepSync(ms) {
|
|
356
|
+
Atomics.wait(WAIT_ARRAY, 0, 0, Math.max(1, ms));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function removeStaleLockSync(lockPath, staleMs) {
|
|
360
|
+
try {
|
|
361
|
+
const info = lstatSync(lockPath);
|
|
362
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe shared REPL lock path: ${lockPath}`);
|
|
363
|
+
assertOwnedByCurrentUser(lockPath, info);
|
|
364
|
+
if (Date.now() - info.mtimeMs <= staleMs) return false;
|
|
365
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
366
|
+
return true;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return true;
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function acquireRecordLockSync(recordId, options = {}) {
|
|
374
|
+
ensurePrivateRoot(options);
|
|
375
|
+
const lockPath = getRecordLockPath(recordId, options);
|
|
376
|
+
const deadline = Date.now() + Math.max(0, Number(options.lockWaitMs ?? RECORD_LOCK_WAIT_MS));
|
|
377
|
+
const token = randomUUID();
|
|
378
|
+
while (true) {
|
|
379
|
+
let created = false;
|
|
380
|
+
try {
|
|
381
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
382
|
+
created = true;
|
|
383
|
+
writeFileSync(join(lockPath, "owner.json"), `${JSON.stringify({ token, pid: process.pid, createdAt: Date.now() })}\n`, { mode: 0o600, flag: "wx" });
|
|
384
|
+
break;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (created) {
|
|
387
|
+
try { rmSync(lockPath, { recursive: true, force: true }); } catch { /* preserve the owner-file error */ }
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
|
|
391
|
+
if (removeStaleLockSync(lockPath, Math.max(0, Number(options.lockStaleMs ?? RECORD_LOCK_STALE_MS)))) continue;
|
|
392
|
+
if (Date.now() >= deadline) throw new Error("Timed out waiting to update the shared REPL record.");
|
|
393
|
+
sleepSync(20);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return () => {
|
|
397
|
+
try {
|
|
398
|
+
const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8"));
|
|
399
|
+
if (owner.token !== token) return;
|
|
400
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
401
|
+
} catch {
|
|
402
|
+
// A stale-lock recovery may already have removed the directory.
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function readReplSessionRecord(recordId, expectedIdentity, options = {}) {
|
|
408
|
+
assertRecordId(recordId);
|
|
409
|
+
ensurePrivateRoot(options);
|
|
410
|
+
return readRecordUnlocked(recordId, expectedIdentity, options);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function ensureReplSessionRecord(recordId, identity, options = {}) {
|
|
414
|
+
assertRecordId(recordId);
|
|
415
|
+
const release = acquireRecordLockSync(recordId, options);
|
|
416
|
+
try {
|
|
417
|
+
const existing = readRecordUnlocked(recordId, identity, options);
|
|
418
|
+
if (existing) return existing;
|
|
419
|
+
return writeRecordAtomically(makeEmptyRecord(recordId, identity), options);
|
|
420
|
+
} finally {
|
|
421
|
+
release();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function upsertReplSessionRecordEntry(recordId, identity, input, options = {}) {
|
|
426
|
+
assertRecordId(recordId);
|
|
427
|
+
const release = acquireRecordLockSync(recordId, options);
|
|
428
|
+
try {
|
|
429
|
+
const record = readRecordUnlocked(recordId, identity, options) || makeEmptyRecord(recordId, identity);
|
|
430
|
+
const existingIndex = record.entries.findIndex((entry) => entry.id === input.id);
|
|
431
|
+
const existing = existingIndex >= 0 ? record.entries[existingIndex] : null;
|
|
432
|
+
const entryInput = existing
|
|
433
|
+
? { ...existing, ...input, id: existing.id, createdAt: existing.createdAt }
|
|
434
|
+
: input;
|
|
435
|
+
const entry = normalizeReplSessionRecordEntry({
|
|
436
|
+
...entryInput,
|
|
437
|
+
sessionName: entryInput.sessionName || record.session.sessionName,
|
|
438
|
+
runtime: entryInput.runtime || record.session.runtime,
|
|
439
|
+
}, { origin: options.origin });
|
|
440
|
+
if (entry.sessionName && entry.sessionName !== record.session.sessionName) {
|
|
441
|
+
throw new Error("Shared REPL entry session name does not match the record session.");
|
|
442
|
+
}
|
|
443
|
+
entry.sessionName = record.session.sessionName;
|
|
444
|
+
entry.updatedAt = Math.max(Date.now(), entry.updatedAt);
|
|
445
|
+
const entries = [...record.entries];
|
|
446
|
+
if (existingIndex >= 0) entries[existingIndex] = entry;
|
|
447
|
+
else entries.push(entry);
|
|
448
|
+
const normalizedIdentity = normalizeRecordIdentity(identity);
|
|
449
|
+
const next = writeRecordAtomically({
|
|
450
|
+
...record,
|
|
451
|
+
session: {
|
|
452
|
+
...record.session,
|
|
453
|
+
runtime: normalizedIdentity.runtime === "unknown" ? record.session.runtime : normalizedIdentity.runtime,
|
|
454
|
+
},
|
|
455
|
+
revision: record.revision + 1,
|
|
456
|
+
updatedAt: Date.now(),
|
|
457
|
+
entries,
|
|
458
|
+
}, options);
|
|
459
|
+
return { entry: next.entries.find((candidate) => candidate.id === entry.id) || entry, record: next, path: getReplSessionRecordPath(recordId, options) };
|
|
460
|
+
} finally {
|
|
461
|
+
release();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function clearReplSessionRecord(recordId, identity, options = {}) {
|
|
466
|
+
assertRecordId(recordId);
|
|
467
|
+
const release = acquireRecordLockSync(recordId, options);
|
|
468
|
+
try {
|
|
469
|
+
const record = readRecordUnlocked(recordId, identity, options) || makeEmptyRecord(recordId, identity);
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
return writeRecordAtomically({
|
|
472
|
+
...record,
|
|
473
|
+
revision: record.revision + 1,
|
|
474
|
+
updatedAt: now,
|
|
475
|
+
clearedAt: now,
|
|
476
|
+
droppedEntries: record.droppedEntries + record.entries.length,
|
|
477
|
+
entries: [],
|
|
478
|
+
}, options);
|
|
479
|
+
} finally {
|
|
480
|
+
release();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function removeStaleLease(lockPath, staleMs) {
|
|
485
|
+
try {
|
|
486
|
+
const info = await lstat(lockPath);
|
|
487
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe shared REPL send lease path: ${lockPath}`);
|
|
488
|
+
assertOwnedByCurrentUser(lockPath, info);
|
|
489
|
+
if (Date.now() - info.mtimeMs <= staleMs) return false;
|
|
490
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
491
|
+
return true;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return true;
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function sleep(ms, signal) {
|
|
499
|
+
return new Promise((resolveSleep, reject) => {
|
|
500
|
+
if (signal?.aborted) {
|
|
501
|
+
reject(new Error("Shared REPL send was aborted while waiting for the session lease."));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
let timer;
|
|
505
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
506
|
+
const onAbort = () => {
|
|
507
|
+
clearTimeout(timer);
|
|
508
|
+
cleanup();
|
|
509
|
+
reject(new Error("Shared REPL send was aborted while waiting for the session lease."));
|
|
510
|
+
};
|
|
511
|
+
timer = setTimeout(() => {
|
|
512
|
+
cleanup();
|
|
513
|
+
resolveSleep();
|
|
514
|
+
}, ms);
|
|
515
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Acquire the cross-client send lease for one tmux session record. Compatible
|
|
521
|
+
* clients hold this from the pre-send pane capture through the completion
|
|
522
|
+
* capture, preventing them from attributing each other's output.
|
|
523
|
+
*/
|
|
524
|
+
export async function acquireReplSessionSendLease(recordId, options = {}) {
|
|
525
|
+
assertRecordId(recordId);
|
|
526
|
+
ensurePrivateRoot(options);
|
|
527
|
+
const lockPath = getSendLeasePath(recordId, options);
|
|
528
|
+
const waitMs = Math.max(0, Math.floor(Number(options.waitMs ?? 20_000)));
|
|
529
|
+
const staleMs = Math.max(10_000, Math.floor(Number(options.staleMs ?? SEND_LEASE_STALE_MS)));
|
|
530
|
+
const deadline = Date.now() + waitMs;
|
|
531
|
+
const token = randomUUID();
|
|
532
|
+
while (true) {
|
|
533
|
+
if (options.signal?.aborted) throw new Error("Shared REPL send was aborted while waiting for the session lease.");
|
|
534
|
+
let created = false;
|
|
535
|
+
try {
|
|
536
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
537
|
+
created = true;
|
|
538
|
+
await writeFile(join(lockPath, "owner.json"), `${JSON.stringify({
|
|
539
|
+
token,
|
|
540
|
+
pid: process.pid,
|
|
541
|
+
owner: typeof options.owner === "string" ? options.owner.slice(0, 120) : "unknown",
|
|
542
|
+
createdAt: Date.now(),
|
|
543
|
+
})}\n`, { mode: 0o600, flag: "wx" });
|
|
544
|
+
break;
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (created) {
|
|
547
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
|
|
551
|
+
if (await removeStaleLease(lockPath, staleMs)) continue;
|
|
552
|
+
if (Date.now() >= deadline) {
|
|
553
|
+
throw new Error("The shared REPL session is busy in another compatible client.");
|
|
554
|
+
}
|
|
555
|
+
await sleep(50, options.signal);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let released = false;
|
|
560
|
+
const heartbeat = setInterval(() => {
|
|
561
|
+
const now = new Date();
|
|
562
|
+
void utimes(lockPath, now, now).catch(() => undefined);
|
|
563
|
+
}, Math.min(SEND_LEASE_HEARTBEAT_MS, Math.max(1_000, Math.floor(staleMs / 3))));
|
|
564
|
+
heartbeat.unref?.();
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
recordId,
|
|
568
|
+
path: lockPath,
|
|
569
|
+
async release() {
|
|
570
|
+
if (released) return;
|
|
571
|
+
released = true;
|
|
572
|
+
clearInterval(heartbeat);
|
|
573
|
+
try {
|
|
574
|
+
const owner = JSON.parse(await readFile(join(lockPath, "owner.json"), "utf8"));
|
|
575
|
+
if (owner.token !== token) return;
|
|
576
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error;
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function markdownFence(text, language = "") {
|
|
585
|
+
const value = String(text || "").replace(/\s+$/, "");
|
|
586
|
+
let fence = "```";
|
|
587
|
+
while (value.includes(fence)) fence += "`";
|
|
588
|
+
return `${fence}${language}\n${value}\n${fence}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function markdownRuntime(runtime) {
|
|
592
|
+
return runtime === "ipython" ? "python" : runtime === "unknown" || runtime === "shell" ? "" : runtime;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Produce the deterministic Markdown representation used by compatible UIs. */
|
|
596
|
+
export function renderReplSessionRecordMarkdown(record, options = {}) {
|
|
597
|
+
if (!record || typeof record !== "object" || !Array.isArray(record.entries)) {
|
|
598
|
+
throw new Error("A shared REPL record is required for Markdown rendering.");
|
|
599
|
+
}
|
|
600
|
+
const title = typeof options.title === "string" && options.title.trim() ? options.title.trim() : "Shared REPL Record";
|
|
601
|
+
const lines = [`# ${title}`, "", `Session: \`${record.session?.sessionName || "unknown"}\``, `Record protocol: ${REPL_SESSION_RECORD_PROTOCOL} v${REPL_SESSION_RECORD_VERSION}`];
|
|
602
|
+
if (record.updatedAt) lines.push(`Updated: ${new Date(record.updatedAt).toISOString()}`);
|
|
603
|
+
lines.push("");
|
|
604
|
+
if (!record.entries.length) {
|
|
605
|
+
lines.push("_No compatible-client entries have been recorded for this tmux session._", "");
|
|
606
|
+
} else {
|
|
607
|
+
record.entries.forEach((entry, index) => {
|
|
608
|
+
lines.push(`## ${index + 1}. ${entry.label || "REPL entry"}`, "");
|
|
609
|
+
lines.push(`- Time: ${new Date(entry.createdAt || record.createdAt || Date.now()).toISOString()}`);
|
|
610
|
+
lines.push(`- Origin: ${entry.origin || "unknown"}`);
|
|
611
|
+
lines.push(`- Mode: ${entry.mode || "raw"}`);
|
|
612
|
+
lines.push(`- Status: ${entry.status || "sent"}`);
|
|
613
|
+
if (entry.runtime) lines.push(`- Runtime: ${entry.runtime}`);
|
|
614
|
+
if (entry.skippedChunks) lines.push(`- Skipped chunks: ${entry.skippedChunks}`);
|
|
615
|
+
lines.push("");
|
|
616
|
+
if (String(entry.prose || "").trim()) lines.push(String(entry.prose).trim(), "");
|
|
617
|
+
if (String(entry.code || "").trim()) lines.push(markdownFence(entry.code, markdownRuntime(entry.runtime)), "");
|
|
618
|
+
if (String(entry.output || "").trim()) lines.push("Output:", "", markdownFence(entry.output, "text"), "");
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
lines.push("_This clean record contains submissions made through compatible clients. Commands typed directly into an attached tmux pane remain available only in the raw pane/history mirror._", "");
|
|
622
|
+
return lines.join("\n").replace(/\n{4,}/g, "\n\n\n");
|
|
623
|
+
}
|