beatrina 0.9.43 → 0.9.83
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/beatrina_V0.9.83.html +1903 -0
- package/beatrina_V0.9.83.html.inputs.json +1 -0
- package/build-info.json +1 -1
- package/host/journal-store.mjs +19 -3
- package/host/planes/choose.mjs +13 -2
- package/host/planes/expect-term.mjs +102 -0
- package/host/planes/terminal.mjs +23 -9
- package/kernel/check/knit-file.mjs +254 -38
- package/kernel/kernel-version +1 -1
- package/kernel/knitr-run.R +8 -1
- package/kernel/mcp/beatrina-mcp.mjs +6 -5
- package/kernel/worker.R +21 -1
- package/menu/Beatrina Menu.app/Contents/Info.plist +2 -2
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/package.json +3 -3
- package/beatrina_V0.9.43.html +0 -1885
- package/beatrina_V0.9.43.html.inputs.json +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema":1,"sources":"a3ef9738bd9d341aca4181dfe7d7522293c1def1568ad55f425b751bedee5f11","artifact":"0d474616d33fdf5a95b6735e25f1214b3f82bf2490ccba3af9d5883e8b9c652c"}
|
package/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.83","commit":"9d7ed5ae63cb367b7fd428d71041c44cf91fdbb5","dirty":false}
|
package/host/journal-store.mjs
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
// the page's checkpoint codec wrote)
|
|
23
23
|
// checkpoint.rev the revision of that envelope
|
|
24
24
|
// pins/<rev>.json a named version, with its content
|
|
25
|
-
// meta.json display name, file binding, gaps, updated
|
|
25
|
+
// meta.json display name, file binding, gaps, updated,
|
|
26
|
+
// and `saved` — what the last Save wrote (N3)
|
|
26
27
|
// journal.corrupt-<time> bytes cut from a journal that did not end at
|
|
27
28
|
// a line boundary — kept as evidence
|
|
28
29
|
//
|
|
@@ -295,6 +296,10 @@ export function createJournalStore() {
|
|
|
295
296
|
|
|
296
297
|
// ── the ops ───────────────────────────────────────────────────────────────
|
|
297
298
|
|
|
299
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
300
|
+
const validSaved = (v) => v != null && typeof v === "object" && !Array.isArray(v)
|
|
301
|
+
&& typeof v.model === "string" && HEX64.test(v.model) && typeof v.file === "string" && HEX64.test(v.file);
|
|
302
|
+
|
|
298
303
|
const open = (id, key, meta, takeover) => {
|
|
299
304
|
if (!enabled) return err("journal_open", id, "unavailable", "The document journal is disabled (BEATRINA_NO_JOURNAL=1).");
|
|
300
305
|
if (!validKey(key)) return err("journal_open", id, "invalid_key", "A journal needs a durable document key.");
|
|
@@ -428,7 +433,7 @@ export function createJournalStore() {
|
|
|
428
433
|
return before - st.bytes;
|
|
429
434
|
};
|
|
430
435
|
|
|
431
|
-
const checkpoint = (id, key, revision, generation, envelope, meta, rebase, epoch) => {
|
|
436
|
+
const checkpoint = (id, key, revision, generation, envelope, meta, rebase, epoch, saved) => {
|
|
432
437
|
if (!enabled) return err("journal_checkpoint", id, "unavailable", "The document journal is disabled.");
|
|
433
438
|
if (!validKey(key)) return err("journal_checkpoint", id, "invalid_key", "A journal needs a durable document key.");
|
|
434
439
|
const refused = leaseRefusal("journal_checkpoint", id, dirOf(key), epoch);
|
|
@@ -437,6 +442,9 @@ export function createJournalStore() {
|
|
|
437
442
|
|| typeof envelope !== "string" || !envelope.length) {
|
|
438
443
|
return err("journal_checkpoint", id, "invalid_checkpoint", "A checkpoint needs a revision and its envelope.");
|
|
439
444
|
}
|
|
445
|
+
// Refused before anything is written, so a bad marker never leaves a
|
|
446
|
+
// checkpoint on disk that the store's state does not know about.
|
|
447
|
+
if (saved != null && !validSaved(saved)) return err("journal_checkpoint", id, "invalid_saved", "A save marker needs two SHA-256 digests.");
|
|
440
448
|
let st;
|
|
441
449
|
try { st = stateOf(key); } catch (e) { return err("journal_checkpoint", id, "io", e.message); }
|
|
442
450
|
const rev = Math.trunc(revision);
|
|
@@ -464,6 +472,13 @@ export function createJournalStore() {
|
|
|
464
472
|
stored.gaps = [...(stored.gaps ?? []), gap];
|
|
465
473
|
}
|
|
466
474
|
if (meta && typeof meta === "object" && Object.keys(meta).length) stored.display = meta;
|
|
475
|
+
// N3: a Save rides on a checkpoint and says what it wrote — two digests,
|
|
476
|
+
// the document's content and the file's bytes. It is kept apart from
|
|
477
|
+
// `display` because the next opening's journal_open replaces `display`
|
|
478
|
+
// before it loads, and this is exactly what that opening needs to read.
|
|
479
|
+
// Content-addressed, so it is never cleared: a later edit simply stops it
|
|
480
|
+
// matching. Anything malformed is refused rather than stored half-read.
|
|
481
|
+
if (saved != null) stored.saved = { model: saved.model, file: saved.file, at: Date.now() };
|
|
467
482
|
try { writeMeta(st.dir, stored); } catch { /* the checkpoint still stands */ }
|
|
468
483
|
st.checkpoint = rev;
|
|
469
484
|
st.last = rev;
|
|
@@ -501,6 +516,7 @@ export function createJournalStore() {
|
|
|
501
516
|
return { type: "journal_load", id, key, aliases: meta.aliases ?? [],
|
|
502
517
|
checkpointRevision: st.checkpoint, durableRevision: st.last,
|
|
503
518
|
more, repaired: st.repaired === true, gaps: meta.gaps ?? [], pins: pinsOf(st.dir),
|
|
519
|
+
saved: validSaved(meta.saved) ? meta.saved : null,
|
|
504
520
|
__raw: { records, checkpoint: envelope } };
|
|
505
521
|
};
|
|
506
522
|
|
|
@@ -691,7 +707,7 @@ export function createJournalStore() {
|
|
|
691
707
|
case "journal_probe": return probe(cmd.id, cmd.key);
|
|
692
708
|
case "journal_append": return append(cmd.id, cmd.key, cmd.records, cmd.epoch);
|
|
693
709
|
case "journal_checkpoint": return checkpoint(cmd.id, cmd.key, cmd.revision, cmd.generation, cmd.envelope,
|
|
694
|
-
cmd.meta && typeof cmd.meta === "object" ? cmd.meta : null, cmd.rebase === true, cmd.epoch);
|
|
710
|
+
cmd.meta && typeof cmd.meta === "object" ? cmd.meta : null, cmd.rebase === true, cmd.epoch, cmd.saved ?? null);
|
|
695
711
|
case "journal_archive": return archive(cmd.id, cmd.key);
|
|
696
712
|
case "journal_rename": return rename(cmd.id, cmd.from, cmd.to);
|
|
697
713
|
case "journal_load": return load(cmd.id, cmd.key, cmd.afterRevision);
|
package/host/planes/choose.mjs
CHANGED
|
@@ -50,7 +50,12 @@ import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
|
50
50
|
import { canonicalPath, fsAbs, makeRoot } from "./files.mjs";
|
|
51
51
|
|
|
52
52
|
export const CHOOSE_MODES = Object.freeze(["file", "files", "dir", "save"]);
|
|
53
|
-
|
|
53
|
+
// `choose_cancel` closes the panel the SAME page opened, from the page: the
|
|
54
|
+
// panel can open behind the window or on another Space, and a page that says
|
|
55
|
+
// "a panel is open on your desktop" must also offer a way out of it
|
|
56
|
+
// (FINDINGS 2026-09-22 B6). Answered as a plain cancel, not an error.
|
|
57
|
+
const OPS = ["choose", "choose_cancel"];
|
|
58
|
+
const PAGE_CANCEL = "cancelled from the page";
|
|
54
59
|
/** The env suffix that overrides each tool's binary (BEATRINA_<suffix>). */
|
|
55
60
|
const TOOL_ENV = Object.freeze({ osascript: "OSASCRIPT", powershell: "POWERSHELL", zenity: "ZENITY", kdialog: "KDIALOG" });
|
|
56
61
|
/** Output kept per stream: a path list is small; a runaway tool is not. */
|
|
@@ -342,6 +347,11 @@ export function createPlane({ env, audit }) {
|
|
|
342
347
|
audit("choose-refused", { reason: "class", class: rec.class || "unknown" });
|
|
343
348
|
return reply({ error: "Only the local notebook page may open the file panel." });
|
|
344
349
|
}
|
|
350
|
+
if (cmd.type === "choose_cancel") {
|
|
351
|
+
const had = !!(open && open.rec === rec && alive(open.child));
|
|
352
|
+
if (had) kill(PAGE_CANCEL);
|
|
353
|
+
return reply({ ok: true, closed: had });
|
|
354
|
+
}
|
|
345
355
|
const req = readChooseRequest(cmd);
|
|
346
356
|
if (req.error) return reply({ error: req.error });
|
|
347
357
|
if (flagged) return reply({ unsupported: true, reason: offReason });
|
|
@@ -382,7 +392,8 @@ export function createPlane({ env, audit }) {
|
|
|
382
392
|
};
|
|
383
393
|
child.on("error", (e) => finish({ error: `The file panel could not start: ${e.message}` }));
|
|
384
394
|
child.on("close", (status, signal) => {
|
|
385
|
-
if (mine.killed) return finish(
|
|
395
|
+
if (mine.killed) return finish(mine.killed === PAGE_CANCEL ? { cancelled: true }
|
|
396
|
+
: { error: `The file panel was closed (${mine.killed}).` });
|
|
386
397
|
const read = readChooserResult(spec.tool, req.mode, { status, signal, stdout, stderr });
|
|
387
398
|
if (read.cancelled || read.error) return finish(read);
|
|
388
399
|
finish(validateChosen(req.mode, read, { root }));
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// expect-term.mjs — a REAL pty for the terminal, without a native module.
|
|
2
|
+
//
|
|
3
|
+
// A packaged Beatrina executable can load no native module, so node-pty is
|
|
4
|
+
// never there and the terminal fell to plain pipes: "This terminal has no pty:
|
|
5
|
+
// line programs work; full-screen programs and password prompts do not"
|
|
6
|
+
// (FINDINGS 2026-09-22 B8, the installed app). But the machine usually has a
|
|
7
|
+
// pty provider of its own: `expect` (Tcl) ships with macOS and is one package
|
|
8
|
+
// away on Linux, and its whole job is to run a program on a pseudo-terminal
|
|
9
|
+
// while reading and writing plain pipes. Measured on macOS 15: `tty` answers
|
|
10
|
+
// /dev/ttys001, `stty size` the size asked for, and `read -s` hides a
|
|
11
|
+
// password as a terminal should.
|
|
12
|
+
//
|
|
13
|
+
// The shape is node-pty's IPty (pid · pty · onData · onExit · write · resize ·
|
|
14
|
+
// kill), so host/planes/terminal.mjs keeps one code path:
|
|
15
|
+
// · write: raw bytes into the pty — Enter, ^C, ^D, arrows mean what they
|
|
16
|
+
// mean at a terminal, because the line discipline is real;
|
|
17
|
+
// · resize: a private escape sequence the expect script intercepts and turns
|
|
18
|
+
// into `stty rows … columns …` on the pty, which is what makes the kernel
|
|
19
|
+
// deliver SIGWINCH to the foreground program;
|
|
20
|
+
// · kill: the expect process group; the shell, in its own session on the
|
|
21
|
+
// pty, gets the hangup a closed terminal gives.
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
/** The escape a resize travels as; nothing a person types can produce it. */
|
|
26
|
+
export const RESIZE_PREFIX = "\x1b]beatrina-resize;";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The expect program. It reads its parameters from the environment rather
|
|
30
|
+
* than from a command line, so a shell path with a space or a quote is never
|
|
31
|
+
* pasted into Tcl source: BEATRINA_TERM_ARGV is the argument vector joined by
|
|
32
|
+
* TAB (a TAB cannot occur in a path we would run), and the size is two numbers.
|
|
33
|
+
*/
|
|
34
|
+
export const EXPECT_SCRIPT = [
|
|
35
|
+
"log_user 1",
|
|
36
|
+
"set stty_init \"rows $env(BEATRINA_TERM_ROWS) columns $env(BEATRINA_TERM_COLS)\"",
|
|
37
|
+
"set argl [split $env(BEATRINA_TERM_ARGV) \"\\t\"]",
|
|
38
|
+
"spawn -noecho {*}$argl",
|
|
39
|
+
"set slave $spawn_out(slave,name)",
|
|
40
|
+
"interact {",
|
|
41
|
+
" -re \"\\033\\\\\\]beatrina-resize;(\\[0-9\\]+);(\\[0-9\\]+)\\007\" {",
|
|
42
|
+
" catch {stty rows $interact_out(2,string) columns $interact_out(1,string) < $slave}",
|
|
43
|
+
" }",
|
|
44
|
+
"}",
|
|
45
|
+
"catch {wait} result",
|
|
46
|
+
"exit [lindex $result 3]",
|
|
47
|
+
].join("\n");
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start `file args…` on a pty through expect.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} expectBin the expect executable (whichSync("expect"))
|
|
53
|
+
* @param {string} file
|
|
54
|
+
* @param {string[]} args
|
|
55
|
+
* @param {{cwd?: string, env?: object, cols?: number, rows?: number, spawnImpl?: Function, signal?: Function}} opts
|
|
56
|
+
* @returns {{pid:number, pty:true, onData:Function, onExit:Function, write:Function, resize:Function, kill:Function}}
|
|
57
|
+
*/
|
|
58
|
+
export function expectSpawn(expectBin, file, args, opts = {}) {
|
|
59
|
+
const spawnImpl = opts.spawnImpl || spawn;
|
|
60
|
+
const signal = opts.signal || ((pid, sig) => process.kill(pid, sig));
|
|
61
|
+
const cols = Math.round(Number(opts.cols) || 100);
|
|
62
|
+
const rows = Math.round(Number(opts.rows) || 30);
|
|
63
|
+
const argv = [file, ...(args || [])];
|
|
64
|
+
if (argv.some((a) => String(a).includes("\t"))) throw new Error("a terminal argument contains a TAB");
|
|
65
|
+
const child = spawnImpl(expectBin, ["-c", EXPECT_SCRIPT], {
|
|
66
|
+
cwd: opts.cwd,
|
|
67
|
+
env: { ...(opts.env || process.env), BEATRINA_TERM_ARGV: argv.join("\t"),
|
|
68
|
+
BEATRINA_TERM_COLS: String(cols), BEATRINA_TERM_ROWS: String(rows) },
|
|
69
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
70
|
+
detached: true,
|
|
71
|
+
});
|
|
72
|
+
const dataFns = [];
|
|
73
|
+
const exitFns = [];
|
|
74
|
+
const emit = (d) => dataFns.forEach((fn) => { try { fn(d); } catch { /* a listener must not break the stream */ } });
|
|
75
|
+
child.stdout.setEncoding("utf8");
|
|
76
|
+
child.stderr.setEncoding("utf8");
|
|
77
|
+
child.stdout.on("data", emit);
|
|
78
|
+
child.stderr.on("data", emit);
|
|
79
|
+
child.stdin.on("error", () => { /* the terminal has gone; exit says so */ });
|
|
80
|
+
let exited = false;
|
|
81
|
+
const done = (exitCode) => { if (exited) return; exited = true; exitFns.forEach((fn) => fn({ exitCode })); };
|
|
82
|
+
child.on("exit", (code) => done(code));
|
|
83
|
+
child.on("error", (e) => { emit(`[the terminal could not start: ${e.message}]\r\n`); done(null); });
|
|
84
|
+
return {
|
|
85
|
+
pid: child.pid,
|
|
86
|
+
pty: true,
|
|
87
|
+
onData: (fn) => { dataFns.push(fn); },
|
|
88
|
+
onExit: (fn) => { exitFns.push(fn); },
|
|
89
|
+
write(text) { try { child.stdin.write(String(text)); } catch { /* gone */ } },
|
|
90
|
+
resize(c, r) {
|
|
91
|
+
const cc = Math.round(Number(c)), rr = Math.round(Number(r));
|
|
92
|
+
if (!(cc > 0) || !(rr > 0)) return;
|
|
93
|
+
try { child.stdin.write(`${RESIZE_PREFIX}${cc};${rr}\x07`); } catch { /* gone */ }
|
|
94
|
+
},
|
|
95
|
+
kill() {
|
|
96
|
+
try { signal(-child.pid, "SIGKILL"); } catch { /* the group is gone */ }
|
|
97
|
+
try { signal(child.pid, "SIGKILL"); } catch { /* gone */ }
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default expectSpawn;
|
package/host/planes/terminal.mjs
CHANGED
|
@@ -30,6 +30,8 @@ import path from "node:path";
|
|
|
30
30
|
import { createRequire } from "node:module";
|
|
31
31
|
import { killProcessTree } from "./proc-tree.mjs";
|
|
32
32
|
import { PIPE_NOTICE, pipeShell, pipeSpawn } from "./pipe-term.mjs";
|
|
33
|
+
import { expectSpawn } from "./expect-term.mjs";
|
|
34
|
+
import { whichSync } from "../which.mjs";
|
|
33
35
|
import { envSetName, mirrorEnv } from "../env-names.mjs";
|
|
34
36
|
|
|
35
37
|
const MAX_TERMS = 4;
|
|
@@ -123,6 +125,7 @@ export function createPlane({ env, audit }) {
|
|
|
123
125
|
const terms = new Map();
|
|
124
126
|
let seq = 0;
|
|
125
127
|
let loaded = null; // lazy: a kernel that never opens one pays nothing
|
|
128
|
+
let expectBin; // undefined: not yet looked for; "" when there is none
|
|
126
129
|
let ctxRef = null;
|
|
127
130
|
const ctxPort = () => (ctxRef ? ctxRef.port() : 0);
|
|
128
131
|
|
|
@@ -181,7 +184,17 @@ export function createPlane({ env, audit }) {
|
|
|
181
184
|
if (scalarChr(cmd.cwd) && cmd.cwd) {
|
|
182
185
|
try { if (fs.statSync(cmd.cwd).isDirectory()) wd = cmd.cwd; } catch { /* keep the kernel's cwd */ }
|
|
183
186
|
}
|
|
184
|
-
|
|
187
|
+
// THREE rungs: node-pty; else `expect`, which ships with macOS and runs a
|
|
188
|
+
// program on a real pty over plain pipes (host/planes/expect-term.mjs) —
|
|
189
|
+
// the packaged app, which can load no native module, gets a real terminal
|
|
190
|
+
// from it (FINDINGS 2026-09-22 B8); else the pipe. BEATRINA_TERMINAL_EXPECT=0
|
|
191
|
+
// skips the middle rung, which is how a machine with expect tests the pipe.
|
|
192
|
+
if (expectBin === undefined) {
|
|
193
|
+
expectBin = loaded.pty || process.platform === "win32" || env("BEATRINA_TERMINAL_EXPECT") === "0"
|
|
194
|
+
? "" : whichSync("expect");
|
|
195
|
+
}
|
|
196
|
+
const useExpect = !loaded.pty && !!expectBin;
|
|
197
|
+
const usePipe = !loaded.pty && !useExpect;
|
|
185
198
|
const sh = usePipe ? pipeShell(process.env) : termShell(process.env);
|
|
186
199
|
const argv = usePipe ? { file: sh.cmd, args: sh.args } : termArgv(sh);
|
|
187
200
|
// The shell runs in the PHYSICAL directory (processx resolves the wd for
|
|
@@ -191,16 +204,16 @@ export function createPlane({ env, audit }) {
|
|
|
191
204
|
let real = wd;
|
|
192
205
|
try { real = fs.realpathSync(wd); } catch { real = wd; }
|
|
193
206
|
let term;
|
|
207
|
+
const ptyEnv = () => mirrorEnv({ ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
|
|
208
|
+
COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) });
|
|
194
209
|
try {
|
|
195
|
-
term =
|
|
210
|
+
term = useExpect
|
|
211
|
+
? expectSpawn(expectBin, argv.file, argv.args, { cols, rows, cwd: real, env: ptyEnv() })
|
|
212
|
+
: usePipe
|
|
196
213
|
? pipeSpawn(argv.file, argv.args, { cwd: real,
|
|
197
214
|
env: mirrorEnv({ ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }) })
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// The shell sees BEATRINA_PORT and, mirrored, its older spelling (a script written before the rename).
|
|
201
|
-
env: mirrorEnv({ ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
|
|
202
|
-
COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }),
|
|
203
|
-
});
|
|
215
|
+
// The shell sees BEATRINA_PORT and, mirrored, its older spelling (a script written before the rename).
|
|
216
|
+
: loaded.pty.spawn(argv.file, argv.args, { name: "xterm-256color", cols, rows, cwd: real, env: ptyEnv() });
|
|
204
217
|
} catch (e) { return refuse(`the shell could not start: ${e.message}`); }
|
|
205
218
|
|
|
206
219
|
seq += 1;
|
|
@@ -218,7 +231,8 @@ export function createPlane({ env, audit }) {
|
|
|
218
231
|
audit("term-exit", { id, detail: String(exitCode) });
|
|
219
232
|
send(rec, id, { event: "exit", code: exitCode ?? null, reason: "exited" });
|
|
220
233
|
});
|
|
221
|
-
audit("term-open", { id, detail: sh.cmd, class: rec.class ?? "unknown", pty: !usePipe
|
|
234
|
+
audit("term-open", { id, detail: sh.cmd, class: rec.class ?? "unknown", pty: !usePipe,
|
|
235
|
+
via: loaded.pty ? "node-pty" : useExpect ? "expect" : "pipe" });
|
|
222
236
|
send(rec, id, { event: "open", shell: sh.cmd, pid: term.pid, cols, rows, cwd: wd, pty: !usePipe });
|
|
223
237
|
if (usePipe) {
|
|
224
238
|
if (loaded.error) audit("term-pipe", { id, detail: loaded.error });
|