pi-studio 0.9.55 → 0.9.57
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 +29 -0
- package/README.md +28 -2
- package/ROADMAP.md +31 -1
- package/client/studio-client.js +228 -50
- package/index.ts +914 -220
- package/package.json +1 -1
- package/shared/REPL_SESSION_RECORD_PROTOCOL.md +93 -0
- package/shared/repl-control-files.js +158 -0
- package/shared/repl-session-record.js +623 -0
- package/shared/repl-submission-display.js +227 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.57",
|
|
4
4
|
"description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,93 @@
|
|
|
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.
|
|
71
|
+
|
|
72
|
+
**Off** is the default and writes no optional display or alignment anchors. Opt-in **Summary** shows a short submission in full, truncating after 6 source lines or 600 source characters. **Full** raises those bounds to 40 lines or 4,000 characters and warns that source becomes part of persistent raw terminal history. Display text normalizes newlines and tabs, removes trailing display whitespace, and escapes terminal, line-separator, and bidirectional control characters.
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
── pi-repl · a1b2c3d4e5f6 · 2 lines ──
|
|
76
|
+
│ x = 1
|
|
77
|
+
│ x + 1
|
|
78
|
+
── output ──
|
|
79
|
+
2
|
|
80
|
+
── done · a1b2c3d4e5f6 ──
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
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. 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.
|
|
84
|
+
|
|
85
|
+
## Runtime control files (outside protocol v1)
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
## Compatibility
|
|
92
|
+
|
|
93
|
+
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,158 @@
|
|
|
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_STALE_MS = 24 * 60 * 60 * 1_000;
|
|
19
|
+
const prunedRoots = new Set();
|
|
20
|
+
|
|
21
|
+
function currentUid() {
|
|
22
|
+
return typeof process.getuid === "function" ? process.getuid() : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getPrivateReplControlRoot() {
|
|
26
|
+
const uid = currentUid();
|
|
27
|
+
const base = process.platform === "win32" ? tmpdir() : "/tmp";
|
|
28
|
+
const userKey = uid === null ? "user" : uid.toString(36);
|
|
29
|
+
return join(base, `pi-rc-${userKey}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertPrivateReplControlRoot(root) {
|
|
33
|
+
const stats = lstatSync(root);
|
|
34
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
35
|
+
throw new Error(`REPL control root is not a real directory: ${root}`);
|
|
36
|
+
}
|
|
37
|
+
const uid = currentUid();
|
|
38
|
+
if (uid !== null && stats.uid !== uid) {
|
|
39
|
+
throw new Error(`REPL control root is not owned by the current user: ${root}`);
|
|
40
|
+
}
|
|
41
|
+
if (process.platform !== "win32" && (stats.mode & 0o777) !== 0o700) {
|
|
42
|
+
throw new Error(`REPL control root must have mode 0700: ${root}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pruneStaleReplControlFiles(root, now = Date.now()) {
|
|
47
|
+
if (prunedRoots.has(root)) return;
|
|
48
|
+
prunedRoots.add(root);
|
|
49
|
+
let entries = [];
|
|
50
|
+
try {
|
|
51
|
+
entries = readdirSync(root);
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const uid = currentUid();
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (!/^[a-f0-9]{16}\.(?:[A-Za-z0-9]{1,8}|done)$/.test(entry)) continue;
|
|
58
|
+
const file = join(root, entry);
|
|
59
|
+
try {
|
|
60
|
+
const stats = lstatSync(file);
|
|
61
|
+
if (!stats.isFile() || stats.isSymbolicLink()) continue;
|
|
62
|
+
if (uid !== null && stats.uid !== uid) continue;
|
|
63
|
+
if (now - stats.mtimeMs < REPL_CONTROL_STALE_MS) continue;
|
|
64
|
+
unlinkSync(file);
|
|
65
|
+
} catch {
|
|
66
|
+
// Another process may have removed the same stale file.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ensurePrivateReplControlRoot(root = getPrivateReplControlRoot()) {
|
|
72
|
+
let created = false;
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(root, { mode: 0o700 });
|
|
75
|
+
created = true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
|
|
78
|
+
}
|
|
79
|
+
if (created && process.platform !== "win32") chmodSync(root, 0o700);
|
|
80
|
+
assertPrivateReplControlRoot(root);
|
|
81
|
+
pruneStaleReplControlFiles(root);
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeExtension(extension) {
|
|
86
|
+
const normalized = String(extension || "").replace(/^\.+/, "");
|
|
87
|
+
if (!/^[A-Za-z0-9]{1,8}$/.test(normalized)) {
|
|
88
|
+
throw new Error(`Invalid REPL control-file extension: ${extension}`);
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create and populate one private, collision-resistant REPL control file.
|
|
95
|
+
* The builder receives the final paths so it can embed the matching done-file
|
|
96
|
+
* path in the runtime-specific wrapper.
|
|
97
|
+
*/
|
|
98
|
+
export function createPrivateReplControlFiles(options) {
|
|
99
|
+
const extension = normalizeExtension(options?.extension);
|
|
100
|
+
const root = ensurePrivateReplControlRoot(options?.root || getPrivateReplControlRoot());
|
|
101
|
+
if (typeof options?.buildSource !== "function") {
|
|
102
|
+
throw new Error("REPL control-file source builder is required.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
106
|
+
const token = randomBytes(REPL_CONTROL_TOKEN_BYTES).toString("hex");
|
|
107
|
+
const paths = {
|
|
108
|
+
dir: root,
|
|
109
|
+
sourceFile: join(root, `${token}.${extension}`),
|
|
110
|
+
doneFile: join(root, `${token}.done`),
|
|
111
|
+
};
|
|
112
|
+
if (existsSync(paths.doneFile)) continue;
|
|
113
|
+
|
|
114
|
+
let descriptor;
|
|
115
|
+
try {
|
|
116
|
+
descriptor = openSync(paths.sourceFile, "wx", 0o600);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error && typeof error === "object" && error.code === "EEXIST") continue;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let complete = false;
|
|
123
|
+
try {
|
|
124
|
+
const source = String(options.buildSource(paths));
|
|
125
|
+
writeFileSync(descriptor, source, "utf8");
|
|
126
|
+
if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
|
|
127
|
+
complete = true;
|
|
128
|
+
return paths;
|
|
129
|
+
} finally {
|
|
130
|
+
closeSync(descriptor);
|
|
131
|
+
if (!complete) {
|
|
132
|
+
try {
|
|
133
|
+
unlinkSync(paths.sourceFile);
|
|
134
|
+
} catch {
|
|
135
|
+
// Preserve the source-builder error.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw new Error("Could not allocate a unique REPL control file.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function cleanupPrivateReplControlFiles(paths) {
|
|
144
|
+
if (!paths || typeof paths !== "object") return;
|
|
145
|
+
const root = String(paths.dir || "");
|
|
146
|
+
const sourceFile = String(paths.sourceFile || "");
|
|
147
|
+
const doneFile = String(paths.doneFile || "");
|
|
148
|
+
const sourceName = basename(sourceFile);
|
|
149
|
+
const token = sourceName.match(/^([a-f0-9]{16})\.[A-Za-z0-9]{1,8}$/)?.[1];
|
|
150
|
+
if (!token || dirname(sourceFile) !== root || dirname(doneFile) !== root || basename(doneFile) !== `${token}.done`) return;
|
|
151
|
+
for (const file of [sourceFile, doneFile]) {
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(file);
|
|
154
|
+
} catch {
|
|
155
|
+
// Cleanup is idempotent and best effort.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|