gesso-vite-plugin 0.6.30 → 0.8.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/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { a as blankLiterals, c as firstArgumentName, d as CATALOG_TOOLS, f as ComponentCatalog, i as describedModule, l as importSources, n as declaresChannel, o as findCall, p as mayDeclareComponent, r as describeCalls, s as findCalls, t as ContractReader, u as CATALOG_INSTRUCTIONS } from "./contracts-Dwn5pg-0.js";
1
2
  import { defaultClientConditions } from "vite";
2
3
  //#region src/agent.ts
3
4
  /**
@@ -19,48 +20,218 @@ import { defaultClientConditions } from "vite";
19
20
  * Several tabs may be open. The newest to announce itself answers,
20
21
  * which is the one a person most likely means, and a tab that closes
21
22
  * hands back to the one before it.
23
+ *
24
+ * A few tools are the server's own: the component catalog, which the
25
+ * checker reads here and which an agent should be able to read before
26
+ * any page is open. Those are answered here, and listed alongside the
27
+ * page's; everything else still goes to the page.
28
+ *
29
+ * A page that cannot answer is said to be one, rather than let the
30
+ * agent wait or wonder. The tools the page last listed are remembered
31
+ * (across a restart too, given `memory`), so with no page open they are
32
+ * still listed, and a call of one says no page is open to answer it.
33
+ * And a page takes each request as it arrives (`received`), so one that
34
+ * has gone quiet, a window hidden and paused by its browser, is said to
35
+ * be in moments, not when the call would have timed out.
22
36
  */
23
37
  /** The path an agent is pointed at. */
24
38
  const AGENT_PATH = "/__gesso/mcp";
39
+ /**
40
+ * What the dev server prints when it starts: the endpoint, which any
41
+ * MCP client connects to, and the line to add it in the agents most
42
+ * used from a terminal.
43
+ */
44
+ function connectLines(name, url) {
45
+ return [
46
+ ` ➜ Agents: ${url} (MCP, as ${name})`,
47
+ ` claude mcp add --transport http ${name} ${url}`,
48
+ ` codex mcp add ${name} --url ${url}`,
49
+ ` gemini mcp add --transport http ${name} ${url}`
50
+ ].join("\n");
51
+ }
25
52
  /** The HMR events, as `connectDevAgent` names them. */
26
53
  const EVENTS = {
27
54
  ready: "gesso:agent:ready",
28
55
  request: "gesso:agent:request",
56
+ /** The page has a request and is answering it. */
57
+ received: "gesso:agent:received",
29
58
  response: "gesso:agent:response"
30
59
  };
