@aliceshimada/mica 1.0.0

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.
@@ -0,0 +1,356 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import http from "node:http";
3
+ import { renderDashboard } from "./dashboard.js";
4
+ const JSON_BODY_LIMIT_BYTES = 1024 * 1024;
5
+ const DEFAULT_VERSION = "0.1.0";
6
+ export async function createBunHttpApp({ state, host = "127.0.0.1", port, authToken, version = DEFAULT_VERSION }) {
7
+ const runtimeInfo = {
8
+ host,
9
+ port,
10
+ authToken,
11
+ version,
12
+ startedAtMs: Date.now(),
13
+ };
14
+ const fetchHandler = createFetchHandler(state, runtimeInfo);
15
+ const bun = globalThis.Bun;
16
+ if (bun?.serve) {
17
+ const server = bun.serve({ hostname: host, port, fetch: fetchHandler });
18
+ runtimeInfo.port = server.port;
19
+ return {
20
+ port: server.port,
21
+ stop: async () => {
22
+ await server.stop();
23
+ },
24
+ };
25
+ }
26
+ const nodeServer = await startNodeFallbackServer(fetchHandler, host, port);
27
+ runtimeInfo.port = nodeServer.port;
28
+ return nodeServer;
29
+ }
30
+ export function createFetchHandler(state, options = {}) {
31
+ const runtimeInfo = {
32
+ host: options.host ?? "127.0.0.1",
33
+ port: options.port ?? 19_791,
34
+ authToken: options.authToken,
35
+ version: options.version ?? DEFAULT_VERSION,
36
+ startedAtMs: options.startedAtMs ?? Date.now(),
37
+ };
38
+ return async (request) => {
39
+ const url = new URL(request.url);
40
+ try {
41
+ if (request.method === "GET" && url.pathname === "/") {
42
+ return htmlResponse(renderDashboard());
43
+ }
44
+ if (!isAuthorized(request.headers.get("authorization"), options.authToken)) {
45
+ return jsonResponse({ error: { code: "UNAUTHORIZED" } }, 401);
46
+ }
47
+ if (request.method === "GET" && url.pathname === "/status") {
48
+ state.sweepLiveness(Date.now());
49
+ return jsonResponse({
50
+ server: {
51
+ state: "running",
52
+ version: runtimeInfo.version,
53
+ pid: process.pid,
54
+ host: runtimeInfo.host,
55
+ port: runtimeInfo.port,
56
+ uptimeMs: Math.max(0, Date.now() - runtimeInfo.startedAtMs),
57
+ },
58
+ security: {
59
+ authEnabled: Boolean(runtimeInfo.authToken),
60
+ // The dashboard uses the same generated bearer token as protocol endpoints.
61
+ // Keep this explicit so the UI can describe dashboard access without exposing it.
62
+ dashboardTokenPresent: Boolean(runtimeInfo.authToken),
63
+ },
64
+ agents: state.agents.list(),
65
+ notebooks: state.notebooks.listLive(),
66
+ requests: summarizeRequests(state.queue.snapshot()),
67
+ });
68
+ }
69
+ if (request.method === "POST" && url.pathname === "/agents/register") {
70
+ const body = await readJsonObjectBody(request);
71
+ const agentSessionId = readRequiredString(body.agentSessionId, "agentSessionId");
72
+ const wolframVersion = readRequiredString(body.wolframVersion, "wolframVersion");
73
+ const platform = readRequiredString(body.platform, "platform");
74
+ const seenAt = readOptionalNumber(body.seenAt) ?? Date.now();
75
+ const machineId = readOptionalString(body.machineId);
76
+ const frontendSessionId = readOptionalString(body.frontendSessionId);
77
+ const wolframProcessId = readOptionalString(body.wolframProcessId);
78
+ const agent = state.agents.register({ agentSessionId, wolframVersion, platform, seenAt, machineId, frontendSessionId, wolframProcessId });
79
+ return jsonResponse({ agent });
80
+ }
81
+ const agentHeartbeatMatch = url.pathname.match(/^\/agents\/([^/]+)\/heartbeat$/);
82
+ if (request.method === "POST" && (url.pathname === "/agents/heartbeat" || agentHeartbeatMatch)) {
83
+ const body = await readJsonObjectBody(request);
84
+ const pathAgentSessionId = agentHeartbeatMatch ? decodeURIComponent(agentHeartbeatMatch[1]) : undefined;
85
+ const agentSessionId = readOptionalString(body.agentSessionId) ?? pathAgentSessionId;
86
+ if (!agentSessionId)
87
+ throw new Error("BAD_REQUEST");
88
+ const seenAt = readOptionalNumber(body.seenAt) ?? Date.now();
89
+ const existingAgent = state.agents.get(agentSessionId);
90
+ const agent = state.agents.heartbeat(agentSessionId, seenAt);
91
+ if (!agent) {
92
+ return jsonResponse(noLiveAgentPayload(existingAgent), 404);
93
+ }
94
+ return jsonResponse({ agent });
95
+ }
96
+ if (request.method === "POST" && url.pathname === "/notebooks/heartbeat") {
97
+ const body = await readJsonObjectBody(request);
98
+ const agentSessionId = readRequiredString(body.agentSessionId, "agentSessionId");
99
+ const liveAgent = state.agents.get(agentSessionId);
100
+ if (!liveAgent || liveAgent.offline || liveAgent.retired) {
101
+ return jsonResponse(noLiveAgentPayload(liveAgent), 404);
102
+ }
103
+ const notebook = state.notebooks.upsertHeartbeat({
104
+ agentSessionId,
105
+ frontendObjectKey: readRequiredString(body.frontendObjectKey, "frontendObjectKey"),
106
+ displayName: readRequiredString(body.displayName, "displayName"),
107
+ windowTitle: readRequiredPossiblyEmptyString(body.windowTitle),
108
+ wolframVersion: readRequiredString(body.wolframVersion, "wolframVersion"),
109
+ platform: readRequiredString(body.platform, "platform"),
110
+ permissions: readPermissions(body.permissions),
111
+ seenAt: readOptionalNumber(body.seenAt) ?? Date.now(),
112
+ notebookPath: readOptionalString(body.notebookPath),
113
+ savedPath: readOptionalString(body.savedPath),
114
+ });
115
+ return jsonResponse({ notebook });
116
+ }
117
+ const notebookClosedMatch = url.pathname.match(/^\/notebooks\/([^/]+)\/closed$/);
118
+ if (request.method === "POST" && notebookClosedMatch) {
119
+ const body = await readJsonObjectBody(request);
120
+ const agentSessionId = readRequiredString(body.agentSessionId, "agentSessionId");
121
+ const notebookId = decodeURIComponent(notebookClosedMatch[1]);
122
+ const notebook = state.notebooks.get(notebookId);
123
+ if (!notebook) {
124
+ return jsonResponse({ error: { code: "NOTEBOOK_NOT_FOUND" } }, 404);
125
+ }
126
+ if (notebook.agentSessionId !== agentSessionId) {
127
+ return jsonResponse({ error: { code: "NOT_OWNER" } }, 403);
128
+ }
129
+ state.closeNotebook(notebookId, Date.now());
130
+ return jsonResponse({ ok: true });
131
+ }
132
+ if (request.method === "GET" && url.pathname === "/notebooks") {
133
+ state.sweepLiveness(Date.now());
134
+ return jsonResponse({ notebooks: state.notebooks.listLive(), activeNotebookId: state.activeNotebookId ?? null });
135
+ }
136
+ const nextRequestMatch = url.pathname.match(/^\/agents\/([^/]+)\/next-request$/);
137
+ if (request.method === "GET" && nextRequestMatch) {
138
+ const agentSessionId = decodeURIComponent(nextRequestMatch[1]);
139
+ state.sweepLiveness(Date.now());
140
+ state.queue.markTimedOut(Date.now());
141
+ const agent = state.agents.get(agentSessionId);
142
+ if (!agent || agent.offline || agent.retired) {
143
+ return jsonResponse(noLiveAgentPayload(agent), 404);
144
+ }
145
+ const nextRequest = state.queue.claimNext(agentSessionId, Date.now());
146
+ return jsonResponse({ request: nextRequest ?? null, cancelRequests: state.queue.cancellationsForAgent(agentSessionId) });
147
+ }
148
+ const requestResultMatch = url.pathname.match(/^\/requests\/([^/]+)\/result$/);
149
+ const requestFailureMatch = url.pathname.match(/^\/requests\/([^/]+)\/failure$/);
150
+ if (request.method === "POST" && (requestResultMatch || requestFailureMatch)) {
151
+ const requestId = decodeURIComponent((requestResultMatch ?? requestFailureMatch)[1]);
152
+ const body = await readJsonObjectBody(request);
153
+ state.queue.markTimedOut(Date.now());
154
+ if (requestFailureMatch || body.ok === false || body.success === false || body.failed === true) {
155
+ const existing = state.queue.get(requestId);
156
+ const failed = state.queue.fail(requestId, body.error ?? body);
157
+ return jsonResponse({ accepted: failed, late: existing?.status === "timed_out" || existing?.status === "cancelled" });
158
+ }
159
+ return jsonResponse(state.queue.resolve(requestId, body.result ?? body, Date.now()));
160
+ }
161
+ return jsonResponse({ error: { code: "NOT_FOUND" } }, 404);
162
+ }
163
+ catch (error) {
164
+ const message = error instanceof Error ? error.message : String(error);
165
+ if (message === "BAD_REQUEST") {
166
+ return jsonResponse({ error: { code: "BAD_REQUEST" } }, 400);
167
+ }
168
+ if (message === "MALFORMED_JSON") {
169
+ return jsonResponse({ error: { code: "BAD_REQUEST" } }, 400);
170
+ }
171
+ if (message === "PAYLOAD_TOO_LARGE") {
172
+ return jsonResponse({ error: { code: "PAYLOAD_TOO_LARGE" } }, 413);
173
+ }
174
+ return jsonResponse({ error: { code: "INTERNAL_ERROR", message } }, 500);
175
+ }
176
+ };
177
+ }
178
+ function summarizeRequests(snapshot) {
179
+ const allRequests = Object.values(snapshot).flat().sort((a, b) => b.createdAt - a.createdAt);
180
+ return {
181
+ queued: snapshot.queued.length,
182
+ running: snapshot.running.length,
183
+ timed_out: snapshot.timed_out.length,
184
+ cancelled: snapshot.cancelled.length,
185
+ latestRequestIds: allRequests.slice(0, 5).map((request) => request.requestId),
186
+ };
187
+ }
188
+ function isAuthorized(authorizationHeader, authToken) {
189
+ if (!authToken)
190
+ return true;
191
+ const expected = Buffer.from(`Bearer ${authToken}`);
192
+ const actual = Buffer.from(authorizationHeader ?? "");
193
+ return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected);
194
+ }
195
+ async function startNodeFallbackServer(fetchHandler, host, port) {
196
+ const server = http.createServer(async (incoming, outgoing) => {
197
+ try {
198
+ const body = await readNodeBody(incoming, JSON_BODY_LIMIT_BYTES);
199
+ const request = new Request(`http://${host}${incoming.url ?? "/"}`, {
200
+ method: incoming.method ?? "GET",
201
+ headers: incoming.headers,
202
+ body: body.length > 0 ? Buffer.from(body) : undefined,
203
+ });
204
+ const response = await fetchHandler(request);
205
+ await writeNodeResponse(outgoing, response);
206
+ }
207
+ catch (error) {
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ if (message === "PAYLOAD_TOO_LARGE") {
210
+ await writeNodeResponse(outgoing, jsonResponse({ error: { code: "PAYLOAD_TOO_LARGE" } }, 413));
211
+ return;
212
+ }
213
+ await writeNodeResponse(outgoing, jsonResponse({ error: { code: "INTERNAL_ERROR", message } }, 500));
214
+ }
215
+ });
216
+ await new Promise((resolve, reject) => {
217
+ server.once("error", reject);
218
+ server.listen(port, host, () => {
219
+ server.off("error", reject);
220
+ resolve();
221
+ });
222
+ });
223
+ const address = server.address();
224
+ return {
225
+ port: address?.port ?? port,
226
+ stop: async () => {
227
+ await new Promise((resolve, reject) => {
228
+ server.close((error) => (error ? reject(error) : resolve()));
229
+ });
230
+ },
231
+ };
232
+ }
233
+ async function readNodeBody(request, limitBytes) {
234
+ const chunks = [];
235
+ let size = 0;
236
+ for await (const chunk of request) {
237
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
238
+ size += buffer.length;
239
+ if (size > limitBytes) {
240
+ throw new Error("PAYLOAD_TOO_LARGE");
241
+ }
242
+ chunks.push(buffer);
243
+ }
244
+ return Buffer.concat(chunks);
245
+ }
246
+ async function writeNodeResponse(response, result) {
247
+ const headers = {};
248
+ result.headers.forEach((value, key) => {
249
+ headers[key] = value;
250
+ });
251
+ const body = Buffer.from(await result.arrayBuffer());
252
+ response.writeHead(result.status, headers);
253
+ response.end(body);
254
+ }
255
+ async function readJsonObjectBody(request) {
256
+ const text = await readLimitedRequestText(request, JSON_BODY_LIMIT_BYTES);
257
+ if (!text.trim())
258
+ throw new Error("BAD_REQUEST");
259
+ let parsed;
260
+ try {
261
+ parsed = JSON.parse(text);
262
+ }
263
+ catch {
264
+ throw new Error("MALFORMED_JSON");
265
+ }
266
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
267
+ throw new Error("BAD_REQUEST");
268
+ }
269
+ return parsed;
270
+ }
271
+ async function readLimitedRequestText(request, limitBytes) {
272
+ const body = request.body;
273
+ if (!body)
274
+ return "";
275
+ const reader = body.getReader();
276
+ const decoder = new TextDecoder();
277
+ const parts = [];
278
+ let size = 0;
279
+ try {
280
+ while (true) {
281
+ const { done, value } = await reader.read();
282
+ if (done)
283
+ break;
284
+ if (!value)
285
+ continue;
286
+ size += value.byteLength;
287
+ if (size > limitBytes) {
288
+ throw new Error("PAYLOAD_TOO_LARGE");
289
+ }
290
+ parts.push(decoder.decode(value, { stream: true }));
291
+ }
292
+ parts.push(decoder.decode());
293
+ return parts.join("");
294
+ }
295
+ finally {
296
+ reader.releaseLock();
297
+ }
298
+ }
299
+ function readRequiredString(value, fieldName) {
300
+ const text = readOptionalString(value);
301
+ if (!text)
302
+ throw new Error("BAD_REQUEST");
303
+ return text;
304
+ }
305
+ function readRequiredPossiblyEmptyString(value) {
306
+ if (typeof value !== "string")
307
+ throw new Error("BAD_REQUEST");
308
+ return value.trim();
309
+ }
310
+ function readOptionalString(value) {
311
+ if (typeof value !== "string")
312
+ return undefined;
313
+ const text = value.trim();
314
+ return text ? text : undefined;
315
+ }
316
+ function readOptionalNumber(value) {
317
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
318
+ }
319
+ function readPermissions(value) {
320
+ if (typeof value !== "object" || value === null || Array.isArray(value))
321
+ throw new Error("BAD_REQUEST");
322
+ const record = value;
323
+ const keys = ["ReadNotebook", "InsertCell", "ModifyCell", "DeleteCell", "RunCell", "SaveNotebook"];
324
+ const permissions = {};
325
+ for (const key of keys) {
326
+ if (typeof record[key] !== "boolean")
327
+ throw new Error("BAD_REQUEST");
328
+ permissions[key] = record[key];
329
+ }
330
+ return permissions;
331
+ }
332
+ function noLiveAgentPayload(agent) {
333
+ const error = { code: "NO_LIVE_AGENT" };
334
+ if (agent?.retiredReason)
335
+ error.reason = agent.retiredReason;
336
+ return { error };
337
+ }
338
+ function jsonResponse(payload, status = 200) {
339
+ return new Response(escapeJsonForWolfram(JSON.stringify(payload)), {
340
+ status,
341
+ headers: { "content-type": "application/json; charset=utf-8" },
342
+ });
343
+ }
344
+ function escapeJsonForWolfram(json) {
345
+ return json.replace(/[^\x00-\x7F]/g, (character) => {
346
+ return [...character]
347
+ .map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`)
348
+ .join("");
349
+ });
350
+ }
351
+ function htmlResponse(body) {
352
+ return new Response(body, {
353
+ status: 200,
354
+ headers: { "content-type": "text/html; charset=utf-8" },
355
+ });
356
+ }
@@ -0,0 +1,91 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { pathToFileURL } from "node:url";
4
+ import { BackendState } from "../backend/backendState.js";
5
+ import { registerBackendMcpTools } from "../mcp/backendTools.js";
6
+ import { createMicaMcpServer, registerMicaPrompts } from "../mcp/prompts.js";
7
+ import { loadRuntimeConfig } from "../runtime/config.js";
8
+ import { writeSessionFile } from "../runtime/session.js";
9
+ import { createBunHttpApp } from "./httpServer.js";
10
+ const MCP_SERVER_NAME = "mica-bun";
11
+ const MICA_PACKAGE_VERSION = "0.1.0";
12
+ export async function startBunRuntime(deps = {}) {
13
+ const config = deps.runtimeConfig ?? loadRuntimeConfig();
14
+ const bridgeOnly = deps.bridgeOnly ?? config.bridgeOnly;
15
+ const state = deps.state ?? new BackendState(() => `notebook-${randomUUID()}`);
16
+ const createHttpApp = deps.createHttpApp ?? createBunHttpApp;
17
+ const createMcpServer = deps.createMcpServer ?? (() => createMicaMcpServer(MCP_SERVER_NAME));
18
+ const createTransport = deps.createTransport ?? (() => new StdioServerTransport());
19
+ const writeSession = deps.writeSessionFile ?? writeSessionFile;
20
+ const version = deps.version ?? MICA_PACKAGE_VERSION;
21
+ const installSignalHandlers = deps.installSignalHandlers ??
22
+ ((onSignal) => {
23
+ process.once("SIGINT", onSignal);
24
+ process.once("SIGTERM", onSignal);
25
+ return () => {
26
+ process.off("SIGINT", onSignal);
27
+ process.off("SIGTERM", onSignal);
28
+ };
29
+ });
30
+ const httpApp = await createHttpApp({ state, host: config.host, port: config.preferredPort, authToken: config.authToken, version });
31
+ let cleanupSignals = () => { };
32
+ let stopped = false;
33
+ let stopPromise;
34
+ const stop = async () => {
35
+ if (stopPromise)
36
+ return stopPromise;
37
+ stopPromise = (async () => {
38
+ if (stopped)
39
+ return;
40
+ stopped = true;
41
+ cleanupSignals();
42
+ await httpApp.stop();
43
+ })();
44
+ return stopPromise;
45
+ };
46
+ const onSignal = (signal) => {
47
+ void stop().finally(() => process.exit(0));
48
+ };
49
+ try {
50
+ cleanupSignals = installSignalHandlers(onSignal);
51
+ await writeSession(config.sessionFile, {
52
+ host: config.host,
53
+ port: httpApp.port,
54
+ authToken: config.authToken,
55
+ pid: process.pid,
56
+ version,
57
+ status: "running",
58
+ });
59
+ const server = createMcpServer();
60
+ registerBackendMcpTools(server, state);
61
+ registerMicaPrompts(server);
62
+ console.error(`Bun HTTP server listening on http://${config.host}:${httpApp.port}`);
63
+ console.error(`Dashboard: http://${config.host}:${httpApp.port}/#token=${config.authToken}`);
64
+ if (!bridgeOnly) {
65
+ console.error("Bun MCP mode enabled; connecting stdio transport.");
66
+ await server.connect(createTransport());
67
+ }
68
+ }
69
+ catch (error) {
70
+ await stop();
71
+ throw error;
72
+ }
73
+ return {
74
+ state,
75
+ httpApp,
76
+ stop,
77
+ keepAlive: new Promise(() => { })
78
+ };
79
+ }
80
+ async function main() {
81
+ const runtime = await startBunRuntime();
82
+ await runtime.keepAlive;
83
+ }
84
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
85
+ main().catch((error) => {
86
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
87
+ console.error(message);
88
+ process.exitCode = 1;
89
+ });
90
+ }
91
+ export { MCP_SERVER_NAME };
@@ -0,0 +1,235 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { defaultSessionFile } from "../runtime/config.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Constants
6
+ // ---------------------------------------------------------------------------
7
+ const AUTOLOAD_MARKER = "(* BEGIN MICA control-kernel autoload *)";
8
+ // ---------------------------------------------------------------------------
9
+ // runDoctor
10
+ // ---------------------------------------------------------------------------
11
+ export async function runDoctor(deps = {}) {
12
+ const lines = [];
13
+ let hasFailure = false;
14
+ const projectRoot = deps.projectRoot ?? process.cwd();
15
+ const env = deps.env ?? process.env;
16
+ const nodeVersion = deps.nodeVersion ?? process.version;
17
+ const _exists = deps.exists ?? existsSync;
18
+ const _readFile = deps.readFile ?? ((p) => readFileSync(p, "utf8"));
19
+ const _fetch = deps.fetch ??
20
+ (typeof globalThis.fetch === "function"
21
+ ? (url, init) => globalThis.fetch(url, init)
22
+ : undefined);
23
+ const _detectWolframUserBase = deps.detectWolframUserBase;
24
+ const ok = (label, detail) => {
25
+ const d = detail !== undefined ? `: ${detail}` : "";
26
+ lines.push(`OK ${label}${d}`);
27
+ };
28
+ const fail = (label, detail) => {
29
+ hasFailure = true;
30
+ const d = detail !== undefined ? `: ${detail}` : "";
31
+ lines.push(`FAIL ${label}${d}`);
32
+ };
33
+ const fix = (msg) => {
34
+ lines.push(`FIX ${msg}`);
35
+ };
36
+ // -----------------------------------------------------------------------
37
+ // Header
38
+ // -----------------------------------------------------------------------
39
+ lines.push("MICA doctor");
40
+ lines.push("");
41
+ // -----------------------------------------------------------------------
42
+ // 1. Node version
43
+ // -----------------------------------------------------------------------
44
+ const nodeMajor = Number.parseInt(nodeVersion.replace(/^v/, "").split(".")[0], 10);
45
+ if (Number.isFinite(nodeMajor) && nodeMajor >= 20) {
46
+ ok("Node version", nodeVersion);
47
+ }
48
+ else {
49
+ fail("Node version", `${nodeVersion} (Node >=20 required)`);
50
+ fix("Install Node.js 20 or newer");
51
+ }
52
+ // -----------------------------------------------------------------------
53
+ // 2. Package build
54
+ // -----------------------------------------------------------------------
55
+ const distCliIndex = path.join(projectRoot, "dist", "src", "cli", "index.js");
56
+ const distBunIndex = path.join(projectRoot, "dist", "src", "bun", "index.js");
57
+ const bridgeSource = path.join(projectRoot, "paclet", "Kernel", "MMAAgentBridge.wl");
58
+ const buildOk = _exists(distCliIndex) && _exists(distBunIndex);
59
+ if (buildOk) {
60
+ ok("Package build");
61
+ }
62
+ else {
63
+ const missing = [];
64
+ if (!_exists(distCliIndex))
65
+ missing.push("dist/src/cli/index.js");
66
+ if (!_exists(distBunIndex))
67
+ missing.push("dist/src/bun/index.js");
68
+ fail("Package build", `missing: ${missing.join(", ")}`);
69
+ fix("Run: npm run build");
70
+ }
71
+ if (_exists(bridgeSource)) {
72
+ ok("Bridge source path", bridgeSource);
73
+ }
74
+ else {
75
+ fail("Bridge source path", `${bridgeSource} (not found)`);
76
+ fix("Run: npm run build");
77
+ }
78
+ // -----------------------------------------------------------------------
79
+ // 3. Session file
80
+ // -----------------------------------------------------------------------
81
+ const sessionFile = env.MICA_SESSION_FILE ?? defaultSessionFile(env);
82
+ let sessionData = null;
83
+ let sessionBaseUrl;
84
+ if (!_exists(sessionFile)) {
85
+ fail("Session file", `${sessionFile} (not found)`);
86
+ fix("Run: mica start");
87
+ }
88
+ else {
89
+ try {
90
+ const raw = _readFile(sessionFile);
91
+ sessionData = JSON.parse(raw);
92
+ sessionBaseUrl =
93
+ sessionData?.baseUrl ??
94
+ `http://${sessionData?.host ?? "127.0.0.1"}:${sessionData?.port ?? 19791}`;
95
+ ok("Session file", sessionFile);
96
+ ok("Session target", sessionBaseUrl);
97
+ }
98
+ catch (e) {
99
+ fail("Session file", `${sessionFile} (${e instanceof Error ? e.message : String(e)})`);
100
+ fix("Run: mica start");
101
+ }
102
+ }
103
+ // -----------------------------------------------------------------------
104
+ // 4. Auth token
105
+ // -----------------------------------------------------------------------
106
+ if (sessionData && !sessionData.authToken) {
107
+ fail("Auth token", "missing in session file");
108
+ }
109
+ else if (!sessionData) {
110
+ fail("Auth token", "session file not available");
111
+ }
112
+ // -----------------------------------------------------------------------
113
+ // 5–7. Server checks (only when session data is available)
114
+ // -----------------------------------------------------------------------
115
+ if (sessionData && sessionData.authToken && _fetch) {
116
+ const statusUrl = `${sessionBaseUrl}/status`;
117
+ try {
118
+ const res = await _fetch(statusUrl, {
119
+ headers: { Authorization: `Bearer ${sessionData.authToken}` },
120
+ });
121
+ if (res.status === 401) {
122
+ fail("Auth token", "401 Unauthorized");
123
+ fail("Server /status reachable", "authentication failed");
124
+ fail("Live agent count", "server not reachable");
125
+ fail("Live notebook count", "server not reachable");
126
+ }
127
+ else if (res.status === 200) {
128
+ ok("Auth token");
129
+ ok("Server /status reachable");
130
+ const body = (await res.json());
131
+ // Live agent count
132
+ let agentCount;
133
+ if (typeof body.agentCount === "number") {
134
+ agentCount = body.agentCount;
135
+ }
136
+ else if (Array.isArray(body.agents)) {
137
+ agentCount = body.agents.filter((a) => a.status !== "offline" && a.status !== "retired").length;
138
+ }
139
+ else {
140
+ agentCount = 0;
141
+ }
142
+ if (agentCount > 0) {
143
+ ok("Live agent count", String(agentCount));
144
+ }
145
+ else {
146
+ fail("Live agent count", "0");
147
+ }
148
+ // Live notebook count
149
+ let notebookCount;
150
+ if (typeof body.notebookCount === "number") {
151
+ notebookCount = body.notebookCount;
152
+ }
153
+ else if (Array.isArray(body.notebooks)) {
154
+ notebookCount = body.notebooks.length;
155
+ }
156
+ else {
157
+ notebookCount = 0;
158
+ }
159
+ if (notebookCount > 0) {
160
+ ok("Live notebook count", String(notebookCount));
161
+ }
162
+ else {
163
+ fail("Live notebook count", "0");
164
+ }
165
+ }
166
+ else {
167
+ ok("Auth token");
168
+ fail("Server /status reachable", `HTTP ${res.status}`);
169
+ fail("Live agent count", "server not reachable");
170
+ fail("Live notebook count", "server not reachable");
171
+ }
172
+ }
173
+ catch (e) {
174
+ fail("Auth token", "server not reachable");
175
+ fail("Server /status reachable", e instanceof Error ? e.message : String(e));
176
+ fix("Run: mica start");
177
+ fail("Live agent count", "server not reachable");
178
+ fail("Live notebook count", "server not reachable");
179
+ }
180
+ }
181
+ else if (sessionData && sessionData.authToken && !_fetch) {
182
+ fail("Auth token", "fetch unavailable");
183
+ fail("Server /status reachable", "fetch unavailable");
184
+ fail("Live agent count", "server not reachable");
185
+ fail("Live notebook count", "server not reachable");
186
+ }
187
+ else {
188
+ // No valid session data
189
+ fail("Server /status reachable", "session file not available");
190
+ fail("Live agent count", "server not reachable");
191
+ fail("Live notebook count", "server not reachable");
192
+ }
193
+ // -----------------------------------------------------------------------
194
+ // 8. Wolfram user base
195
+ // -----------------------------------------------------------------------
196
+ if (_detectWolframUserBase) {
197
+ try {
198
+ const detection = _detectWolframUserBase();
199
+ ok("Wolfram user base", `${detection.userBase} (${detection.source})`);
200
+ // Kernel/init.m
201
+ const initPath = path.join(detection.userBase, "Kernel", "init.m");
202
+ if (_exists(initPath)) {
203
+ ok("Kernel/init.m", initPath);
204
+ // Autoload block
205
+ const content = _readFile(initPath);
206
+ if (content.includes(AUTOLOAD_MARKER)) {
207
+ ok("Autoload block");
208
+ }
209
+ else {
210
+ fail("Autoload block", `not found in ${initPath}`);
211
+ fix("Run: mica install");
212
+ }
213
+ }
214
+ else {
215
+ fail("Kernel/init.m", `${initPath} (not found)`);
216
+ fix("Run: mica install");
217
+ fail("Autoload block", "Kernel/init.m not found");
218
+ }
219
+ }
220
+ catch (e) {
221
+ fail("Wolfram user base", e instanceof Error ? e.message : String(e));
222
+ fail("Kernel/init.m", "Wolfram user base detection failed");
223
+ fail("Autoload block", "Wolfram user base detection failed");
224
+ }
225
+ }
226
+ else {
227
+ fail("Wolfram user base", "detector unavailable");
228
+ fail("Kernel/init.m", "Wolfram user base detection failed");
229
+ fail("Autoload block", "Wolfram user base detection failed");
230
+ }
231
+ // -----------------------------------------------------------------------
232
+ // Result
233
+ // -----------------------------------------------------------------------
234
+ return { exitCode: hasFailure ? 1 : 0, output: `${lines.join("\n")}\n` };
235
+ }