yarramate 0.18.0 → 0.19.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.
Files changed (65) hide show
  1. package/README.md +2 -1
  2. package/catalogues/core-enrichment.yaml +52 -1
  3. package/dist/adapters/graphify-cli.js +0 -0
  4. package/dist/adapters/likec4-cli.js +0 -0
  5. package/dist/adapters/mcp-cli.js +0 -0
  6. package/dist/adapters/visual/client.js +0 -1
  7. package/dist/adapters/visual/protocol-contract.d.ts +118 -35
  8. package/dist/adapters/visual/protocol-contract.js +1 -11
  9. package/dist/adapters/visual/protocol.d.ts +8 -1
  10. package/dist/adapters/visual/protocol.js +86 -46
  11. package/dist/adapters/visual/request.d.ts +37 -0
  12. package/dist/adapters/visual/request.js +131 -0
  13. package/dist/adapters/visual/session-server.d.ts +12 -14
  14. package/dist/adapters/visual/session-server.js +641 -283
  15. package/dist/adapters/visual/session-store.d.ts +28 -28
  16. package/dist/adapters/visual/session-store.js +32 -93
  17. package/dist/adapters/visual/wire.d.ts +36 -10
  18. package/dist/adapters/visual-cli.d.ts +3 -0
  19. package/dist/adapters/visual-cli.js +66 -1
  20. package/dist/apply-command.d.ts +10 -0
  21. package/dist/apply-command.js +399 -247
  22. package/dist/cli.js +0 -0
  23. package/dist/compiler.d.ts +13 -0
  24. package/dist/compiler.js +36 -2
  25. package/dist/graph-projection.d.ts +62 -0
  26. package/dist/graph-projection.js +222 -0
  27. package/dist/interrogate-command.d.ts +2 -0
  28. package/dist/interrogate-command.js +22 -0
  29. package/dist/kind-label.d.ts +8 -0
  30. package/dist/kind-label.js +8 -0
  31. package/dist/operations.d.ts +139 -0
  32. package/dist/operations.js +11 -0
  33. package/dist/projection.d.ts +2 -0
  34. package/dist/projection.js +8 -2
  35. package/dist/reconciliation.js +8 -7
  36. package/dist/source-document.d.ts +10 -0
  37. package/dist/source-document.js +21 -1
  38. package/dist/visual-app/assets/index-6oSR9RGM.js +354 -0
  39. package/dist/visual-app/assets/index-dwA9AOr5.css +1 -0
  40. package/dist/visual-app/index.html +2 -2
  41. package/docs/CONSUMING-YARRAMATE.md +25 -19
  42. package/package.json +6 -1
  43. package/schema/yarramate-adapter-mapping.schema.json +1 -1
  44. package/schema/yarramate-apply-result.schema.json +8 -2
  45. package/schema/yarramate-document.schema.json +1 -1
  46. package/schema/yarramate-operations.schema.json +203 -1
  47. package/schema/yarramate-projection.schema.json +12 -3
  48. package/schema/yarramate-question-catalogue.schema.json +15 -2
  49. package/schema/yarramate-visual-event.schema.json +143 -0
  50. package/schema/yarramate-visual-graph.schema.json +272 -0
  51. package/schema/yarramate-visual-layout.schema.json +41 -0
  52. package/schema/yarramate-visual-model.schema.json +6 -44
  53. package/schema/yarramate-visual-response.schema.json +170 -19
  54. package/schema/yarramate-visual-session-descriptor.schema.json +1 -1
  55. package/schema/yarramate-visual-session-request.schema.json +0 -70
  56. package/schema/yarramate-visual-session-started.schema.json +2 -9
  57. package/schema/yarramate-visual-status.schema.json +3 -2
  58. package/skills/yarramate-architecture/SKILL.md +20 -13
  59. package/skills/yarramate-architecture/references/journey-checklists.md +4 -2
  60. package/skills/yarramate-architecture/references/native-authoring.md +30 -0
  61. package/skills/yarramate-architecture/references/visual-conversations.md +149 -150
  62. package/dist/adapters/visual/likec4-compiler.d.ts +0 -55
  63. package/dist/adapters/visual/likec4-compiler.js +0 -460
  64. package/dist/visual-app/assets/index-CGvC8e3R.css +0 -1
  65. package/dist/visual-app/assets/index-CXuW1tOj.js +0 -209
@@ -1,27 +1,46 @@
1
- import { createHash, createHmac, randomBytes as randomSource, timingSafeEqual, } from 'node:crypto';
2
- import { lstat, readFile } from 'node:fs/promises';
3
- import { createServer, } from 'node:http';
4
- import { basename, extname, join, resolve, sep } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { WebSocketServer } from 'ws';
7
- import { compileVisualModel, } from './likec4-compiler.js';
8
- import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, } from './protocol.js';
9
- import { appendTerminalEvent, appendVisualEvent, appendVisualResponse, createVisualSession, isActionableVisualEvent, recoverVisualSession, removeVisualSession, writeVisualSessionDescriptor, } from './session-store.js';
1
+ import { createHash, createHmac, randomBytes as randomSource, timingSafeEqual, } from "node:crypto";
2
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { lstat, readFile } from "node:fs/promises";
4
+ import { createServer, } from "node:http";
5
+ import { basename, extname, join, resolve, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import Ajv2020Module from "ajv/dist/2020.js";
8
+ import { WebSocketServer } from "ws";
9
+ import { parse, stringify } from "yaml";
10
+ import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, visualBrowserInputType, } from "./protocol.js";
11
+ import { appendTerminalEvent, appendVisualEvent, appendVisualResponse, createVisualSession, isActionableVisualEvent, recoverVisualSession, removeVisualSession, writeVisualSessionDescriptor, } from "./session-store.js";
12
+ import { loadProjection, evaluateProjection, } from "../../projection.js";
13
+ import { compileWorkspaceWithProfileContext, } from "../../compiler.js";
14
+ import { applyOperations } from "../../apply-command.js";
15
+ import { projectGraphForCanvas } from "../../graph-projection.js";
16
+ import { kindLabelOf } from "../../kind-label.js";
17
+ import { loadWorkspaceManifest, } from "../../workspace.js";
18
+ import visualLayoutSchema from "../../../schema/yarramate-visual-layout.schema.json" with { type: "json" };
19
+ import visualProjectionSchema from "../../../schema/yarramate-projection.schema.json" with { type: "json" };
20
+ // The layout sidecar is adapter-owned presentation state (ADR 0023) that
21
+ // protocol.ts's validators never touch — the browser's own `layout.save`
22
+ // payload is already schema-validated by `parseVisualBrowserInput` before it
23
+ // reaches this module — so this file compiles its own single-purpose
24
+ // validator for the sidecar files it reads directly off disk.
25
+ const Ajv2020 = Ajv2020Module.default;
26
+ const layoutAjv = new Ajv2020({ allErrors: true });
27
+ layoutAjv.addSchema(visualProjectionSchema);
28
+ const validateVisualLayout = layoutAjv.compile(visualLayoutSchema);
10
29
  /**
11
30
  * Document name reported by diagnostics the server itself raises — a refused
12
31
  * frame, a frozen queue, or a transport violation that no protocol document
13
32
  * owns.
14
33
  */
15
- export const VISUAL_SERVER_DOCUMENT = 'visual-session-server';
34
+ export const VISUAL_SERVER_DOCUMENT = "visual-session-server";
16
35
  /**
17
36
  * Returned on every response. The policy admits no external origin at all, so
18
37
  * neither a compromised model nor a hostile chat message can reach the network,
19
38
  * and the page can be neither framed nor used as a base for relative fetches.
20
39
  */
21
40
  export const VISUAL_BROWSER_HEADERS = {
22
- 'Referrer-Policy': 'no-referrer',
23
- 'X-Content-Type-Options': 'nosniff',
24
- 'Cache-Control': 'no-store',
41
+ "Referrer-Policy": "no-referrer",
42
+ "X-Content-Type-Options": "nosniff",
43
+ "Cache-Control": "no-store",
25
44
  };
26
45
  /**
27
46
  * This session's content policy. Every source stays `'self'`; the one addition
@@ -42,36 +61,36 @@ export const VISUAL_SERVER_LIMITS = {
42
61
  /** Browser sockets one session serves at once. */
43
62
  browserConnections: 8,
44
63
  };
45
- export const VISUAL_SOCKET_PATH = '/socket';
46
- const COOKIE_NAME = 'ym_visual';
64
+ export const VISUAL_SOCKET_PATH = "/socket";
65
+ const COOKIE_NAME = "ym_visual";
47
66
  /**
48
67
  * Assets are addressed by a single flat, hashed file name. Rejecting every
49
68
  * separator here means a traversal never reaches path resolution at all.
50
69
  */
51
70
  const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
52
71
  const CONTENT_TYPES = {
53
- '.css': 'text/css; charset=utf-8',
54
- '.html': 'text/html; charset=utf-8',
55
- '.js': 'text/javascript; charset=utf-8',
56
- '.json': 'application/json; charset=utf-8',
57
- '.png': 'image/png',
58
- '.svg': 'image/svg+xml',
59
- '.woff2': 'font/woff2',
72
+ ".css": "text/css; charset=utf-8",
73
+ ".html": "text/html; charset=utf-8",
74
+ ".js": "text/javascript; charset=utf-8",
75
+ ".json": "application/json; charset=utf-8",
76
+ ".png": "image/png",
77
+ ".svg": "image/svg+xml",
78
+ ".woff2": "font/woff2",
60
79
  };
61
80
  /** Response types that finish the agent turn an event opened. */
62
81
  const TURN_COMPLETING = {
63
- 'chat.response': true,
64
- 'choice.present': true,
65
- 'handoff.complete': true,
82
+ "chat.response": true,
83
+ "choice.present": true,
84
+ "handoff.complete": true,
66
85
  diagnostic: true,
67
86
  };
