yarramate 0.16.0 → 0.18.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 (49) hide show
  1. package/README.md +2 -1
  2. package/dist/adapters/graphify-cli.js +0 -0
  3. package/dist/adapters/likec4-cli.js +0 -0
  4. package/dist/adapters/mcp-cli.js +0 -0
  5. package/dist/adapters/visual/client.d.ts +74 -0
  6. package/dist/adapters/visual/client.js +448 -0
  7. package/dist/adapters/visual/likec4-compiler.d.ts +55 -0
  8. package/dist/adapters/visual/likec4-compiler.js +460 -0
  9. package/dist/adapters/visual/protocol-contract.d.ts +246 -0
  10. package/dist/adapters/visual/protocol-contract.js +19 -0
  11. package/dist/adapters/visual/protocol.d.ts +13 -0
  12. package/dist/adapters/visual/protocol.js +258 -0
  13. package/dist/adapters/visual/session-server.d.ts +130 -0
  14. package/dist/adapters/visual/session-server.js +1406 -0
  15. package/dist/adapters/visual/session-store.d.ts +126 -0
  16. package/dist/adapters/visual/session-store.js +755 -0
  17. package/dist/adapters/visual/wire.d.ts +93 -0
  18. package/dist/adapters/visual/wire.js +1 -0
  19. package/dist/adapters/visual-cli.d.ts +42 -0
  20. package/dist/adapters/visual-cli.js +274 -0
  21. package/dist/attestation-staleness.js +8 -1
  22. package/dist/cli.js +0 -0
  23. package/dist/compiler.d.ts +12 -0
  24. package/dist/compiler.js +46 -2
  25. package/dist/reconciliation.d.ts +17 -1
  26. package/dist/reconciliation.js +48 -1
  27. package/dist/rtm.d.ts +1 -0
  28. package/dist/rtm.js +9 -7
  29. package/dist/visual-app/assets/index-CGvC8e3R.css +1 -0
  30. package/dist/visual-app/assets/index-CXuW1tOj.js +209 -0
  31. package/dist/visual-app/index.html +18 -0
  32. package/docs/CONSUMING-YARRAMATE.md +49 -0
  33. package/package.json +25 -5
  34. package/schema/yarramate-ask-result.schema.json +4 -0
  35. package/schema/yarramate-document.schema.json +5 -0
  36. package/schema/yarramate-operations.schema.json +7 -1
  37. package/schema/yarramate-reconciliation-report.schema.json +36 -1
  38. package/schema/yarramate-visual-diagnostic-result.schema.json +67 -0
  39. package/schema/yarramate-visual-event.schema.json +255 -0
  40. package/schema/yarramate-visual-handoff.schema.json +120 -0
  41. package/schema/yarramate-visual-model.schema.json +111 -0
  42. package/schema/yarramate-visual-response.schema.json +230 -0
  43. package/schema/yarramate-visual-session-descriptor.schema.json +45 -0
  44. package/schema/yarramate-visual-session-request.schema.json +110 -0
  45. package/schema/yarramate-visual-session-started.schema.json +121 -0
  46. package/schema/yarramate-visual-status.schema.json +123 -0
  47. package/skills/yarramate-architecture/SKILL.md +28 -1
  48. package/skills/yarramate-architecture/references/native-authoring.md +15 -3
  49. package/skills/yarramate-architecture/references/visual-conversations.md +263 -0