60
+ /** What a request sent to a page that went away is answered with, before it is said to the agent. */
61
+ const PAGE_GONE = Symbol("page gone");
31
62
  function createAgentBridge(socket, options = {}) {
32
63
  const timeoutMs = options.timeoutMs ?? 13e4;
64
+ const receiveTimeoutMs = options.receiveTimeoutMs ?? 1e4;
65
+ const local = options.local;
66
+ const localNames = new Set(local?.tools.map((tool) => tool.name) ?? []);
33
67
  /** Pages that announced themselves, newest last. */
34
68
  const pages = [];
35
69
  const pending = /* @__PURE__ */ new Map();
70
+ /** Pages that say when they take a request: one that goes quiet is waited on only `receiveTimeoutMs`. */
71
+ const receiving = /* @__PURE__ */ new WeakSet();
72
+ /** What a page last listed, said for it while none is open. */
73
+ let remembered = options.memory?.read() ?? { tools: [] };
36
74
  let next = 1;
75
+ /**
76
+ * How long each tool the page listed says it may wait, under MCP's
77
+ * `_meta` as `gesso/waitsUpToMs` (gesso-framework's `WAITS_UP_TO`):
78
+ * a call of one is given that long, and a moment more, where any other
79
+ * is given `timeoutMs`.
80
+ */
81
+ const waits = /* @__PURE__ */ new Map();
37
82
  socket.on(EVENTS.ready, (_data, client) => {
38
83
  const at = pages.indexOf(client);
39
84
  if (at >= 0) pages.splice(at, 1);
40
85
  pages.push(client);
86
+ relay({
87
+ jsonrpc: "2.0",
88
+ id: "gesso:tools",
89
+ method: "tools/list",
90
+ params: {}
91
+ });
92
+ relay({
93
+ jsonrpc: "2.0",
94
+ id: "gesso:initialize",
95
+ method: "initialize",
96
+ params: {
97
+ protocolVersion: "2025-06-18",
98
+ capabilities: {},
99
+ clientInfo: {
100
+ name: "gesso-vite-plugin",
101
+ version: "0"
102
+ }
103
+ }
104
+ });
41
105
  });
42
- socket.on("vite:ws:disconnect", (_data, client) => {
106
+ const dropPage = (_data, client) => {
43
107
  const at = pages.indexOf(client);
44
108
  if (at >= 0) pages.splice(at, 1);
109
+ for (const [id, waiting] of pending) if (waiting.page === client) {
110
+ pending.delete(id);
111
+ waiting.resolve(PAGE_GONE);
112
+ }
113
+ };
114
+ socket.on("vite:client:disconnect", dropPage);
115
+ socket.on("vite:ws:disconnect", dropPage);
116
+ socket.on(EVENTS.received, (data, client) => {
117
+ receiving.add(client);
118
+ pending.get(data.id)?.received?.();
45
119
  });
46
120
  socket.on(EVENTS.response, (data) => {
47
- const resolve = pending.get(data.id);
48
- if (resolve !== void 0) {
121
+ const waiting = pending.get(data.id);
122
+ if (waiting !== void 0) {
49
123
  pending.delete(data.id);
50
- resolve(data.response);
124
+ waiting.resolve(data.response);
51
125
  }
52
126
  });
127
+ /**
128
+ * The answer the server gives itself, or undefined to relay. A call
129
+ * to one of its own tools is always answered here; with no page open,
130
+ * so are the handshake and the listings, so an agent connected before
131
+ * the page still reads the catalog, and still sees the page's tools,
132
+ * as it last listed them, each saying no page is open to answer it.
133
+ */
134
+ const answerHere = (message) => {
135
+ const request = asRequest(message);
136
+ if (request === void 0) return;
137
+ const { id, method, params } = request;
138
+ if (method === "tools/call" && typeof params.name === "string" && options.gate !== void 0) {
139
+ const refused = options.gate(params.name, params.arguments);
140
+ if (refused !== void 0) return Promise.resolve(failed(id, refused));
141
+ }
142
+ if (local !== void 0 && method === "tools/call" && typeof params.name === "string" && localNames.has(params.name)) {
143
+ const args = params.arguments;
144
+ return local.call(params.name, args).then((result) => ({
145
+ jsonrpc: "2.0",
146
+ id,
147
+ result
148
+ }));
149
+ }
150
+ if (pages.length > 0) return;
151
+ const pageTools = remembered.tools.filter((tool) => !localNames.has(tool.name));
152
+ if (local === void 0 && pageTools.length === 0) return;
153
+ const instructions = [remembered.instructions, local?.instructions].filter((text) => text !== void 0 && text !== "");
154
+ switch (method) {
155
+ case "initialize": return Promise.resolve({
156
+ jsonrpc: "2.0",
157
+ id,
158
+ result: {
159
+ protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18",
160
+ capabilities: {
161
+ tools: {},
162
+ resources: {}
163
+ },
164
+ serverInfo: {
165
+ name: "gesso",
166
+ version: "0.0.0"
167
+ },
168
+ ...instructions.length === 0 ? {} : { instructions: instructions.join("\n\n") }
169
+ }
170
+ });
171
+ case "tools/list": return Promise.resolve({
172
+ jsonrpc: "2.0",
173
+ id,
174
+ result: { tools: [...local?.tools ?? [], ...pageTools] }
175
+ });
176
+ case "tools/call":
177
+ if (typeof params.name === "string" && pageTools.some((tool) => tool.name === params.name)) return Promise.resolve(failed(id, `No window of the app is open to answer ${params.name}: it was closed, or it is reloading (a page that lost the dev server reconnects when it comes back). Open the app from this dev server, or bring its window forward and reload it if it is blank, then call ${params.name} again.`));
178
+ return;
179
+ case "ping": return Promise.resolve({
180
+ jsonrpc: "2.0",
181
+ id,
182
+ result: {}
183
+ });
184
+ case "resources/list": return Promise.resolve({
185
+ jsonrpc: "2.0",
186
+ id,
187
+ result: { resources: [] }
188
+ });
189
+ case "resources/templates/list": return Promise.resolve({
190
+ jsonrpc: "2.0",
191
+ id,
192
+ result: { resourceTemplates: [] }
193
+ });
194
+ default: return;
195
+ }
196
+ };
53
197
  const relay = (message) => {
54
198
  const page = pages[pages.length - 1];
55
199
  const id = next++;
200
+ const request = asRequest(message);
201
+ const tool = request?.method === "tools/call" && typeof request.params.name === "string" ? request.params.name : null;
202
+ const called = tool === null ? void 0 : waits.get(tool);
203
+ const limit = called === void 0 ? timeoutMs : Math.max(timeoutMs, called + 1e4);
56
204
  return new Promise((resolve) => {
57
205
  const timer = setTimeout(() => {
58
206
  pending.delete(id);
59
- resolve(rpcError(message, -32001, `The page did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`));
60
- }, timeoutMs);
61
- pending.set(id, (response) => {
207
+ resolve(rpcError(message, -32001, `The page did not answer within ${Math.round(limit / 1e3)} seconds.`));
208
+ }, limit);
209
+ const quiet = receiving.has(page) ? setTimeout(() => {
210
+ pending.delete(id);
62
211
  clearTimeout(timer);
63
- resolve(response);
212
+ const text = `The app's window did not take ${tool ?? "the request"} within ${Math.round(receiveTimeoutMs / 1e3)} seconds: it is most likely hidden or minimised, and paused by its browser, or it is stuck. Bring the window forward (reload it if it is blank), then call ${tool ?? "again"}${tool === null ? "" : " again"}.`;
213
+ resolve(request === void 0 || tool === null ? rpcError(message, -32001, text) : failed(request.id, text));
214
+ }, receiveTimeoutMs) : void 0;
215
+ pending.set(id, {
216
+ page,
217
+ received: () => clearTimeout(quiet),
218
+ resolve: (response) => {
219
+ clearTimeout(timer);
220
+ clearTimeout(quiet);
221
+ if (response === PAGE_GONE) {
222
+ resolve(rpcError(message, -32001, "The page closed before it answered (it may have reloaded); call again."));
223
+ return;
224
+ }
225
+ if (request?.method === "tools/list") {
226
+ noteWaits(response);
227
+ remember({ tools: listed(response) });
228
+ }
229
+ if (request?.method === "initialize") {
230
+ const instructions = response?.result?.instructions;
231
+ remember({ instructions: typeof instructions === "string" ? instructions : void 0 });
232
+ }
233
+ resolve(response);
234
+ }
64
235
  });
65
236
  page.send(EVENTS.request, {
66
237
  id,
@@ -68,6 +239,27 @@ function createAgentBridge(socket, options = {}) {
68
239
  });
69
240
  });
70
241
  };
242
+ /** Keeps what a page said of itself, for while none is open. */
243
+ const remember = (part) => {
244
+ const instructions = "instructions" in part ? part.instructions : remembered.instructions;
245
+ const now = {
246
+ tools: part.tools ?? remembered.tools,
247
+ ...instructions === void 0 ? {} : { instructions }
248
+ };
249
+ if (JSON.stringify(now) === JSON.stringify(remembered)) return;
250
+ remembered = now;
251
+ try {
252
+ options.memory?.write(now);
253
+ } catch {}
254
+ };
255
+ const noteWaits = (response) => {
256
+ const tools = response?.result?.tools;
257
+ if (!Array.isArray(tools)) return;
258
+ for (const tool of tools) {
259
+ const wait = tool._meta?.["gesso/waitsUpToMs"];
260
+ if (typeof tool.name === "string" && typeof wait === "number") waits.set(tool.name, wait);
261
+ }
262
+ };
71
263
  return {
72
264
  get connected() {
73
265
  return pages.length > 0;
@@ -99,6 +291,11 @@ function createAgentBridge(socket, options = {}) {
99
291
  return;
100
292
  }
101
293
  const notification = typeof message === "object" && message !== null && !("id" in message);
294
+ const own = answerHere(message);
295
+ if (own !== void 0) {
296
+ own.then((answer) => send(response, 200, "application/json", JSON.stringify(answer)));
297
+ return;
298
+ }
102
299
  if (pages.length === 0) {
103
300
  if (notification) {
104
301
  send(response, 202);
@@ -107,7 +304,7 @@ function createAgentBridge(socket, options = {}) {
107
304
  send(response, 200, "application/json", JSON.stringify(rpcError(message, -32e3, "No page is running the application. Open it in a browser from this dev server; the agent bridge runs in the page, because that is where the channels are.")));
108
305
  return;
109
306
  }
110
- relay(message).then((answer) => {
307
+ relay(message).then((answer) => local === void 0 ? answer : mergeLocal(message, answer, local)).then((answer) => {
111
308
  if (answer === null || answer === void 0) send(response, 202);
112
309
  else send(response, 200, "application/json", JSON.stringify(answer));
113
310
  });
@@ -120,6 +317,25 @@ function send(response, status, type, body) {
120
317
  if (type !== void 0) response.setHeader("content-type", type);
121
318
  response.end(body);
122
319
  }
320
+ /** A tool call that could not be made, as MCP says one: a result marked as an error, which the agent reads. */
321
+ function failed(id, text) {
322
+ return {
323
+ jsonrpc: "2.0",
324
+ id,
325
+ result: {
326
+ content: [{
327
+ type: "text",
328
+ text
329
+ }],
330
+ isError: true
331
+ }
332
+ };
333
+ }
334
+ /** The tools a page's `tools/list` answer lists, or none. */
335
+ function listed(response) {
336
+ const tools = response?.result?.tools;
337
+ return Array.isArray(tools) ? tools.filter((tool) => typeof tool?.name === "string") : [];
338
+ }
123
339
  function rpcError(message, code, text) {
124
340
  const id = message?.id;
125
341
  return {
@@ -148,713 +364,48 @@ const SHELL_BRIDGE = `function __gessoAgent(app) {
148
364
  return app;
149
365
  }
150
366
  `;
151
- //#endregion
152
- //#region src/source.ts
153
- const IDENTIFIER = /[A-Za-z0-9_$]/;
154
- /**
155
- * A copy of `code` in which every string, template literal, regular
156
- * expression body and comment is spaces, and everything else is
157
- * untouched.
158
- *
159
- * Same length as the input, and newlines are kept, so an index into
160
- * the result is an index into the original and a line number is still
161
- * a line number. String and template delimiters are kept as well, so a
162
- * specifier can be found by its quotes here and read from there.
163
- */
164
- function blankLiterals(code) {
165
- const out = code.split("");
166
- let index = 0;
167
- let regexAllowed = true;
168
- const blank = (from, to) => {
169
- for (let i = from; i < to; i++) if (out[i] !== "\n") out[i] = " ";
367
+ /** A JSON-RPC request, with an id and params, or undefined for anything else. */
368
+ function asRequest(message) {
369
+ if (typeof message !== "object" || message === null || Array.isArray(message)) return;
370
+ const { id, method, params } = message;
371
+ if (typeof id !== "string" && typeof id !== "number" || typeof method !== "string") return;
372
+ return {
373
+ id,
374
+ method,
375
+ params: typeof params === "object" && params !== null ? params : {}
170
376
  };
171
- while (index < code.length) {
172
- const char = code[index];
173
- if (char === "/" && code[index + 1] === "/") {
174
- const end = code.indexOf("\n", index);
175
- blank(index, end === -1 ? code.length : end);
176
- index = end === -1 ? code.length : end;
177
- continue;
178
- }
179
- if (char === "/" && code[index + 1] === "*") {
180
- const end = code.indexOf("*/", index + 2);
181
- const stop = end === -1 ? code.length : end + 2;
182
- blank(index, stop);
183
- index = stop;
184
- continue;
185
- }
186
- if (char === "\"" || char === "'") {
187
- const end = closingQuote(code, index, char);
188
- blank(index + 1, end);
189
- index = end + 1;
190
- regexAllowed = false;
191
- continue;
192
- }
193
- if (char === "`") {
194
- const end = closingQuote(code, index, "`");
195
- blank(index + 1, end);
196
- index = end + 1;
197
- regexAllowed = false;
198
- continue;
199
- }
200
- if (char === "/" && regexAllowed) {
201
- const end = closingRegex(code, index);
202
- if (end !== -1) {
203
- blank(index + 1, end);
204
- index = end + 1;
205
- regexAllowed = false;
206
- continue;
207
- }
208
- }
209
- if (!/\s/.test(char)) regexAllowed = !IDENTIFIER.test(char) && char !== ")" && char !== "]";
210
- index++;
211
- }
212
- return out.join("");
213
- }
214
- /** The index of the quote that closes the one at `open`, or the end. */
215
- function closingQuote(code, open, quote) {
216
- for (let i = open + 1; i < code.length; i++) {
217
- if (code[i] === "\\") {
218
- i++;
219
- continue;
220
- }
221
- if (code[i] === quote) return i;
222
- }
223
- return code.length;
224
- }
225
- /**
226
- * The index of the slash closing a regular expression, or -1 when the
227
- * slash was division after all.
228
- *
229
- * A newline inside means it was not a regular expression, which is the
230
- * cheap test that keeps `a / b` and `c / d` on one line from swallowing
231
- * everything between them.
232
- */
233
- function closingRegex(code, open) {
234
- let inClass = false;
235
- for (let i = open + 1; i < code.length; i++) {
236
- const char = code[i];
237
- if (char === "\\") {
238
- i++;
239
- continue;
240
- }
241
- if (char === "\n") return -1;
242
- if (char === "[") {
243
- inClass = true;
244
- continue;
245
- }
246
- if (char === "]") {
247
- inClass = false;
248
- continue;
249
- }
250
- if (char === "/" && !inClass) return i;
251
- }
252
- return -1;
253
- }
254
- /**
255
- * Every call of `name` in the module, in source order.
256
- *
257
- * A bare name has to stand alone: `renderRoot(` matches, `myRenderRoot(`
258
- * and `thing.renderRoot(` do not, because a method of that name on some
259
- * other object is not the framework's function. A name written with a
260
- * leading dot is the opposite and matches only the method call:
261
- * `.useService(` is how a builder chain reads and the only way it is
262
- * ever written.
263
- */
264
- function findCalls(code, name, blank = blankLiterals(code)) {
265
- const member = name.startsWith(".");
266
- const bare = member ? name.slice(1) : name;
267
- const sites = [];
268
- let from = 0;
269
- for (;;) {
270
- const at = blank.indexOf(bare, from);
271
- if (at === -1) return sites;
272
- from = at + bare.length;
273
- const before = at === 0 ? "" : blank[at - 1];
274
- if (member ? before !== "." : before !== "" && (IDENTIFIER.test(before) || before === ".")) continue;
275
- let cursor = from;
276
- while (cursor < blank.length && /\s/.test(blank[cursor])) cursor++;
277
- if (blank[cursor] !== "(") continue;
278
- const argsEnd = matchingBracket(blank, cursor);
279
- if (argsEnd === -1) continue;
280
- sites.push({
281
- start: at,
282
- argsStart: cursor + 1,
283
- argsEnd,
284
- end: argsEnd + 1
285
- });
286
- from = argsEnd + 1;
287
- }
288
- }
289
- /** The first call of `name`, or null. */
290
- function findCall(code, name, blank = blankLiterals(code)) {
291
- return findCalls(code, name, blank)[0] ?? null;
292
- }
293
- /** The index of the bracket closing the one at `open`, or -1. */
294
- function matchingBracket(blank, open) {
295
- const stack = [];
296
- for (let i = open; i < blank.length; i++) {
297
- const char = blank[i];
298
- if (char === "(" || char === "[" || char === "{") {
299
- stack.push(char);
300
- continue;
301
- }
302
- if (char === ")" || char === "]" || char === "}") {
303
- stack.pop();
304
- if (stack.length === 0) return i;
305
- }
306
- }
307
- return -1;
308
- }
309
- /**
310
- * The identifier a call's first argument is, or null when the argument
311
- * is anything more complicated than a name.
312
- *
313
- * `renderRoot(SegueApp)` is the shape every entry in this repository
314
- * writes and the only one that can be hot-replaced, because a
315
- * replacement has to be looked up by name in the module that changed.
316
- * `renderRoot(<App x={1} />)` is legal and gets no HMR wiring, which
317
- * the plugin says rather than guessing.
318
- */
319
- function firstArgumentName(code, call, blank = blankLiterals(code)) {
320
- const first = (splitTopLevel(blank.slice(call.argsStart, call.argsEnd))[0] ?? "").trim();
321
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(first) ? first : null;
322
- }
323
- /** Splits an argument list on its top-level commas. */
324
- function splitTopLevel(text) {
325
- const parts = [];
326
- let depth = 0;
327
- let start = 0;
328
- for (let i = 0; i < text.length; i++) {
329
- const char = text[i];
330
- if (char === "(" || char === "[" || char === "{") depth++;
331
- else if (char === ")" || char === "]" || char === "}") depth--;
332
- else if (char === "," && depth === 0) {
333
- parts.push(text.slice(start, i));
334
- start = i + 1;
335
- }
336
- }
337
- parts.push(text.slice(start));
338
- return parts;
339
- }
340
- /**
341
- * Which module each imported name came from: local name to specifier,
342
- * exactly as the import wrote it.
343
- *
344
- * Default and namespace imports are recorded under their local names
345
- * too, so `import App from './App'` and `import * as app from './App'`
346
- * both answer. What the map does not carry is whether the binding is a
347
- * type, because `import type` is stripped before this runs in every
348
- * configuration that matters and a type never reaches a call.
349
- */
350
- function importSources(code, blank = blankLiterals(code)) {
351
- const sources = /* @__PURE__ */ new Map();
352
- const statement = /\bimport\b([\s\S]*?)\bfrom\b\s*(['"])/g;
353
- for (;;) {
354
- const match = statement.exec(blank);
355
- if (match === null) return sources;
356
- const quote = match[2];
357
- const open = match.index + match[0].length;
358
- const close = code.indexOf(quote, open);
359
- if (close === -1) continue;
360
- const specifier = code.slice(open, close);
361
- for (const local of importedNames(match[1])) sources.set(local, specifier);
362
- statement.lastIndex = close + 1;
363
- }
364
- }
365
- /** The local names an import clause binds. */
366
- function importedNames(clause) {
367
- const names = [];
368
- const braces = clause.indexOf("{");
369
- const head = (braces === -1 ? clause : clause.slice(0, braces)).replace(/\btype\b/g, "");
370
- for (const part of head.split(",")) {
371
- const named = /^\s*(?:\*\s*as\s*)?([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(part);
372
- if (named !== null) names.push(named[1]);
373
- }
374
- if (braces !== -1) {
375
- const close = clause.lastIndexOf("}");
376
- const list = clause.slice(braces + 1, close === -1 ? clause.length : close);
377
- for (const part of list.split(",")) {
378
- const aliased = /^\s*(?:type\s+)?[A-Za-z_$][A-Za-z0-9_$]*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(part);
379
- const plain = /^\s*(?:type\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(part);
380
- const name = aliased?.[1] ?? plain?.[1];
381
- if (name !== void 0) names.push(name);
382
- }
383
- }
384
- return names;
385
- }
386
- //#endregion
387
- //#region src/contracts.ts
388
- /** The functions that declare a channel. */
389
- const DECLARATIONS = ["channel", "defineChannel"];
390
- /**
391
- * Whether a module declares a channel, without asking the checker.
392
- *
393
- * The checker is a process round trip per question, and every module
394
- * the application has passes through `transform`, so this is the gate:
395
- * a call to `channel` or `defineChannel` imported from
396
- * `gesso-framework`. A module that re-exports a token someone else
397
- * declared is not a contract, and the module that declared it is the
398
- * one described.
399
- */
400
- function declaresChannel(code) {
401
- if (!code.includes("gesso-framework")) return false;
402
- const blank = blankLiterals(code);
403
- const imports = importSources(code, blank);
404
- return DECLARATIONS.some((name) => imports.get(name) === "gesso-framework" && (findCalls(code, name, blank).length > 0 || new RegExp(`(?<![\\w$.])${name}\\s*<`).test(blank)));
405
- }
406
- /**
407
- * The code appended to a contract module.
408
- *
409
- * An import at the bottom is still hoisted, and appending rather than
410
- * rewriting leaves every line the author wrote where they wrote it, so
411
- * the module's source map needs nothing.
412
- */
413
- function describeCalls(channels) {
414
- const lines = ["import { describeChannel as __gessoDescribeChannel } from 'gesso-framework';"];
415
- for (const [name, schema] of channels) lines.push(`__gessoDescribeChannel(${name}, ${JSON.stringify(schema)});`);
416
- return `\n${lines.join("\n")}\n`;
417
377
  }
418
378
  /**
419
- * A module that describes channels when it is imported, for a bundle
420
- * this plugin never sees.
421
- *
422
- * The plugin describes a contract by appending to it as Vite bundles
423
- * it. An Electrobun main process is bundled by Electrobun's own build,
424
- * which takes no plugins, so there the description is written ahead of
425
- * time instead: `gesso-channels` reads the contracts with the same
426
- * checker and writes this module, and the main process imports it once.
427
- * A token is imported from its contract, so the schema lands on the
428
- * same object the application serves.
429
- *
430
- * `outFile` and every `file` are absolute POSIX paths.
431
- */
432
- function describedModule(contracts, outFile) {
433
- const lines = [
434
- "// Written by gesso-channels from the channel contracts it names below.",
435
- "// Run it again after changing a contract rather than editing this file.",
436
- "import { describeChannel } from 'gesso-framework';"
437
- ];
438
- const from = outFile.slice(0, outFile.lastIndexOf("/"));
439
- for (const contract of contracts) if (contract.channels.size > 0) {
440
- const names = [...contract.channels.keys()].join(", ");
441
- lines.push(`import { ${names} } from '${importPath(from, contract.file)}';`);
442
- }
443
- for (const contract of contracts) for (const [name, schema] of contract.channels) lines.push("", `describeChannel(${name}, ${JSON.stringify(schema, null, 2)});`);
444
- return `${lines.join("\n")}\n`;
445
- }
446
- /** `./Counter` from `/app/src/shared` to `/app/src/shared/Counter.ts`, without the extension. */
447
- function importPath(fromDir, file) {
448
- const from = fromDir.split("/").filter(Boolean);
449
- const to = file.replace(/\.[cm]?[jt]sx?$/, "").split("/").filter(Boolean);
450
- let shared = 0;
451
- while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared]) shared++;
452
- const up = from.length - shared;
453
- const rest = to.slice(shared).join("/");
454
- return up === 0 ? `./${rest}` : `${"../".repeat(up)}${rest}`;
455
- }
456
- /**
457
- * The checker, held open across reads.
458
- *
459
- * Spawning it and loading a project costs about a hundred
460
- * milliseconds; a question after that costs well under one. So one
461
- * reader lives as long as the dev server or the build, is told which
462
- * files changed between reads, and is closed at the end, because the
463
- * process it holds would otherwise keep the build from exiting.
379
+ * The page's answer with the server's own tools in it: listed after the
380
+ * page's, and the instructions joined, so an agent sees one server.
464
381
  */
465
- var ContractReader = class {
466
- api = null;
467
- changed = /* @__PURE__ */ new Set();
468
- /** Each contract's tsconfig, found once. */
469
- configs = /* @__PURE__ */ new Map();
470
- /**
471
- * @param fs Callbacks the checker reads through before the disk, for
472
- * a spec that keeps its project in memory.
473
- */
474
- ts;
475
- cwd;
476
- fs;
477
- constructor(ts, cwd, fs) {
478
- this.ts = ts;
479
- this.cwd = cwd;
480
- this.fs = fs;
481
- }
482
- /** A file changed on disk since the last read. */
483
- invalidate(file) {
484
- this.changed.add(file);
485
- }
486
- /** Every channel `file` exports, described. */
487
- read(file) {
488
- const api = this.api ??= new this.ts.API({
489
- cwd: this.cwd,
490
- ...this.fs === void 0 ? {} : { fs: this.fs }
491
- });
492
- this.changed.add(file);
493
- let snapshot = api.updateSnapshot({ fileChanges: { changed: [...this.changed] } });
494
- this.changed.clear();
495
- let config = this.configs.get(file);
496
- if (config === void 0) {
497
- config = api.updateSnapshot({ openFiles: [file] }).getDefaultProjectForFile(file)?.configFileName;
498
- snapshot = api.updateSnapshot({
499
- ...config === void 0 ? {} : { openProjects: [config] },
500
- closeFiles: [file]
501
- });
502
- if (config !== void 0) this.configs.set(file, config);
503
- }
504
- const project = config === void 0 ? void 0 : snapshot.getProject(config);
505
- const source = project?.program.getSourceFile(file);
506
- if (project === void 0 || source === void 0) return {
507
- channels: /* @__PURE__ */ new Map(),
508
- warnings: [`TypeScript has no project that includes ${file}.`]
509
- };
510
- const checker = project.checker;
511
- const module = checker.getSymbolAtLocation(source);
512
- const describer = new Describer(this.ts, checker);
513
- const channels = /* @__PURE__ */ new Map();
514
- for (const exported of module === void 0 ? [] : checker.getExportsOfModule(module)) {
515
- const type = checker.getTypeOfSymbol(exported);
516
- if (type === void 0 || type.getSymbol()?.name !== "ChannelToken") continue;
517
- const [view, commands] = checker.getTypeArguments(type);
518
- channels.set(exported.name, describer.channel(exported, view, commands));
519
- }
382
+ function mergeLocal(message, answer, local) {
383
+ const method = asRequest(message)?.method;
384
+ const result = answer?.result;
385
+ if (result === void 0 || method !== "tools/list" && method !== "initialize") return answer;
386
+ if (method === "tools/list" && Array.isArray(result.tools)) {
387
+ const taken = new Set(result.tools.map((tool) => tool.name));
388
+ const added = local.tools.filter((tool) => !taken.has(tool.name));
520
389
  return {
521
- channels,
522
- warnings: describer.warnings
523
- };
524
- }
525
- close() {
526
- this.api?.close();
527
- this.api = null;
528
- }
529
- };
530
- /**
531
- * Library types that are objects to the checker and not plain data to
532
- * a channel. They compare by reference, so `provide` rejects them on
533
- * their first value; describing their methods as properties would be
534
- * describing something that never arrives.
535
- */
536
- const NOT_PLAIN = /* @__PURE__ */ new Set([
537
- "Date",
538
- "Map",
539
- "ReadonlyMap",
540
- "Set",
541
- "ReadonlySet",
542
- "WeakMap",
543
- "WeakSet",
544
- "Promise",
545
- "RegExp",
546
- "Error",
547
- "ArrayBuffer",
548
- "SharedArrayBuffer",
549
- "DataView",
550
- "Blob",
551
- "File",
552
- "URL",
553
- "Int8Array",
554
- "Uint8Array",
555
- "Uint8ClampedArray",
556
- "Int16Array",
557
- "Uint16Array",
558
- "Int32Array",
559
- "Uint32Array",
560
- "Float32Array",
561
- "Float64Array",
562
- "BigInt64Array",
563
- "BigUint64Array"
564
- ]);
565
- /**
566
- * Bytes, which a command may carry and a view key may not.
567
- *
568
- * A command's argument is structured-cloned onto the owning thread, and
569
- * a typed array or an `ArrayBuffer` clones as itself: a file a person
570
- * dropped crosses as its bytes, with no encoding pass on the render
571
- * thread. A view key is diffed, and a differ that compares bytes by
572
- * reference reports a change on every emission, so there they stay
573
- * unplain. In a command's schema they are a base64 string, which is
574
- * what an agent can write, tagged with the type the command expects so
575
- * the agent surface can turn the string back into it.
576
- */
577
- const BINARY = /* @__PURE__ */ new Set([
578
- "ArrayBuffer",
579
- "Int8Array",
580
- "Uint8Array",
581
- "Uint8ClampedArray",
582
- "Int16Array",
583
- "Uint16Array",
584
- "Int32Array",
585
- "Uint32Array",
586
- "Float32Array",
587
- "Float64Array",
588
- "BigInt64Array",
589
- "BigUint64Array"
590
- ]);
591
- /** Turns checker types into JSON Schema for one module's channels. */
592
- var Describer = class {
593
- warnings = [];
594
- flags;
595
- /** Describing a command's parameters, where bytes are allowed; see `BINARY`. */
596
- inCommand = false;
597
- /** Types being described right now, to notice a type that contains itself. */
598
- inProgress = /* @__PURE__ */ new Set();
599
- /** Types found to contain themselves, which are described once in `$defs`. */
600
- recursive = /* @__PURE__ */ new Set();
601
- /** `$defs` for the schema being built. Reset per root, so each schema stands alone. */
602
- defs = /* @__PURE__ */ new Map();
603
- defNames = /* @__PURE__ */ new Map();
604
- ts;
605
- checker;
606
- constructor(ts, checker) {
607
- this.ts = ts;
608
- this.checker = checker;
609
- this.flags = ts.TypeFlags;
610
- }
611
- channel(token, view, commands) {
612
- const out = {
613
- view: this.root(view, `${token.name}.view`),
614
- commands: {}
615
- };
616
- const description = this.checker.getDocumentationCommentOfSymbol(token);
617
- if (description !== "") out.description = description;
618
- if (this.checker.getJsDocTagsOfSymbol(token).some((tag) => tag.name === "hidden")) out.internal = true;
619
- const hidden = this.checker.getPropertiesOfType(view).filter((key) => this.checker.getJsDocTagsOfSymbol(key).some((tag) => tag.name === "hidden")).map((key) => key.name);
620
- if (hidden.length > 0) out.hidden = hidden;
621
- if (commands !== void 0) for (const command of this.checker.getPropertiesOfType(commands)) {
622
- const described = this.command(command, `${token.name}.commands.${command.name}`);
623
- if (described !== null) out.commands[command.name] = described;
624
- }
625
- return out;
626
- }
627
- /** A schema that stands alone: its own `$defs`, if anything in it recurs. */
628
- root(type, path) {
629
- this.defs = /* @__PURE__ */ new Map();
630
- const schema = this.describe(type, path);
631
- if (this.defs.size > 0) return {
632
- ...schema,
633
- $defs: Object.fromEntries(this.defs)
634
- };
635
- return schema;
636
- }
637
- command(symbol, path) {
638
- const type = this.checker.getTypeOfSymbol(symbol);
639
- const signatures = type === void 0 ? [] : this.checker.getSignaturesOfType(type, this.ts.SignatureKind.Call);
640
- if (signatures.length === 0) {
641
- this.warnings.push(`${path} is not a function, so it is not a command an agent can send.`);
642
- return null;
643
- }
644
- if (signatures.length > 1) this.warnings.push(`${path} is overloaded; only its first signature is described.`);
645
- const signature = signatures[0];
646
- const tags = this.checker.getJsDocTagsOfSymbol(symbol);
647
- const parameters = signature.getParameters();
648
- this.defs = /* @__PURE__ */ new Map();
649
- const properties = {};
650
- const required = [];
651
- this.inCommand = true;
652
- parameters.forEach((parameter, index) => {
653
- const parameterType = this.checker.getTypeOfSymbol(parameter);
654
- const optional = parameterType !== void 0 && this.includesUndefined(parameterType);
655
- const schema = parameterType === void 0 ? {} : this.describe(parameterType, `${path}(${parameter.name})`);
656
- const description = this.parameterDescription(parameter, tags);
657
- properties[parameter.name] = description === "" ? schema : {
658
- ...schema,
659
- description
660
- };
661
- const rest = signature.hasRestParameter && index === parameters.length - 1;
662
- if (!optional && !rest) required.push(parameter.name);
663
- });
664
- this.inCommand = false;
665
- const input = {
666
- type: "object",
667
- properties,
668
- additionalProperties: false
669
- };
670
- if (required.length > 0) input.required = required;
671
- if (this.defs.size > 0) input.$defs = Object.fromEntries(this.defs);
672
- const out = {
673
- parameters: parameters.map((parameter) => parameter.name),
674
- input
675
- };
676
- const description = this.checker.getDocumentationCommentOfSymbol(symbol);
677
- if (description !== "") out.description = description;
678
- if (signature.hasRestParameter) out.rest = true;
679
- for (const annotation of [
680
- "destructive",
681
- "idempotent",
682
- "confirm",
683
- "hidden"
684
- ]) if (tags.some((tag) => tag.name === annotation)) out[annotation] = true;
685
- return out;
686
- }
687
- /**
688
- * A parameter's own comment, or failing that its `@param` line.
689
- *
690
- * The checker attaches `@param` text to the parameter of a method
691
- * signature. A command written as a property, `move: (from: number)
692
- * => void`, keeps its comment on the property, so the tag is read
693
- * from there instead.
694
- */
695
- parameterDescription(parameter, tags) {
696
- const own = this.checker.getDocumentationCommentOfSymbol(parameter);
697
- if (own !== "") return own;
698
- for (const tag of tags) {
699
- const match = tag.name === "param" ? /^\s*(\S+)\s*(?:-\s*)?([\s\S]*)$/.exec(tag.text ?? "") : null;
700
- if (match !== null && match[1] === parameter.name) return match[2].trim();
701
- }
702
- return "";
703
- }
704
- includesUndefined(type) {
705
- const { Undefined, Void } = this.flags;
706
- if (type.isUnionType()) return (type.getTypes() ?? []).some((member) => (member.flags & (Undefined | Void)) !== 0);
707
- return (type.flags & (Undefined | Void)) !== 0;
708
- }
709
- describe(type, path) {
710
- const f = this.flags;
711
- const flags = type.flags;
712
- if (flags & (f.Any | f.Unknown)) return {};
713
- if (flags & f.Never) {
714
- this.warnings.push(`${path} is never, which describes no value. An empty array literal is never[] unless it says what it holds: write \`[] as Row[]\`.`);
715
- return { not: {} };
716
- }
717
- if (flags & (f.String | f.TemplateLiteral | f.StringMapping)) return { type: "string" };
718
- if (flags & f.Number) return { type: "number" };
719
- if (flags & f.Boolean) return { type: "boolean" };
720
- if (flags & f.Null) return { type: "null" };
721
- if (flags & (f.Undefined | f.Void)) return {};
722
- if (flags & f.BooleanLiteral) return { const: type.intrinsicName === "true" };
723
- if (flags & (f.StringLiteral | f.NumberLiteral)) return { const: type.value };
724
- if (flags & (f.BigInt | f.BigIntLiteral | f.ESSymbol | f.UniqueESSymbol)) return this.unplain(path, flags & f.BigIntLike ? "a bigint" : "a symbol");
725
- if (type.isUnionType()) return this.union(type, path);
726
- if (flags & f.TypeParameter) return {};
727
- if (flags & (f.Object | f.Intersection)) {
728
- if (this.checker.isArrayType(type)) {
729
- const [element] = this.checker.getTypeArguments(type);
730
- return {
731
- type: "array",
732
- items: element === void 0 ? {} : this.describe(element, `${path}[]`)
733
- };
734
- }
735
- if (this.checker.isTupleType(type)) return this.tuple(type, path);
736
- return this.named(type, path);
737
- }
738
- this.warnings.push(`${path} has a type this reader does not describe, so it is left open.`);
739
- return {};
740
- }
741
- /**
742
- * A union, with `undefined` taken out (optionality is said by
743
- * `required`), `true | false` folded back into `boolean`, and a union
744
- * of nothing but literals written as an `enum`.
745
- */
746
- union(type, path) {
747
- const f = this.flags;
748
- let members = (type.getTypes() ?? []).filter((member) => (member.flags & (f.Undefined | f.Void)) === 0);
749
- const booleans = members.filter((member) => member.flags & f.BooleanLiteral);
750
- const schemas = [];
751
- if (booleans.length === 2) {
752
- members = members.filter((member) => !(member.flags & f.BooleanLiteral));
753
- schemas.push({ type: "boolean" });
754
- }
755
- if (members.length > 0 && schemas.length === 0 && members.every((member) => member.flags & (f.StringLiteral | f.NumberLiteral))) return { enum: members.map((member) => member.value) };
756
- members.sort((a, b) => Number((a.flags & f.Null) !== 0) - Number((b.flags & f.Null) !== 0));
757
- schemas.push(...members.map((member) => this.describe(member, path)));
758
- return schemas.length === 1 ? schemas[0] : { anyOf: schemas };
759
- }
760
- /**
761
- * An object type, described once however often it appears, and by
762
- * reference when it contains itself.
763
- */
764
- named(type, path) {
765
- const id = type.id;
766
- if (this.inProgress.has(id)) {
767
- this.recursive.add(id);
768
- return { $ref: `#/$defs/${this.defName(type)}` };
769
- }
770
- this.inProgress.add(id);
771
- const schema = this.object(type, path);
772
- this.inProgress.delete(id);
773
- if (this.recursive.has(id)) {
774
- const name = this.defName(type);
775
- this.defs.set(name, schema);
776
- return { $ref: `#/$defs/${name}` };
777
- }
778
- return schema;
779
- }
780
- defName(type) {
781
- let name = this.defNames.get(type.id);
782
- if (name === void 0) {
783
- const base = (type.getAliasSymbol() ?? type.getSymbol())?.name ?? "Type";
784
- name = new Set(this.defNames.values()).has(base) ? `${base}${type.id}` : base;
785
- this.defNames.set(type.id, name);
786
- }
787
- return name;
788
- }
789
- object(type, path) {
790
- const checker = this.checker;
791
- const symbolName = type.getSymbol()?.name;
792
- if (symbolName !== void 0 && this.inCommand && BINARY.has(symbolName)) return {
793
- type: "string",
794
- contentEncoding: "base64",
795
- "x-gesso-binary": symbolName
796
- };
797
- if (symbolName === "SharedArrayBuffer" && !this.inCommand) return {
798
- type: "string",
799
- description: "Shared memory, shown as its size.",
800
- "x-gesso-shared": true
801
- };
802
- if (symbolName !== void 0 && NOT_PLAIN.has(symbolName)) return this.unplain(path, `a ${symbolName}`);
803
- if (type.objectFlags & this.ts.ObjectFlags.Class) return this.unplain(path, `an instance of ${symbolName ?? "a class"}`);
804
- if (checker.getSignaturesOfType(type, this.ts.SignatureKind.Call).length > 0) return this.unplain(path, "a function");
805
- const properties = {};
806
- const required = [];
807
- for (const property of checker.getPropertiesOfType(type)) {
808
- const propertyType = checker.getTypeOfSymbol(property);
809
- const at = `${path}.${property.name}`;
810
- if (propertyType === void 0) {
811
- properties[property.name] = {};
812
- continue;
390
+ ...answer,
391
+ result: {
392
+ ...result,
393
+ tools: [...result.tools, ...added]
813
394
  }
814
- const schema = this.describe(propertyType, at);
815
- const description = checker.getDocumentationCommentOfSymbol(property);
816
- properties[property.name] = description === "" ? schema : {
817
- ...schema,
818
- description
819
- };
820
- if (!((property.flags & this.ts.SymbolFlags.Optional) !== 0 || this.includesUndefined(propertyType))) required.push(property.name);
821
- }
822
- const schema = {
823
- type: "object",
824
- properties
825
395
  };
826
- if (required.length > 0) schema.required = required;
827
- const index = checker.getIndexInfosOfType(type).find((info) => info.keyType.flags & this.flags.String);
828
- if (index !== void 0) schema.additionalProperties = this.describe(index.valueType, `${path}[key]`);
829
- return schema;
830
396
  }
831
- tuple(type, path) {
832
- const elements = this.checker.getTypeArguments(type);
833
- const elementFlags = type.getTarget().elementFlags ?? [];
834
- const { Required, Rest } = this.ts.ElementFlags;
835
- const prefixItems = [];
836
- let items = false;
837
- let minItems = 0;
838
- elements.forEach((element, index) => {
839
- const schema = this.describe(element, `${path}[${index}]`);
840
- if (elementFlags[index] & Rest) items = schema;
841
- else {
842
- prefixItems.push(schema);
843
- if (elementFlags[index] & Required) minItems = index + 1;
844
- }
845
- });
397
+ if (method === "initialize" && local.instructions !== void 0) {
398
+ const given = typeof result.instructions === "string" ? `${result.instructions}\n\n` : "";
846
399
  return {
847
- type: "array",
848
- prefixItems,
849
- items,
850
- minItems
400
+ ...answer,
401
+ result: {
402
+ ...result,
403
+ instructions: `${given}${local.instructions}`
404
+ }
851
405
  };
852
406
  }
853
- unplain(path, what) {
854
- this.warnings.push(`${path} is ${what}, which is not plain data and cannot cross a channel.`);
855
- return {};
856
- }
857
- };
407
+ return answer;
408
+ }
858
409
  //#endregion
859
410
  //#region src/render.ts
860
411
  const MARKER$1 = "/* gesso-vite-plugin: hot replacement */";
@@ -1164,6 +715,8 @@ function gesso(options = {}) {
1164
715
  /** The checker, started by the first contract, or null once it is known to be unavailable. */
1165
716
  let contracts;
1166
717
  let root = "";
718
+ /** The project's components, offered to an agent while the dev server runs. */
719
+ let catalog = null;
1167
720
  const readerFor = (warn) => contracts ??= import("typescript/unstable/sync").then((ts) => new ContractReader(ts, root), () => {
1168
721
  warn("Channels are not described, because TypeScript 7 is not installed. Add `typescript` (7 or later) to devDependencies, or pass `channelSchemas: false` to stop this message.");
1169
722
  return null;
@@ -1230,18 +783,35 @@ function gesso(options = {}) {
1230
783
  */
1231
784
  configureServer(server) {
1232
785
  if (options.agent === false) return;
1233
- const bridge = createAgentBridge(server.ws);
786
+ const catalogOptions = typeof options.catalog === "object" ? options.catalog : {};
787
+ const catalogRoot = catalogOptions.root ?? root;
788
+ catalog = options.catalog === false ? null : new ComponentCatalog(() => readerFor((message) => server.config.logger.warn(message)), catalogRoot, {
789
+ tsconfig: absolute(catalogRoot, catalogOptions.tsconfig ?? "tsconfig.json"),
790
+ libraries: () => resolveLibraries(server, root, catalogOptions.libraries ?? []),
791
+ ...catalogOptions.exclude === void 0 ? {} : { exclude: catalogOptions.exclude }
792
+ });
793
+ if (catalogRoot !== root) server.watcher.add(catalogRoot);
794
+ const bridge = createAgentBridge(server.ws, {
795
+ memory: pageMemory(root),
796
+ ...options.agentGate === void 0 ? {} : { gate: options.agentGate },
797
+ ...catalog === null ? {} : { local: {
798
+ tools: CATALOG_TOOLS,
799
+ call: (name, args) => catalog.call(name, args),
800
+ instructions: [CATALOG_INSTRUCTIONS, ...options.agentInstructions === void 0 ? [] : [options.agentInstructions]].join("\n\n")
801
+ } }
802
+ });
1234
803
  server.middlewares.use((request, response, next) => bridge.middleware(request, response, next));
1235
804
  server.httpServer?.once("listening", () => {
1236
805
  const base = server.resolvedUrls?.local[0];
1237
806
  if (base !== void 0) {
1238
807
  const url = new URL(AGENT_PATH, base).href;
1239
- server.config.logger.info(` ➜ Agents: ${url}\n claude mcp add --transport http ${agentName(server.config.root)} ${url}`);
808
+ server.config.logger.info(connectLines(options.agentName ?? agentName(server.config.root), url));
1240
809
  }
1241
810
  });
1242
811
  },
1243
- watchChange(id) {
1244
- contracts?.then((reader) => reader?.invalidate(id));
812
+ watchChange(id, change) {
813
+ contracts?.then((reader) => reader?.invalidate(id, change.event));
814
+ catalog?.invalidate();
1245
815
  },
1246
816
  /**
1247
817
  * The checker is a child process, and a build that left it running
@@ -1356,6 +926,31 @@ function scanShell(options) {
1356
926
  }
1357
927
  };
1358
928
  }
929
+ /** A path from the root, made absolute; an absolute one is kept. */
930
+ function absolute(root, path) {
931
+ return path.startsWith("/") ? path : `${root.replace(/\/+$/, "")}/${path.replace(/^\.\//, "")}`;
932
+ }
933
+ /**
934
+ * Where each named library's components are declared: the module Vite
935
+ * resolves its name to, or the declaration file beside it when that is
936
+ * built JavaScript, which says nothing about types. A name that does
937
+ * not resolve is reported and left out.
938
+ */
939
+ async function resolveLibraries(server, root, names) {
940
+ const out = [];
941
+ for (const name of names) {
942
+ const resolved = await server.pluginContainer.resolveId(name, absolute(root, "index.html"));
943
+ if (resolved === null) {
944
+ server.config.logger.warn(`[gesso] The catalog library ${name} does not resolve from ${root}, so it is left out.`);
945
+ continue;
946
+ }
947
+ out.push({
948
+ name,
949
+ file: resolved.id.replace(/\?.*$/, "").replace(/\.([cm]?)js$/, ".d.$1ts")
950
+ });
951
+ }
952
+ return out;
953
+ }
1359
954
  /** A name for the MCP server: the project's directory, which is what a person calls the app. */
1360
955
  function agentName(root) {
1361
956
  return (root.split("/").filter(Boolean).pop() ?? "gesso").replace(/[^A-Za-z0-9_-]/g, "-");
@@ -1419,7 +1014,19 @@ async function firstResolved(context, importer, names) {
1419
1014
  for (const name of names) if (await context.resolve(name, importer) !== null) return name;
1420
1015
  return null;
1421
1016
  }
1017
+ /**
1018
+ * What each project's page last listed, by root. Kept in the module,
1019
+ * which Node holds across Vite's own restarts (a config saved), so a
1020
+ * restarted server lists the page's tools before the page reconnects.
1021
+ */
1022
+ const pagesListed = /* @__PURE__ */ new Map();
1023
+ function pageMemory(root) {
1024
+ return {
1025
+ read: () => pagesListed.get(root),
1026
+ write: (page) => pagesListed.set(root, page)
1027
+ };
1028
+ }
1422
1029
  //#endregion
1423
- export { ContractReader, blankLiterals, gesso as default, gesso, describedModule, findCall, findCalls, findShellCall, firstArgumentName, importSources, transformRenderWorker, transformShell, transformSyncShell, workerConditions };
1030
+ export { ComponentCatalog, ContractReader, blankLiterals, gesso as default, gesso, describedModule, findCall, findCalls, findShellCall, firstArgumentName, importSources, mayDeclareComponent, transformRenderWorker, transformShell, transformSyncShell, workerConditions };
1424
1031
 
1425
1032
  //# sourceMappingURL=index.js.map