68
87
  const TERMINATION_REASONS = {
69
- 'user-ended': true,
70
- 'child-failed': true,
71
- 'browser-timeout': true,
72
- 'main-cancelled': true,
73
- 'server-failed': true,
74
- 'compiler-failed': true,
88
+ "user-ended": true,
89
+ "child-failed": true,
90
+ "browser-timeout": true,
91
+ "main-cancelled": true,
92
+ "server-failed": true,
93
+ "compiler-failed": true,
75
94
  };
76
95
  /**
77
96
  * Which freezes are a runtime failing rather than a session closing.
@@ -82,44 +101,34 @@ const TERMINATION_REASONS = {
82
101
  * the reconnect grace's business, so neither ends a session a second time.
83
102
  */
84
103
  const LIMIT_FREEZE = {
85
- 'message-bytes': true,
86
- 'model-bytes': true,
87
- 'transcript-bytes': true,
88
- 'pending-events': true,
89
- 'browser-disconnected': false,
90
- 'terminal-event': false,
104
+ "message-bytes": true,
105
+ "model-bytes": true,
106
+ "transcript-bytes": true,
107
+ "pending-events": true,
108
+ "browser-disconnected": false,
109
+ "terminal-event": false,
110
+ // A landed changeset that leaves the workspace unable to recompile is our
111
+ // own bug, not the reviewer's: the runtime is why the conversation stopped,
112
+ // same as the byte and queue ceilings above.
113
+ "recompile-failed": true,
91
114
  };
92
115
  /** Built browser application, beside the compiled adapter in `dist`. */
93
- const DEFAULT_ASSET_ROOT = fileURLToPath(new URL('../../visual-app/', import.meta.url));
94
- /**
95
- * One promoted candidate as the browser receives it. The export is read back
96
- * from the candidate directory rather than re-derived, so what the browser
97
- * draws is exactly the document the trusted compiler wrote.
98
- */
99
- const renderCompiled = async (compiled) => ({
100
- candidate: compiled.candidate,
101
- authority: compiled.authority,
102
- initialView: compiled.initialView,
103
- views: compiled.views,
104
- compiled: JSON.parse(await readFile(compiled.exportPath, 'utf8')),
105
- });
116
+ const DEFAULT_ASSET_ROOT = fileURLToPath(new URL("../../visual-app/", import.meta.url));
106
117
  /**
107
118
  * Takes one session terminal, whatever caused it: a reviewer's End, a child
108
119
  * that failed, a browser that never came back, a cancelling main agent, or a
109
120
  * runtime shutting itself down.
110
121
  *
111
- * The order is the invariant. Input freezes before anything is read; the
112
- * compiler is cancelled rather than waited out; work already admitted is still
113
- * allowed to finish; exactly one terminal event is journaled; and the handoff
114
- * is recovered without deleting anything, so `stop` — and only `stop` — is what
115
- * removes the session directory.
122
+ * The order is the invariant. Input freezes before anything is read; work
123
+ * already admitted is still allowed to finish; exactly one terminal event is
124
+ * journaled; and the handoff is recovered without deleting anything, so
125
+ * `stop` — and only `stop` — is what removes the session directory.
116
126
  */
