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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- 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
|
+
}
|