@classcad/mcp 0.1.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.
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/dist/auth.d.ts +32 -0
- package/dist/auth.js +272 -0
- package/dist/auth.js.map +1 -0
- package/dist/bridge/protocol.d.ts +87 -0
- package/dist/bridge/protocol.js +14 -0
- package/dist/bridge/protocol.js.map +1 -0
- package/dist/bridge/server.d.ts +35 -0
- package/dist/bridge/server.js +236 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/client.d.ts +98 -0
- package/dist/client.js +879 -0
- package/dist/client.js.map +1 -0
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +390 -0
- package/dist/daemon.js.map +1 -0
- package/dist/engine/wasm-worker.d.ts +1 -0
- package/dist/engine/wasm-worker.js +176 -0
- package/dist/engine/wasm-worker.js.map +1 -0
- package/dist/engine/wasm.d.ts +60 -0
- package/dist/engine/wasm.js +212 -0
- package/dist/engine/wasm.js.map +1 -0
- package/dist/mcp-server.d.ts +23 -0
- package/dist/mcp-server.js +141 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/ports.d.ts +2 -0
- package/dist/ports.js +5 -0
- package/dist/ports.js.map +1 -0
- package/dist/queue.d.ts +9 -0
- package/dist/queue.js +50 -0
- package/dist/queue.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +423 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/auth.d.ts +7 -0
- package/dist/tools/auth.js +63 -0
- package/dist/tools/auth.js.map +1 -0
- package/dist/tools/bridge.d.ts +4 -0
- package/dist/tools/bridge.js +115 -0
- package/dist/tools/bridge.js.map +1 -0
- package/dist/tools/docs.d.ts +13 -0
- package/dist/tools/docs.js +180 -0
- package/dist/tools/docs.js.map +1 -0
- package/dist/tools/lifecycle.d.ts +3 -0
- package/dist/tools/lifecycle.js +156 -0
- package/dist/tools/lifecycle.js.map +1 -0
- package/dist/tools/script.d.ts +3 -0
- package/dist/tools/script.js +102 -0
- package/dist/tools/script.js.map +1 -0
- package/dist/tools/snapshot.d.ts +3 -0
- package/dist/tools/snapshot.js +174 -0
- package/dist/tools/snapshot.js.map +1 -0
- package/dist/tools/state.d.ts +3 -0
- package/dist/tools/state.js +95 -0
- package/dist/tools/state.js.map +1 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +44 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
import { PendingRequests, completeGraphic, normalizeResult, withEmissionOverride } from '@classcad/script';
|
|
2
|
+
// client.ts — ClassCAD WebSocket client (TS port of scripts/client.mjs).
|
|
3
|
+
//
|
|
4
|
+
// Connects to a Drogon WS server and exposes promise-based execute()/request()
|
|
5
|
+
// helpers plus a cached structure tree / graphic that MCP tools read.
|
|
6
|
+
//
|
|
7
|
+
// ── Emission model (per CONNECTION, suppression per SCRIPT) ──────────────────
|
|
8
|
+
// The engine keeps ONE CommandConfig per connection (a per-request `config`
|
|
9
|
+
// field is ignored). This client does NOT touch it on connect: the MCP may
|
|
10
|
+
// dock into a session shared with an interactive app (invite URL), and the
|
|
11
|
+
// server broadcasts what the ORIGINATOR of a command emits - a permanently
|
|
12
|
+
// suppressed MCP would mutate the model without the app ever seeing it. So
|
|
13
|
+
// the connection runs with the engine's defaults (full content, bundled);
|
|
14
|
+
// run_script suppresses payloads for the duration of ONE script (runScript
|
|
15
|
+
// in @classcad/script reads, switches, restores) and pulls once afterwards
|
|
16
|
+
// so the app and the caches converge.
|
|
17
|
+
// • pull() → GetTree. The structure ALWAYS comes back (GetTree is an
|
|
18
|
+
// explicit request); inside a suppressed script the pull switches
|
|
19
|
+
// sendGraphic_Kernel on around it (PULL_GRAPHIC_ON) and back.
|
|
20
|
+
// Caches: a mutation counter makes them self-invalidating; a Result that
|
|
21
|
+
// carries the structure keeps the tree cache current without a pull, the
|
|
22
|
+
// complete graphic only ever comes from a pull.
|
|
23
|
+
//
|
|
24
|
+
// Supports reconnecting with a different ClassCAD-Session-Id header at runtime
|
|
25
|
+
// via reconnect(sessionId) — used by the use_session MCP tool.
|
|
26
|
+
//
|
|
27
|
+
// ── Three engines, one client ────────────────────────────────────────────────
|
|
28
|
+
// ws a ClassCAD worker (Drogon) over WebSocket - own session or a shared
|
|
29
|
+
// one (?invite=). The default.
|
|
30
|
+
// bridge an app's in-page WASM engine reached through the bridge listener
|
|
31
|
+
// (?bridge= share link); commands are relayed to the page.
|
|
32
|
+
// wasm the MCP's OWN engine: the published WASM build hosted in a worker
|
|
33
|
+
// thread of this process (engine/wasm.ts). Needs a key.
|
|
34
|
+
// Which one is used: a token/URL decides by itself (invite → ws, bridge →
|
|
35
|
+
// bridge). Without one the ENGINE POLICY applies (opts.engine / use_session's
|
|
36
|
+
// engine argument): 'auto' tries the worker first and falls back to the local
|
|
37
|
+
// WASM when the worker is not reachable; 'drogon' insists on the worker;
|
|
38
|
+
// 'wasm' goes local right away. The replies of both WASM paths have the same
|
|
39
|
+
// shape (messages + binary packages) and share one delivery path.
|
|
40
|
+
import WebSocket from 'ws';
|
|
41
|
+
import { randomUUID } from 'crypto';
|
|
42
|
+
import { connect as tcpConnect } from 'net';
|
|
43
|
+
import { startLocalEngine } from './engine/wasm.js';
|
|
44
|
+
const DEFAULT_URL = 'ws://0.0.0.0:9094/';
|
|
45
|
+
const REQUEST_TIMEOUT = 30_000;
|
|
46
|
+
const CONNECT_TIMEOUT = 5_000;
|
|
47
|
+
/** How often an 'auto' session that fell back to WASM looks for a worker again (ms). */
|
|
48
|
+
const WORKER_PROBE_MS = 3_000;
|
|
49
|
+
/** True when something accepts TCP connections at the worker URL's host:port. */
|
|
50
|
+
function workerListening(url) {
|
|
51
|
+
let host;
|
|
52
|
+
let port;
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(url);
|
|
55
|
+
host = u.hostname.replace(/^\[|\]$/g, '');
|
|
56
|
+
port = Number(u.port || (u.protocol === 'wss:' ? 443 : 80));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return Promise.resolve(false);
|
|
60
|
+
}
|
|
61
|
+
return new Promise(resolve => {
|
|
62
|
+
const sock = tcpConnect({ host, port });
|
|
63
|
+
const done = (ok) => {
|
|
64
|
+
sock.destroy();
|
|
65
|
+
resolve(ok);
|
|
66
|
+
};
|
|
67
|
+
sock.once('connect', () => done(true));
|
|
68
|
+
sock.once('error', () => done(false));
|
|
69
|
+
sock.setTimeout(500, () => done(false));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Around a graphic pull inside a suppressed script: kernel graphics only —
|
|
74
|
+
* the content the renderer contract has always been built on. Switched back
|
|
75
|
+
* right after the GetTree. (The suppression profile itself lives in
|
|
76
|
+
* @classcad/script's emission.ts and is applied by runScript.)
|
|
77
|
+
*/
|
|
78
|
+
export const PULL_GRAPHIC_ON = { sendGraphic_Kernel: true };
|
|
79
|
+
/**
|
|
80
|
+
* This release does not hand out OFB (the native, parametric format): the
|
|
81
|
+
* commands that serialize to OFB — common.save and assembly.exportNode, whose
|
|
82
|
+
* default format IS OFB — are refused whether the data would be returned,
|
|
83
|
+
* written to a file or sent to a url. STEP/STL exports and loading OFB stay.
|
|
84
|
+
* Returns the refusal message, or null when the task may run.
|
|
85
|
+
*/
|
|
86
|
+
export function ofbExportRefusal(task) {
|
|
87
|
+
for (const [command, args] of Object.entries(task)) {
|
|
88
|
+
if (command !== 'v1.common.save' && command !== 'v1.assembly.exportNode')
|
|
89
|
+
continue;
|
|
90
|
+
const params = (Array.isArray(args) ? args[0] : args);
|
|
91
|
+
const fromFile = typeof params?.file === 'string' && /\.[a-z0-9]+$/i.test(params.file) ? params.file.split('.').pop() : undefined;
|
|
92
|
+
const format = String(params?.format ?? fromFile ?? 'OFB').toUpperCase();
|
|
93
|
+
if (format === 'OFB')
|
|
94
|
+
return `${command.slice(3)}: OFB export is not available in this release of the ClassCAD MCP. Export STEP (format: "STP") or STL instead; checkpoint/restore still work for rollback.`;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
export async function connect(url = DEFAULT_URL, opts = {}) {
|
|
99
|
+
const graphics = opts.graphics !== false;
|
|
100
|
+
const debug = opts.debug === true;
|
|
101
|
+
let outcomeUnknown = false;
|
|
102
|
+
const pending = new PendingRequests();
|
|
103
|
+
// The URL is used VERBATIM — it may carry a multi-client token/invite query
|
|
104
|
+
// (e.g. wss://host/?invite=…), in which case the server itself decides the
|
|
105
|
+
// session. currentUrl is mutable (reconnectUrl switches servers at runtime);
|
|
106
|
+
// baseUrl is the configured worker — session-id reconnects always return to it.
|
|
107
|
+
const baseUrl = url;
|
|
108
|
+
let generation = 0;
|
|
109
|
+
let currentUrl = url;
|
|
110
|
+
let lastGraphic = null;
|
|
111
|
+
let lastStructure = null;
|
|
112
|
+
// Cache bookkeeping: `version` bumps on every tracked request. treeVersion
|
|
113
|
+
// is the version whose structure the tree cache holds (fed by every Result
|
|
114
|
+
// that carries a structure snapshot - full emission - or by a pull);
|
|
115
|
+
// graphicVersion the one the graphic cache holds (fed by pulls only).
|
|
116
|
+
// Equal to `version` → current → no round trip.
|
|
117
|
+
let version = 0;
|
|
118
|
+
let treeVersion = -1;
|
|
119
|
+
let graphicVersion = -1;
|
|
120
|
+
// Last known sendGraphic_Kernel of the connection (from every config echo);
|
|
121
|
+
// null = never asked (engine default: on). pull() uses it to decide
|
|
122
|
+
// whether the kernel graphic must be switched on for the GetTree.
|
|
123
|
+
let knownKernel = null;
|
|
124
|
+
let ws = undefined;
|
|
125
|
+
// Tracks the *intended* session id. Starts as the value passed to connect()
|
|
126
|
+
// (typically null) and is updated by reconnect(). It only becomes the
|
|
127
|
+
// actual session id of the worker connection after openWs() succeeds.
|
|
128
|
+
let currentSessionId = opts.sessionId ?? null;
|
|
129
|
+
// Single-flight guard so concurrent tool calls share one open attempt.
|
|
130
|
+
let connectPromise = null;
|
|
131
|
+
// ── Bridge transport (in-app engines) ──
|
|
132
|
+
// Instead of a WebSocket to a worker, commands go to the app that announced
|
|
133
|
+
// `bridgeToken` on the bridge listener; the app runs them on its WASM engine
|
|
134
|
+
// and returns everything the engine emitted. The replies are normalized to
|
|
135
|
+
// the worker's frame shape and fed through handleFrame like WS frames.
|
|
136
|
+
let transport = 'ws';
|
|
137
|
+
let bridgeConn = null;
|
|
138
|
+
// ── Local WASM engine (engine/wasm.ts) ──
|
|
139
|
+
// Started on first use under the 'wasm' policy (or 'auto' with the worker
|
|
140
|
+
// unreachable); kept for the client's lifetime — a restart costs seconds
|
|
141
|
+
// and there is no other session to switch to. close() terminates it.
|
|
142
|
+
let localEngine = null;
|
|
143
|
+
let policy = opts.engine ?? 'auto';
|
|
144
|
+
// 'auto' landed on the local engine because no worker answered. The order is
|
|
145
|
+
// token > worker > WASM, so a worker that comes up later takes over — while
|
|
146
|
+
// the drawing is still empty; after that, switching would lose the model.
|
|
147
|
+
let autoFallback = false;
|
|
148
|
+
let lastWorkerProbe = 0;
|
|
149
|
+
let workerWaitingLogged = false;
|
|
150
|
+
let probing = false;
|
|
151
|
+
const wasmAvailable = () => !!opts.wasm?.key;
|
|
152
|
+
const log = opts.log ?? (() => { });
|
|
153
|
+
let bridgeToken = null;
|
|
154
|
+
let bridgeUnsubClose = null;
|
|
155
|
+
let bridgePeerId = null;
|
|
156
|
+
// The invite token of a server session joined via reconnectUrl (?invite=…).
|
|
157
|
+
let inviteToken = null;
|
|
158
|
+
// In-app engines emit graphic as binary packages per command (no bundled
|
|
159
|
+
// graphic on GetTree). Accumulate containers by id so a pull can hand the
|
|
160
|
+
// renderer the complete picture; the renderer drops consumed bodies itself.
|
|
161
|
+
const bridgeContainers = new Map();
|
|
162
|
+
function send(obj) {
|
|
163
|
+
if (transport === 'bridge' || transport === 'wasm') {
|
|
164
|
+
sendViaEngine(obj);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!ws)
|
|
168
|
+
throw new Error('WebSocket not open');
|
|
169
|
+
ws.send(JSON.stringify(obj));
|
|
170
|
+
}
|
|
171
|
+
/** Bridge and local WASM: one request → everything the engine emitted for it. */
|
|
172
|
+
function sendViaEngine(req) {
|
|
173
|
+
const txId = String(req.transactionID);
|
|
174
|
+
let run;
|
|
175
|
+
if (transport === 'wasm') {
|
|
176
|
+
if (!localEngine) {
|
|
177
|
+
const entry = pending.get(txId);
|
|
178
|
+
pending.delete(txId);
|
|
179
|
+
entry?.reject(new Error('local WASM engine is not running'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
run = localEngine.execute(req);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const conn = bridgeConn;
|
|
186
|
+
if (!conn) {
|
|
187
|
+
const entry = pending.get(txId);
|
|
188
|
+
pending.delete(txId);
|
|
189
|
+
entry?.reject(new Error('bridge not attached — call use_session with the app\'s share link'));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
run = conn.request('engine.execute', req);
|
|
193
|
+
}
|
|
194
|
+
run.then(res => deliverEngineResult(req, res), err => {
|
|
195
|
+
const entry = pending.get(txId);
|
|
196
|
+
if (entry) {
|
|
197
|
+
pending.delete(txId);
|
|
198
|
+
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/** Turns an engine reply (bridge or local WASM) into worker-shaped Result frames and delivers them. */
|
|
203
|
+
function deliverEngineResult(req, res) {
|
|
204
|
+
const txId = String(req.transactionID);
|
|
205
|
+
const fail = (why) => {
|
|
206
|
+
const entry = pending.get(txId);
|
|
207
|
+
if (entry) {
|
|
208
|
+
pending.delete(txId);
|
|
209
|
+
entry.reject(new Error(why));
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
if (res.decodeErrors?.length)
|
|
213
|
+
log(`${String(req.command)}: dropped undecodable engine output (${res.decodeErrors.join('; ')})`);
|
|
214
|
+
for (const pkg of res.binaryMessages ?? []) {
|
|
215
|
+
for (const c of pkg?.containers ?? []) {
|
|
216
|
+
if (c && c.id != null)
|
|
217
|
+
bridgeContainers.set(String(c.id), c);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Errors the engine reported on the side (ErrorMessage frames, errorState 2).
|
|
221
|
+
const sideErrors = (res.messages ?? [])
|
|
222
|
+
.filter(m => m?.command === 'ErrorMessage' && Number(m.attributes?.errorState) >= 2)
|
|
223
|
+
.map(m => ({ level: 51, levelStr: 'ERROR', code: m.attributes?.errorCode ?? 0, message: String(m.attributes?.errorMessage ?? 'engine error') }));
|
|
224
|
+
let delivered = false;
|
|
225
|
+
for (const m of res.messages ?? []) {
|
|
226
|
+
if (m.command !== 'Result')
|
|
227
|
+
continue;
|
|
228
|
+
const frame = {
|
|
229
|
+
...m,
|
|
230
|
+
_from_: m._from_ ?? m.from ?? req.command,
|
|
231
|
+
_transactionID_: m._transactionID_ ?? m.transactionID ?? req.transactionID,
|
|
232
|
+
};
|
|
233
|
+
if (frame._from_ === 'Execute') {
|
|
234
|
+
// Every Execute Result carries a value (null included) or a level. One
|
|
235
|
+
// with neither is not an answer — never let it pass as a success.
|
|
236
|
+
if (!('result' in frame) && frame.maxLevel == null) {
|
|
237
|
+
fail(`engine returned an empty Result for ${describeRequest(req)}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// An error reported only as ErrorMessage must not turn into an empty success.
|
|
241
|
+
const n = normalizeResult(frame);
|
|
242
|
+
if (sideErrors.length && (n.maxLevel ?? 0) < 51 && n.result == null) {
|
|
243
|
+
frame.maxLevel = 51;
|
|
244
|
+
frame.messages = [...(Array.isArray(frame.messages) ? frame.messages : []), ...sideErrors];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (frame._from_ === 'GetTree' || frame._from_ === 'Sync') {
|
|
248
|
+
// Legacy engines put the structure into `result`; the WS worker puts it into `structure`.
|
|
249
|
+
if (!frame.structure && frame.result && typeof frame.result === 'object' && 'tree' in frame.result) {
|
|
250
|
+
frame.structure = frame.result;
|
|
251
|
+
delete frame.result;
|
|
252
|
+
}
|
|
253
|
+
// A pull delivers the COMPLETE graphic: the accumulated containers.
|
|
254
|
+
frame.graphic = { containers: [...bridgeContainers.values()] };
|
|
255
|
+
}
|
|
256
|
+
delivered = true;
|
|
257
|
+
handleFrame(Buffer.from(JSON.stringify(frame)), false);
|
|
258
|
+
}
|
|
259
|
+
if (!delivered) {
|
|
260
|
+
const detail = [...sideErrors.map(e => e.message), ...(res.decodeErrors ?? [])];
|
|
261
|
+
fail(`engine returned no Result for ${describeRequest(req)}${detail.length ? ` (${detail.join('; ')})` : ''}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function describeRequest(req) {
|
|
265
|
+
const task = Array.isArray(req.task) ? req.task[0] : undefined;
|
|
266
|
+
const method = task ? Object.keys(task)[0] : undefined;
|
|
267
|
+
return method ? `${String(req.command)} ${method}` : String(req.command);
|
|
268
|
+
}
|
|
269
|
+
function detachBridge() {
|
|
270
|
+
if (bridgeUnsubClose)
|
|
271
|
+
bridgeUnsubClose();
|
|
272
|
+
bridgeUnsubClose = null;
|
|
273
|
+
const conn = bridgeConn;
|
|
274
|
+
bridgeConn = null;
|
|
275
|
+
if (conn && bridgePeerId) {
|
|
276
|
+
conn.request('session.detached', { peerId: bridgePeerId }).catch(() => { });
|
|
277
|
+
}
|
|
278
|
+
bridgePeerId = null;
|
|
279
|
+
bridgeToken = null;
|
|
280
|
+
bridgeContainers.clear();
|
|
281
|
+
}
|
|
282
|
+
function handleFrame(data, isBinary) {
|
|
283
|
+
if (isBinary)
|
|
284
|
+
return;
|
|
285
|
+
let frame;
|
|
286
|
+
try {
|
|
287
|
+
frame = JSON.parse(data.toString());
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const txId = frame._transactionID_;
|
|
293
|
+
if (!txId)
|
|
294
|
+
return;
|
|
295
|
+
const entry = pending.get(txId);
|
|
296
|
+
if (!entry)
|
|
297
|
+
return;
|
|
298
|
+
if (frame.command !== 'Result')
|
|
299
|
+
return;
|
|
300
|
+
pending.delete(txId);
|
|
301
|
+
// A pull (GetTree/Sync) delivers the COMPLETE graphic — replace, never
|
|
302
|
+
// merge (a merge would keep containers of deleted bodies alive). Graphic
|
|
303
|
+
// on other Results covers only the changed ids and is ignored here.
|
|
304
|
+
const complete = completeGraphic(frame);
|
|
305
|
+
if (complete.present) {
|
|
306
|
+
lastGraphic = complete.graphic;
|
|
307
|
+
graphicVersion = Math.max(graphicVersion, entry.at);
|
|
308
|
+
}
|
|
309
|
+
// Every structure snapshot is complete (the engine sends the whole tree).
|
|
310
|
+
if (frame.structure && typeof frame.structure === 'object' && !Array.isArray(frame.structure)) {
|
|
311
|
+
lastStructure = frame.structure;
|
|
312
|
+
treeVersion = Math.max(treeVersion, entry.at);
|
|
313
|
+
}
|
|
314
|
+
entry.resolve(normalizeResult(frame));
|
|
315
|
+
}
|
|
316
|
+
async function request(command, extra = {}, o = {}) {
|
|
317
|
+
if (outcomeUnknown)
|
|
318
|
+
throw new Error('Session outcome unknown after request timeout; reconnect before further work');
|
|
319
|
+
await ensureOpen();
|
|
320
|
+
if (o.track !== false)
|
|
321
|
+
version++;
|
|
322
|
+
const transactionID = randomUUID();
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
pending.register(transactionID, { resolve: resolve, reject, at: version }, { command, timeoutMs: debug ? undefined : opts.requestTimeoutMs ?? REQUEST_TIMEOUT, onTimeout: () => {
|
|
325
|
+
// The local engine runs one command at a time on its own thread: one
|
|
326
|
+
// that does not answer is stuck, and everything queued behind it with
|
|
327
|
+
// it. Retire it — the next command starts a new one (empty drawing).
|
|
328
|
+
if (transport === 'wasm' && localEngine && !localEngine.closed) {
|
|
329
|
+
log(`local WASM engine did not answer ${command} within the request timeout — retired; the next command starts a new engine with an empty drawing`);
|
|
330
|
+
localEngine.close(`did not answer ${command} within the request timeout`);
|
|
331
|
+
}
|
|
332
|
+
else
|
|
333
|
+
outcomeUnknown = true;
|
|
334
|
+
} });
|
|
335
|
+
// No emission flags travel with a request — the engine keeps them per
|
|
336
|
+
// connection (see SUPPRESS_EMISSION / setEmissionConfig in openWs).
|
|
337
|
+
try {
|
|
338
|
+
send({ command, commandVersion: 'v1', transactionID, ...extra });
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
pending.delete(transactionID);
|
|
342
|
+
reject(error);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
// Open the WS lazily on first use. Single-flight: concurrent callers share
|
|
347
|
+
// the in-flight open. After a successful open, ws stays defined; close()
|
|
348
|
+
// and reconnect() reset it as needed.
|
|
349
|
+
async function ensureOpen() {
|
|
350
|
+
if (transport === 'bridge') {
|
|
351
|
+
if (bridgeConn)
|
|
352
|
+
return;
|
|
353
|
+
throw new Error('The app behind the share link is gone (bridge closed). Either reopen the share in the app and call use_session with the new link, ' +
|
|
354
|
+
'or leave the app: use_session with engine "wasm" runs on the MCP\'s own local engine, engine "drogon" on the ClassCAD worker, "auto" picks.');
|
|
355
|
+
}
|
|
356
|
+
if (transport === 'wasm' && localEngine?.closed) {
|
|
357
|
+
if (connectPromise)
|
|
358
|
+
return connectPromise;
|
|
359
|
+
connectPromise = restartLocalEngine(localEngine.deathReason ?? 'closed').finally(() => {
|
|
360
|
+
connectPromise = null;
|
|
361
|
+
});
|
|
362
|
+
return connectPromise;
|
|
363
|
+
}
|
|
364
|
+
if (transport === 'wasm' && localEngine) {
|
|
365
|
+
// Not single-flight on purpose: the probe itself reads the tree through
|
|
366
|
+
// request() → ensureOpen(), which must pass straight through (probing).
|
|
367
|
+
if (autoFallback && policy === 'auto' && !probing) {
|
|
368
|
+
probing = true;
|
|
369
|
+
try {
|
|
370
|
+
await preferWorker();
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
probing = false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (transport === 'ws' && ws && ws.readyState === WebSocket.OPEN)
|
|
379
|
+
return;
|
|
380
|
+
if (connectPromise)
|
|
381
|
+
return connectPromise;
|
|
382
|
+
connectPromise = openByPolicy(policy, currentSessionId).finally(() => {
|
|
383
|
+
connectPromise = null;
|
|
384
|
+
});
|
|
385
|
+
return connectPromise;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Opens the engine the policy asks for. 'auto' = the worker, and when it
|
|
389
|
+
* is not reachable (refused/timeout — not a failure AFTER connecting) the
|
|
390
|
+
* local WASM engine, if a key is configured. Explicit session ids only
|
|
391
|
+
* make sense on a worker: with one given, 'auto' does not fall back.
|
|
392
|
+
*/
|
|
393
|
+
async function openByPolicy(how, sessionId) {
|
|
394
|
+
autoFallback = false;
|
|
395
|
+
if (how === 'wasm') {
|
|
396
|
+
if (!wasmAvailable())
|
|
397
|
+
throw new Error('No local WASM engine: set CLASSCAD_WASM_KEY (a ClassCAD key from classcad.ch/user) to let the MCP host the engine itself, or use engine "drogon" with a running ClassCAD worker.');
|
|
398
|
+
await openWasm();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
await openWs(sessionId);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
const unreachable = err instanceof Error && err.connectFailure === true;
|
|
406
|
+
if (how === 'auto' && unreachable && !sessionId && wasmAvailable()) {
|
|
407
|
+
log(`no ClassCAD worker at ${currentUrl} (${err.message || err.code || 'refused'}) — using the local WASM engine until one is up`);
|
|
408
|
+
await openWasm();
|
|
409
|
+
autoFallback = true;
|
|
410
|
+
lastWorkerProbe = Date.now();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (unreachable && how === 'auto' && !wasmAvailable()) {
|
|
414
|
+
throw new Error(`No ClassCAD worker reachable at ${currentUrl} (${err.message}). Start one (classcad-cli worker) or set CLASSCAD_WS_URL — ` +
|
|
415
|
+
'or set CLASSCAD_WASM_KEY so the MCP can host the engine itself (local WASM).');
|
|
416
|
+
}
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The 'auto' fallback, re-checked before commands (throttled): a worker that
|
|
422
|
+
* is listening now takes over an EMPTY local drawing. MCP tool calls run
|
|
423
|
+
* one at a time (queue.ts), so nothing else is mid-command on the engine.
|
|
424
|
+
*/
|
|
425
|
+
async function preferWorker() {
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
if (now - lastWorkerProbe < WORKER_PROBE_MS)
|
|
428
|
+
return;
|
|
429
|
+
lastWorkerProbe = now;
|
|
430
|
+
if (!(await workerListening(currentUrl)))
|
|
431
|
+
return;
|
|
432
|
+
let empty = false;
|
|
433
|
+
try {
|
|
434
|
+
const tree = await getTree({ refresh: true });
|
|
435
|
+
empty = Object.values(tree).every((n) => n?.class === 'AllObjects');
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (!empty) {
|
|
441
|
+
if (!workerWaitingLogged) {
|
|
442
|
+
log(`ClassCAD worker at ${currentUrl} is up, but this session's model lives on the local WASM engine — staying there (use_session engine "drogon" starts fresh on the worker)`);
|
|
443
|
+
workerWaitingLogged = true;
|
|
444
|
+
}
|
|
445
|
+
autoFallback = false;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
await openWs(null);
|
|
450
|
+
autoFallback = false;
|
|
451
|
+
log(`ClassCAD worker at ${currentUrl} is up — moved this (still empty) session from the local WASM engine to the worker`);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
log(`worker at ${currentUrl} listens but did not open (${err.message}) — staying on the local WASM engine`);
|
|
455
|
+
await openWasm();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** Switches this client to the MCP's own WASM engine (started on first use). */
|
|
459
|
+
async function openWasm() {
|
|
460
|
+
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
461
|
+
try {
|
|
462
|
+
ws.close();
|
|
463
|
+
}
|
|
464
|
+
catch { }
|
|
465
|
+
ws = undefined;
|
|
466
|
+
}
|
|
467
|
+
if (transport === 'bridge')
|
|
468
|
+
detachBridge();
|
|
469
|
+
resetCaches();
|
|
470
|
+
transport = 'wasm';
|
|
471
|
+
currentSessionId = null;
|
|
472
|
+
inviteToken = null;
|
|
473
|
+
// A reused engine (use_session, the 'auto' fallback) must still answer;
|
|
474
|
+
// a dead or wedged one is replaced by a new engine (empty drawing).
|
|
475
|
+
if (localEngine && (localEngine.closed || !(await localEngine.ping()))) {
|
|
476
|
+
const why = localEngine.deathReason ?? 'did not answer a health check';
|
|
477
|
+
log(`local WASM engine ${why} — starting a new one (the drawing is lost)`);
|
|
478
|
+
localEngine.close(why);
|
|
479
|
+
localEngine = null;
|
|
480
|
+
}
|
|
481
|
+
if (!localEngine) {
|
|
482
|
+
localEngine = await startLocalEngine({ ...opts.wasm, log });
|
|
483
|
+
// A new engine has default database settings. Checkpoints (save
|
|
484
|
+
// payloads held here) stay valid: restore loads them into this one.
|
|
485
|
+
ensuredGeneration = -1;
|
|
486
|
+
await bootstrapSession();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Replaces a retired local engine (crash, trap, timeout) with a new one. */
|
|
490
|
+
async function restartLocalEngine(why) {
|
|
491
|
+
log(`local WASM engine is gone (${why}) — starting a new one with an empty drawing`);
|
|
492
|
+
localEngine?.close(why);
|
|
493
|
+
localEngine = null;
|
|
494
|
+
await openWasm();
|
|
495
|
+
}
|
|
496
|
+
function execute(task, opts = {}) {
|
|
497
|
+
const refused = opts.internalOfb ? null : ofbExportRefusal(task);
|
|
498
|
+
if (refused)
|
|
499
|
+
return Promise.reject(new Error(refused));
|
|
500
|
+
return request('Execute', { task: [task], options: { undoable: false } });
|
|
501
|
+
}
|
|
502
|
+
function close() {
|
|
503
|
+
if (transport === 'bridge')
|
|
504
|
+
detachBridge();
|
|
505
|
+
if (ws && ws.readyState <= WebSocket.OPEN)
|
|
506
|
+
ws.close();
|
|
507
|
+
if (localEngine) {
|
|
508
|
+
localEngine.close();
|
|
509
|
+
localEngine = null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function getLastGraphic() {
|
|
513
|
+
return lastGraphic;
|
|
514
|
+
}
|
|
515
|
+
function getStructure() {
|
|
516
|
+
return lastStructure;
|
|
517
|
+
}
|
|
518
|
+
// Per-connection emission config (untracked: it changes what the engine
|
|
519
|
+
// SENDS, not the model). The reply carries the effective flags in `result`;
|
|
520
|
+
// an engine without the commands answers without `result`. Nothing is set
|
|
521
|
+
// on connect - run_script scopes the suppression to a script.
|
|
522
|
+
function rememberKernel(cfg) {
|
|
523
|
+
if (cfg && typeof cfg.sendGraphic_Kernel === 'boolean')
|
|
524
|
+
knownKernel = cfg.sendGraphic_Kernel;
|
|
525
|
+
return cfg;
|
|
526
|
+
}
|
|
527
|
+
async function getEmissionConfig() {
|
|
528
|
+
return rememberKernel((await request('GetEmissionConfig', {}, { track: false })).result);
|
|
529
|
+
}
|
|
530
|
+
async function setEmissionConfig(partial) {
|
|
531
|
+
return rememberKernel((await request('SetEmissionConfig', { config: partial }, { track: false })).result);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Fill BOTH caches with one GetTree. Not tracked — it is not a mutation.
|
|
535
|
+
* GetTree always returns the structure; inside a suppressed script the
|
|
536
|
+
* kernel graphic is switched on around it and back to what it was.
|
|
537
|
+
*/
|
|
538
|
+
async function pull() {
|
|
539
|
+
const at = version;
|
|
540
|
+
const toggle = graphics && knownKernel === false;
|
|
541
|
+
const read = async () => {
|
|
542
|
+
await request('GetTree', {}, { track: false });
|
|
543
|
+
graphicVersion = Math.max(graphicVersion, at);
|
|
544
|
+
};
|
|
545
|
+
if (toggle)
|
|
546
|
+
await withEmissionOverride({ getEmissionConfig, setEmissionConfig }, PULL_GRAPHIC_ON, read);
|
|
547
|
+
else
|
|
548
|
+
await read();
|
|
549
|
+
}
|
|
550
|
+
const treeCurrent = () => treeVersion === version && lastStructure !== null;
|
|
551
|
+
// No null check on lastGraphic: a pull that delivered no graphic (graphics:false, empty model) is still current.
|
|
552
|
+
const graphicCurrent = () => graphicVersion === version;
|
|
553
|
+
async function getTree(o) {
|
|
554
|
+
if (o?.refresh || !treeCurrent())
|
|
555
|
+
await pull();
|
|
556
|
+
return (lastStructure?.tree ?? {});
|
|
557
|
+
}
|
|
558
|
+
// The engine omits brep EDGE data from graphic payloads until the graphic
|
|
559
|
+
// database settings are enabled — ensure once per (re)connect generation.
|
|
560
|
+
// Untracked: it changes engine settings, not the model.
|
|
561
|
+
let ensuredGeneration = -1;
|
|
562
|
+
async function ensureGraphics() {
|
|
563
|
+
if (ensuredGeneration === generation)
|
|
564
|
+
return;
|
|
565
|
+
ensuredGeneration = generation;
|
|
566
|
+
try {
|
|
567
|
+
await request('Execute', {
|
|
568
|
+
task: [
|
|
569
|
+
{
|
|
570
|
+
'v1.common.setDatabaseSettings': [
|
|
571
|
+
{ isGraphicEnabled: true, isCCGraphicEnabled: true, isSketchGraphicEnabled: true, doCurveTessellation: true },
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
options: { undoable: false },
|
|
576
|
+
}, { track: false });
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
/* older servers — proceed without edges */
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function getGraphic(o) {
|
|
583
|
+
await ensureGraphics();
|
|
584
|
+
// recalc is OPT-IN: it regenerates the whole model and DESTROYS
|
|
585
|
+
// entity-injection bodies. The pull alone reflects the current model.
|
|
586
|
+
if (o?.recalc === true) {
|
|
587
|
+
const result = await execute({ 'v1.common.recalc': [{}] });
|
|
588
|
+
if ((result.maxLevel ?? 0) >= 51)
|
|
589
|
+
throw new Error('Model regeneration failed: ' + JSON.stringify(result.messages));
|
|
590
|
+
}
|
|
591
|
+
if (!graphicCurrent())
|
|
592
|
+
await pull();
|
|
593
|
+
return lastGraphic;
|
|
594
|
+
}
|
|
595
|
+
async function refreshTree() {
|
|
596
|
+
try {
|
|
597
|
+
await pull();
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
/* non-fatal — caller sees lastStructure unchanged */
|
|
601
|
+
}
|
|
602
|
+
return lastStructure;
|
|
603
|
+
}
|
|
604
|
+
// After (re)connecting, populate the caches and make sure a current
|
|
605
|
+
// instance is set. Without this:
|
|
606
|
+
// 1. tree/find return null/empty until the first pull.
|
|
607
|
+
// 2. snapshot renders only the BaseWCSys axes because the renderer's
|
|
608
|
+
// pull has no current product to walk.
|
|
609
|
+
// Both effects bite hardest when use_session attaches to a session another
|
|
610
|
+
// client (Buerligons, etc.) already has a model loaded in.
|
|
611
|
+
async function bootstrapSession() {
|
|
612
|
+
try {
|
|
613
|
+
await pull();
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const s = lastStructure;
|
|
619
|
+
if (!s)
|
|
620
|
+
return;
|
|
621
|
+
const cur = Number(s.currentInstance ?? 0);
|
|
622
|
+
if (cur)
|
|
623
|
+
return;
|
|
624
|
+
let rootId = null;
|
|
625
|
+
for (const node of Object.values(s.tree ?? {})) {
|
|
626
|
+
if (node && node.class === 'CC_AssemblyRoot') {
|
|
627
|
+
rootId = node.id;
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (rootId == null)
|
|
632
|
+
return;
|
|
633
|
+
try {
|
|
634
|
+
await request('Execute', {
|
|
635
|
+
task: [{ 'v1.assembly.setCurrentInstance': [{ id: rootId }] }],
|
|
636
|
+
options: { undoable: false },
|
|
637
|
+
});
|
|
638
|
+
await pull();
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
/* non-fatal */
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Open (or replace) the underlying WebSocket. Used both for the initial
|
|
645
|
+
// connect() call and for reconnect(sessionId). Resets cached structure /
|
|
646
|
+
// graphic state and rejects any in-flight requests on the old socket.
|
|
647
|
+
function resetCaches() {
|
|
648
|
+
outcomeUnknown = false;
|
|
649
|
+
for (const [, entry] of pending) {
|
|
650
|
+
try {
|
|
651
|
+
entry.reject(new Error('WebSocket reconnecting'));
|
|
652
|
+
}
|
|
653
|
+
catch { }
|
|
654
|
+
}
|
|
655
|
+
pending.clear();
|
|
656
|
+
lastGraphic = null;
|
|
657
|
+
lastStructure = null;
|
|
658
|
+
treeVersion = -1;
|
|
659
|
+
graphicVersion = -1;
|
|
660
|
+
knownKernel = null;
|
|
661
|
+
}
|
|
662
|
+
async function openBridge(token) {
|
|
663
|
+
const registry = opts.bridge?.() ?? null;
|
|
664
|
+
if (!registry)
|
|
665
|
+
throw new Error('The bridge listener is not running in this MCP (CLASSCAD_BRIDGE_LISTEN failed to bind, or the MCP runs in-process without a daemon).');
|
|
666
|
+
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
667
|
+
try {
|
|
668
|
+
ws.close();
|
|
669
|
+
}
|
|
670
|
+
catch { }
|
|
671
|
+
ws = undefined;
|
|
672
|
+
}
|
|
673
|
+
detachBridge();
|
|
674
|
+
resetCaches();
|
|
675
|
+
transport = 'bridge';
|
|
676
|
+
bridgeToken = token;
|
|
677
|
+
// Apps reconnect with backoff (buerli connectBridge: 500 ms … 5 s) after a
|
|
678
|
+
// daemon restart; wait longer than the largest backoff step.
|
|
679
|
+
const conn = await registry.waitFor(token, 8_000);
|
|
680
|
+
if (!conn) {
|
|
681
|
+
transport = 'ws';
|
|
682
|
+
bridgeToken = null;
|
|
683
|
+
throw new Error(`No app has announced share token "${token}" on ${registry.url}. ` +
|
|
684
|
+
'Create the share in the app first (it connects to this MCP when the token is minted), then call use_session again.');
|
|
685
|
+
}
|
|
686
|
+
if (conn.kind !== 'bridge') {
|
|
687
|
+
transport = 'ws';
|
|
688
|
+
bridgeToken = null;
|
|
689
|
+
throw new Error(`Token "${token}" belongs to a server session (invite), not an in-app engine — pass it as ?invite= instead.`);
|
|
690
|
+
}
|
|
691
|
+
bridgeConn = conn;
|
|
692
|
+
bridgePeerId = randomUUID();
|
|
693
|
+
bridgeUnsubClose = conn.onClose(() => {
|
|
694
|
+
if (bridgeConn !== conn)
|
|
695
|
+
return;
|
|
696
|
+
bridgeConn = null;
|
|
697
|
+
for (const [, entry] of pending) {
|
|
698
|
+
try {
|
|
699
|
+
entry.reject(new Error('bridge closed — the app revoked the share or went away'));
|
|
700
|
+
}
|
|
701
|
+
catch { }
|
|
702
|
+
}
|
|
703
|
+
pending.clear();
|
|
704
|
+
});
|
|
705
|
+
try {
|
|
706
|
+
await conn.request('session.attached', { peerId: bridgePeerId, role: 'edit', client: 'classcad-mcp' });
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
/* older apps without the session.* handlers still relay engine commands */
|
|
710
|
+
}
|
|
711
|
+
await bootstrapSession();
|
|
712
|
+
}
|
|
713
|
+
async function openWs(sessionId) {
|
|
714
|
+
if (transport === 'bridge')
|
|
715
|
+
detachBridge();
|
|
716
|
+
// Leaving the local engine for a worker: the engine stays warm in case
|
|
717
|
+
// the policy brings us back; close() ends it with the client.
|
|
718
|
+
transport = 'ws';
|
|
719
|
+
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
720
|
+
try {
|
|
721
|
+
ws.close();
|
|
722
|
+
}
|
|
723
|
+
catch { }
|
|
724
|
+
}
|
|
725
|
+
for (const [, entry] of pending) {
|
|
726
|
+
try {
|
|
727
|
+
entry.reject(new Error('WebSocket reconnecting'));
|
|
728
|
+
}
|
|
729
|
+
catch { }
|
|
730
|
+
}
|
|
731
|
+
pending.clear();
|
|
732
|
+
lastGraphic = null;
|
|
733
|
+
lastStructure = null;
|
|
734
|
+
treeVersion = -1;
|
|
735
|
+
graphicVersion = -1;
|
|
736
|
+
knownKernel = null;
|
|
737
|
+
const wsOpts = sessionId ? { headers: { 'ClassCAD-Session-Id': sessionId } } : {};
|
|
738
|
+
const sock = new WebSocket(currentUrl, wsOpts);
|
|
739
|
+
ws = sock;
|
|
740
|
+
sock.on('error', (err) => log(`ClassCAD WebSocket error: ${err.message}`));
|
|
741
|
+
// Failures HERE mean "no worker there" (refused, unreachable, timeout) —
|
|
742
|
+
// marked so the policy can fall back; failures after the open are not.
|
|
743
|
+
await new Promise((resolve, reject) => {
|
|
744
|
+
let timer;
|
|
745
|
+
const cleanup = () => {
|
|
746
|
+
if (timer)
|
|
747
|
+
clearTimeout(timer);
|
|
748
|
+
sock.off('open', opened);
|
|
749
|
+
sock.off('error', fail);
|
|
750
|
+
};
|
|
751
|
+
const opened = () => {
|
|
752
|
+
cleanup();
|
|
753
|
+
resolve();
|
|
754
|
+
};
|
|
755
|
+
const fail = (err) => {
|
|
756
|
+
cleanup();
|
|
757
|
+
err.connectFailure = true;
|
|
758
|
+
try {
|
|
759
|
+
sock.close();
|
|
760
|
+
}
|
|
761
|
+
catch { }
|
|
762
|
+
reject(err);
|
|
763
|
+
};
|
|
764
|
+
sock.once('open', opened);
|
|
765
|
+
sock.once('error', fail);
|
|
766
|
+
if (!debug)
|
|
767
|
+
timer = setTimeout(() => fail(new Error('Connection timeout')), opts.connectTimeoutMs ?? CONNECT_TIMEOUT);
|
|
768
|
+
});
|
|
769
|
+
sock.on('message', (d, b) => handleFrame(d, b));
|
|
770
|
+
sock.on('close', () => {
|
|
771
|
+
if (ws !== sock)
|
|
772
|
+
return;
|
|
773
|
+
for (const entry of pending.values())
|
|
774
|
+
entry.reject(new Error('Worker disconnected; in-flight mutation outcome unknown'));
|
|
775
|
+
pending.clear();
|
|
776
|
+
});
|
|
777
|
+
// No emission config is set here on purpose (see the header): the
|
|
778
|
+
// connection keeps the engine's defaults so a shared session's app sees
|
|
779
|
+
// everything this client emits. run_script suppresses per script.
|
|
780
|
+
await bootstrapSession();
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* use_session without a URL: a named worker session, or "whatever the
|
|
784
|
+
* policy says" (engine argument; undefined keeps the client's policy).
|
|
785
|
+
*/
|
|
786
|
+
async function reconnect(sessionId, engine) {
|
|
787
|
+
generation++;
|
|
788
|
+
inviteToken = null;
|
|
789
|
+
// Always perform an open so the caller gets back a connected, usable
|
|
790
|
+
// socket. Cancels any in-flight ensureOpen() so it doesn't race with us.
|
|
791
|
+
connectPromise = null;
|
|
792
|
+
currentUrl = baseUrl;
|
|
793
|
+
currentSessionId = sessionId;
|
|
794
|
+
if (engine)
|
|
795
|
+
policy = engine;
|
|
796
|
+
await openByPolicy(sessionId ? 'drogon' : policy, sessionId);
|
|
797
|
+
}
|
|
798
|
+
async function reconnectUrl(newUrl) {
|
|
799
|
+
generation++;
|
|
800
|
+
// Token/invite URLs address the session themselves — no session header.
|
|
801
|
+
connectPromise = null;
|
|
802
|
+
currentUrl = newUrl;
|
|
803
|
+
currentSessionId = null;
|
|
804
|
+
try {
|
|
805
|
+
inviteToken = new URL(newUrl).searchParams.get('invite');
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
inviteToken = null;
|
|
809
|
+
}
|
|
810
|
+
await openWs(null);
|
|
811
|
+
}
|
|
812
|
+
async function reconnectBridge(token) {
|
|
813
|
+
generation++;
|
|
814
|
+
connectPromise = null;
|
|
815
|
+
currentSessionId = null;
|
|
816
|
+
inviteToken = null;
|
|
817
|
+
await openBridge(token);
|
|
818
|
+
}
|
|
819
|
+
// No eager open here. The WS is opened on first request/execute/pull call
|
|
820
|
+
// (via ensureOpen) or immediately by reconnect(). This keeps the MCP
|
|
821
|
+
// passive at startup so it doesn't create stray ephemeral worker sessions.
|
|
822
|
+
return {
|
|
823
|
+
request,
|
|
824
|
+
execute,
|
|
825
|
+
close,
|
|
826
|
+
getStructure,
|
|
827
|
+
getLastGraphic,
|
|
828
|
+
getTree,
|
|
829
|
+
getGraphic,
|
|
830
|
+
pull,
|
|
831
|
+
getEmissionConfig,
|
|
832
|
+
setEmissionConfig,
|
|
833
|
+
refreshTree,
|
|
834
|
+
reconnect,
|
|
835
|
+
reconnectUrl,
|
|
836
|
+
reconnectBridge,
|
|
837
|
+
get ws() {
|
|
838
|
+
return ws;
|
|
839
|
+
},
|
|
840
|
+
get transport() {
|
|
841
|
+
return transport;
|
|
842
|
+
},
|
|
843
|
+
get engine() {
|
|
844
|
+
return policy;
|
|
845
|
+
},
|
|
846
|
+
get wasmAvailable() {
|
|
847
|
+
return wasmAvailable();
|
|
848
|
+
},
|
|
849
|
+
get localEngine() {
|
|
850
|
+
return localEngine;
|
|
851
|
+
},
|
|
852
|
+
get shareToken() {
|
|
853
|
+
return bridgeToken ?? inviteToken;
|
|
854
|
+
},
|
|
855
|
+
get connected() {
|
|
856
|
+
if (transport === 'bridge')
|
|
857
|
+
return bridgeConn !== null;
|
|
858
|
+
if (transport === 'wasm')
|
|
859
|
+
return localEngine !== null;
|
|
860
|
+
return !!ws && ws.readyState === WebSocket.OPEN;
|
|
861
|
+
},
|
|
862
|
+
get sessionId() {
|
|
863
|
+
return currentSessionId;
|
|
864
|
+
},
|
|
865
|
+
get url() {
|
|
866
|
+
return currentUrl;
|
|
867
|
+
},
|
|
868
|
+
get baseUrl() {
|
|
869
|
+
return baseUrl;
|
|
870
|
+
},
|
|
871
|
+
get generation() {
|
|
872
|
+
return generation;
|
|
873
|
+
},
|
|
874
|
+
get version() {
|
|
875
|
+
return version;
|
|
876
|
+
},
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
//# sourceMappingURL=client.js.map
|