117
127
  export const terminateVisualSession = (session, reason) => {
118
128
  session.terminating ??= (async () => {
119
- if (session.lifecycle !== 'stopped')
120
- session.lifecycle = 'draining';
121
- session.freeze('terminal-event');
122
- session.compilerAbort.abort();
129
+ if (session.lifecycle !== "stopped")
130
+ session.lifecycle = "draining";
131
+ session.freeze("terminal-event");
123
132
  await session.quiesce();
124
133
  // A terminal event that cannot be journaled must not be why a session
125
134
  // stays up: `runVisualStart` waits on this to return, so a transition that
@@ -143,8 +152,8 @@ const serverError = (code, message) => new Error(`${code}: ${message}`);
143
152
  * `/`, because these diagnostics are published in `visual-diagnostic-result/v1`
144
153
  * documents and read back by the one-shot agent clients.
145
154
  */
146
- const serverDiagnostic = (code, message, pointer = '/') => ({
147
- severity: 'error',
155
+ const serverDiagnostic = (code, message, pointer = "/") => ({
156
+ severity: "error",
148
157
  code,
149
158
  message,
150
159
  path: VISUAL_SERVER_DOCUMENT,
@@ -160,14 +169,14 @@ const serverDiagnostic = (code, message, pointer = '/') => ({
160
169
  const secretEquals = (expected, presented) => {
161
170
  if (presented === undefined)
162
171
  return false;
163
- const digest = (value) => createHash('sha256').update(value, 'utf8').digest();
172
+ const digest = (value) => createHash("sha256").update(value, "utf8").digest();
164
173
  return timingSafeEqual(digest(expected), digest(presented));
165
174
  };
166
175
  const cookieValue = (header, name) => {
167
176
  if (header === undefined)
168
177
  return undefined;
169
- for (const part of header.split(';')) {
170
- const split = part.indexOf('=');
178
+ for (const part of header.split(";")) {
179
+ const split = part.indexOf("=");
171
180
  if (split < 0)
172
181
  continue;
173
182
  if (part.slice(0, split).trim() === name) {
@@ -186,12 +195,12 @@ const isJsonContent = (header) => header !== undefined && /^application\/json\s*
186
195
  * instead of a severed connection.
187
196
  */
188
197
  const readJsonBody = async (incoming, limit) => {
189
- if (!isJsonContent(incoming.headers['content-type'])) {
198
+ if (!isJsonContent(incoming.headers["content-type"])) {
190
199
  incoming.resume();
191
200
  return {
192
201
  ok: false,
193
202
  status: 415,
194
- message: 'Request body must be application/json',
203
+ message: "Request body must be application/json",
195
204
  };
196
205
  }
197
206
  const chunks = [];
@@ -216,11 +225,11 @@ const readJsonBody = async (incoming, limit) => {
216
225
  try {
217
226
  return {
218
227
  ok: true,
219
- value: JSON.parse(Buffer.concat(chunks).toString('utf8')),
228
+ value: JSON.parse(Buffer.concat(chunks).toString("utf8")),
220
229
  };
221
230
  }
222
231
  catch {
223
- return { ok: false, status: 400, message: 'Request body is not JSON' };
232
+ return { ok: false, status: 400, message: "Request body is not JSON" };
224
233
  }
225
234
  };
226
235
  /**
@@ -229,16 +238,47 @@ const readJsonBody = async (incoming, limit) => {
229
238
  */
230
239
  const eventFrom = (input, envelope) => {
231
240
  switch (input.type) {
232
- case 'chat.message':
241
+ case "chat.message":
242
+ return { ...envelope, type: input.type, payload: input.payload };
243
+ case "choice.selected":
233
244
  return { ...envelope, type: input.type, payload: input.payload };
234
- case 'choice.selected':
245
+ case "view.navigate":
235
246
  return { ...envelope, type: input.type, payload: input.payload };
236
- case 'view.navigate':
247
+ case "session.end":
237
248
  return { ...envelope, type: input.type, payload: input.payload };
238
- case 'session.end':
249
+ case "filter.query":
250
+ return { ...envelope, type: input.type, payload: input.payload };
251
+ case "view.save":
252
+ return { ...envelope, type: input.type, payload: input.payload };
253
+ case "changeset.commit":
254
+ return { ...envelope, type: input.type, payload: input.payload };
255
+ case "layout.save":
239
256
  return { ...envelope, type: input.type, payload: input.payload };
240
257
  }
241
258
  };
259
+ /**
260
+ * Turns a view title into a schema-valid projection id: lowercase,
261
+ * hyphen-separated, letter-led. A title with no letters or digits degrades
262
+ * to "view" rather than producing an id the schema would reject.
263
+ */
264
+ const slugify = (title) => {
265
+ const cleaned = title
266
+ .toLowerCase()
267
+ .replace(/[^a-z0-9]+/g, "-")
268
+ .replace(/^-+|-+$/g, "");
269
+ if (cleaned.length === 0)
270
+ return "view";
271
+ return /^[a-z]/.test(cleaned) ? cleaned : `view-${cleaned}`;
272
+ };
273
+ /** Appends a numeric suffix only when the base id collides with a known view. */
274
+ const uniqueViewId = (base, taken) => {
275
+ if (!taken.has(base))
276
+ return base;
277
+ let suffix = 2;
278
+ while (taken.has(`${base}-${suffix}`))
279
+ suffix += 1;
280
+ return `${base}-${suffix}`;
281
+ };
242
282
  /**
243
283
  * Serves one authenticated visual session over loopback. The handle owns the
244
284
  * session directory it created: `stop` recovers the handoff before deleting it,
@@ -253,9 +293,9 @@ export const startVisualServer = async (options) => {
253
293
  const drawHex = (size) => {
254
294
  const drawn = randomBytes(size);
255
295
  if (drawn.byteLength < size) {
256
- throw serverError('YMVS301', `Random source returned ${drawn.byteLength} bytes for a ${size}-byte draw`);
296
+ throw serverError("YMVS301", `Random source returned ${drawn.byteLength} bytes for a ${size}-byte draw`);
257
297
  }
258
- return drawn.subarray(0, size).toString('hex');
298
+ return drawn.subarray(0, size).toString("hex");
259
299
  };
260
300
  const stamp = () => now().toISOString();
261
301
  const session = await createVisualSession(request, {
@@ -268,47 +308,183 @@ export const startVisualServer = async (options) => {
268
308
  const cookieSigningKey = drawHex(32);
269
309
  // The browser stores only a derived bearer value; the signing key stays in
270
310
  // this server process and disappears with the session.
271
- const browserAuthenticator = createHmac('sha256', cookieSigningKey)
311
+ const browserAuthenticator = createHmac("sha256", cookieSigningKey)
272
312
  .update(sessionId)
273
- .digest('hex');
313
+ .digest("hex");
274
314
  // Not a credential: it authorises inline style for this page, nothing else.
275
315
  const styleNonce = drawHex(16);
276
316
  const browserHeaders = {
277
317
  ...VISUAL_BROWSER_HEADERS,
278
- 'Content-Security-Policy': visualContentSecurityPolicy(styleNonce),
318
+ "Content-Security-Policy": visualContentSecurityPolicy(styleNonce),
279
319
  };
280
- /** Cancels every compile this session runs, including one in flight. */
281
- const compilerAbort = new AbortController();
282
320
  /** Set once the loopback listener exists; a failed start has to close it. */
283
321
  let closeListener;
284
322
  // Past this point a session directory exists on disk; a failed start must not
285
323
  // leave one behind for the next run to prune, nor a listener for the process
286
324
  // to outlive.
287
325
  const abandon = async (cause) => {
288
- compilerAbort.abort();
289
326
  await closeListener?.().catch(() => undefined);
290
327
  await removeVisualSession(paths).catch(() => undefined);
291
328
  throw cause;
292
329
  };
293
- const compilation = await compileVisualModel({
294
- model: request.initialModel,
295
- command: request.compiler,
296
- paths,
297
- now,
298
- signal: compilerAbort.signal,
299
- }).catch(abandon);
300
- if (!compilation.ok) {
301
- const first = compilation.diagnostics[0];
302
- return abandon(serverError(first?.code ?? 'YMVS302', `Initial visual model did not compile: ${first?.message ?? 'unknown violation'}`));
303
- }
304
- let rendered = await renderCompiled(compilation.compiled).catch(abandon);
330
+ const manifestPath = resolve(options.cwd, ".yarramate/workspace.yaml");
331
+ // A session with no resolvable workspace manifest has nothing to compile,
332
+ // filter, or land a `changeset.commit` against, so a missing, unreadable, or
333
+ // invalid manifest refuses the session outright rather than starting one
334
+ // nothing can act on.
335
+ const resolvedWorkspace = await (async () => {
336
+ try {
337
+ const manifestSource = readFileSync(manifestPath, "utf8");
338
+ const loaded = loadWorkspaceManifest({ path: manifestPath, source: manifestSource }, options.cwd);
339
+ if (loaded.ok)
340
+ return loaded.workspace;
341
+ }
342
+ catch {
343
+ // Falls through to the same refusal as an invalid manifest below.
344
+ }
345
+ return abandon(serverError("YMVS132", `Session requires a resolvable workspace manifest at ${manifestPath}`));
346
+ })();
347
+ // Saved views join this list as they are written (see `view.save`), because
348
+ // a reconnecting browser is handed it in its snapshot and `layout.save`
349
+ // checks projection ids against it.
350
+ const views = resolvedWorkspace.projections.flatMap((projectionPath) => {
351
+ try {
352
+ const projectionSource = readFileSync(resolve(options.cwd, projectionPath), "utf8");
353
+ const loaded = loadProjection({
354
+ path: projectionPath,
355
+ source: projectionSource,
356
+ });
357
+ if (!loaded.ok)
358
+ return [];
359
+ const { projection } = loaded;
360
+ return [
361
+ {
362
+ id: projection.id,
363
+ title: projection.presentation?.title ?? projection.id,
364
+ description: projection.presentation?.description ?? "",
365
+ query: projection.query,
366
+ presentation: projection.presentation,
367
+ },
368
+ ];
369
+ }
370
+ catch {
371
+ // Skipped projection: session continues with remaining views.
372
+ return [];
373
+ }
374
+ });
375
+ // Drag positions are adapter-owned presentation state (ADR 0023): never
376
+ // validated by Core. An invalid or unreadable sidecar is skipped exactly
377
+ // like a broken saved view above — presentation state must never fail a
378
+ // session.
379
+ const layoutDir = resolve(options.cwd, ".yarramate/visual-layout");
380
+ const layouts = (() => {
381
+ const built = {};
382
+ let entries;
383
+ try {
384
+ entries = readdirSync(layoutDir);
385
+ }
386
+ catch {
387
+ return built;
388
+ }
389
+ for (const entry of entries) {
390
+ if (extname(entry) !== ".yaml" && extname(entry) !== ".yml")
391
+ continue;
392
+ try {
393
+ const source = readFileSync(join(layoutDir, entry), "utf8");
394
+ const parsed = parse(source);
395
+ if (!validateVisualLayout(parsed))
396
+ continue;
397
+ const sidecar = parsed;
398
+ built[sidecar.projectionId] = sidecar.positions;
399
+ }
400
+ catch {
401
+ // Skipped sidecar: presentation state must never fail a session.
402
+ }
403
+ }
404
+ return built;
405
+ })();
406
+ // `request.initialModel.graph` is the caller's compile (`buildVisualModelGraph`,
407
+ // before invoking `yarramate-visual start`) and is only the fallback below:
408
+ // `recompileWorkspace` immediately below is the one path that actually fills
409
+ // `documents`/`vocabulary`, reused for both the initial render and every
410
+ // later `changeset.commit`.
411
+ let rendered = {
412
+ authority: request.initialModel.authority,
413
+ initialView: request.initialModel.initialView,
414
+ graph: request.initialModel.graph,
415
+ documents: [],
416
+ vocabulary: { conceptKinds: [], relationshipKinds: [] },
417
+ layouts,
418
+ };
305
419
  const capabilities = {
306
420
  chat: request.chatEnabled,
307
421
  choices: request.chatEnabled,
308
422
  navigation: true,
309
- modelReplacement: true,
310
423
  transcript: true,
311
424
  };
425
+ let compiledWorkspace;
426
+ /**
427
+ * `filter.query` needs a `SemanticGraph`, not the `CanvasGraph` the rendered
428
+ * model carries — that graph is compiled here, from the same
429
+ * `resolvedWorkspace` profiles and documents `ask-command.ts` compiles from,
430
+ * and reused for every query the session receives. A session whose sources
431
+ * fail to compile has nothing to filter against: `filterMatchedIds`
432
+ * degrades to an empty match set rather than failing the session.
433
+ *
434
+ * This is the single compile path: called once for the initial `rendered`
435
+ * above and again after every landed `changeset.commit`, it re-reads
436
+ * sources from disk, reassigns `compiledWorkspace`, rebuilds the
437
+ * `CanvasGraph`, and reassigns `rendered` — carrying forward `authority`,
438
+ * `initialView`, and `layouts`, which a document recompile never touches.
439
+ * Returns whether it compiled, so a post-write failure can freeze the
440
+ * session instead of serving a stale graph.
441
+ */
442
+ const recompileWorkspace = () => {
443
+ try {
444
+ const sources = [
445
+ ...resolvedWorkspace.profiles,
446
+ ...resolvedWorkspace.documents,
447
+ ].map((path) => ({
448
+ path,
449
+ source: readFileSync(resolve(options.cwd, path), "utf8"),
450
+ }));
451
+ const compiled = compileWorkspaceWithProfileContext(sources);
452
+ if (!compiled.ok) {
453
+ compiledWorkspace = undefined;
454
+ return false;
455
+ }
456
+ compiledWorkspace = {
457
+ graph: compiled.graph,
458
+ profileContext: compiled.profileContext,
459
+ };
460
+ const conceptKinds = [
461
+ ...compiled.profileContext.conceptKindLineages.keys(),
462
+ ].map((id) => ({ id, label: kindLabelOf(id) }));
463
+ const relationshipKinds = [
464
+ ...compiled.profileContext.relationshipKindLineages.keys(),
465
+ ].map((id) => ({ id, label: kindLabelOf(id) }));
466
+ rendered = {
467
+ authority: rendered.authority,
468
+ initialView: rendered.initialView,
469
+ graph: projectGraphForCanvas(compiled.graph, compiled.profileContext),
470
+ documents: resolvedWorkspace.documents,
471
+ vocabulary: { conceptKinds, relationshipKinds },
472
+ layouts: rendered.layouts,
473
+ };
474
+ return true;
475
+ }
476
+ catch {
477
+ compiledWorkspace = undefined;
478
+ return false;
479
+ }
480
+ };
481
+ recompileWorkspace();
482
+ const filterMatchedIds = (query) => {
483
+ if (compiledWorkspace === undefined)
484
+ return [];
485
+ const result = evaluateProjection(compiledWorkspace.graph, { format: "yarramate/projection/v1", id: "ad-hoc", version: "0", query }, compiledWorkspace.profileContext);
486
+ return result.subjects.map(({ id }) => id);
487
+ };
312
488
  let listening = false;
313
489
  let bootstrapSpent = false;
314
490
  let agentAttached = false;
@@ -381,7 +557,7 @@ export const startVisualServer = async (options) => {
381
557
  // `server-failed` is the truth of it: not the reviewer, not the child,
382
558
  // but the runtime is why the conversation stopped. Recovery reads that
383
559
  // back as a failed handoff carrying everything the session did accept.
384
- await terminateVisualSession(active, 'server-failed');
560
+ await terminateVisualSession(active, "server-failed");
385
561
  }
386
562
  finally {
387
563
  // Whatever the journal managed, the child must not be left holding a
@@ -407,8 +583,7 @@ export const startVisualServer = async (options) => {
407
583
  */
408
584
  const active = {
409
585
  paths,
410
- lifecycle: 'starting',
411
- compilerAbort,
586
+ lifecycle: "starting",
412
587
  freeze,
413
588
  quiesce: drainAdmission,
414
589
  terminalEvent: { now, randomBytes },
@@ -429,13 +604,13 @@ export const startVisualServer = async (options) => {
429
604
  */
430
605
  let cancelGrace;
431
606
  const armGrace = () => {
432
- if (cancelGrace !== undefined || active.lifecycle !== 'running')
607
+ if (cancelGrace !== undefined || active.lifecycle !== "running")
433
608
  return;
434
609
  cancelGrace = schedule(async () => {
435
610
  cancelGrace = undefined;
436
- if (connections.size > 0 || active.lifecycle !== 'running')
611
+ if (connections.size > 0 || active.lifecycle !== "running")
437
612
  return;
438
- await stop('browser-timeout');
613
+ await stop("browser-timeout");
439
614
  }, VISUAL_LIMITS.reconnectMs);
440
615
  };
441
616
  const disarmGrace = () => {
@@ -446,20 +621,20 @@ export const startVisualServer = async (options) => {
446
621
  const recordEvent = (event) => {
447
622
  // A session on its way out waits on nobody, and asking past the question
448
623
  // is how a reviewer declines to answer it.
449
- if (event.type === 'chat.message' || event.type === 'session.end') {
624
+ if (event.type === "chat.message" || event.type === "session.end") {
450
625
  pendingChoice = null;
451
626
  }
452
- if (event.type === 'session.end')
627
+ if (event.type === "session.end")
453
628
  choicesClosed = true;
454
- if (event.type === 'chat.message') {
629
+ if (event.type === "chat.message") {
455
630
  transcript.push({
456
631
  id: event.eventId,
457
- speaker: 'reviewer',
632
+ speaker: "reviewer",
458
633
  text: event.payload.text,
459
634
  });
460
635
  return;
461
636
  }
462
- if (event.type !== 'choice.selected')
637
+ if (event.type !== "choice.selected")
463
638
  return;
464
639
  if (pendingChoice?.choiceId === event.payload.choiceId)
465
640
  pendingChoice = null;
@@ -470,26 +645,26 @@ export const startVisualServer = async (options) => {
470
645
  ?.get(event.payload.optionId);
471
646
  transcript.push({
472
647
  id: event.eventId,
473
- speaker: 'reviewer',
648
+ speaker: "reviewer",
474
649
  text: label ?? event.payload.optionId,
475
650
  });
476
651
  };
477
652
  /** An accepted agent response, as the line the reviewer sees. */
478
653
  const recordResponse = (response) => {
479
- if (response.type === 'choice.present') {
654
+ if (response.type === "choice.present") {
480
655
  if (!choicesClosed)
481
656
  pendingChoice = response.payload;
482
657
  choiceLabels.set(response.payload.choiceId, new Map(response.payload.options.map((option) => [option.id, option.label])));
483
658
  return;
484
659
  }
485
- const text = response.type === 'chat.response'
660
+ const text = response.type === "chat.response"
486
661
  ? response.payload.text
487
- : response.type === 'handoff.complete'
662
+ : response.type === "handoff.complete"
488
663
  ? response.payload.summary
489
664
  : undefined;
490
665
  if (text === undefined)
491
666
  return;
492
- transcript.push({ id: response.responseId, speaker: 'agent', text });
667
+ transcript.push({ id: response.responseId, speaker: "agent", text });
493
668
  };
494
669
  const snapshot = () => ({
495
670
  protocolVersion: VISUAL_PROTOCOL_VERSION,
@@ -502,6 +677,7 @@ export const startVisualServer = async (options) => {
502
677
  webSocketUrl,
503
678
  model: rendered,
504
679
  transcript: [...transcript],
680
+ views,
505
681
  agentTurnOpen: openTurn(),
506
682
  pendingChoice,
507
683
  styleNonce,
@@ -510,11 +686,11 @@ export const startVisualServer = async (options) => {
510
686
  });
511
687
  const status = () => {
512
688
  const value = {
513
- format: 'yarramate/visual-status/v1',
689
+ format: "yarramate/visual-status/v1",
514
690
  protocolVersion: VISUAL_PROTOCOL_VERSION,
515
691
  sessionId,
516
692
  lifecycle: active.lifecycle,
517
- alreadyStopped: active.lifecycle === 'stopped',
693
+ alreadyStopped: active.lifecycle === "stopped",
518
694
  server: { listening, origin },
519
695
  browser: {
520
696
  connected: connections.size > 0,
@@ -546,7 +722,7 @@ export const startVisualServer = async (options) => {
546
722
  // untrusted input: the agent must never have to parse a broken report.
547
723
  const validated = parseVisualStatus(value);
548
724
  if (!validated.ok) {
549
- throw serverError(validated.diagnostics[0]?.code ?? 'YMVS108', `Session status is invalid: ${validated.diagnostics[0]?.message ?? 'unknown violation'}`);
725
+ throw serverError(validated.diagnostics[0]?.code ?? "YMVS108", `Session status is invalid: ${validated.diagnostics[0]?.message ?? "unknown violation"}`);
550
726
  }
551
727
  return value;
552
728
  };
@@ -602,22 +778,22 @@ export const startVisualServer = async (options) => {
602
778
  }
603
779
  };
604
780
  const httpServer = createServer();
605
- httpServer.on('connection', (socket) => {
781
+ httpServer.on("connection", (socket) => {
606
782
  httpSockets.add(socket);
607
783
  unclassifiedHttpSockets.add(socket);
608
- socket.on('close', () => {
784
+ socket.on("close", () => {
609
785
  httpSockets.delete(socket);
610
786
  unclassifiedHttpSockets.delete(socket);
611
787
  stopResponseSockets.delete(socket);
612
788
  });
613
789
  });
614
- httpServer.on('clientError', (_error, socket) => socket.destroy());
790
+ httpServer.on("clientError", (_error, socket) => socket.destroy());
615
791
  const bound = Promise.withResolvers();
616
- httpServer.once('error', bound.reject);
792
+ httpServer.once("error", bound.reject);
617
793
  // Loopback only, on a kernel-assigned port: the session is never reachable
618
794
  // from another host, and never collides with a well-known service.
619
- httpServer.listen(0, '127.0.0.1', () => {
620
- httpServer.removeListener('error', bound.reject);
795
+ httpServer.listen(0, "127.0.0.1", () => {
796
+ httpServer.removeListener("error", bound.reject);
621
797
  bound.resolve();
622
798
  });
623
799
  await bound.promise.catch(abandon);
@@ -645,23 +821,40 @@ export const startVisualServer = async (options) => {
645
821
  const respond = (server, code, body, contentType) => {
646
822
  server.writeHead(code, {
647
823
  ...browserHeaders,
648
- 'Content-Type': contentType,
649
- 'Content-Length': Buffer.byteLength(body),
824
+ "Content-Type": contentType,
825
+ "Content-Length": Buffer.byteLength(body),
650
826
  // A draining session must not leave a pooled connection behind that
651
827
  // outlives its listener.
652
- ...(active.lifecycle === 'running' ? {} : { Connection: 'close' }),
828
+ ...(active.lifecycle === "running" ? {} : { Connection: "close" }),
653
829
  });
654
830
  server.end(body);
655
831
  };
656
- const respondJson = (server, code, value) => respond(server, code, JSON.stringify(value), 'application/json; charset=utf-8');
657
- const refuse = (server, code, message) => respond(server, code, `${message}\n`, 'text/plain; charset=utf-8');
832
+ const respondJson = (server, code, value) => respond(server, code, JSON.stringify(value), "application/json; charset=utf-8");
833
+ const refuse = (server, code, message) => respond(server, code, `${message}\n`, "text/plain; charset=utf-8");
658
834
  // ------------------------------------------------------------ browser input
835
+ /**
836
+ * What a raw frame claims to be, for a refusal that never got a document.
837
+ *
838
+ * Admitting can fail before anything parsed, and the browser is still
839
+ * holding a control open for whatever it sent. Reading the claim back off
840
+ * the bytes costs a parse only on the path where admitting already failed.
841
+ */
842
+ const claimedBrowserInput = (raw, binary) => {
843
+ if (binary)
844
+ return undefined;
845
+ try {
846
+ return visualBrowserInputType(JSON.parse(raw.toString()));
847
+ }
848
+ catch {
849
+ return undefined;
850
+ }
851
+ };
659
852
  const admitBrowserInput = async (socket, raw, binary) => {
660
853
  if (binary) {
661
854
  sendFrame(socket, {
662
- kind: 'rejected',
855
+ kind: "rejected",
663
856
  diagnostics: [
664
- serverDiagnostic('YMVS303', 'Browser frames must be UTF-8 JSON text'),
857
+ serverDiagnostic("YMVS303", "Browser frames must be UTF-8 JSON text"),
665
858
  ],
666
859
  });
667
860
  return;
@@ -672,53 +865,64 @@ export const startVisualServer = async (options) => {
672
865
  }
673
866
  catch {
674
867
  sendFrame(socket, {
675
- kind: 'rejected',
868
+ kind: "rejected",
676
869
  diagnostics: [
677
- serverDiagnostic('YMVS109', 'Browser frame is not a JSON document'),
870
+ serverDiagnostic("YMVS109", "Browser frame is not a JSON document"),
678
871
  ],
679
872
  });
680
873
  return;
681
874
  }
875
+ // What the frame says it is, before anything has agreed that it is well
876
+ // formed. A browser holding a control open for this input is told which
877
+ // one died even when the document that named it is the thing at fault.
878
+ const refused = visualBrowserInputType(parsed);
682
879
  const input = parseVisualBrowserInput(parsed);
683
880
  if (!input.ok) {
684
- sendFrame(socket, { kind: 'rejected', diagnostics: input.diagnostics });
881
+ sendFrame(socket, {
882
+ kind: "rejected",
883
+ ...(refused === undefined ? {} : { refused }),
884
+ diagnostics: input.diagnostics,
885
+ });
685
886
  return;
686
887
  }
687
888
  await admit(async () => {
688
889
  // A frame can already be on the chain when a stop begins. Recovery must
689
890
  // see a journal nothing is still writing to, so anything that has not
690
891
  // been journaled yet is refused rather than raced.
691
- if (active.lifecycle !== 'running') {
892
+ if (active.lifecycle !== "running") {
692
893
  sendFrame(socket, {
693
- kind: 'rejected',
894
+ kind: "rejected",
895
+ refused: input.value.type,
694
896
  diagnostics: [
695
- serverDiagnostic('YMVS306', `Session is ${active.lifecycle} and no longer accepts input`),
897
+ serverDiagnostic("YMVS306", `Session is ${active.lifecycle} and no longer accepts input`),
696
898
  ],
697
899
  });
698
900
  return;
699
901
  }
700
902
  if (frozen !== undefined) {
701
903
  sendFrame(socket, {
702
- kind: 'rejected',
904
+ kind: "rejected",
905
+ refused: input.value.type,
703
906
  frozen,
704
907
  diagnostics: [
705
- serverDiagnostic('YMVS304', `Session input is frozen: ${frozen}`),
908
+ serverDiagnostic("YMVS304", `Session input is frozen: ${frozen}`),
706
909
  ],
707
910
  });
708
911
  return;
709
912
  }
710
913
  // A session that was started without a conversation has no chat and no
711
914
  // choices to make; the diagram, and moving around it, is all it granted.
712
- const granted = input.value.type === 'chat.message'
915
+ const granted = input.value.type === "chat.message"
713
916
  ? capabilities.chat
714
- : input.value.type === 'choice.selected'
917
+ : input.value.type === "choice.selected"
715
918
  ? capabilities.choices
716
919
  : true;
717
920
  if (!granted) {
718
921
  sendFrame(socket, {
719
- kind: 'rejected',
922
+ kind: "rejected",
923
+ refused: input.value.type,
720
924
  diagnostics: [
721
- serverDiagnostic('YMVS309', `Session did not grant the capability "${input.value.type}" needs`, '/type'),
925
+ serverDiagnostic("YMVS309", `Session did not grant the capability "${input.value.type}" needs`, "/type"),
722
926
  ],
723
927
  });
724
928
  return;
@@ -730,15 +934,16 @@ export const startVisualServer = async (options) => {
730
934
  // journaled behind a sequence its sender never read.
731
935
  if (input.value.lastAcknowledgedSequence > lastSequence) {
732
936
  sendFrame(socket, {
733
- kind: 'rejected',
937
+ kind: "rejected",
938
+ refused: input.value.type,
734
939
  diagnostics: [
735
- serverDiagnostic('YMVS308', `Browser acknowledged sequence ${input.value.lastAcknowledgedSequence}, past the journal's ${lastSequence}`, '/lastAcknowledgedSequence'),
940
+ serverDiagnostic("YMVS308", `Browser acknowledged sequence ${input.value.lastAcknowledgedSequence}, past the journal's ${lastSequence}`, "/lastAcknowledgedSequence"),
736
941
  ],
737
942
  });
738
943
  return;
739
944
  }
740
945
  const event = eventFrom(input.value, {
741
- format: 'yarramate/visual-event/v1',
946
+ format: "yarramate/visual-event/v1",
742
947
  sessionId,
743
948
  sequence: lastSequence + 1,
744
949
  eventId: drawHex(16),
@@ -746,12 +951,13 @@ export const startVisualServer = async (options) => {
746
951
  });
747
952
  const actionable = isActionableVisualEvent(event);
748
953
  if (actionable && pending.length >= VISUAL_LIMITS.pendingEvents) {
749
- freeze('pending-events');
954
+ freeze("pending-events");
750
955
  sendFrame(socket, {
751
- kind: 'rejected',
956
+ kind: "rejected",
957
+ refused: input.value.type,
752
958
  frozen,
753
959
  diagnostics: [
754
- serverDiagnostic('YMVS305', `Queue already holds the ${VISUAL_LIMITS.pendingEvents} event maximum`, '/sequence'),
960
+ serverDiagnostic("YMVS305", `Queue already holds the ${VISUAL_LIMITS.pendingEvents} event maximum`, "/sequence"),
755
961
  ],
756
962
  });
757
963
  return;
@@ -761,7 +967,8 @@ export const startVisualServer = async (options) => {
761
967
  if (appended.freeze !== undefined)
762
968
  freeze(appended.freeze);
763
969
  sendFrame(socket, {
764
- kind: 'rejected',
970
+ kind: "rejected",
971
+ refused: input.value.type,
765
972
  ...(frozen === undefined ? {} : { frozen }),
766
973
  diagnostics: appended.diagnostics,
767
974
  });
@@ -774,10 +981,168 @@ export const startVisualServer = async (options) => {
774
981
  pending.push(event);
775
982
  // A journaled end is the last input this session takes. The agent still
776
983
  // has to see it, so the queue freezes rather than the socket closing.
777
- if (event.type === 'session.end')
778
- freeze('terminal-event');
984
+ if (event.type === "session.end")
985
+ freeze("terminal-event");
986
+ if (event.type === "filter.query") {
987
+ // Filtering is a pure read of the compiled workspace: it never asks
988
+ // the agent anything, so it is answered here directly rather than
989
+ // through the pending queue a poll would drain.
990
+ sendFrame(socket, {
991
+ kind: "filter-result",
992
+ result: {
993
+ query: event.payload.query,
994
+ matchedIds: filterMatchedIds(event.payload.query),
995
+ },
996
+ });
997
+ return;
998
+ }
999
+ if (event.type === "view.save") {
1000
+ // Saving a view is a pure filesystem write plus schema validation: it
1001
+ // never asks the agent anything, so it is answered here directly
1002
+ // rather than through the pending queue a poll would drain.
1003
+ const existingIds = new Set(views.map((view) => view.id));
1004
+ const id = event.payload.id ??
1005
+ uniqueViewId(slugify(event.payload.title), existingIds);
1006
+ const presentation = {
1007
+ ...(event.payload.presentation ?? {}),
1008
+ title: event.payload.title,
1009
+ description: event.payload.description,
1010
+ };
1011
+ const candidate = {
1012
+ format: "yarramate/projection/v1",
1013
+ id,
1014
+ version: "1.0",
1015
+ query: event.payload.query,
1016
+ presentation,
1017
+ };
1018
+ const path = `.yarramate/projections/${id}.yaml`;
1019
+ const source = stringify(candidate);
1020
+ const loaded = loadProjection({ path, source });
1021
+ if (!loaded.ok) {
1022
+ sendFrame(socket, {
1023
+ kind: "view-save-result",
1024
+ result: { ok: false, diagnostics: loaded.diagnostics },
1025
+ });
1026
+ return;
1027
+ }
1028
+ mkdirSync(resolve(options.cwd, ".yarramate/projections"), {
1029
+ recursive: true,
1030
+ });
1031
+ writeFileSync(resolve(options.cwd, path), source, "utf8");
1032
+ // On disk is not enough: the list handed to a reconnecting browser and
1033
+ // checked by `layout.save` lives here, so an overwrite replaces its
1034
+ // summary in place and a new view joins the end.
1035
+ const summary = {
1036
+ id,
1037
+ title: event.payload.title,
1038
+ description: event.payload.description,
1039
+ query: event.payload.query,
1040
+ presentation,
1041
+ };
1042
+ const saved = views.findIndex((view) => view.id === id);
1043
+ if (saved === -1)
1044
+ views.push(summary);
1045
+ else
1046
+ views[saved] = summary;
1047
+ sendFrame(socket, {
1048
+ kind: "view-save-result",
1049
+ result: { ok: true, id, path },
1050
+ });
1051
+ return;
1052
+ }
1053
+ if (event.type === "changeset.commit") {
1054
+ // Landing a batch is mechanical: Core's `applyOperations` never asks
1055
+ // the agent anything, so it is answered here directly rather than
1056
+ // through the pending queue a poll would drain. This never runs
1057
+ // `git commit` - the user reverts a landed batch with `git revert`.
1058
+ const operationsSource = stringify({
1059
+ format: "yarramate/operations/v1",
1060
+ operations: event.payload.operations,
1061
+ });
1062
+ const outcome = applyOperations({ path: "changeset.yaml", source: operationsSource }, { path: manifestPath, source: readFileSync(manifestPath, "utf8") }, options.cwd);
1063
+ if (!outcome.ok) {
1064
+ // Nothing landed: forward Core's diagnostics verbatim (ADR 0062)
1065
+ // rather than recompiling or broadcasting anything.
1066
+ sendFrame(socket, {
1067
+ kind: "apply-result",
1068
+ result: { ok: false, diagnostics: outcome.diagnostics },
1069
+ });
1070
+ return;
1071
+ }
1072
+ sendFrame(socket, {
1073
+ kind: "apply-result",
1074
+ result: { ok: true, result: outcome.result },
1075
+ });
1076
+ if (recompileWorkspace()) {
1077
+ broadcast({ kind: "model", model: rendered });
1078
+ return;
1079
+ }
1080
+ // A post-write compile failure is a bug, not a user error: the batch
1081
+ // landed but produced a document Core itself can no longer parse.
1082
+ // The freeze alone only tells the browser input is refused, not why
1083
+ // the graph on screen has gone stale, so a diagnostic response is
1084
+ // journaled alongside it.
1085
+ const diagnosticResponse = {
1086
+ format: "yarramate/visual-response/v1",
1087
+ sessionId,
1088
+ responseId: drawHex(16),
1089
+ eventId: event.eventId,
1090
+ type: "diagnostic",
1091
+ timestamp: stamp(),
1092
+ payload: {
1093
+ diagnostics: [
1094
+ serverDiagnostic("YMVS310", "Workspace failed to recompile after a landed changeset"),
1095
+ ],
1096
+ },
1097
+ };
1098
+ const appendedResponse = await appendVisualResponse(paths, diagnosticResponse);
1099
+ if (appendedResponse.ok) {
1100
+ transcriptBytes = appendedResponse.transcriptBytes;
1101
+ recordResponse(diagnosticResponse);
1102
+ broadcast({ kind: "response", response: diagnosticResponse });
1103
+ }
1104
+ else if (appendedResponse.freeze !== undefined) {
1105
+ freeze(appendedResponse.freeze);
1106
+ }
1107
+ freeze("recompile-failed");
1108
+ return;
1109
+ }
1110
+ if (event.type === "layout.save") {
1111
+ // Saving a drag position is adapter-owned presentation state
1112
+ // (ADR 0023): never routed through `apply`, never validated by Core,
1113
+ // never `git commit`ed. It never asks the agent anything, so it is
1114
+ // answered here directly rather than through the pending queue a
1115
+ // poll would drain.
1116
+ const { projectionId, positions } = event.payload;
1117
+ if (!views.some((view) => view.id === projectionId)) {
1118
+ sendFrame(socket, {
1119
+ kind: "layout-save-result",
1120
+ result: {
1121
+ ok: false,
1122
+ message: `"${projectionId}" is not a known view id`,
1123
+ },
1124
+ });
1125
+ return;
1126
+ }
1127
+ const path = `.yarramate/visual-layout/${projectionId}.yaml`;
1128
+ mkdirSync(layoutDir, { recursive: true });
1129
+ writeFileSync(resolve(options.cwd, path), stringify({
1130
+ format: "yarramate/visual-layout/v1",
1131
+ projectionId,
1132
+ positions,
1133
+ }), "utf8");
1134
+ rendered = {
1135
+ ...rendered,
1136
+ layouts: { ...rendered.layouts, [projectionId]: positions },
1137
+ };
1138
+ sendFrame(socket, {
1139
+ kind: "layout-save-result",
1140
+ result: { ok: true, path },
1141
+ });
1142
+ return;
1143
+ }
779
1144
  sendFrame(socket, {
780
- kind: 'accepted',
1145
+ kind: "accepted",
781
1146
  sequence: event.sequence,
782
1147
  eventId: event.eventId,
783
1148
  });
@@ -800,20 +1165,24 @@ export const startVisualServer = async (options) => {
800
1165
  * the process never handles.
801
1166
  */
802
1167
  const journalConnection = (connectionId, closedWith, onJournaled) => admit(async () => {
803
- if (active.lifecycle !== 'running' || frozen !== undefined)
1168
+ if (active.lifecycle !== "running" || frozen !== undefined)
804
1169
  return false;
805
1170
  const envelope = {
806
- format: 'yarramate/visual-event/v1',
1171
+ format: "yarramate/visual-event/v1",
807
1172
  sessionId,
808
1173
  sequence: lastSequence + 1,
809
1174
  eventId: drawHex(16),
810
1175
  timestamp: stamp(),
811
1176
  };
812
1177
  const event = closedWith === undefined
813
- ? { ...envelope, type: 'browser.connected', payload: { connectionId } }
1178
+ ? {
1179
+ ...envelope,
1180
+ type: "browser.connected",
1181
+ payload: { connectionId },
1182
+ }
814
1183
  : {
815
1184
  ...envelope,
816
- type: 'browser.disconnected',
1185
+ type: "browser.disconnected",
817
1186
  payload: { connectionId, code: closedWith },
818
1187
  };
819
1188
  const appended = await appendVisualEvent(paths, event);
@@ -831,16 +1200,18 @@ export const startVisualServer = async (options) => {
831
1200
  const connectionId = drawHex(16);
832
1201
  let registered = false;
833
1202
  lastSeenAt = stamp();
834
- socket.on('message', (raw, binary) => {
1203
+ socket.on("message", (raw, binary) => {
835
1204
  // Nothing awaits this handler, so a rejection here would reach the
836
1205
  // process as an unhandled one and take the runtime down with it. The
837
1206
  // browser is told its frame failed; the session stays up.
838
1207
  admitBrowserInput(socket, raw, binary).catch((cause) => {
1208
+ const refused = claimedBrowserInput(raw, binary);
839
1209
  try {
840
1210
  sendFrame(socket, {
841
- kind: 'rejected',
1211
+ kind: "rejected",
1212
+ ...(refused === undefined ? {} : { refused }),
842
1213
  diagnostics: [
843
- serverDiagnostic('YMVS307', `Admitting the browser frame failed: ${cause instanceof Error ? cause.message : String(cause)}`),
1214
+ serverDiagnostic("YMVS307", `Admitting the browser frame failed: ${cause instanceof Error ? cause.message : String(cause)}`),
844
1215
  ],
845
1216
  });
846
1217
  }
@@ -849,17 +1220,17 @@ export const startVisualServer = async (options) => {
849
1220
  }
850
1221
  });
851
1222
  });
852
- socket.on('error', (error) => {
1223
+ socket.on("error", (error) => {
853
1224
  // ws refuses an oversized frame before buffering it, which is exactly the
854
1225
  // byte ceiling the protocol calls a freeze.
855
- const code = typeof error === 'object' && error !== null && 'code' in error
1226
+ const code = typeof error === "object" && error !== null && "code" in error
856
1227
  ? error.code
857
1228
  : undefined;
858
- if (code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH') {
859
- freeze('message-bytes');
1229
+ if (code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") {
1230
+ freeze("message-bytes");
860
1231
  }
861
1232
  });
862
- socket.on('close', (code) => {
1233
+ socket.on("close", (code) => {
863
1234
  if (!registered)
864
1235
  return;
865
1236
  connections.delete(connectionId);
@@ -883,19 +1254,19 @@ export const startVisualServer = async (options) => {
883
1254
  socket.close(1011);
884
1255
  return;
885
1256
  }
886
- sendFrame(socket, { kind: 'ready', snapshot: snapshot() });
1257
+ sendFrame(socket, { kind: "ready", snapshot: snapshot() });
887
1258
  });
888
1259
  };
889
1260
  // ------------------------------------------------------------- agent routes
890
1261
  const answerPoll = (server, query) => {
891
- const raw = query.get('after') ?? '0';
1262
+ const raw = query.get("after") ?? "0";
892
1263
  const after = Number(raw);
893
1264
  if (!Number.isSafeInteger(after) || after < 0) {
894
1265
  refuse(server, 400, `Query "after" must be a sequence, not "${raw}"`);
895
1266
  return;
896
1267
  }
897
1268
  const immediate = takeDelivery(after);
898
- if (!immediate.waiting || active.lifecycle !== 'running') {
1269
+ if (!immediate.waiting || active.lifecycle !== "running") {
899
1270
  respondJson(server, 200, immediate);
900
1271
  return;
901
1272
  }
@@ -908,51 +1279,11 @@ export const startVisualServer = async (options) => {
908
1279
  }, agentPollMs),
909
1280
  };
910
1281
  polls.add(poll);
911
- server.on('close', () => {
1282
+ server.on("close", () => {
912
1283
  if (polls.delete(poll))
913
1284
  clearTimeout(poll.timer);
914
1285
  });
915
1286
  };
916
- const replaceModel = async (response) => {
917
- const compiled = await compileVisualModel({
918
- model: response.payload.model,
919
- command: request.compiler,
920
- paths,
921
- now,
922
- signal: compilerAbort.signal,
923
- });
924
- if (compiled.ok) {
925
- rendered = await renderCompiled(compiled.compiled);
926
- broadcast({ kind: 'model', model: rendered });
927
- return { model: rendered, diagnostics: [] };
928
- }
929
- // The candidate is discarded and the last good rendering stays live; the
930
- // browser learns why from a journaled diagnostic response.
931
- const diagnostic = {
932
- format: 'yarramate/visual-response/v1',
933
- sessionId,
934
- responseId: drawHex(16),
935
- eventId: response.eventId,
936
- type: 'diagnostic',
937
- timestamp: stamp(),
938
- payload: { diagnostics: compiled.diagnostics },
939
- };
940
- const appended = await appendVisualResponse(paths, diagnostic);
941
- if (appended.ok)
942
- transcriptBytes = appended.transcriptBytes;
943
- // A journal that refused the explanation is still a journal that reached a
944
- // limit, and the limit outlives this response.
945
- else if (appended.freeze !== undefined)
946
- freeze(appended.freeze);
947
- broadcast({ kind: 'response', response: diagnostic });
948
- // The diagnostic is the answer to the event the replacement was answering,
949
- // and it is the only one that event will get. It retires the turn exactly
950
- // as an agent-posted diagnostic would, so the reviewer's next message is
951
- // deliverable instead of waiting behind a turn nobody will ever close.
952
- markAnswered(diagnostic);
953
- completeTurn(diagnostic);
954
- return { diagnostics: compiled.diagnostics };
955
- };
956
1287
  /**
957
1288
  * Events a turn-completing response has already answered.
958
1289
  *
@@ -1002,7 +1333,7 @@ export const startVisualServer = async (options) => {
1002
1333
  respondJson(server, 409, {
1003
1334
  accepted: false,
1004
1335
  diagnostics: [
1005
- serverDiagnostic('YMVS126', `Response belongs to session "${response.sessionId}", not "${sessionId}"`, '/sessionId'),
1336
+ serverDiagnostic("YMVS126", `Response belongs to session "${response.sessionId}", not "${sessionId}"`, "/sessionId"),
1006
1337
  ],
1007
1338
  });
1008
1339
  return;
@@ -1010,30 +1341,61 @@ export const startVisualServer = async (options) => {
1010
1341
  // The agent cannot hold a conversation a diagram-only session never
1011
1342
  // offered; the browser has nowhere to show one.
1012
1343
  if (!capabilities.chat &&
1013
- (response.type === 'chat.response' || response.type === 'choice.present')) {
1344
+ (response.type === "chat.response" || response.type === "choice.present")) {
1014
1345
  respondJson(server, 409, {
1015
1346
  accepted: false,
1016
1347
  diagnostics: [
1017
- serverDiagnostic('YMVS309', `Session did not grant the capability "${response.type}" needs`, '/type'),
1348
+ serverDiagnostic("YMVS309", `Session did not grant the capability "${response.type}" needs`, "/type"),
1018
1349
  ],
1019
1350
  });
1020
1351
  return;
1021
1352
  }
1353
+ // A chat filter is a query, not a match set (ADR 0090). The agent states
1354
+ // what to filter by; the runtime evaluates it through `filterMatchedIds`
1355
+ // - the same evaluator, over the same compiled graph, that a `filter.query`
1356
+ // event from the panel goes through - so a chat filter and a panel filter
1357
+ // carrying the same query can never highlight different subjects. The
1358
+ // resolution happens here, before the append, so the transcript records
1359
+ // what the browser was actually shown.
1360
+ let delivered = response;
1361
+ if (response.type === "chat.response" &&
1362
+ response.payload.appliedQuery !== undefined) {
1363
+ const applied = response.payload.appliedQuery;
1364
+ if (applied.matchedIds !== undefined) {
1365
+ respondJson(server, 400, {
1366
+ accepted: false,
1367
+ diagnostics: [
1368
+ serverDiagnostic("YMVS311", "A chat filter states a query and the runtime resolves the match set; send appliedQuery.query without matchedIds", "/payload/appliedQuery/matchedIds"),
1369
+ ],
1370
+ });
1371
+ return;
1372
+ }
1373
+ delivered = {
1374
+ ...response,
1375
+ payload: {
1376
+ ...response.payload,
1377
+ appliedQuery: {
1378
+ query: applied.query,
1379
+ matchedIds: filterMatchedIds(applied.query),
1380
+ },
1381
+ },
1382
+ };
1383
+ }
1022
1384
  const result = await admit(async () => {
1023
1385
  // Same race as a browser frame: a request already on the chain must not
1024
1386
  // append to a session recovery has begun reading.
1025
- if (active.lifecycle !== 'running') {
1387
+ if (active.lifecycle !== "running") {
1026
1388
  return {
1027
1389
  code: 503,
1028
1390
  body: {
1029
1391
  accepted: false,
1030
1392
  diagnostics: [
1031
- serverDiagnostic('YMVS306', `Session is ${active.lifecycle} and no longer accepts responses`),
1393
+ serverDiagnostic("YMVS306", `Session is ${active.lifecycle} and no longer accepts responses`),
1032
1394
  ],
1033
1395
  },
1034
1396
  };
1035
1397
  }
1036
- const appended = await appendVisualResponse(paths, response);
1398
+ const appended = await appendVisualResponse(paths, delivered);
1037
1399
  if (!appended.ok) {
1038
1400
  if (appended.freeze !== undefined)
1039
1401
  freeze(appended.freeze);
@@ -1056,21 +1418,17 @@ export const startVisualServer = async (options) => {
1056
1418
  },
1057
1419
  };
1058
1420
  }
1059
- recordResponse(response);
1060
- markAnswered(response);
1061
- broadcast({ kind: 'response', response });
1062
- const replaced = response.type === 'model.replace'
1063
- ? await replaceModel(response)
1064
- : { diagnostics: [] };
1065
- completeTurn(response);
1421
+ recordResponse(delivered);
1422
+ markAnswered(delivered);
1423
+ broadcast({ kind: "response", response: delivered });
1424
+ completeTurn(delivered);
1066
1425
  return {
1067
1426
  code: 200,
1068
1427
  body: {
1069
1428
  accepted: true,
1070
1429
  duplicate: false,
1071
1430
  lastSequence,
1072
- ...(replaced.model === undefined ? {} : { model: replaced.model }),
1073
- diagnostics: replaced.diagnostics,
1431
+ diagnostics: [],
1074
1432
  },
1075
1433
  };
1076
1434
  });
@@ -1083,16 +1441,16 @@ export const startVisualServer = async (options) => {
1083
1441
  refuse(server, body.status, body.message);
1084
1442
  return;
1085
1443
  }
1086
- const asked = typeof body.value === 'object' && body.value !== null
1444
+ const asked = typeof body.value === "object" && body.value !== null
1087
1445
  ? body.value
1088
1446
  : {};
1089
1447
  const reason = asked.reason;
1090
- if (typeof reason !== 'string' || TERMINATION_REASONS[reason] !== true) {
1091
- refuse(server, 400, 'Stop requires a known termination reason');
1448
+ if (typeof reason !== "string" || TERMINATION_REASONS[reason] !== true) {
1449
+ refuse(server, 400, "Stop requires a known termination reason");
1092
1450
  return;
1093
1451
  }
1094
1452
  if (asked.includeTranscript !== undefined &&
1095
- typeof asked.includeTranscript !== 'boolean') {
1453
+ typeof asked.includeTranscript !== "boolean") {
1096
1454
  refuse(server, 400, 'Stop takes a boolean "includeTranscript" or none');
1097
1455
  return;
1098
1456
  }
@@ -1100,58 +1458,58 @@ export const startVisualServer = async (options) => {
1100
1458
  // teardown. The response that wins the stop does not get to close its
1101
1459
  // concurrent callers before they can observe the same result.
1102
1460
  const closed = await stop(reason, asked.includeTranscript);
1103
- server.on('finish', () => responseSocket.end());
1461
+ server.on("finish", () => responseSocket.end());
1104
1462
  respondJson(server, 200, closed);
1105
1463
  };
1106
1464
  const serveAgent = async (incoming, server, pathname, query) => {
1107
1465
  // An agent is not a browser. A request carrying an Origin came from a page,
1108
1466
  // which must never be able to spend a leaked agent capability.
1109
1467
  if (incoming.headers.origin !== undefined) {
1110
- refuse(server, 403, 'Agent routes reject browser-originated requests');
1468
+ refuse(server, 403, "Agent routes reject browser-originated requests");
1111
1469
  return;
1112
1470
  }
1113
1471
  if (!secretEquals(session.agentToken, bearerToken(incoming.headers.authorization))) {
1114
- refuse(server, 401, 'Agent capability required');
1472
+ refuse(server, 401, "Agent capability required");
1115
1473
  return;
1116
1474
  }
1117
1475
  agentAttached = true;
1118
- if (pathname === '/api/agent/events') {
1119
- if (incoming.method !== 'GET')
1120
- return refuse(server, 405, 'Use GET');
1476
+ if (pathname === "/api/agent/events") {
1477
+ if (incoming.method !== "GET")
1478
+ return refuse(server, 405, "Use GET");
1121
1479
  answerPoll(server, query);
1122
1480
  return;
1123
1481
  }
1124
- if (pathname === '/api/agent/status') {
1125
- if (incoming.method !== 'GET')
1126
- return refuse(server, 405, 'Use GET');
1482
+ if (pathname === "/api/agent/status") {
1483
+ if (incoming.method !== "GET")
1484
+ return refuse(server, 405, "Use GET");
1127
1485
  respondJson(server, 200, status());
1128
1486
  return;
1129
1487
  }
1130
- if (pathname === '/api/agent/responses') {
1131
- if (incoming.method !== 'POST')
1132
- return refuse(server, 405, 'Use POST');
1488
+ if (pathname === "/api/agent/responses") {
1489
+ if (incoming.method !== "POST")
1490
+ return refuse(server, 405, "Use POST");
1133
1491
  await acceptResponse(server, incoming);
1134
1492
  return;
1135
1493
  }
1136
- if (pathname === '/api/agent/stop') {
1137
- if (incoming.method !== 'POST')
1138
- return refuse(server, 405, 'Use POST');
1494
+ if (pathname === "/api/agent/stop") {
1495
+ if (incoming.method !== "POST")
1496
+ return refuse(server, 405, "Use POST");
1139
1497
  await acceptStop(server, incoming);
1140
1498
  return;
1141
1499
  }
1142
- refuse(server, 404, 'No such route');
1500
+ refuse(server, 404, "No such route");
1143
1501
  };
1144
1502
  // ----------------------------------------------------------- browser routes
1145
1503
  const assetPath = (pathname) => {
1146
- if (pathname === '/')
1147
- return join(assetRoot, 'index.html');
1148
- const prefix = '/assets/';
1504
+ if (pathname === "/")
1505
+ return join(assetRoot, "index.html");
1506
+ const prefix = "/assets/";
1149
1507
  if (!pathname.startsWith(prefix))
1150
1508
  return undefined;
1151
1509
  const name = pathname.slice(prefix.length);
1152
1510
  if (!ASSET_NAME.test(name))
1153
1511
  return undefined;
1154
- const target = resolve(assetRoot, 'assets', name);
1512
+ const target = resolve(assetRoot, "assets", name);
1155
1513
  // The name cannot carry a separator, so this only confirms what the pattern
1156
1514
  // already guaranteed — which is the point of having both.
1157
1515
  return target.startsWith(`${assetRoot}${sep}`) ? target : undefined;
@@ -1160,26 +1518,26 @@ export const startVisualServer = async (options) => {
1160
1518
  const target = assetPath(pathname);
1161
1519
  const contentType = target === undefined ? undefined : CONTENT_TYPES[extname(target)];
1162
1520
  if (target === undefined || contentType === undefined) {
1163
- refuse(server, 404, 'No such asset');
1521
+ refuse(server, 404, "No such asset");
1164
1522
  return;
1165
1523
  }
1166
1524
  try {
1167
1525
  // lstat, never stat: a symlink inside the asset root is a link out of it.
1168
1526
  const entry = await lstat(target);
1169
1527
  if (!entry.isFile()) {
1170
- refuse(server, 404, 'No such asset');
1528
+ refuse(server, 404, "No such asset");
1171
1529
  return;
1172
1530
  }
1173
1531
  respond(server, 200, await readFile(target), contentType);
1174
1532
  }
1175
1533
  catch {
1176
- refuse(server, 404, 'No such asset');
1534
+ refuse(server, 404, "No such asset");
1177
1535
  }
1178
1536
  };
1179
1537
  const serveBootstrap = (server, query) => {
1180
- const key = query.get('key') ?? undefined;
1538
+ const key = query.get("key") ?? undefined;
1181
1539
  if (bootstrapSpent || !secretEquals(session.browserToken, key)) {
1182
- refuse(server, 403, 'Bootstrap capability is not valid');
1540
+ refuse(server, 403, "Bootstrap capability is not valid");
1183
1541
  return;
1184
1542
  }
1185
1543
  // One-time by construction: the URL capability buys exactly one cookie, and
@@ -1188,70 +1546,70 @@ export const startVisualServer = async (options) => {
1188
1546
  bootstrapSpent = true;
1189
1547
  server.writeHead(303, {
1190
1548
  ...browserHeaders,
1191
- Location: '/',
1192
- 'Set-Cookie': `${COOKIE_NAME}=${browserAuthenticator}; HttpOnly; SameSite=Strict; Secure; Path=/`,
1193
- 'Content-Length': 0,
1549
+ Location: "/",
1550
+ "Set-Cookie": `${COOKIE_NAME}=${browserAuthenticator}; HttpOnly; SameSite=Strict; Secure; Path=/`,
1551
+ "Content-Length": 0,
1194
1552
  });
1195
1553
  server.end();
1196
1554
  };
1197
1555
  const serveBrowser = async (incoming, server, pathname, query) => {
1198
1556
  const sent = incoming.headers.origin;
1199
1557
  if (sent !== undefined && sent !== origin) {
1200
- refuse(server, 403, 'Origin is not this session');
1558
+ refuse(server, 403, "Origin is not this session");
1201
1559
  return;
1202
1560
  }
1203
- if (incoming.method !== 'GET') {
1204
- refuse(server, 405, 'Use GET');
1561
+ if (incoming.method !== "GET") {
1562
+ refuse(server, 405, "Use GET");
1205
1563
  return;
1206
1564
  }
1207
- if (pathname === '/bootstrap') {
1565
+ if (pathname === "/bootstrap") {
1208
1566
  serveBootstrap(server, query);
1209
1567
  return;
1210
1568
  }
1211
1569
  if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
1212
- refuse(server, 401, 'Session cookie required');
1570
+ refuse(server, 401, "Session cookie required");
1213
1571
  return;
1214
1572
  }
1215
- if (pathname === '/api/session') {
1573
+ if (pathname === "/api/session") {
1216
1574
  respondJson(server, 200, snapshot());
1217
1575
  return;
1218
1576
  }
1219
1577
  await serveAsset(server, pathname);
1220
1578
  };
1221
- httpServer.on('request', (incoming, server) => {
1579
+ httpServer.on("request", (incoming, server) => {
1222
1580
  const responseSocket = incoming.socket;
1223
1581
  unclassifiedHttpSockets.delete(responseSocket);
1224
- if ((incoming.url ?? '/').split('?')[0] === '/api/agent/stop') {
1582
+ if ((incoming.url ?? "/").split("?")[0] === "/api/agent/stop") {
1225
1583
  stopResponseSockets.add(responseSocket);
1226
1584
  const releaseResponseSocket = () => {
1227
1585
  stopResponseSockets.delete(responseSocket);
1228
1586
  };
1229
- server.once('finish', releaseResponseSocket);
1230
- server.once('close', releaseResponseSocket);
1587
+ server.once("finish", releaseResponseSocket);
1588
+ server.once("close", releaseResponseSocket);
1231
1589
  }
1232
1590
  void (async () => {
1233
1591
  try {
1234
1592
  // Rebinding defence: a request that reached this port under any other
1235
1593
  // name was routed here by something other than the browser we started.
1236
1594
  if (incoming.headers.host !== authority) {
1237
- refuse(server, 403, 'Host is not the bound loopback authority');
1595
+ refuse(server, 403, "Host is not the bound loopback authority");
1238
1596
  return;
1239
1597
  }
1240
- const [rawPath = '/', rawQuery = ''] = (incoming.url ?? '/').split('?');
1598
+ const [rawPath = "/", rawQuery = ""] = (incoming.url ?? "/").split("?");
1241
1599
  let pathname;
1242
1600
  try {
1243
1601
  pathname = decodeURIComponent(rawPath);
1244
1602
  }
1245
1603
  catch {
1246
- refuse(server, 400, 'Request target is not a valid URL path');
1604
+ refuse(server, 400, "Request target is not a valid URL path");
1247
1605
  return;
1248
1606
  }
1249
- if (pathname.includes('\0')) {
1250
- refuse(server, 400, 'Request target is not a valid URL path');
1607
+ if (pathname.includes("\0")) {
1608
+ refuse(server, 400, "Request target is not a valid URL path");
1251
1609
  return;
1252
1610
  }
1253
1611
  const query = new URLSearchParams(rawQuery);
1254
- if (pathname.startsWith('/api/agent/')) {
1612
+ if (pathname.startsWith("/api/agent/")) {
1255
1613
  await serveAgent(incoming, server, pathname, query);
1256
1614
  return;
1257
1615
  }
@@ -1261,31 +1619,31 @@ export const startVisualServer = async (options) => {
1261
1619
  if (server.headersSent)
1262
1620
  server.destroy(cause);
1263
1621
  else
1264
- refuse(server, 500, 'Session server failed');
1622
+ refuse(server, 500, "Session server failed");
1265
1623
  }
1266
1624
  })();
1267
1625
  });
1268
- httpServer.on('upgrade', (incoming, socket, head) => {
1626
+ httpServer.on("upgrade", (incoming, socket, head) => {
1269
1627
  const deny = (code, message) => {
1270
1628
  socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
1271
1629
  socket.destroy();
1272
1630
  };
1273
- if (active.lifecycle !== 'running')
1274
- return deny(503, 'Service Unavailable');
1631
+ if (active.lifecycle !== "running")
1632
+ return deny(503, "Service Unavailable");
1275
1633
  if (incoming.headers.host !== authority)
1276
- return deny(403, 'Forbidden');
1634
+ return deny(403, "Forbidden");
1277
1635
  // An upgrade always carries an Origin, so it is checked without exception.
1278
1636
  if (incoming.headers.origin !== origin)
1279
- return deny(403, 'Forbidden');
1280
- const [rawPath = '/'] = (incoming.url ?? '/').split('?');
1637
+ return deny(403, "Forbidden");
1638
+ const [rawPath = "/"] = (incoming.url ?? "/").split("?");
1281
1639
  if (rawPath !== VISUAL_SOCKET_PATH)
1282
- return deny(404, 'Not Found');
1640
+ return deny(404, "Not Found");
1283
1641
  if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
1284
- return deny(401, 'Unauthorized');
1642
+ return deny(401, "Unauthorized");
1285
1643
  }
1286
1644
  if (connections.size + browserConnectionReservations >=
1287
1645
  VISUAL_SERVER_LIMITS.browserConnections) {
1288
- return deny(503, 'Service Unavailable');
1646
+ return deny(503, "Service Unavailable");
1289
1647
  }
1290
1648
  browserConnectionReservations += 1;
1291
1649
  try {
@@ -1310,14 +1668,14 @@ export const startVisualServer = async (options) => {
1310
1668
  const attempt = (async () => {
1311
1669
  // Frozen before anything is torn down: a request already on the wire
1312
1670
  // must not be journaled behind the recovery this stop is about to read.
1313
- active.lifecycle = 'draining';
1671
+ active.lifecycle = "draining";
1314
1672
  listening = false;
1315
1673
  disarmGrace();
1316
1674
  // Waiting agents are answered before any socket is torn down, so a poll
1317
1675
  // in flight during a stop still receives its non-terminal idle result.
1318
1676
  settleAllPolls();
1319
1677
  for (const socket of connections.values()) {
1320
- sendFrame(socket, { kind: 'closing', reason });
1678
+ sendFrame(socket, { kind: "closing", reason });
1321
1679
  socket.close(1001);
1322
1680
  }
1323
1681
  sockets.close();
@@ -1341,7 +1699,7 @@ export const startVisualServer = async (options) => {
1341
1699
  // The transcript is read once, here, and only if this stop asked for it;
1342
1700
  // the same read is what removes the marked root.
1343
1701
  const removed = await removeVisualSession(paths, includeTranscript);
1344
- active.lifecycle = 'stopped';
1702
+ active.lifecycle = "stopped";
1345
1703
  const outcome = {
1346
1704
  reason,
1347
1705
  alreadyStopped: false,
@@ -1369,7 +1727,7 @@ export const startVisualServer = async (options) => {
1369
1727
  let started;
1370
1728
  try {
1371
1729
  await writeVisualSessionDescriptor(paths, {
1372
- format: 'yarramate/visual-session-descriptor/v1',
1730
+ format: "yarramate/visual-session-descriptor/v1",
1373
1731
  protocolVersion: VISUAL_PROTOCOL_VERSION,
1374
1732
  sessionId,
1375
1733
  origin,
@@ -1379,7 +1737,7 @@ export const startVisualServer = async (options) => {
1379
1737
  createdAt: stamp(),
1380
1738
  });
1381
1739
  started = {
1382
- format: 'yarramate/visual-session-started/v1',
1740
+ format: "yarramate/visual-session-started/v1",
1383
1741
  protocolVersion: VISUAL_PROTOCOL_VERSION,
1384
1742
  sessionId,
1385
1743
  authority: request.authority,
@@ -1395,12 +1753,12 @@ export const startVisualServer = async (options) => {
1395
1753
  };
1396
1754
  const validated = parseVisualSessionStarted(started);
1397
1755
  if (!validated.ok) {
1398
- throw serverError(validated.diagnostics[0]?.code ?? 'YMVS102', `Started session is invalid: ${validated.diagnostics[0]?.message ?? 'unknown violation'}`);
1756
+ throw serverError(validated.diagnostics[0]?.code ?? "YMVS102", `Started session is invalid: ${validated.diagnostics[0]?.message ?? "unknown violation"}`);
1399
1757
  }
1400
1758
  }
1401
1759
  catch (cause) {
1402
1760
  return abandon(cause);
1403
1761
  }
1404
- active.lifecycle = 'running';
1762
+ active.lifecycle = "running";
1405
1763
  return { started, closed, status, stop: (reason) => stop(reason) };
1406
1764
  };