beatrina 0.8.6

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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,493 @@
1
+ // mcp.mjs — the agent plane, and the subscription chat that rides it the other
2
+ // way. A port of the MCP half of spike/serve.R.
3
+ //
4
+ // ── the plane ──────────────────────────────────────────────────────────────
5
+ //
6
+ // A local agent (Claude Code / Codex through tools/mcp/carmar-mcp.mjs) connects
7
+ // through the SAME loopback/Origin/Host gates a page does, then DECLARES itself
8
+ // with `mcp-hello`. From then on it may only ASK — `mcp-request`, routed to the
9
+ // active notebook window — and use the read-side forwarded ops; pages may only
10
+ // ANSWER. A declared agent is refused exec/interrupt/restart elsewhere on
11
+ // purpose: an agent's runs go through a chunk in the notebook, where the user
12
+ // can see them, or they do not happen.
13
+ //
14
+ // Three rules that look like plumbing and are not.
15
+ //
16
+ // · A request carries BOTH ENDS AND A CLOCK. The asker is who the answer goes
17
+ // to; the PAGE is the only thing that can produce one, so its disappearance
18
+ // is the precise end of that request — reported immediately, as a fact —
19
+ // while the clock is only the backstop for a page that is alive and silent.
20
+ // `MCP_PENDING_MAX_S` matches the ceiling carmar-mcp.mjs lets a caller ask
21
+ // for (chunk_run's timeout_s caps at 3600), so the supervisor never gives up
22
+ // on a call the agent is still legitimately waiting for.
23
+ // · The roster is REPLAYED to a page that connects late. Same lesson as the
24
+ // ready frame: a page opened after an agent connected must still learn the
25
+ // agent is there, or its chip lies by omission.
26
+ // · `mcp-request` and `mcp-response` are forwarded as BYTES, never re-encoded.
27
+ // relay_frame's lesson, one door along: a JSON round trip through a
28
+ // convenience encoder is where `args: {}` becomes `args: []`.
29
+ //
30
+ // ── the subscription chat ──────────────────────────────────────────────────
31
+ //
32
+ // One conversation turn = one `claude -p` (or `codex exec`) process, spawned
33
+ // HERE, never in the worker: a model turn must not block or be blocked by a
34
+ // running cell. The CLI owns its own login — the host passes a prompt on STDIN
35
+ // (argv is visible to every local user in `ps`), streams the CLI's stream-json
36
+ // lines back to the ASKING page verbatim, and that is the whole exchange.
37
+ // CarmaR reads, stores and proxies no credential. Pages only: a declared agent
38
+ // asking the kernel to spawn another agent is a loop nobody ordered.
39
+ //
40
+ // Two deliberate differences from serve.R, both mechanism rather than policy.
41
+ // (1) There is no `pump_chats()` poll: a spawned child's stdout is an event
42
+ // here, so lines are forwarded as they arrive and the `done` frame is sent on
43
+ // `close`, which fires after the streams have drained — the same ordering the
44
+ // pump achieved by reading what was left before reporting the exit. (2) `node`
45
+ // is looked for on PATH as there, but falls back to THIS process's own
46
+ // executable, which an app-bundle supervisor knows and `Sys.which` cannot.
47
+
48
+ import fs from "node:fs";
49
+ import os from "node:os";
50
+ import path from "node:path";
51
+ import crypto from "node:crypto";
52
+ import { spawn } from "node:child_process";
53
+
54
+ const OPS = ["mcp-hello", "mcp-active", "mcp-request", "mcp-response", "agent-chat", "agent-chat-stop"];
55
+ /** The ceiling carmar-mcp.mjs allows a caller to ask for. */
56
+ const MCP_PENDING_MAX_S = 3600;
57
+ const MAX_CHATS = 3;
58
+ /** The spawned session gets the DOCUMENT tools: it is an author, not a chatbot. */
59
+ const AGENT_CHAT_TOOLS = [
60
+ "mcp__carmar__carmar_status", "mcp__carmar__notebook_read",
61
+ "mcp__carmar__chunk_read", "mcp__carmar__chunk_insert",
62
+ "mcp__carmar__chunk_update", "mcp__carmar__chunk_run",
63
+ "mcp__carmar__file_list", "mcp__carmar__file_read",
64
+ ].join(",");
65
+
66
+ /* ── the AI policy, only as far as this plane needs it ────────────────────── */
67
+ //
68
+ // spike/ai-policy.R is a whole file and a whole work package of its own (the
69
+ // `ai-policy`, `ai-key` and `ai-audit` ops are not this one's). What IS this
70
+ // plane's is the door the supervisor genuinely owns: a CLI turn is a process
71
+ // THIS process spawns, so "not permitted" means never spawned, with no
72
+ // page-side spawn to work around. That question needs only the provider list,
73
+ // which is what is ported here — the three-state rule included.
74
+
75
+ const ON_MACHINE_PROVIDERS = ["chrome", "webgpu", "ollama", "lmstudio", "jan"];
76
+ const KEY_PROVIDERS = ["openai", "anthropic", "google", "groq", "together", "openrouter", "azure", "custom"];
77
+ const CLI_PROVIDERS = ["claudecode", "codex"];
78
+ const ALL_PROVIDERS = [...ON_MACHINE_PROVIDERS, ...KEY_PROVIDERS, ...CLI_PROVIDERS];
79
+
80
+ /** @returns {{set:boolean, providers:string[], note:string}} */
81
+ export function aiProviderPolicy(env) {
82
+ const file = env("CARMAR_AI_POLICY", "").trim();
83
+ let named = [];
84
+ let localOnly = env("CARMAR_AI_LOCAL_ONLY", "") === "1";
85
+ let note = "";
86
+ let source = "";
87
+ const errors = [];
88
+ if (file) {
89
+ source = file;
90
+ if (!fs.existsSync(file)) errors.push("missing");
91
+ else {
92
+ try {
93
+ const doc = JSON.parse(fs.readFileSync(file, "utf8"));
94
+ named = [].concat(doc.providers ?? []).map(String);
95
+ if (doc.local_only === true) localOnly = true;
96
+ note = String(doc.note ?? "").trim();
97
+ } catch { errors.push("unreadable"); }
98
+ }
99
+ } else if (env("CARMAR_AI_PROVIDERS", "")) {
100
+ source = "env";
101
+ named = String(env("CARMAR_AI_PROVIDERS", "")).split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
102
+ } else if (localOnly) {
103
+ source = "env";
104
+ }
105
+ const asked = named.length > 0;
106
+ named = named.filter((p) => ALL_PROVIDERS.includes(p));
107
+ let providers = named.length ? named : asked ? [] : ALL_PROVIDERS.slice();
108
+ if (localOnly) providers = providers.filter((p) => ON_MACHINE_PROVIDERS.includes(p));
109
+ let set = Boolean(source) && !errors.length;
110
+ if (set && !providers.length) errors.push("empty");
111
+ if (errors.length) { set = false; providers = ALL_PROVIDERS.slice(); }
112
+ // eslint-disable-next-line no-control-regex
113
+ note = note.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400).trim();
114
+ return { set, providers, note };
115
+ }
116
+
117
+ /** May this provider be used at all? */
118
+ export const aiProviderAllowed = (provider, policy) =>
119
+ policy.set !== true || (Boolean(provider) && policy.providers.includes(provider));
120
+
121
+ /** The sentence the notebook shows when something is refused. */
122
+ export const aiPolicyReason = (policy) => {
123
+ const base = `This CarmaR is configured to allow only: ${policy.providers.join(", ")}.`;
124
+ return policy.note ? `${base} ${policy.note}` : base;
125
+ };
126
+
127
+ /* ── finding things on this machine ───────────────────────────────────────── */
128
+
129
+ /**
130
+ * Find a binary the way a launcher must: an app-bundle supervisor inherits a
131
+ * minimal PATH, so the PATH scan alone misses user-local installs.
132
+ */
133
+ export function findBinary(name, extra = []) {
134
+ const exe = process.platform === "win32" ? `${name}.exe` : name;
135
+ for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
136
+ const cand = path.join(dir, exe);
137
+ try { fs.accessSync(cand, fs.constants.X_OK); if (fs.statSync(cand).isFile()) return cand; } catch { /* next */ }
138
+ }
139
+ for (const p of [...extra, path.join(os.homedir(), ".local", "bin", name),
140
+ path.join("/opt/homebrew/bin", name), path.join("/usr/local/bin", name)]) {
141
+ if (fs.existsSync(p)) return p;
142
+ }
143
+ return "";
144
+ }
145
+
146
+ /**
147
+ * Where the carmar-mcp stdio server lives on THIS machine, so the setup dialog
148
+ * can show a command that works verbatim. Repo layout first (tools/mcp beside
149
+ * spike/), then a distribution copy beside the kernel.
150
+ */
151
+ export function mcpServerPath(here) {
152
+ for (const p of [path.join(here, "..", "tools", "mcp", "carmar-mcp.mjs"),
153
+ path.join(here, "mcp", "carmar-mcp.mjs")]) {
154
+ if (fs.existsSync(p)) return fs.realpathSync(p);
155
+ }
156
+ return "";
157
+ }
158
+
159
+ /* ── the plane ────────────────────────────────────────────────────────────── */
160
+
161
+ export function createPlane({ spike, env, audit }) {
162
+ const here = spike;
163
+ const aiPolicy = aiProviderPolicy(env);
164
+ /** id → {rec, page, at} — an agent's tool call parked on one page. */
165
+ const pending = new Map();
166
+ /** id → one running CLI turn, owned by the page socket that asked. */
167
+ const chats = new Map();
168
+ let activePage = null;
169
+ let sweeper = null;
170
+ let ctxRef = null;
171
+
172
+ const claudeBin = () => {
173
+ const override = env("CARMAR_CLAUDE_BIN", "");
174
+ if (override && fs.existsSync(override)) return override;
175
+ return findBinary("claude");
176
+ };
177
+ const codexBin = () => {
178
+ const override = env("CARMAR_CODEX_BIN", "");
179
+ if (override && fs.existsSync(override)) return override;
180
+ return findBinary("codex");
181
+ };
182
+
183
+ const mcpRecs = (ctx) => ctx.sockets.filter((r) => r.role === "mcp");
184
+
185
+ /**
186
+ * The page an agent's request should land on: the one the user most recently
187
+ * focused, else the most recently opened page. NULL when no notebook window is
188
+ * connected at all — which is an ANSWER (the agent is told), never silence.
189
+ */
190
+ const targetPage = (ctx) => {
191
+ if (activePage && ctx.sockets.includes(activePage)) return activePage;
192
+ const pages = ctx.pageRecs();
193
+ return pages.length ? pages[pages.length - 1] : null;
194
+ };
195
+
196
+ /** Tell every notebook page who is connected, so the UI can show it. */
197
+ const notifyMcpClients = (ctx) => {
198
+ const agents = mcpRecs(ctx);
199
+ const frame = ctx.enc({ type: "mcp-clients", count: agents.length,
200
+ names: agents.map((r) => r.name || "agent") });
201
+ ctx.pageRecs().forEach((r) => r.ws.send(frame));
202
+ };
203
+
204
+ const chatSend = (rec, id, payload) =>
205
+ rec.ws.send(ctxRef.enc({ type: "agent-chat", id, ...payload }));
206
+
207
+ /** The two subscription CLIs the pane can ride. */
208
+ const chatClient = (cmd) => (cmd.client === "codex" ? "codex" : "claude");
209
+ const chatLabel = (client) => (client === "codex" ? "Codex" : "Claude Code");
210
+
211
+ function chatKill(id, notify = false) {
212
+ const ch = chats.get(id);
213
+ if (!ch) return;
214
+ ch.finished = true;
215
+ chats.delete(id);
216
+ try { ch.proc.kill("SIGKILL"); } catch { /* gone */ }
217
+ if (ch.cfg) fs.rmSync(ch.cfg, { recursive: true, force: true });
218
+ if (notify) chatSend(ch.rec, id, { done: true, code: -2, stopped: true });
219
+ }
220
+
221
+ function chatStart(rec, cmd, ctx) {
222
+ const id = cmd.id;
223
+ // Reviewed source proposals must never get the notebook's MUTATING MCP
224
+ // tools. Enforced at the process boundary, not merely in a prompt.
225
+ const textOnly = cmd.text_only === true;
226
+ const client = chatClient(cmd);
227
+ const label = chatLabel(client);
228
+ // THE DOOR WITH NO PAGE-SIDE BYPASS AT ALL: if the policy does not name the
229
+ // provider, the process is never spawned — there is nothing in the browser
230
+ // to work around, because the spawn was never there.
231
+ if (!aiProviderAllowed(client, aiPolicy)) {
232
+ audit("agent-chat-refused", { client, reason: "policy" });
233
+ chatSend(rec, id, { done: true, code: -1, error: `${label} is not permitted here. ${aiPolicyReason(aiPolicy)}` });
234
+ return;
235
+ }
236
+ const bin = client === "codex" ? codexBin() : claudeBin();
237
+ if (!bin) {
238
+ chatSend(rec, id, { done: true, code: -1,
239
+ error: `${label} is not installed on this machine (the \`${client === "codex" ? "codex" : "claude"}\` CLI was not found).` });
240
+ return;
241
+ }
242
+ if (chats.size >= MAX_CHATS) {
243
+ chatSend(rec, id, { done: true, code: -1, error: "Too many agent conversations are already running. Stop one first." });
244
+ return;
245
+ }
246
+
247
+ let cfg = ""; // claude: the 0600 mcp-config; codex: the 0700 workdir
248
+ const server = mcpServerPath(here);
249
+ const node = findBinary("node") || process.execPath;
250
+ const originOk = `http://${ctx.deployment.loopback ? ctx.deployment.bind : "127.0.0.1"}:${ctx.port()}`;
251
+ const scalar = (v) => typeof v === "string" && v !== "";
252
+ let args;
253
+ if (client === "codex") {
254
+ cfg = fs.mkdtempSync(path.join(os.tmpdir(), "carmar-codex-"));
255
+ fs.chmodSync(cfg, 0o700);
256
+ // The values are TOML — a JSON string literal IS a TOML basic string —
257
+ // and carry no secret: the MCP URL is the kernel origin, nothing more.
258
+ const toml = (x) => JSON.stringify(x);
259
+ args = ["exec", "--json", "--ephemeral", "--skip-git-repo-check", "--ignore-user-config", "-C", cfg];
260
+ args.push(...(textOnly
261
+ ? ["--sandbox", "read-only", "--disable", "shell_tool", "-c", 'web_search="disabled"']
262
+ : ["--approve-for-me"]));
263
+ if (scalar(cmd.system)) args.push("-c", `developer_instructions=${toml(cmd.system)}`);
264
+ if (scalar(cmd.model) && cmd.model !== "default") args.push("-m", cmd.model);
265
+ if (!textOnly && server && node) {
266
+ args.push("-c", `mcp_servers.carmar.command=${toml(node)}`,
267
+ "-c", `mcp_servers.carmar.args=[${toml(server)}]`,
268
+ "-c", `mcp_servers.carmar.env={CARMAR_MCP_URL=${toml(`${originOk}/`)}}`);
269
+ }
270
+ args.push("-"); // the prompt arrives on stdin
271
+ } else {
272
+ args = ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages"];
273
+ if (scalar(cmd.system)) args.push("--append-system-prompt", cmd.system);
274
+ if (scalar(cmd.model) && cmd.model !== "default") args.push("--model", cmd.model);
275
+ if (textOnly) {
276
+ args.push("--tools", "", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config", "--setting-sources", "");
277
+ } else if (server && node) {
278
+ cfg = path.join(os.tmpdir(), `carmar-chat-${crypto.randomBytes(8).toString("hex")}.json`);
279
+ fs.writeFileSync(cfg, `${JSON.stringify({ mcpServers: { carmar: {
280
+ command: node, args: [server], env: { CARMAR_MCP_URL: `${originOk}/` },
281
+ } } })}\n`, { mode: 0o600 });
282
+ fs.chmodSync(cfg, 0o600);
283
+ args.push("--mcp-config", cfg, "--strict-mcp-config", "--allowedTools", AGENT_CHAT_TOOLS);
284
+ }
285
+ }
286
+
287
+ let proc = null;
288
+ try { proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] }); }
289
+ catch (e) {
290
+ if (cfg) fs.rmSync(cfg, { recursive: true, force: true });
291
+ chatSend(rec, id, { done: true, code: -1, error: `Could not start ${label}: ${e.message}` });
292
+ return;
293
+ }
294
+ const ch = { proc, rec, cfg, stderrTail: [], out: "", finished: false };
295
+ chats.set(id, ch);
296
+
297
+ proc.once("error", (e) => {
298
+ if (ch.finished) return;
299
+ ch.finished = true;
300
+ chats.delete(id);
301
+ if (cfg) fs.rmSync(cfg, { recursive: true, force: true });
302
+ chatSend(rec, id, { done: true, code: -1, error: `Could not start ${label}: ${e.message}` });
303
+ });
304
+ proc.stdout.setEncoding("utf8");
305
+ proc.stderr.setEncoding("utf8");
306
+ proc.stdout.on("data", (d) => {
307
+ ch.out += d;
308
+ let nl = ch.out.indexOf("\n");
309
+ while (nl >= 0) {
310
+ const line = ch.out.slice(0, nl);
311
+ ch.out = ch.out.slice(nl + 1);
312
+ if (line) chatSend(rec, id, { line });
313
+ nl = ch.out.indexOf("\n");
314
+ }
315
+ });
316
+ proc.stderr.on("data", (d) => {
317
+ ch.stderrTail = [...ch.stderrTail, ...String(d).split("\n").filter(Boolean)].slice(-20);
318
+ });
319
+ proc.once("close", (code) => {
320
+ if (ch.finished) return;
321
+ ch.finished = true;
322
+ chats.delete(id);
323
+ if (ch.out.trim()) chatSend(rec, id, { line: ch.out.trim() });
324
+ const exit = Number.isInteger(code) ? code : -1;
325
+ if (exit !== 0 && ch.stderrTail.length) {
326
+ chatSend(rec, id, { done: true, code: exit, error: ch.stderrTail.join("\n") });
327
+ } else {
328
+ chatSend(rec, id, { done: true, code: exit });
329
+ }
330
+ audit("agent-chat-done", { id, detail: String(exit) });
331
+ if (cfg) fs.rmSync(cfg, { recursive: true, force: true });
332
+ });
333
+
334
+ // The prompt goes on stdin (argv leaks to `ps`, and long contexts overflow
335
+ // it); EOF tells the CLI the prompt is complete.
336
+ proc.stdin.on("error", () => { /* the CLI closed stdin early; `close` reports it */ });
337
+ try { proc.stdin.end(`${cmd.prompt}\n`); }
338
+ catch {
339
+ chatKill(id);
340
+ chatSend(rec, id, { done: true, code: -1, error: `${label} did not accept the prompt.` });
341
+ return;
342
+ }
343
+ audit("agent-chat", { id, client });
344
+ }
345
+
346
+ /**
347
+ * A page that is gone is a FACT — nothing can answer that request ever — and
348
+ * is reported immediately. The clock is only the backstop for a page that is
349
+ * alive and silent.
350
+ */
351
+ const sweep = (ctx) => {
352
+ if (!pending.size) return;
353
+ const now = Date.now();
354
+ for (const [id, entry] of [...pending]) {
355
+ if (!entry || !entry.rec) { pending.delete(id); continue; }
356
+ const pageGone = !entry.page || !ctx.sockets.includes(entry.page);
357
+ const stale = (now - entry.at) / 1000 >= MCP_PENDING_MAX_S;
358
+ if (!pageGone && !stale) continue;
359
+ const why = pageGone
360
+ ? "The CarmaR notebook window that was answering this request closed before it replied."
361
+ : `The notebook did not answer within ${MCP_PENDING_MAX_S} seconds.`;
362
+ audit("mcp-abandoned", { id, reason: pageGone ? "page closed" : "timeout" });
363
+ pending.delete(id);
364
+ entry.rec.ws.send(ctx.enc({ type: "mcp-response", id, ok: false, error: why }));
365
+ }
366
+ };
367
+
368
+ return {
369
+ name: "mcp",
370
+ ops: OPS,
371
+ // `agent-chat` is deliberately NOT here: serve.R refuses a non-page socket
372
+ // in SILENCE (an agent asking for another agent gets no frame at all), and
373
+ // the seam's refusal would answer it. The role check is in `handle`.
374
+ agentRefused: [],
375
+ commands: ["mcp-hello", "mcp-active", "agent-chat", "agent-chat-stop"],
376
+ capabilities: ["agent-plane-v1"],
377
+
378
+ init(ctx) {
379
+ ctxRef = ctx;
380
+ sweeper = setInterval(() => { try { sweep(ctx); } catch (e) { audit("plane-error", { plane: "mcp", detail: e.message }); } }, 1000);
381
+ sweeper.unref();
382
+ },
383
+
384
+ onOpen(rec, ctx) {
385
+ ctxRef = ctx;
386
+ // Replay the agent roster — same lesson as the ready frame.
387
+ const agents = mcpRecs(ctx);
388
+ if (agents.length) {
389
+ rec.ws.send(ctx.enc({ type: "mcp-clients", count: agents.length,
390
+ names: agents.map((r) => r.name || "agent") }));
391
+ }
392
+ },
393
+
394
+ onClose(rec, ctx) {
395
+ if (activePage === rec) activePage = null;
396
+ // A page that goes away takes its conversations with it — an orphan
397
+ // `claude -p` would keep billing a turn nobody can see.
398
+ for (const [id, ch] of [...chats]) if (ch.rec === rec) chatKill(id);
399
+ // An agent that vanishes must not leave its request ids parked forever.
400
+ // The PAGE dying is the other half and is NOT handled here: it cannot be,
401
+ // because the answer owed to the agent is "the window you asked is gone",
402
+ // which the sweep says on its next turn.
403
+ for (const [id, entry] of [...pending]) if (entry.rec === rec) pending.delete(id);
404
+ if (rec.role === "mcp") notifyMcpClients(ctx);
405
+ },
406
+
407
+ /** Loopback-only: where the MCP server is, and whether discovery is in place. */
408
+ http(req, res, pathname, ctx) {
409
+ if (pathname !== "/mcp-info") return false;
410
+ const blocked = ctx.controlRejection(req, { allowFile: true });
411
+ if (blocked) { ctx.reject(res, blocked.reason, blocked.detail); return true; }
412
+ const dir = env("CARMAR_RUNTIME_DIR") || path.join(os.homedir(), ".carmar", "run");
413
+ const runtime = env("CARMAR_NO_RUNTIME_FILE") !== "1"
414
+ && fs.existsSync(path.join(dir, `kernel-${ctx.port()}.json`));
415
+ ctx.respond(res, 200, "application/json", ctx.enc({
416
+ server: mcpServerPath(here), runtime, text_only_chat: true,
417
+ claude: Boolean(claudeBin()), codex: Boolean(codexBin()),
418
+ }), { "Access-Control-Allow-Origin": "null" });
419
+ return true;
420
+ },
421
+
422
+ handle(cmd, rec, ctx, rawMessage) {
423
+ ctxRef = ctx;
424
+ const { enc, scalarChr } = ctx;
425
+
426
+ if (cmd.type === "mcp-hello") {
427
+ rec.role = "mcp";
428
+ rec.name = scalarChr(cmd.client) ? cmd.client.slice(0, 64) : "agent";
429
+ audit("mcp-hello", { detail: rec.name });
430
+ rec.ws.send(enc({ type: "mcp-hello", id: scalarChr(cmd.id) ? cmd.id : "hello",
431
+ ok: true, pages: ctx.pageRecs().length }));
432
+ notifyMcpClients(ctx);
433
+ return undefined;
434
+ }
435
+ if (cmd.type === "mcp-active") {
436
+ if (rec.role === "page") activePage = rec;
437
+ return undefined;
438
+ }
439
+ if (cmd.type === "mcp-request") {
440
+ if (rec.role !== "mcp") {
441
+ audit("mcp-refused", { reason: "mcp-request from a non-agent socket" });
442
+ return undefined;
443
+ }
444
+ if (!scalarChr(cmd.id) || !scalarChr(cmd.tool)) return undefined;
445
+ const page = targetPage(ctx);
446
+ audit("mcp-request", { id: cmd.id, detail: cmd.tool });
447
+ if (!page) {
448
+ rec.ws.send(enc({ type: "mcp-response", id: cmd.id, ok: false,
449
+ error: "No CarmaR notebook window is connected to this kernel. Open the notebook first." }));
450
+ return undefined;
451
+ }
452
+ pending.set(cmd.id, { rec, page, at: Date.now() });
453
+ page.ws.send(rawMessage); // forwarded verbatim
454
+ return undefined;
455
+ }
456
+ if (cmd.type === "mcp-response") {
457
+ if (rec.role === "mcp") return undefined; // agents ask, pages answer
458
+ if (!scalarChr(cmd.id)) return undefined;
459
+ const waiting = pending.get(cmd.id);
460
+ if (waiting) {
461
+ pending.delete(cmd.id);
462
+ waiting.rec.ws.send(rawMessage); // forwarded verbatim
463
+ }
464
+ return undefined;
465
+ }
466
+ if (cmd.type === "agent-chat") {
467
+ if (rec.role !== "page") {
468
+ audit("mcp-refused", { reason: "agent-chat from a non-page socket" });
469
+ return undefined;
470
+ }
471
+ if (!scalarChr(cmd.id) || !scalarChr(cmd.prompt)) return undefined;
472
+ if (chats.has(cmd.id)) return undefined; // id reuse: ignore
473
+ chatStart(rec, cmd, ctx);
474
+ return undefined;
475
+ }
476
+ if (cmd.type === "agent-chat-stop") {
477
+ if (!scalarChr(cmd.id)) return undefined;
478
+ const ch = chats.get(cmd.id);
479
+ // Only the socket that started a conversation may stop it.
480
+ if (ch && ch.rec === rec) {
481
+ audit("agent-chat-stop", { id: cmd.id });
482
+ chatKill(cmd.id, true);
483
+ }
484
+ return undefined;
485
+ }
486
+ return undefined;
487
+ },
488
+
489
+ // A Quit needs no hook of its own: host.close() closes every socket, each
490
+ // close fires onClose above, and a page's conversations die with its page.
491
+ // The sweeper is unref'd, so it never holds the process open either.
492
+ };
493
+ }