@@ -0,0 +1,1406 @@
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';
10
+ /**
11
+ * Document name reported by diagnostics the server itself raises — a refused
12
+ * frame, a frozen queue, or a transport violation that no protocol document
13
+ * owns.
14
+ */
15
+ export const VISUAL_SERVER_DOCUMENT = 'visual-session-server';
16
+ /**
17
+ * Returned on every response. The policy admits no external origin at all, so
18
+ * neither a compromised model nor a hostile chat message can reach the network,
19
+ * and the page can be neither framed nor used as a base for relative fetches.
20
+ */
21
+ export const VISUAL_BROWSER_HEADERS = {
22
+ 'Referrer-Policy': 'no-referrer',
23
+ 'X-Content-Type-Options': 'nosniff',
24
+ 'Cache-Control': 'no-store',
25
+ };
26
+ /**
27
+ * This session's content policy. Every source stays `'self'`; the one addition
28
+ * is a per-session nonce for inline style, because the diagram renderer injects
29
+ * its own stylesheets at runtime. A nonce admits exactly the styles this
30
+ * application emits, where `'unsafe-inline'` would admit anyone's.
31
+ */
32
+ export const visualContentSecurityPolicy = (styleNonce) => `default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'nonce-${styleNonce}'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`;
33
+ export const VISUAL_SERVER_LIMITS = {
34
+ /** WebSocket frame ceiling: one 64 KiB chat message plus its envelope. */
35
+ browserFrameBytes: VISUAL_LIMITS.messageBytes + 1024,
36
+ /** Agent request body ceiling: one 5 MiB candidate model plus its envelope. */
37
+ agentBodyBytes: VISUAL_LIMITS.modelBytes + 64 * 1024,
38
+ /** Agent stop requests carry a termination reason and nothing else. */
39
+ agentControlBytes: 4096,
40
+ /** How long one agent long poll waits before answering "still idle". */
41
+ agentPollMs: 30_000,
42
+ /** Browser sockets one session serves at once. */
43
+ browserConnections: 8,
44
+ };
45
+ export const VISUAL_SOCKET_PATH = '/socket';
46
+ const COOKIE_NAME = 'ym_visual';
47
+ /**
48
+ * Assets are addressed by a single flat, hashed file name. Rejecting every
49
+ * separator here means a traversal never reaches path resolution at all.
50
+ */
51
+ const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
52
+ 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',
60
+ };
61
+ /** Response types that finish the agent turn an event opened. */
62
+ const TURN_COMPLETING = {
63
+ 'chat.response': true,
64
+ 'choice.present': true,
65
+ 'handoff.complete': true,
66
+ diagnostic: true,
67
+ };
68
+ 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,
75
+ };
76
+ /**
77
+ * Which freezes are a runtime failing rather than a session closing.
78
+ *
79
+ * A ceiling the runtime enforced for itself means nothing more can be
80
+ * journaled, so the conversation is over whether or not anyone asks it to
81
+ * stop. `terminal-event` is already that ending, and a disconnected browser is
82
+ * the reconnect grace's business, so neither ends a session a second time.
83
+ */
84
+ 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,
91
+ };
92
+ /** 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
+ });
106
+ /**
107
+ * Takes one session terminal, whatever caused it: a reviewer's End, a child
108
+ * that failed, a browser that never came back, a cancelling main agent, or a
109
+ * runtime shutting itself down.
110
+ *
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.
116
+ */
117
+ export const terminateVisualSession = (session, reason) => {
118
+ session.terminating ??= (async () => {
119
+ if (session.lifecycle !== 'stopped')
120
+ session.lifecycle = 'draining';
121
+ session.freeze('terminal-event');
122
+ session.compilerAbort.abort();
123
+ await session.quiesce();
124
+ // A terminal event that cannot be journaled must not be why a session
125
+ // stays up: `runVisualStart` waits on this to return, so a transition that
126
+ // refuses to settle hangs the command and strands the directory. The
127
+ // journal is then a runtime that died without saying why, and recovery
128
+ // reports precisely that.
129
+ await appendTerminalEvent(session.paths, reason, session.terminalEvent).catch(() => undefined);
130
+ session.handoff = await recoverVisualSession(session.paths, false);
131
+ return session.handoff;
132
+ })().catch((cause) => {
133
+ // A transition that failed is not this session's outcome: the next
134
+ // terminal cause has to be able to try it again.
135
+ session.terminating = undefined;
136
+ throw cause;
137
+ });
138
+ return session.terminating;
139
+ };
140
+ const serverError = (code, message) => new Error(`${code}: ${message}`);
141
+ /**
142
+ * `pointer` is an RFC 6901 pointer into the document being refused, rooted at
143
+ * `/`, because these diagnostics are published in `visual-diagnostic-result/v1`
144
+ * documents and read back by the one-shot agent clients.
145
+ */
146
+ const serverDiagnostic = (code, message, pointer = '/') => ({
147
+ severity: 'error',
148
+ code,
149
+ message,
150
+ path: VISUAL_SERVER_DOCUMENT,
151
+ pointer,
152
+ line: 1,
153
+ column: 1,
154
+ });
155
+ /**
156
+ * Compares two capabilities without leaking where they differ, or how long the
157
+ * presented one is: both sides are reduced to a fixed-width digest first, so
158
+ * the comparison is over equal-length buffers by construction.
159
+ */
160
+ const secretEquals = (expected, presented) => {
161
+ if (presented === undefined)
162
+ return false;
163
+ const digest = (value) => createHash('sha256').update(value, 'utf8').digest();
164
+ return timingSafeEqual(digest(expected), digest(presented));
165
+ };
166
+ const cookieValue = (header, name) => {
167
+ if (header === undefined)
168
+ return undefined;
169
+ for (const part of header.split(';')) {
170
+ const split = part.indexOf('=');
171
+ if (split < 0)
172
+ continue;
173
+ if (part.slice(0, split).trim() === name) {
174
+ return part.slice(split + 1).trim();
175
+ }
176
+ }
177
+ return undefined;
178
+ };
179
+ const bearerToken = (header) => header === undefined
180
+ ? undefined
181
+ : /^Bearer +([\x21-\x7e]+)$/.exec(header)?.[1];
182
+ const isJsonContent = (header) => header !== undefined && /^application\/json\s*(;.*)?$/i.test(header.trim());
183
+ /**
184
+ * Buffers at most `limit` bytes while always draining the request, so an
185
+ * oversized body costs bounded memory and still gets a clean HTTP answer
186
+ * instead of a severed connection.
187
+ */
188
+ const readJsonBody = async (incoming, limit) => {
189
+ if (!isJsonContent(incoming.headers['content-type'])) {
190
+ incoming.resume();
191
+ return {
192
+ ok: false,
193
+ status: 415,
194
+ message: 'Request body must be application/json',
195
+ };
196
+ }
197
+ const chunks = [];
198
+ let bytes = 0;
199
+ let overflowed = false;
200
+ for await (const chunk of incoming) {
201
+ const part = chunk;
202
+ bytes += part.byteLength;
203
+ if (bytes > limit) {
204
+ overflowed = true;
205
+ continue;
206
+ }
207
+ chunks.push(part);
208
+ }
209
+ if (overflowed) {
210
+ return {
211
+ ok: false,
212
+ status: 413,
213
+ message: `Request body of ${bytes} bytes exceeds the ${limit} byte ceiling`,
214
+ };
215
+ }
216
+ try {
217
+ return {
218
+ ok: true,
219
+ value: JSON.parse(Buffer.concat(chunks).toString('utf8')),
220
+ };
221
+ }
222
+ catch {
223
+ return { ok: false, status: 400, message: 'Request body is not JSON' };
224
+ }
225
+ };
226
+ /**
227
+ * Widens one validated browser input into the journaled event. The runtime owns
228
+ * every field the browser is not allowed to choose.
229
+ */
230
+ const eventFrom = (input, envelope) => {
231
+ switch (input.type) {
232
+ case 'chat.message':
233
+ return { ...envelope, type: input.type, payload: input.payload };
234
+ case 'choice.selected':
235
+ return { ...envelope, type: input.type, payload: input.payload };
236
+ case 'view.navigate':
237
+ return { ...envelope, type: input.type, payload: input.payload };
238
+ case 'session.end':
239
+ return { ...envelope, type: input.type, payload: input.payload };
240
+ }
241
+ };
242
+ /**
243
+ * Serves one authenticated visual session over loopback. The handle owns the
244
+ * session directory it created: `stop` recovers the handoff before deleting it,
245
+ * and answers every later call with that same outcome.
246
+ */
247
+ export const startVisualServer = async (options) => {
248
+ const now = options.now ?? (() => new Date());
249
+ const randomBytes = options.randomBytes ?? randomSource;
250
+ const agentPollMs = options.agentPollMs ?? VISUAL_SERVER_LIMITS.agentPollMs;
251
+ const assetRoot = resolve(options.assetRoot ?? DEFAULT_ASSET_ROOT);
252
+ const request = options.request;
253
+ const drawHex = (size) => {
254
+ const drawn = randomBytes(size);
255
+ if (drawn.byteLength < size) {
256
+ throw serverError('YMVS301', `Random source returned ${drawn.byteLength} bytes for a ${size}-byte draw`);
257
+ }
258
+ return drawn.subarray(0, size).toString('hex');
259
+ };
260
+ const stamp = () => now().toISOString();
261
+ const session = await createVisualSession(request, {
262
+ baseDir: options.baseDir,
263
+ now,
264
+ randomBytes,
265
+ });
266
+ const paths = session.paths;
267
+ const sessionId = basename(paths.root);
268
+ const cookieSigningKey = drawHex(32);
269
+ // The browser stores only a derived bearer value; the signing key stays in
270
+ // this server process and disappears with the session.
271
+ const browserAuthenticator = createHmac('sha256', cookieSigningKey)
272
+ .update(sessionId)
273
+ .digest('hex');
274
+ // Not a credential: it authorises inline style for this page, nothing else.
275
+ const styleNonce = drawHex(16);
276
+ const browserHeaders = {
277
+ ...VISUAL_BROWSER_HEADERS,
278
+ 'Content-Security-Policy': visualContentSecurityPolicy(styleNonce),
279
+ };
280
+ /** Cancels every compile this session runs, including one in flight. */
281
+ const compilerAbort = new AbortController();
282
+ /** Set once the loopback listener exists; a failed start has to close it. */
283
+ let closeListener;
284
+ // Past this point a session directory exists on disk; a failed start must not
285
+ // leave one behind for the next run to prune, nor a listener for the process
286
+ // to outlive.
287
+ const abandon = async (cause) => {
288
+ compilerAbort.abort();
289
+ await closeListener?.().catch(() => undefined);
290
+ await removeVisualSession(paths).catch(() => undefined);
291
+ throw cause;
292
+ };
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);
305
+ const capabilities = {
306
+ chat: request.chatEnabled,
307
+ choices: request.chatEnabled,
308
+ navigation: true,
309
+ modelReplacement: true,
310
+ transcript: true,
311
+ };
312
+ let listening = false;
313
+ let bootstrapSpent = false;
314
+ let agentAttached = false;
315
+ let lastSequence = 0;
316
+ let transcriptBytes = 0;
317
+ let frozen;
318
+ let inFlight;
319
+ let lastSeenAt;
320
+ let pending = [];
321
+ /**
322
+ * What the browser has seen said, in order. It is derived from the same
323
+ * events and responses the journal takes, so a browser that reloads inside
324
+ * the grace is handed back the conversation it left rather than a blank one.
325
+ */
326
+ const transcript = [];
327
+ /** Option labels the agent presented, so a selection restores as its label. */
328
+ const choiceLabels = new Map();
329
+ /**
330
+ * The choice the agent presented and nobody has answered. It is session
331
+ * state, not transcript: the reviewer's browser can only render the buttons
332
+ * again after a reload if the session hands the question back.
333
+ */
334
+ let pendingChoice = null;
335
+ /**
336
+ * A journaled End is the last thing this session takes from the reviewer, so
337
+ * no later question can ever be answered. The lock outlives the clearing
338
+ * below: an agent response still in flight when the End landed must not hand
339
+ * a reloading browser buttons that lead nowhere.
340
+ */
341
+ let choicesClosed = false;
342
+ const connections = new Map();
343
+ let browserConnectionReservations = 0;
344
+ const polls = new Set();
345
+ const httpSockets = new Set();
346
+ const unclassifiedHttpSockets = new Set();
347
+ const stopResponseSockets = new Set();
348
+ /** Every sequence assignment and queue mutation runs in call order. */
349
+ let admission = Promise.resolve();
350
+ const admit = (action) => {
351
+ const next = admission.then(action);
352
+ admission = next.then(() => undefined, () => undefined);
353
+ return next;
354
+ };
355
+ /**
356
+ * Waits until nothing is left on the admission chain. `admission` absorbs its
357
+ * own rejections, and work can still enqueue while draining (it refuses
358
+ * itself), so the tail is re-read until it stops moving.
359
+ */
360
+ const drainAdmission = async () => {
361
+ for (let tail = admission;; tail = admission) {
362
+ await tail;
363
+ if (tail === admission)
364
+ return;
365
+ }
366
+ };
367
+ /**
368
+ * Ends a session that reached one of its own ceilings, exactly once.
369
+ *
370
+ * `freeze` is reached from inside the admission chain and the transition
371
+ * drains that same chain, so the entry that detected the limit is allowed to
372
+ * answer its sender first: starting the transition inline would be a session
373
+ * waiting on itself. Nothing is truncated — every record already accepted
374
+ * stands, and only the closing one is added.
375
+ */
376
+ let limitFailure;
377
+ const failOnLimit = () => {
378
+ limitFailure ??= (async () => {
379
+ await drainAdmission();
380
+ try {
381
+ // `server-failed` is the truth of it: not the reviewer, not the child,
382
+ // but the runtime is why the conversation stopped. Recovery reads that
383
+ // back as a failed handoff carrying everything the session did accept.
384
+ await terminateVisualSession(active, 'server-failed');
385
+ }
386
+ finally {
387
+ // Whatever the journal managed, the child must not be left holding a
388
+ // turn on a session that has stopped answering.
389
+ settleAllPolls();
390
+ }
391
+ })().catch(() => undefined);
392
+ };
393
+ /**
394
+ * The first limit a session reaches is the one it reports for good, and a
395
+ * limit the runtime detected for itself also ends it.
396
+ */
397
+ const freeze = (reason) => {
398
+ if (frozen !== undefined)
399
+ return;
400
+ frozen = reason;
401
+ if (LIMIT_FREEZE[reason])
402
+ failOnLimit();
403
+ };
404
+ /**
405
+ * Everything a terminal cause touches, in one place. The transition below is
406
+ * the only thing that moves a live session off `running`.
407
+ */
408
+ const active = {
409
+ paths,
410
+ lifecycle: 'starting',
411
+ compilerAbort,
412
+ freeze,
413
+ quiesce: drainAdmission,
414
+ terminalEvent: { now, randomBytes },
415
+ terminating: undefined,
416
+ handoff: undefined,
417
+ };
418
+ const schedule = options.schedule ??
419
+ ((task, ms) => {
420
+ const timer = setTimeout(() => void task().catch(() => undefined), ms);
421
+ // A grace nobody is waiting on must never be why a process stays alive.
422
+ timer.unref();
423
+ return () => clearTimeout(timer);
424
+ });
425
+ /**
426
+ * The reviewer's browser is gone and the session is holding the conversation
427
+ * open for it. Reconnecting inside the window resumes exactly where it left
428
+ * off; reaching the end of it is a terminal cause like any other.
429
+ */
430
+ let cancelGrace;
431
+ const armGrace = () => {
432
+ if (cancelGrace !== undefined || active.lifecycle !== 'running')
433
+ return;
434
+ cancelGrace = schedule(async () => {
435
+ cancelGrace = undefined;
436
+ if (connections.size > 0 || active.lifecycle !== 'running')
437
+ return;
438
+ await stop('browser-timeout');
439
+ }, VISUAL_LIMITS.reconnectMs);
440
+ };
441
+ const disarmGrace = () => {
442
+ cancelGrace?.();
443
+ cancelGrace = undefined;
444
+ };
445
+ /** A journaled browser event, as the line the reviewer sees. */
446
+ const recordEvent = (event) => {
447
+ // A session on its way out waits on nobody, and asking past the question
448
+ // is how a reviewer declines to answer it.
449
+ if (event.type === 'chat.message' || event.type === 'session.end') {
450
+ pendingChoice = null;
451
+ }
452
+ if (event.type === 'session.end')
453
+ choicesClosed = true;
454
+ if (event.type === 'chat.message') {
455
+ transcript.push({
456
+ id: event.eventId,
457
+ speaker: 'reviewer',
458
+ text: event.payload.text,
459
+ });
460
+ return;
461
+ }
462
+ if (event.type !== 'choice.selected')
463
+ return;
464
+ if (pendingChoice?.choiceId === event.payload.choiceId)
465
+ pendingChoice = null;
466
+ // The reviewer chose a label, not an identifier, so that is what the
467
+ // restored conversation says they chose.
468
+ const label = choiceLabels
469
+ .get(event.payload.choiceId)
470
+ ?.get(event.payload.optionId);
471
+ transcript.push({
472
+ id: event.eventId,
473
+ speaker: 'reviewer',
474
+ text: label ?? event.payload.optionId,
475
+ });
476
+ };
477
+ /** An accepted agent response, as the line the reviewer sees. */
478
+ const recordResponse = (response) => {
479
+ if (response.type === 'choice.present') {
480
+ if (!choicesClosed)
481
+ pendingChoice = response.payload;
482
+ choiceLabels.set(response.payload.choiceId, new Map(response.payload.options.map((option) => [option.id, option.label])));
483
+ return;
484
+ }
485
+ const text = response.type === 'chat.response'
486
+ ? response.payload.text
487
+ : response.type === 'handoff.complete'
488
+ ? response.payload.summary
489
+ : undefined;
490
+ if (text === undefined)
491
+ return;
492
+ transcript.push({ id: response.responseId, speaker: 'agent', text });
493
+ };
494
+ const snapshot = () => ({
495
+ protocolVersion: VISUAL_PROTOCOL_VERSION,
496
+ sessionId,
497
+ authority: request.authority,
498
+ title: request.title,
499
+ description: request.description,
500
+ chatEnabled: request.chatEnabled,
501
+ capabilities,
502
+ webSocketUrl,
503
+ model: rendered,
504
+ transcript: [...transcript],
505
+ agentTurnOpen: openTurn(),
506
+ pendingChoice,
507
+ styleNonce,
508
+ lastSequence,
509
+ frozen: frozen !== undefined,
510
+ });
511
+ const status = () => {
512
+ const value = {
513
+ format: 'yarramate/visual-status/v1',
514
+ protocolVersion: VISUAL_PROTOCOL_VERSION,
515
+ sessionId,
516
+ lifecycle: active.lifecycle,
517
+ alreadyStopped: active.lifecycle === 'stopped',
518
+ server: { listening, origin },
519
+ browser: {
520
+ connected: connections.size > 0,
521
+ connections: connections.size,
522
+ ...(lastSeenAt === undefined ? {} : { lastSeenAt }),
523
+ // Only while one is actually armed: a session whose window already
524
+ // elapsed is not still waiting for that browser, and must not say so.
525
+ ...(cancelGrace !== undefined && lastSeenAt !== undefined
526
+ ? {
527
+ graceExpiresAt: new Date(Date.parse(lastSeenAt) + VISUAL_LIMITS.reconnectMs).toISOString(),
528
+ }
529
+ : {}),
530
+ },
531
+ agent: {
532
+ attached: agentAttached,
533
+ inFlightEventId: inFlight?.eventId ?? null,
534
+ },
535
+ queue: {
536
+ pendingEvents: pending.length,
537
+ lastSequence,
538
+ frozen: frozen !== undefined,
539
+ ...(frozen === undefined ? {} : { frozenReason: frozen }),
540
+ },
541
+ capabilities,
542
+ transcriptBytes,
543
+ updatedAt: stamp(),
544
+ };
545
+ // A status document that does not validate is a runtime defect, not
546
+ // untrusted input: the agent must never have to parse a broken report.
547
+ const validated = parseVisualStatus(value);
548
+ if (!validated.ok) {
549
+ throw serverError(validated.diagnostics[0]?.code ?? 'YMVS108', `Session status is invalid: ${validated.diagnostics[0]?.message ?? 'unknown violation'}`);
550
+ }
551
+ return value;
552
+ };
553
+ const sendFrame = (socket, frame) => {
554
+ if (socket.readyState === socket.OPEN)
555
+ socket.send(JSON.stringify(frame));
556
+ };
557
+ const broadcast = (frame) => {
558
+ for (const socket of connections.values())
559
+ sendFrame(socket, frame);
560
+ };
561
+ const idleDelivery = () => ({
562
+ waiting: true,
563
+ lastSequence,
564
+ pendingEvents: pending.length,
565
+ });
566
+ /**
567
+ * While a turn is outstanding the only event the agent may see is the one it
568
+ * already owns, so a repeated poll replays it and a later chat message waits
569
+ * for the current response.
570
+ */
571
+ const deliverable = (after) => inFlight === undefined
572
+ ? pending.find((event) => event.sequence > after)
573
+ : inFlight.sequence > after
574
+ ? inFlight
575
+ : undefined;
576
+ const takeDelivery = (after) => {
577
+ const event = deliverable(after);
578
+ if (event === undefined)
579
+ return idleDelivery();
580
+ inFlight = event;
581
+ return {
582
+ waiting: false,
583
+ event,
584
+ lastSequence,
585
+ pendingEvents: pending.length,
586
+ };
587
+ };
588
+ const wakePolls = () => {
589
+ for (const poll of [...polls]) {
590
+ if (deliverable(poll.after) === undefined)
591
+ continue;
592
+ polls.delete(poll);
593
+ clearTimeout(poll.timer);
594
+ poll.settle(takeDelivery(poll.after));
595
+ }
596
+ };
597
+ const settleAllPolls = () => {
598
+ for (const poll of [...polls]) {
599
+ polls.delete(poll);
600
+ clearTimeout(poll.timer);
601
+ poll.settle(idleDelivery());
602
+ }
603
+ };
604
+ const httpServer = createServer();
605
+ httpServer.on('connection', (socket) => {
606
+ httpSockets.add(socket);
607
+ unclassifiedHttpSockets.add(socket);
608
+ socket.on('close', () => {
609
+ httpSockets.delete(socket);
610
+ unclassifiedHttpSockets.delete(socket);
611
+ stopResponseSockets.delete(socket);
612
+ });
613
+ });
614
+ httpServer.on('clientError', (_error, socket) => socket.destroy());
615
+ const bound = Promise.withResolvers();
616
+ httpServer.once('error', bound.reject);
617
+ // Loopback only, on a kernel-assigned port: the session is never reachable
618
+ // 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);
621
+ bound.resolve();
622
+ });
623
+ await bound.promise.catch(abandon);
624
+ const address = httpServer.address();
625
+ const origin = `http://127.0.0.1:${address.port}`;
626
+ const authority = `127.0.0.1:${address.port}`;
627
+ const webSocketUrl = `ws://${authority}${VISUAL_SOCKET_PATH}`;
628
+ listening = true;
629
+ const sockets = new WebSocketServer({
630
+ noServer: true,
631
+ maxPayload: VISUAL_SERVER_LIMITS.browserFrameBytes,
632
+ });
633
+ // A start that fails after the port is bound has to give it back; nothing
634
+ // has published this origin yet, so there is nothing to drain first.
635
+ closeListener = async () => {
636
+ listening = false;
637
+ sockets.close();
638
+ for (const socket of httpSockets)
639
+ socket.destroy();
640
+ const shut = Promise.withResolvers();
641
+ httpServer.close(() => shut.resolve());
642
+ await shut.promise;
643
+ };
644
+ // ---------------------------------------------------------------- responses
645
+ const respond = (server, code, body, contentType) => {
646
+ server.writeHead(code, {
647
+ ...browserHeaders,
648
+ 'Content-Type': contentType,
649
+ 'Content-Length': Buffer.byteLength(body),
650
+ // A draining session must not leave a pooled connection behind that
651
+ // outlives its listener.
652
+ ...(active.lifecycle === 'running' ? {} : { Connection: 'close' }),
653
+ });
654
+ server.end(body);
655
+ };
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');
658
+ // ------------------------------------------------------------ browser input
659
+ const admitBrowserInput = async (socket, raw, binary) => {
660
+ if (binary) {
661
+ sendFrame(socket, {
662
+ kind: 'rejected',
663
+ diagnostics: [
664
+ serverDiagnostic('YMVS303', 'Browser frames must be UTF-8 JSON text'),
665
+ ],
666
+ });
667
+ return;
668
+ }
669
+ let parsed;
670
+ try {
671
+ parsed = JSON.parse(raw.toString());
672
+ }
673
+ catch {
674
+ sendFrame(socket, {
675
+ kind: 'rejected',
676
+ diagnostics: [
677
+ serverDiagnostic('YMVS109', 'Browser frame is not a JSON document'),
678
+ ],
679
+ });
680
+ return;
681
+ }
682
+ const input = parseVisualBrowserInput(parsed);
683
+ if (!input.ok) {
684
+ sendFrame(socket, { kind: 'rejected', diagnostics: input.diagnostics });
685
+ return;
686
+ }
687
+ await admit(async () => {
688
+ // A frame can already be on the chain when a stop begins. Recovery must
689
+ // see a journal nothing is still writing to, so anything that has not
690
+ // been journaled yet is refused rather than raced.
691
+ if (active.lifecycle !== 'running') {
692
+ sendFrame(socket, {
693
+ kind: 'rejected',
694
+ diagnostics: [
695
+ serverDiagnostic('YMVS306', `Session is ${active.lifecycle} and no longer accepts input`),
696
+ ],
697
+ });
698
+ return;
699
+ }
700
+ if (frozen !== undefined) {
701
+ sendFrame(socket, {
702
+ kind: 'rejected',
703
+ frozen,
704
+ diagnostics: [
705
+ serverDiagnostic('YMVS304', `Session input is frozen: ${frozen}`),
706
+ ],
707
+ });
708
+ return;
709
+ }
710
+ // A session that was started without a conversation has no chat and no
711
+ // choices to make; the diagram, and moving around it, is all it granted.
712
+ const granted = input.value.type === 'chat.message'
713
+ ? capabilities.chat
714
+ : input.value.type === 'choice.selected'
715
+ ? capabilities.choices
716
+ : true;
717
+ if (!granted) {
718
+ sendFrame(socket, {
719
+ kind: 'rejected',
720
+ diagnostics: [
721
+ serverDiagnostic('YMVS309', `Session did not grant the capability "${input.value.type}" needs`, '/type'),
722
+ ],
723
+ });
724
+ return;
725
+ }
726
+ // A browser cannot have seen an acknowledgement the journal never issued.
727
+ // A stale claim is ordinary — a reconnect mid-turn looks exactly like one
728
+ // — but a claim ahead of the journal means this frame was composed against
729
+ // a session state that does not exist, so it is refused rather than
730
+ // journaled behind a sequence its sender never read.
731
+ if (input.value.lastAcknowledgedSequence > lastSequence) {
732
+ sendFrame(socket, {
733
+ kind: 'rejected',
734
+ diagnostics: [
735
+ serverDiagnostic('YMVS308', `Browser acknowledged sequence ${input.value.lastAcknowledgedSequence}, past the journal's ${lastSequence}`, '/lastAcknowledgedSequence'),
736
+ ],
737
+ });
738
+ return;
739
+ }
740
+ const event = eventFrom(input.value, {
741
+ format: 'yarramate/visual-event/v1',
742
+ sessionId,
743
+ sequence: lastSequence + 1,
744
+ eventId: drawHex(16),
745
+ timestamp: stamp(),
746
+ });
747
+ const actionable = isActionableVisualEvent(event);
748
+ if (actionable && pending.length >= VISUAL_LIMITS.pendingEvents) {
749
+ freeze('pending-events');
750
+ sendFrame(socket, {
751
+ kind: 'rejected',
752
+ frozen,
753
+ diagnostics: [
754
+ serverDiagnostic('YMVS305', `Queue already holds the ${VISUAL_LIMITS.pendingEvents} event maximum`, '/sequence'),
755
+ ],
756
+ });
757
+ return;
758
+ }
759
+ const appended = await appendVisualEvent(paths, event);
760
+ if (!appended.ok) {
761
+ if (appended.freeze !== undefined)
762
+ freeze(appended.freeze);
763
+ sendFrame(socket, {
764
+ kind: 'rejected',
765
+ ...(frozen === undefined ? {} : { frozen }),
766
+ diagnostics: appended.diagnostics,
767
+ });
768
+ return;
769
+ }
770
+ lastSequence = appended.lastSequence;
771
+ transcriptBytes = appended.transcriptBytes;
772
+ recordEvent(event);
773
+ if (actionable)
774
+ pending.push(event);
775
+ // A journaled end is the last input this session takes. The agent still
776
+ // has to see it, so the queue freezes rather than the socket closing.
777
+ if (event.type === 'session.end')
778
+ freeze('terminal-event');
779
+ sendFrame(socket, {
780
+ kind: 'accepted',
781
+ sequence: event.sequence,
782
+ eventId: event.eventId,
783
+ });
784
+ wakePolls();
785
+ });
786
+ };
787
+ /**
788
+ * Journals one transport record the runtime owns outright: a browser was
789
+ * admitted, or the socket it was admitted on went away. `closedWith` is the
790
+ * close code for the second and absent for the first.
791
+ *
792
+ * The browser neither sends these nor waits on them. They are non-actionable,
793
+ * so they open no agent turn and never join the pending queue; they take
794
+ * their sequence, identifier, and timestamp from the same admission chain
795
+ * every other record does, so a connection is always journaled ahead of the
796
+ * frames it goes on to send. A session that is no longer taking input skips
797
+ * them, because a shutdown closing its own sockets is not the reviewer
798
+ * leaving. The append is deliberately answered to nobody: a socket callback
799
+ * awaits nothing, so a failure here becomes a freeze rather than a rejection
800
+ * the process never handles.
801
+ */
802
+ const journalConnection = (connectionId, closedWith, onJournaled) => admit(async () => {
803
+ if (active.lifecycle !== 'running' || frozen !== undefined)
804
+ return false;
805
+ const envelope = {
806
+ format: 'yarramate/visual-event/v1',
807
+ sessionId,
808
+ sequence: lastSequence + 1,
809
+ eventId: drawHex(16),
810
+ timestamp: stamp(),
811
+ };
812
+ const event = closedWith === undefined
813
+ ? { ...envelope, type: 'browser.connected', payload: { connectionId } }
814
+ : {
815
+ ...envelope,
816
+ type: 'browser.disconnected',
817
+ payload: { connectionId, code: closedWith },
818
+ };
819
+ const appended = await appendVisualEvent(paths, event);
820
+ if (!appended.ok) {
821
+ if (appended.freeze !== undefined)
822
+ freeze(appended.freeze);
823
+ return false;
824
+ }
825
+ lastSequence = appended.lastSequence;
826
+ transcriptBytes = appended.transcriptBytes;
827
+ onJournaled?.();
828
+ return true;
829
+ }).catch(() => false);
830
+ const attachBrowser = (socket, releaseReservation) => {
831
+ const connectionId = drawHex(16);
832
+ let registered = false;
833
+ lastSeenAt = stamp();
834
+ socket.on('message', (raw, binary) => {
835
+ // Nothing awaits this handler, so a rejection here would reach the
836
+ // process as an unhandled one and take the runtime down with it. The
837
+ // browser is told its frame failed; the session stays up.
838
+ admitBrowserInput(socket, raw, binary).catch((cause) => {
839
+ try {
840
+ sendFrame(socket, {
841
+ kind: 'rejected',
842
+ diagnostics: [
843
+ serverDiagnostic('YMVS307', `Admitting the browser frame failed: ${cause instanceof Error ? cause.message : String(cause)}`),
844
+ ],
845
+ });
846
+ }
847
+ catch {
848
+ socket.close(1011);
849
+ }
850
+ });
851
+ });
852
+ socket.on('error', (error) => {
853
+ // ws refuses an oversized frame before buffering it, which is exactly the
854
+ // byte ceiling the protocol calls a freeze.
855
+ const code = typeof error === 'object' && error !== null && 'code' in error
856
+ ? error.code
857
+ : undefined;
858
+ if (code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH') {
859
+ freeze('message-bytes');
860
+ }
861
+ });
862
+ socket.on('close', (code) => {
863
+ if (!registered)
864
+ return;
865
+ connections.delete(connectionId);
866
+ lastSeenAt = stamp();
867
+ // The code the transport reported, held inside the range the journal
868
+ // accepts: a socket that died without one still has to be recorded as
869
+ // having gone, and 1006 is what that abnormal closure is called.
870
+ void journalConnection(connectionId, Number.isInteger(code) && code >= 1000 && code <= 4999 ? code : 1006);
871
+ if (connections.size === 0)
872
+ armGrace();
873
+ });
874
+ // Admission is one serialized operation: the connection is visible only
875
+ // after its event is durable, so no frame can be accepted against a
876
+ void journalConnection(connectionId, undefined, () => {
877
+ registered = true;
878
+ connections.set(connectionId, socket);
879
+ disarmGrace();
880
+ }).then((journaled) => {
881
+ releaseReservation();
882
+ if (!journaled || socket.readyState !== socket.OPEN) {
883
+ socket.close(1011);
884
+ return;
885
+ }
886
+ sendFrame(socket, { kind: 'ready', snapshot: snapshot() });
887
+ });
888
+ };
889
+ // ------------------------------------------------------------- agent routes
890
+ const answerPoll = (server, query) => {
891
+ const raw = query.get('after') ?? '0';
892
+ const after = Number(raw);
893
+ if (!Number.isSafeInteger(after) || after < 0) {
894
+ refuse(server, 400, `Query "after" must be a sequence, not "${raw}"`);
895
+ return;
896
+ }
897
+ const immediate = takeDelivery(after);
898
+ if (!immediate.waiting || active.lifecycle !== 'running') {
899
+ respondJson(server, 200, immediate);
900
+ return;
901
+ }
902
+ const poll = {
903
+ after,
904
+ settle: (result) => respondJson(server, 200, result),
905
+ timer: setTimeout(() => {
906
+ polls.delete(poll);
907
+ respondJson(server, 200, idleDelivery());
908
+ }, agentPollMs),
909
+ };
910
+ polls.add(poll);
911
+ server.on('close', () => {
912
+ if (polls.delete(poll))
913
+ clearTimeout(poll.timer);
914
+ });
915
+ };
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
+ /**
957
+ * Events a turn-completing response has already answered.
958
+ *
959
+ * `completeTurn` only retires an event the agent actually polled for, so an
960
+ * answer that arrives before the poll leaves the event queued. The browser
961
+ * still has to be told its turn is over, and it is pruned back to the queue
962
+ * it describes so it cannot outgrow it.
963
+ */
964
+ const answered = new Set();
965
+ const openTurn = () => (inFlight === undefined ? pending : [inFlight, ...pending]).some((event) => !answered.has(event.eventId));
966
+ const markAnswered = (response) => {
967
+ if (TURN_COMPLETING[response.type] !== true)
968
+ return;
969
+ answered.add(response.eventId);
970
+ for (const eventId of answered) {
971
+ const queued = inFlight?.eventId === eventId ||
972
+ pending.some((event) => event.eventId === eventId);
973
+ if (!queued)
974
+ answered.delete(eventId);
975
+ }
976
+ };
977
+ const completeTurn = (response) => {
978
+ if (TURN_COMPLETING[response.type] !== true)
979
+ return;
980
+ if (inFlight?.eventId !== response.eventId)
981
+ return;
982
+ pending = pending.filter((event) => event.eventId !== response.eventId);
983
+ inFlight = undefined;
984
+ wakePolls();
985
+ };
986
+ const acceptResponse = async (server, incoming) => {
987
+ const body = await readJsonBody(incoming, VISUAL_SERVER_LIMITS.agentBodyBytes);
988
+ if (!body.ok) {
989
+ refuse(server, body.status, body.message);
990
+ return;
991
+ }
992
+ const parsed = parseVisualResponse(body.value);
993
+ if (!parsed.ok) {
994
+ respondJson(server, 400, {
995
+ accepted: false,
996
+ diagnostics: parsed.diagnostics,
997
+ });
998
+ return;
999
+ }
1000
+ const response = parsed.value;
1001
+ if (response.sessionId !== sessionId) {
1002
+ respondJson(server, 409, {
1003
+ accepted: false,
1004
+ diagnostics: [
1005
+ serverDiagnostic('YMVS126', `Response belongs to session "${response.sessionId}", not "${sessionId}"`, '/sessionId'),
1006
+ ],
1007
+ });
1008
+ return;
1009
+ }
1010
+ // The agent cannot hold a conversation a diagram-only session never
1011
+ // offered; the browser has nowhere to show one.
1012
+ if (!capabilities.chat &&
1013
+ (response.type === 'chat.response' || response.type === 'choice.present')) {
1014
+ respondJson(server, 409, {
1015
+ accepted: false,
1016
+ diagnostics: [
1017
+ serverDiagnostic('YMVS309', `Session did not grant the capability "${response.type}" needs`, '/type'),
1018
+ ],
1019
+ });
1020
+ return;
1021
+ }
1022
+ const result = await admit(async () => {
1023
+ // Same race as a browser frame: a request already on the chain must not
1024
+ // append to a session recovery has begun reading.
1025
+ if (active.lifecycle !== 'running') {
1026
+ return {
1027
+ code: 503,
1028
+ body: {
1029
+ accepted: false,
1030
+ diagnostics: [
1031
+ serverDiagnostic('YMVS306', `Session is ${active.lifecycle} and no longer accepts responses`),
1032
+ ],
1033
+ },
1034
+ };
1035
+ }
1036
+ const appended = await appendVisualResponse(paths, response);
1037
+ if (!appended.ok) {
1038
+ if (appended.freeze !== undefined)
1039
+ freeze(appended.freeze);
1040
+ return {
1041
+ code: 409,
1042
+ body: { accepted: false, diagnostics: appended.diagnostics },
1043
+ };
1044
+ }
1045
+ transcriptBytes = appended.transcriptBytes;
1046
+ // A response the runtime already journaled must not be broadcast or
1047
+ // compiled twice: a retried delivery is not a second turn.
1048
+ if (appended.duplicate) {
1049
+ return {
1050
+ code: 200,
1051
+ body: {
1052
+ accepted: true,
1053
+ duplicate: true,
1054
+ lastSequence: appended.lastSequence,
1055
+ diagnostics: [],
1056
+ },
1057
+ };
1058
+ }
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);
1066
+ return {
1067
+ code: 200,
1068
+ body: {
1069
+ accepted: true,
1070
+ duplicate: false,
1071
+ lastSequence,
1072
+ ...(replaced.model === undefined ? {} : { model: replaced.model }),
1073
+ diagnostics: replaced.diagnostics,
1074
+ },
1075
+ };
1076
+ });
1077
+ respondJson(server, result.code, result.body);
1078
+ };
1079
+ const acceptStop = async (server, incoming) => {
1080
+ const responseSocket = incoming.socket;
1081
+ const body = await readJsonBody(incoming, VISUAL_SERVER_LIMITS.agentControlBytes);
1082
+ if (!body.ok) {
1083
+ refuse(server, body.status, body.message);
1084
+ return;
1085
+ }
1086
+ const asked = typeof body.value === 'object' && body.value !== null
1087
+ ? body.value
1088
+ : {};
1089
+ const reason = asked.reason;
1090
+ if (typeof reason !== 'string' || TERMINATION_REASONS[reason] !== true) {
1091
+ refuse(server, 400, 'Stop requires a known termination reason');
1092
+ return;
1093
+ }
1094
+ if (asked.includeTranscript !== undefined &&
1095
+ typeof asked.includeTranscript !== 'boolean') {
1096
+ refuse(server, 400, 'Stop takes a boolean "includeTranscript" or none');
1097
+ return;
1098
+ }
1099
+ // Every stop response already in flight is protected from the shared
1100
+ // teardown. The response that wins the stop does not get to close its
1101
+ // concurrent callers before they can observe the same result.
1102
+ const closed = await stop(reason, asked.includeTranscript);
1103
+ server.on('finish', () => responseSocket.end());
1104
+ respondJson(server, 200, closed);
1105
+ };
1106
+ const serveAgent = async (incoming, server, pathname, query) => {
1107
+ // An agent is not a browser. A request carrying an Origin came from a page,
1108
+ // which must never be able to spend a leaked agent capability.
1109
+ if (incoming.headers.origin !== undefined) {
1110
+ refuse(server, 403, 'Agent routes reject browser-originated requests');
1111
+ return;
1112
+ }
1113
+ if (!secretEquals(session.agentToken, bearerToken(incoming.headers.authorization))) {
1114
+ refuse(server, 401, 'Agent capability required');
1115
+ return;
1116
+ }
1117
+ agentAttached = true;
1118
+ if (pathname === '/api/agent/events') {
1119
+ if (incoming.method !== 'GET')
1120
+ return refuse(server, 405, 'Use GET');
1121
+ answerPoll(server, query);
1122
+ return;
1123
+ }
1124
+ if (pathname === '/api/agent/status') {
1125
+ if (incoming.method !== 'GET')
1126
+ return refuse(server, 405, 'Use GET');
1127
+ respondJson(server, 200, status());
1128
+ return;
1129
+ }
1130
+ if (pathname === '/api/agent/responses') {
1131
+ if (incoming.method !== 'POST')
1132
+ return refuse(server, 405, 'Use POST');
1133
+ await acceptResponse(server, incoming);
1134
+ return;
1135
+ }
1136
+ if (pathname === '/api/agent/stop') {
1137
+ if (incoming.method !== 'POST')
1138
+ return refuse(server, 405, 'Use POST');
1139
+ await acceptStop(server, incoming);
1140
+ return;
1141
+ }
1142
+ refuse(server, 404, 'No such route');
1143
+ };
1144
+ // ----------------------------------------------------------- browser routes
1145
+ const assetPath = (pathname) => {
1146
+ if (pathname === '/')
1147
+ return join(assetRoot, 'index.html');
1148
+ const prefix = '/assets/';
1149
+ if (!pathname.startsWith(prefix))
1150
+ return undefined;
1151
+ const name = pathname.slice(prefix.length);
1152
+ if (!ASSET_NAME.test(name))
1153
+ return undefined;
1154
+ const target = resolve(assetRoot, 'assets', name);
1155
+ // The name cannot carry a separator, so this only confirms what the pattern
1156
+ // already guaranteed — which is the point of having both.
1157
+ return target.startsWith(`${assetRoot}${sep}`) ? target : undefined;
1158
+ };
1159
+ const serveAsset = async (server, pathname) => {
1160
+ const target = assetPath(pathname);
1161
+ const contentType = target === undefined ? undefined : CONTENT_TYPES[extname(target)];
1162
+ if (target === undefined || contentType === undefined) {
1163
+ refuse(server, 404, 'No such asset');
1164
+ return;
1165
+ }
1166
+ try {
1167
+ // lstat, never stat: a symlink inside the asset root is a link out of it.
1168
+ const entry = await lstat(target);
1169
+ if (!entry.isFile()) {
1170
+ refuse(server, 404, 'No such asset');
1171
+ return;
1172
+ }
1173
+ respond(server, 200, await readFile(target), contentType);
1174
+ }
1175
+ catch {
1176
+ refuse(server, 404, 'No such asset');
1177
+ }
1178
+ };
1179
+ const serveBootstrap = (server, query) => {
1180
+ const key = query.get('key') ?? undefined;
1181
+ if (bootstrapSpent || !secretEquals(session.browserToken, key)) {
1182
+ refuse(server, 403, 'Bootstrap capability is not valid');
1183
+ return;
1184
+ }
1185
+ // One-time by construction: the URL capability buys exactly one cookie, and
1186
+ // the cookie carries a different secret, so a leaked URL cannot be replayed
1187
+ // and a leaked cookie cannot be pasted into an address bar.
1188
+ bootstrapSpent = true;
1189
+ server.writeHead(303, {
1190
+ ...browserHeaders,
1191
+ Location: '/',
1192
+ 'Set-Cookie': `${COOKIE_NAME}=${browserAuthenticator}; HttpOnly; SameSite=Strict; Secure; Path=/`,
1193
+ 'Content-Length': 0,
1194
+ });
1195
+ server.end();
1196
+ };
1197
+ const serveBrowser = async (incoming, server, pathname, query) => {
1198
+ const sent = incoming.headers.origin;
1199
+ if (sent !== undefined && sent !== origin) {
1200
+ refuse(server, 403, 'Origin is not this session');
1201
+ return;
1202
+ }
1203
+ if (incoming.method !== 'GET') {
1204
+ refuse(server, 405, 'Use GET');
1205
+ return;
1206
+ }
1207
+ if (pathname === '/bootstrap') {
1208
+ serveBootstrap(server, query);
1209
+ return;
1210
+ }
1211
+ if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
1212
+ refuse(server, 401, 'Session cookie required');
1213
+ return;
1214
+ }
1215
+ if (pathname === '/api/session') {
1216
+ respondJson(server, 200, snapshot());
1217
+ return;
1218
+ }
1219
+ await serveAsset(server, pathname);
1220
+ };
1221
+ httpServer.on('request', (incoming, server) => {
1222
+ const responseSocket = incoming.socket;
1223
+ unclassifiedHttpSockets.delete(responseSocket);
1224
+ if ((incoming.url ?? '/').split('?')[0] === '/api/agent/stop') {
1225
+ stopResponseSockets.add(responseSocket);
1226
+ const releaseResponseSocket = () => {
1227
+ stopResponseSockets.delete(responseSocket);
1228
+ };
1229
+ server.once('finish', releaseResponseSocket);
1230
+ server.once('close', releaseResponseSocket);
1231
+ }
1232
+ void (async () => {
1233
+ try {
1234
+ // Rebinding defence: a request that reached this port under any other
1235
+ // name was routed here by something other than the browser we started.
1236
+ if (incoming.headers.host !== authority) {
1237
+ refuse(server, 403, 'Host is not the bound loopback authority');
1238
+ return;
1239
+ }
1240
+ const [rawPath = '/', rawQuery = ''] = (incoming.url ?? '/').split('?');
1241
+ let pathname;
1242
+ try {
1243
+ pathname = decodeURIComponent(rawPath);
1244
+ }
1245
+ catch {
1246
+ refuse(server, 400, 'Request target is not a valid URL path');
1247
+ return;
1248
+ }
1249
+ if (pathname.includes('\0')) {
1250
+ refuse(server, 400, 'Request target is not a valid URL path');
1251
+ return;
1252
+ }
1253
+ const query = new URLSearchParams(rawQuery);
1254
+ if (pathname.startsWith('/api/agent/')) {
1255
+ await serveAgent(incoming, server, pathname, query);
1256
+ return;
1257
+ }
1258
+ await serveBrowser(incoming, server, pathname, query);
1259
+ }
1260
+ catch (cause) {
1261
+ if (server.headersSent)
1262
+ server.destroy(cause);
1263
+ else
1264
+ refuse(server, 500, 'Session server failed');
1265
+ }
1266
+ })();
1267
+ });
1268
+ httpServer.on('upgrade', (incoming, socket, head) => {
1269
+ const deny = (code, message) => {
1270
+ socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
1271
+ socket.destroy();
1272
+ };
1273
+ if (active.lifecycle !== 'running')
1274
+ return deny(503, 'Service Unavailable');
1275
+ if (incoming.headers.host !== authority)
1276
+ return deny(403, 'Forbidden');
1277
+ // An upgrade always carries an Origin, so it is checked without exception.
1278
+ if (incoming.headers.origin !== origin)
1279
+ return deny(403, 'Forbidden');
1280
+ const [rawPath = '/'] = (incoming.url ?? '/').split('?');
1281
+ if (rawPath !== VISUAL_SOCKET_PATH)
1282
+ return deny(404, 'Not Found');
1283
+ if (!secretEquals(browserAuthenticator, cookieValue(incoming.headers.cookie, COOKIE_NAME))) {
1284
+ return deny(401, 'Unauthorized');
1285
+ }
1286
+ if (connections.size + browserConnectionReservations >=
1287
+ VISUAL_SERVER_LIMITS.browserConnections) {
1288
+ return deny(503, 'Service Unavailable');
1289
+ }
1290
+ browserConnectionReservations += 1;
1291
+ try {
1292
+ sockets.handleUpgrade(incoming, socket, head, (browser) => {
1293
+ attachBrowser(browser, () => {
1294
+ browserConnectionReservations -= 1;
1295
+ });
1296
+ });
1297
+ }
1298
+ catch (cause) {
1299
+ browserConnectionReservations -= 1;
1300
+ throw cause;
1301
+ }
1302
+ });
1303
+ // ----------------------------------------------------------------- shutdown
1304
+ const { promise: closed, resolve: announceClosed } = Promise.withResolvers();
1305
+ let stopping;
1306
+ const stop = async (reason, includeTranscript = options.includeTranscript ?? false) => {
1307
+ if (stopping !== undefined) {
1308
+ return { ...(await stopping), alreadyStopped: true };
1309
+ }
1310
+ const attempt = (async () => {
1311
+ // Frozen before anything is torn down: a request already on the wire
1312
+ // must not be journaled behind the recovery this stop is about to read.
1313
+ active.lifecycle = 'draining';
1314
+ listening = false;
1315
+ disarmGrace();
1316
+ // Waiting agents are answered before any socket is torn down, so a poll
1317
+ // in flight during a stop still receives its non-terminal idle result.
1318
+ settleAllPolls();
1319
+ for (const socket of connections.values()) {
1320
+ sendFrame(socket, { kind: 'closing', reason });
1321
+ socket.close(1001);
1322
+ }
1323
+ sockets.close();
1324
+ // The one transition: freeze, cancel the compiler, let admitted work
1325
+ // finish, journal the terminal event, and recover — all without deleting
1326
+ // anything, so shutdown can never be the step that loses confirmed state.
1327
+ // A session that already ended answers with the handoff it ended with.
1328
+ const terminal = await terminateVisualSession(active, reason);
1329
+ // Keep the listener alive while stop requests already reaching the port
1330
+ // join the shared attempt. Closing it earlier resets accepted idle
1331
+ // sockets before Node has emitted their request events.
1332
+ httpServer.close();
1333
+ // Stop requests already on the wire, including accepted sockets whose
1334
+ // request event has not fired yet, must survive long enough to answer.
1335
+ for (const socket of httpSockets) {
1336
+ if (!stopResponseSockets.has(socket) &&
1337
+ !unclassifiedHttpSockets.has(socket)) {
1338
+ socket.end();
1339
+ }
1340
+ }
1341
+ // The transcript is read once, here, and only if this stop asked for it;
1342
+ // the same read is what removes the marked root.
1343
+ const removed = await removeVisualSession(paths, includeTranscript);
1344
+ active.lifecycle = 'stopped';
1345
+ const outcome = {
1346
+ reason,
1347
+ alreadyStopped: false,
1348
+ handoff: removed ?? terminal,
1349
+ };
1350
+ announceClosed(outcome);
1351
+ return outcome;
1352
+ })();
1353
+ // Every step of a teardown is idempotent, and one that throws has left the
1354
+ // session directory behind for someone to finish. Forgetting the failed
1355
+ // attempt is what makes the next stop that fresh run rather than a replay
1356
+ // of the same rejection; observing it here is what keeps the cached promise
1357
+ // from being a rejection nothing ever handled.
1358
+ stopping = attempt;
1359
+ attempt.catch(() => {
1360
+ if (stopping === attempt)
1361
+ stopping = undefined;
1362
+ });
1363
+ return attempt;
1364
+ };
1365
+ // Everything from here is publication, and every way it can fail — a
1366
+ // descriptor that will not write, a document that does not validate, a clock
1367
+ // that answers with nonsense — leaves a bound port and a session directory
1368
+ // behind unless it goes through `abandon`.
1369
+ let started;
1370
+ try {
1371
+ await writeVisualSessionDescriptor(paths, {
1372
+ format: 'yarramate/visual-session-descriptor/v1',
1373
+ protocolVersion: VISUAL_PROTOCOL_VERSION,
1374
+ sessionId,
1375
+ origin,
1376
+ agentCapability: session.agentToken,
1377
+ sessionRoot: paths.root,
1378
+ journalPath: paths.journal,
1379
+ createdAt: stamp(),
1380
+ });
1381
+ started = {
1382
+ format: 'yarramate/visual-session-started/v1',
1383
+ protocolVersion: VISUAL_PROTOCOL_VERSION,
1384
+ sessionId,
1385
+ authority: request.authority,
1386
+ title: request.title,
1387
+ chatEnabled: request.chatEnabled,
1388
+ browserUrl: `${origin}/bootstrap?key=${session.browserToken}`,
1389
+ webSocketUrl,
1390
+ origin,
1391
+ descriptorPath: paths.descriptor,
1392
+ sessionRoot: paths.root,
1393
+ capabilities,
1394
+ startedAt: stamp(),
1395
+ };
1396
+ const validated = parseVisualSessionStarted(started);
1397
+ if (!validated.ok) {
1398
+ throw serverError(validated.diagnostics[0]?.code ?? 'YMVS102', `Started session is invalid: ${validated.diagnostics[0]?.message ?? 'unknown violation'}`);
1399
+ }
1400
+ }
1401
+ catch (cause) {
1402
+ return abandon(cause);
1403
+ }
1404
+ active.lifecycle = 'running';
1405
+ return { started, closed, status, stop: (reason) => stop(reason) };
1406
+ };