@vibecook/ghosttea-react 0.10.1 → 0.11.1

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 (40) hide show
  1. package/README.md +67 -0
  2. package/dist/TerminalSurface.d.ts +5 -0
  3. package/dist/TerminalSurface.d.ts.map +1 -1
  4. package/dist/TerminalSurface.js +19 -4
  5. package/dist/TerminalSurface.js.map +1 -1
  6. package/dist/index.d.ts +4 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/performance.d.ts +31 -0
  11. package/dist/performance.d.ts.map +1 -1
  12. package/dist/performance.js.map +1 -1
  13. package/dist/routed-activation.d.ts +110 -0
  14. package/dist/routed-activation.d.ts.map +1 -0
  15. package/dist/routed-activation.js +287 -0
  16. package/dist/routed-activation.js.map +1 -0
  17. package/dist/routed-control.d.ts +79 -0
  18. package/dist/routed-control.d.ts.map +1 -0
  19. package/dist/routed-control.js +397 -0
  20. package/dist/routed-control.js.map +1 -0
  21. package/dist/routed-frames.d.ts +69 -0
  22. package/dist/routed-frames.d.ts.map +1 -0
  23. package/dist/routed-frames.js +660 -0
  24. package/dist/routed-frames.js.map +1 -0
  25. package/dist/runtime.d.ts +96 -4
  26. package/dist/runtime.d.ts.map +1 -1
  27. package/dist/runtime.js +1227 -100
  28. package/dist/runtime.js.map +1 -1
  29. package/dist/terminal-render.worker.js +1089 -45
  30. package/dist/terminal-render.worker.js.map +3 -3
  31. package/dist/worker-messages.d.ts +18 -1
  32. package/dist/worker-messages.d.ts.map +1 -1
  33. package/dist/workspace/Workspace.d.ts +12 -1
  34. package/dist/workspace/Workspace.d.ts.map +1 -1
  35. package/dist/workspace/Workspace.js +77 -14
  36. package/dist/workspace/Workspace.js.map +1 -1
  37. package/dist/workspace/index.d.ts +1 -1
  38. package/dist/workspace/index.d.ts.map +1 -1
  39. package/dist/workspace/index.js.map +1 -1
  40. package/package.json +4 -4
package/dist/runtime.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { ControlClient } from "@vibecook/ghosttea";
2
- import { PROTOCOL_MAJOR, PROTOCOL_MINOR, SESSION_SCROLLBACK_PROTOCOL_MINOR, STRUCTURED_ERROR_PROTOCOL_MINOR, isValidScrollbackBytes, } from "@vibecook/ghosttea-protocol";
2
+ import { DEFAULT_ROUTED_PROTOCOL_LIMITS, PROTOCOL_MAJOR, PROTOCOL_MINOR, SESSION_SCROLLBACK_PROTOCOL_MINOR, STRUCTURED_ERROR_PROTOCOL_MINOR, isRoutedSessionAttachGrant, isRoutedTerminalOpenTicket, isValidScrollbackBytes, } from "@vibecook/ghosttea-protocol";
3
3
  import { FRAME_MAGIC, FrameFlag } from "@vibecook/ghosttea-frame";
4
4
  import { FrameResyncController } from "./frame-resync.js";
5
+ import { initialRoutedActivation, reduceRoutedActivation, } from "./routed-activation.js";
6
+ import { RoutedControlTransport, } from "./routed-control.js";
7
+ const MAX_BROWSER_TIMEOUT_MS = 2_147_483_647;
8
+ function routedConnectionRefusalIsRecoverable(refusal) {
9
+ return refusal.retryable || refusal.code === "GRANT_GENERATION_ROLLBACK" || refusal.code === "GRANT_NONCE_REPLAYED";
10
+ }
5
11
  function sameSessionActivity(left, right) {
6
12
  return (left.kind === right.kind &&
7
13
  left.source === right.source &&
@@ -47,6 +53,14 @@ export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
47
53
  export class GhostteaTerminalRuntime extends EventTarget {
48
54
  #worker;
49
55
  #ports;
56
+ #routedHost;
57
+ #routedReceiverCapacities;
58
+ #routedCapabilities;
59
+ #routedControl;
60
+ #routedBySession = new Map();
61
+ #routedByActivation = new Map();
62
+ #routedGeometry = new Map();
63
+ #routedAttachDeadlineByCell = new Map();
50
64
  #platform;
51
65
  #clientBuild;
52
66
  #sessionOwnerId;
@@ -87,16 +101,33 @@ export class GhostteaTerminalRuntime extends EventTarget {
87
101
  #configSnapshot;
88
102
  #configProtocolSupported = false;
89
103
  #metadataTimers = new Map();
104
+ #metadataRefreshes = new Map();
105
+ #metadataRefreshPending = new Set();
106
+ #sessionGenerationByHandle = new Map();
107
+ #appliedRoutedExitEvents = new Set();
108
+ #sessionGeneration = 0;
90
109
  #resync;
91
110
  #performanceRequestId = 1;
92
111
  #performanceRequests = new Map();
112
+ #counterRequests = new Map();
93
113
  #disposed = false;
94
114
  constructor(options) {
95
115
  super();
96
116
  this.#worker =
97
117
  options.workerFactory?.() ??
98
118
  new Worker(new URL("./terminal-render.worker.js", import.meta.url), { type: "module" });
99
- this.#ports = Promise.resolve(options.ports);
119
+ this.#ports = options.transport === "routed" ? undefined : Promise.resolve(options.ports);
120
+ this.#routedHost = options.transport === "routed" ? options.host : undefined;
121
+ this.#routedReceiverCapacities = options.transport === "routed" ? options.receiverCapacities : undefined;
122
+ this.#routedCapabilities = options.transport === "routed" ? (options.capabilities ?? ["resume"]) : [];
123
+ this.#routedControl =
124
+ options.transport === "routed"
125
+ ? new RoutedControlTransport({
126
+ ...(options.websocketFactory === undefined ? {} : { socketFactory: options.websocketFactory }),
127
+ acceptExtensionMessages: options.host.onExtensionMessage !== undefined,
128
+ emit: (event) => this.#handleRoutedControlEvent(event),
129
+ })
130
+ : undefined;
100
131
  this.#platform = options.platform;
101
132
  this.#clientBuild = options.clientBuild ?? "ghosttea-react";
102
133
  this.#sessionOwnerId = options.sessionOwnerId;
@@ -137,6 +168,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
137
168
  this.#resync.complete(data.sessionHandle);
138
169
  }
139
170
  else if (data.type === "frame-committed") {
171
+ if (this.#routedHost?.getSession)
172
+ this.#scheduleMetadataRefresh(data.sessionHandle);
140
173
  this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
141
174
  }
142
175
  else if (data.type === "catalog-pressure") {
@@ -152,6 +185,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
152
185
  else if (data.type === "performance-result") {
153
186
  this.#resolvePerformanceRequest(data.requestId, data.snapshot);
154
187
  }
188
+ else if (data.type === "performance-counters") {
189
+ const pending = this.#counterRequests.get(data.requestId);
190
+ if (!pending)
191
+ return;
192
+ window.clearTimeout(pending.timer);
193
+ this.#counterRequests.delete(data.requestId);
194
+ pending.resolve(data.snapshot);
195
+ }
196
+ else if (data.type === "routed-frames-event") {
197
+ this.#handleRoutedFramesEvent(data.event);
198
+ }
155
199
  else if (data.type === "renderer-reload-required") {
156
200
  console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
157
201
  this.#platform.setForceCanvasFallback(true);
@@ -171,6 +215,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
171
215
  get rendererBackend() {
172
216
  return this.#rendererBackend;
173
217
  }
218
+ /** Current main-authority state for a routed session. */
219
+ routedActivation(sessionId) {
220
+ return this.#routedBySession.get(sessionId)?.state;
221
+ }
222
+ routedViewInputAllowed(viewId) {
223
+ const view = this.#views.get(viewId);
224
+ if (!view?.clientReadWrite || view.readWrite === false)
225
+ return false;
226
+ return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
227
+ }
174
228
  #resolvePerformanceRequest(requestId, value) {
175
229
  const pending = this.#performanceRequests.get(requestId);
176
230
  if (!pending)
@@ -203,6 +257,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
203
257
  throw new Error("Terminal render worker returned no performance snapshot");
204
258
  return result;
205
259
  }
260
+ /** Reads monotonic production counters without starting a sample window or draining the GPU. */
261
+ readPerformanceCounters(timeoutMs = 2_000) {
262
+ if (this.#disposed)
263
+ return Promise.reject(new Error("Terminal runtime is disposed"));
264
+ const requestId = this.#performanceRequestId++;
265
+ return new Promise((resolve, reject) => {
266
+ const timer = window.setTimeout(() => {
267
+ this.#counterRequests.delete(requestId);
268
+ reject(new Error(`Terminal render counter request ${requestId} timed out`));
269
+ }, timeoutMs);
270
+ this.#counterRequests.set(requestId, { resolve, reject, timer });
271
+ this.#postWorker({ type: "performance-counters", requestId });
272
+ });
273
+ }
206
274
  connect() {
207
275
  if (this.#disposed)
208
276
  return Promise.reject(new Error("Terminal runtime is disposed"));
@@ -210,6 +278,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
210
278
  return this.#ready;
211
279
  }
212
280
  async #connect() {
281
+ if (this.#routedHost)
282
+ return;
213
283
  const ports = await this.#ports;
214
284
  if (this.#disposed) {
215
285
  ports.control.close();
@@ -220,22 +290,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
220
290
  this.#control = new ControlClient(ports.control);
221
291
  this.#control.addEventListener("session-exited", (event) => {
222
292
  const detail = event.detail;
223
- const handle = this.#handleBySessionId.get(detail.sessionId);
224
- const session = handle ? this.#sessionByHandle.get(handle) : undefined;
225
- if (!handle || !session)
226
- return;
227
- this.#cancelMetadataRefresh(handle);
228
- const exited = {
229
- ...session,
230
- exited: true,
231
- exitCode: detail.exitCode,
232
- exitSignal: detail.exitSignal,
233
- requestedTermination: detail.requestedTermination,
234
- exitOutcome: detail.exitOutcome,
235
- };
236
- this.#sessionByHandle.set(handle, exited);
237
- this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
238
- this.dispatchEvent(new CustomEvent("session-exited", { detail }));
293
+ this.#applySessionExited(detail);
239
294
  });
240
295
  this.#control.addEventListener("events-lost", () => {
241
296
  // The daemon dropped events faster than this client drained them. Any
@@ -245,14 +300,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
245
300
  });
246
301
  this.#control.addEventListener("session-activity-changed", (event) => {
247
302
  const detail = event.detail;
248
- const handle = this.#handleBySessionId.get(detail.sessionId);
249
- const session = handle ? this.#sessionByHandle.get(handle) : undefined;
250
- if (!handle || !session || session.exited)
251
- return;
252
- const updated = { ...session, activity: detail.activity };
253
- this.#sessionByHandle.set(handle, updated);
254
- this.dispatchEvent(new CustomEvent("session-activity", { detail }));
255
- this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated }));
303
+ this.#applySessionActivity(detail);
256
304
  });
257
305
  this.#control.addEventListener("control-changed", (event) => {
258
306
  const detail = event.detail;
@@ -342,6 +390,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
342
390
  }
343
391
  async reloadConfig() {
344
392
  await this.connect();
393
+ if (this.#routedHost)
394
+ throw new Error("Configuration reload is not part of the routed host contract");
345
395
  const response = await this.#control.request({ type: "reload-config" });
346
396
  if (response.type !== "config")
347
397
  throw new Error("ghosttead returned an unexpected configuration response");
@@ -400,54 +450,196 @@ export class GhostteaTerminalRuntime extends EventTarget {
400
450
  if (this.#frameFlowControlEnabled)
401
451
  this.#frames?.postMessage({ type: "frame-credit", bytes });
402
452
  }
453
+ #bumpSessionGeneration(sessionHandle) {
454
+ this.#sessionGeneration += 1;
455
+ this.#sessionGenerationByHandle.set(sessionHandle, this.#sessionGeneration);
456
+ return this.#sessionGeneration;
457
+ }
458
+ #applySessionExited(detail) {
459
+ const handle = this.#handleBySessionId.get(detail.sessionId);
460
+ const session = handle ? this.#sessionByHandle.get(handle) : undefined;
461
+ if (!handle || !session)
462
+ return;
463
+ this.#cancelMetadataRefresh(handle);
464
+ const exited = {
465
+ ...session,
466
+ exited: true,
467
+ exitCode: detail.exitCode,
468
+ exitSignal: detail.exitSignal,
469
+ requestedTermination: detail.requestedTermination,
470
+ exitOutcome: detail.exitOutcome,
471
+ };
472
+ this.#sessionByHandle.set(handle, exited);
473
+ this.#bumpSessionGeneration(handle);
474
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
475
+ this.dispatchEvent(new CustomEvent("session-exited", { detail }));
476
+ }
477
+ #applySessionActivity(detail) {
478
+ const handle = this.#handleBySessionId.get(detail.sessionId);
479
+ const session = handle ? this.#sessionByHandle.get(handle) : undefined;
480
+ if (!handle || !session || session.exited)
481
+ return;
482
+ const updated = { ...session, activity: detail.activity };
483
+ this.#sessionByHandle.set(handle, updated);
484
+ this.#bumpSessionGeneration(handle);
485
+ this.dispatchEvent(new CustomEvent("session-activity", { detail }));
486
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: updated }));
487
+ }
488
+ #applyRoutedSessionSummary(sessionHandle, next, expectedGeneration) {
489
+ const previous = this.#sessionByHandle.get(sessionHandle);
490
+ if (!previous ||
491
+ previous.exited ||
492
+ previous.id !== next.id ||
493
+ next.handle !== sessionHandle ||
494
+ this.#handleBySessionId.get(next.id) !== sessionHandle ||
495
+ (expectedGeneration !== undefined && this.#sessionGenerationByHandle.get(sessionHandle) !== expectedGeneration)) {
496
+ return false;
497
+ }
498
+ this.#sessionByHandle.set(sessionHandle, next);
499
+ this.#bumpSessionGeneration(sessionHandle);
500
+ if (previous.title !== next.title ||
501
+ previous.cwd !== next.cwd ||
502
+ previous.exited !== next.exited ||
503
+ !sameSessionActivity(previous.activity, next.activity)) {
504
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: next }));
505
+ }
506
+ return true;
507
+ }
403
508
  #scheduleMetadataRefresh(sessionHandle) {
404
509
  const scheduledSession = this.#sessionByHandle.get(sessionHandle);
405
510
  if (!scheduledSession || scheduledSession.exited)
406
511
  return;
512
+ if (this.#routedHost?.getSession && this.#metadataRefreshes.has(sessionHandle)) {
513
+ this.#metadataRefreshPending.add(sessionHandle);
514
+ return;
515
+ }
407
516
  if (this.#metadataTimers.has(sessionHandle))
408
517
  return;
409
518
  const timer = window.setTimeout(() => {
410
519
  this.#metadataTimers.delete(sessionHandle);
411
520
  const session = this.#sessionByHandle.get(sessionHandle);
412
- if (!session || session.exited || !this.#control)
521
+ if (!session || session.exited)
413
522
  return;
414
- void this.#control
415
- .request({ type: "get-session", sessionId: session.id })
416
- .then((response) => {
417
- if (response.type !== "session")
418
- return;
419
- const previous = this.#sessionByHandle.get(sessionHandle);
420
- if (!previous || previous.id !== response.session.id || previous.exited)
421
- return;
422
- this.#sessionByHandle.set(sessionHandle, response.session);
423
- if (previous.title !== response.session.title ||
424
- previous.cwd !== response.session.cwd ||
425
- previous.exited !== response.session.exited ||
426
- !sameSessionActivity(previous.activity, response.session.activity)) {
427
- this.dispatchEvent(new CustomEvent("session-metadata", { detail: response.session }));
428
- }
429
- })
430
- .catch((error) => {
431
- const current = this.#sessionByHandle.get(sessionHandle);
432
- if (!current || current.exited || this.#disposed)
433
- return;
434
- console.warn("[terminal-runtime] session metadata refresh failed", error);
435
- });
523
+ if (this.#control) {
524
+ void this.#control
525
+ .request({ type: "get-session", sessionId: session.id })
526
+ .then((response) => {
527
+ if (response.type !== "session")
528
+ return;
529
+ const previous = this.#sessionByHandle.get(sessionHandle);
530
+ if (!previous || previous.id !== response.session.id || previous.exited)
531
+ return;
532
+ this.#sessionByHandle.set(sessionHandle, response.session);
533
+ if (previous.title !== response.session.title ||
534
+ previous.cwd !== response.session.cwd ||
535
+ previous.exited !== response.session.exited ||
536
+ !sameSessionActivity(previous.activity, response.session.activity)) {
537
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: response.session }));
538
+ }
539
+ })
540
+ .catch((error) => {
541
+ const current = this.#sessionByHandle.get(sessionHandle);
542
+ if (!current || current.exited || this.#disposed)
543
+ return;
544
+ console.warn("[terminal-runtime] session metadata refresh failed", error);
545
+ });
546
+ return;
547
+ }
548
+ if (this.#routedHost?.getSession)
549
+ this.#startRoutedMetadataRefresh(sessionHandle, session);
436
550
  }, 200);
437
551
  this.#metadataTimers.set(sessionHandle, timer);
438
552
  }
553
+ #startRoutedMetadataRefresh(sessionHandle, session) {
554
+ const host = this.#routedHost;
555
+ const getSession = host?.getSession;
556
+ if (!host || !getSession || this.#disposed)
557
+ return;
558
+ if (this.#metadataRefreshes.has(sessionHandle)) {
559
+ this.#metadataRefreshPending.add(sessionHandle);
560
+ return;
561
+ }
562
+ const expectedGeneration = this.#sessionGenerationByHandle.get(sessionHandle);
563
+ const refresh = Promise.resolve()
564
+ .then(() => getSession.call(host, session.id))
565
+ .then((next) => {
566
+ if (next === null)
567
+ return;
568
+ this.#applyRoutedSessionSummary(sessionHandle, next, expectedGeneration);
569
+ })
570
+ .catch((error) => {
571
+ const current = this.#sessionByHandle.get(sessionHandle);
572
+ if (!current || current.exited || this.#disposed)
573
+ return;
574
+ console.warn("[terminal-runtime] session metadata refresh failed", error);
575
+ })
576
+ .finally(() => {
577
+ if (this.#metadataRefreshes.get(sessionHandle) !== refresh)
578
+ return;
579
+ this.#metadataRefreshes.delete(sessionHandle);
580
+ if (!this.#metadataRefreshPending.delete(sessionHandle) || this.#disposed)
581
+ return;
582
+ const current = this.#sessionByHandle.get(sessionHandle);
583
+ if (current && !current.exited)
584
+ this.#scheduleMetadataRefresh(sessionHandle);
585
+ });
586
+ this.#metadataRefreshes.set(sessionHandle, refresh);
587
+ }
439
588
  #cancelMetadataRefresh(sessionHandle) {
440
589
  const timer = this.#metadataTimers.get(sessionHandle);
441
590
  if (timer !== undefined)
442
591
  window.clearTimeout(timer);
443
592
  this.#metadataTimers.delete(sessionHandle);
593
+ this.#metadataRefreshPending.delete(sessionHandle);
444
594
  }
445
595
  sessionMetadata(sessionHandle) {
446
596
  return this.#sessionByHandle.get(sessionHandle);
447
597
  }
448
598
  registerSession(session) {
599
+ const previous = this.#sessionByHandle.get(session.handle);
600
+ if (!previous || previous.id !== session.id || !previous.exited || !session.exited) {
601
+ this.#appliedRoutedExitEvents.delete(session.handle);
602
+ }
449
603
  this.#sessionByHandle.set(session.handle, session);
450
604
  this.#handleBySessionId.set(session.id, session.handle);
605
+ this.#bumpSessionGeneration(session.handle);
606
+ }
607
+ applySessionEvent(event) {
608
+ if (this.#disposed)
609
+ return;
610
+ if (event.type === "updated") {
611
+ const handle = this.#handleBySessionId.get(event.session.id);
612
+ if (handle)
613
+ this.#applyRoutedSessionSummary(handle, event.session);
614
+ return;
615
+ }
616
+ if (event.type === "activity-changed") {
617
+ this.#applySessionActivity({
618
+ requestId: 0,
619
+ type: "session-activity-changed",
620
+ sessionId: event.sessionId,
621
+ activity: event.activity,
622
+ });
623
+ return;
624
+ }
625
+ if (event.type === "exited") {
626
+ const handle = this.#handleBySessionId.get(event.sessionId);
627
+ const current = handle ? this.#sessionByHandle.get(handle) : undefined;
628
+ if (!handle || !current || this.#appliedRoutedExitEvents.has(handle))
629
+ return;
630
+ this.#appliedRoutedExitEvents.add(handle);
631
+ this.#applySessionExited({
632
+ requestId: 0,
633
+ type: "session-exited",
634
+ sessionId: event.sessionId,
635
+ exitCode: event.exitCode,
636
+ exitSignal: event.exitSignal,
637
+ requestedTermination: event.requestedTermination,
638
+ exitOutcome: event.exitOutcome,
639
+ });
640
+ return;
641
+ }
642
+ this.unregisterSession(event.sessionId);
451
643
  }
452
644
  #queueFrameSubscriptionSync() {
453
645
  const frames = this.#frames;
@@ -608,6 +800,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
608
800
  }
609
801
  async createSession(options) {
610
802
  await this.connect();
803
+ if (this.#routedHost) {
804
+ if (!this.#routedHost.createSession)
805
+ throw new Error("The routed host does not provide session creation");
806
+ const session = await this.#routedHost.createSession(options);
807
+ this.registerSession(session);
808
+ return session;
809
+ }
611
810
  if (options.scrollbackBytes !== undefined) {
612
811
  if (!isValidScrollbackBytes(options.scrollbackBytes)) {
613
812
  throw new RangeError("scrollbackBytes must be a non-negative safe integer");
@@ -628,6 +827,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
628
827
  }
629
828
  async listSessions() {
630
829
  await this.connect();
830
+ if (this.#routedHost) {
831
+ const sessions = this.#routedHost.listSessions
832
+ ? await this.#routedHost.listSessions()
833
+ : [...this.#sessionByHandle.values()];
834
+ for (const session of sessions)
835
+ this.registerSession(session);
836
+ return sessions;
837
+ }
631
838
  const response = await this.#control.request({ type: "list-sessions" });
632
839
  if (response.type !== "sessions")
633
840
  throw new Error("ghosttead returned an unexpected response");
@@ -706,6 +913,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
706
913
  }
707
914
  async listRemoteHosts() {
708
915
  await this.connect();
916
+ if (this.#routedHost)
917
+ throw new Error("Remote-host discovery is not part of the routed host contract");
709
918
  const response = await this.#control.request({ type: "list-remote-hosts" });
710
919
  if (response.type !== "remote-hosts")
711
920
  throw new Error("ghosttead returned an unexpected response");
@@ -713,6 +922,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
713
922
  }
714
923
  async listRemoteSessions(deviceId) {
715
924
  await this.connect();
925
+ if (this.#routedHost)
926
+ throw new Error("Remote-session discovery is not part of the routed host contract");
716
927
  const response = await this.#control.request({ type: "list-remote-sessions", deviceId }, 35_000);
717
928
  if (response.type !== "remote-sessions" || response.deviceId !== deviceId)
718
929
  throw new Error("ghosttead returned an unexpected response");
@@ -720,6 +931,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
720
931
  }
721
932
  async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
722
933
  await this.connect();
934
+ if (this.#routedHost)
935
+ throw new Error("Remote-session opening is not part of the routed host contract");
723
936
  const response = await this.#control.request({
724
937
  type: "open-remote-session",
725
938
  deviceId,
@@ -767,6 +980,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
767
980
  mount(sessionId, sessionHandle, viewId, canvas) {
768
981
  if (this.#disposed)
769
982
  throw new Error("Cannot mount a disposed terminal runtime");
983
+ if (this.#routedHost)
984
+ return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
770
985
  const mounted = this.#mountedCanvases.get(canvas);
771
986
  if (mounted) {
772
987
  if (!mounted.active)
@@ -801,6 +1016,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
801
1016
  const view = {
802
1017
  sessionId,
803
1018
  sessionHandle,
1019
+ clientReadWrite: true,
1020
+ resizeControlRequested: false,
1021
+ visible: true,
804
1022
  inputSequence: 0,
805
1023
  resizeSequence: 0,
806
1024
  controlEpoch: undefined,
@@ -869,6 +1087,723 @@ export class GhostteaTerminalRuntime extends EventTarget {
869
1087
  .catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
870
1088
  return this.#createMountLease(entry);
871
1089
  }
1090
+ #mountRouted(sessionId, sessionHandle, viewId, canvas) {
1091
+ const mounted = this.#mountedCanvases.get(canvas);
1092
+ if (mounted) {
1093
+ if (!mounted.active)
1094
+ throw new Error("A released terminal canvas cannot be remounted");
1095
+ if (mounted.sessionHandle !== sessionHandle) {
1096
+ throw new Error("A terminal canvas cannot be reassigned to another session");
1097
+ }
1098
+ mounted.references += 1;
1099
+ if (mounted.disposeTimer !== undefined) {
1100
+ window.clearTimeout(mounted.disposeTimer);
1101
+ mounted.disposeTimer = undefined;
1102
+ }
1103
+ return this.#createMountLease(mounted);
1104
+ }
1105
+ const offscreen = canvas.transferControlToOffscreen();
1106
+ const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
1107
+ this.#mountGenerationBySurface.set(viewId, generation);
1108
+ this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
1109
+ const entry = {
1110
+ canvas,
1111
+ sessionHandle,
1112
+ sessionId,
1113
+ viewId,
1114
+ generation,
1115
+ references: 1,
1116
+ disposeTimer: undefined,
1117
+ active: true,
1118
+ };
1119
+ this.#mountedCanvases.set(canvas, entry);
1120
+ this.#mountedEntries.add(entry);
1121
+ const session = this.#sessionByHandle.get(sessionHandle);
1122
+ this.#views.set(viewId, {
1123
+ sessionId,
1124
+ sessionHandle,
1125
+ ...(session === undefined ? {} : { readWrite: session.readWrite }),
1126
+ clientReadWrite: true,
1127
+ resizeControlRequested: false,
1128
+ visible: true,
1129
+ inputSequence: 0,
1130
+ resizeSequence: 0,
1131
+ controlEpoch: undefined,
1132
+ desiredCols: undefined,
1133
+ desiredRows: undefined,
1134
+ pendingInput: [],
1135
+ lastViewStateSeq: undefined,
1136
+ lastAttachmentEpoch: undefined,
1137
+ claimedEpoch: undefined,
1138
+ claimedRevision: 0,
1139
+ });
1140
+ const activation = this.#routedBySession.get(sessionId);
1141
+ if (activation)
1142
+ activation.viewIds.add(viewId);
1143
+ void this.#ensureRoutedActivation(sessionId, sessionHandle, viewId).catch((error) => {
1144
+ if (!this.#disposed)
1145
+ console.error(`[terminal-runtime] routed activation failed for ${sessionId}`, error);
1146
+ });
1147
+ return this.#createMountLease(entry);
1148
+ }
1149
+ async #ensureRoutedActivation(sessionId, sessionHandle, viewId) {
1150
+ const existing = this.#routedBySession.get(sessionId);
1151
+ if (existing) {
1152
+ existing.viewIds.add(viewId);
1153
+ if (existing.start)
1154
+ await existing.start;
1155
+ const anyWritable = this.#routedHost?.encodeInput !== undefined &&
1156
+ [...existing.viewIds].some((candidate) => {
1157
+ const view = this.#views.get(candidate);
1158
+ return view?.clientReadWrite === true && view.readWrite !== false;
1159
+ });
1160
+ this.#transitionRouted(existing, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
1161
+ this.#declareRoutedDemand(existing);
1162
+ return;
1163
+ }
1164
+ const activationId = crypto.randomUUID();
1165
+ const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
1166
+ this.#views.get(viewId)?.clientReadWrite !== false &&
1167
+ this.#views.get(viewId)?.readWrite !== false
1168
+ ? "read-write"
1169
+ : "read-only";
1170
+ const entry = {
1171
+ sessionId,
1172
+ sessionHandle,
1173
+ state: initialRoutedActivation(sessionId, activationId, inputPolicy),
1174
+ viewIds: new Set([viewId]),
1175
+ recoveryAttempts: 0,
1176
+ preAuthRemints: { control: 0, frames: 0 },
1177
+ protocolFailures: { control: 0, frames: 0 },
1178
+ };
1179
+ this.#routedBySession.set(sessionId, entry);
1180
+ this.#routedByActivation.set(activationId, entry);
1181
+ entry.start = this.#startRoutedActivation(entry, "mount");
1182
+ try {
1183
+ await entry.start;
1184
+ }
1185
+ finally {
1186
+ delete entry.start;
1187
+ }
1188
+ }
1189
+ #routedTicketMatches(entry, ticket) {
1190
+ return (isRoutedTerminalOpenTicket(ticket) &&
1191
+ ticket.route.cellBootId === ticket.transportGrant.claims.audienceCellBootId &&
1192
+ ticket.route.cellBootId === ticket.attachGrant.claims.audienceCellBootId &&
1193
+ ticket.route.cellBootId === ticket.transportGrant.protected.kid.cellBootId &&
1194
+ ticket.route.cellBootId === ticket.attachGrant.protected.kid.cellBootId &&
1195
+ ticket.transportGrant.claims.clientId === ticket.attachGrant.claims.clientId &&
1196
+ ticket.transportGrant.claims.allowedChannels.includes("control") &&
1197
+ ticket.transportGrant.claims.allowedChannels.includes("frames") &&
1198
+ ticket.attachGrant.claims.sessionId === entry.sessionId &&
1199
+ ticket.attachGrant.claims.routeRevision === ticket.route.routeRevision &&
1200
+ (ticket.route.leaseEpoch === undefined ||
1201
+ ticket.attachGrant.claims.leaseEpoch === undefined ||
1202
+ ticket.route.leaseEpoch === ticket.attachGrant.claims.leaseEpoch));
1203
+ }
1204
+ #routedRenewalMatches(entry, value, previousGeneration) {
1205
+ const ticket = entry.ticket;
1206
+ return (ticket !== undefined &&
1207
+ isRoutedSessionAttachGrant(value) &&
1208
+ value.protected.kid.cellBootId === ticket.route.cellBootId &&
1209
+ value.claims.audienceCellBootId === ticket.route.cellBootId &&
1210
+ value.claims.clientId === ticket.attachGrant.claims.clientId &&
1211
+ value.claims.sessionId === entry.sessionId &&
1212
+ value.claims.routeRevision === ticket.route.routeRevision &&
1213
+ value.claims.leaseEpoch === ticket.attachGrant.claims.leaseEpoch &&
1214
+ value.claims.grantGeneration > previousGeneration);
1215
+ }
1216
+ async #startRoutedActivation(entry, reason) {
1217
+ const host = this.#routedHost;
1218
+ if (!host || this.#disposed || entry.viewIds.size === 0)
1219
+ return;
1220
+ let ticket;
1221
+ try {
1222
+ ticket = await host.openTicket(entry.sessionId, { reason });
1223
+ }
1224
+ catch (error) {
1225
+ this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
1226
+ return;
1227
+ }
1228
+ if (this.#routedBySession.get(entry.sessionId) !== entry || this.#disposed)
1229
+ return;
1230
+ if (!this.#routedTicketMatches(entry, ticket)) {
1231
+ this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
1232
+ return;
1233
+ }
1234
+ this.#transitionRouted(entry, { type: "ticket-minted", endpointsPresent: ticket.endpoints !== undefined });
1235
+ if (!ticket.endpoints)
1236
+ return;
1237
+ entry.ticket = ticket;
1238
+ this.#transitionRouted(entry, { type: "transport-ready" });
1239
+ this.#routedControl.attach({
1240
+ cellBootId: ticket.route.cellBootId,
1241
+ controlUrl: ticket.endpoints.controlUrl,
1242
+ transportGrant: ticket.transportGrant,
1243
+ attachGrant: ticket.attachGrant,
1244
+ activationId: entry.state.activationId,
1245
+ ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
1246
+ initialDemand: this.#routedDemand(entry),
1247
+ capabilities: this.#routedCapabilities,
1248
+ });
1249
+ this.#postWorker({
1250
+ type: "routed-frames-attach",
1251
+ request: {
1252
+ cellBootId: ticket.route.cellBootId,
1253
+ sessionHandle: entry.sessionHandle,
1254
+ framesUrl: ticket.endpoints.framesUrl,
1255
+ transportGrant: ticket.transportGrant,
1256
+ attachGrant: ticket.attachGrant,
1257
+ activationId: entry.state.activationId,
1258
+ ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
1259
+ ...(this.#routedReceiverCapacities === undefined ? {} : { receiverCapacities: this.#routedReceiverCapacities }),
1260
+ capabilities: this.#routedCapabilities,
1261
+ },
1262
+ });
1263
+ this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
1264
+ DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
1265
+ this.#scheduleRoutedRenewal(entry);
1266
+ }
1267
+ #stopRoutedActivation(entry) {
1268
+ this.#routedControl?.detach(entry.state.activationId);
1269
+ this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
1270
+ if (entry.attachTimer !== undefined)
1271
+ window.clearTimeout(entry.attachTimer);
1272
+ delete entry.attachTimer;
1273
+ if (entry.renewalTimer !== undefined)
1274
+ window.clearTimeout(entry.renewalTimer);
1275
+ delete entry.renewalTimer;
1276
+ delete entry.ticket;
1277
+ }
1278
+ #transitionRouted(entry, event) {
1279
+ const previous = entry.state;
1280
+ const next = reduceRoutedActivation(previous, event);
1281
+ if (next === previous)
1282
+ return;
1283
+ entry.state = next;
1284
+ if (next.phase !== "attaching" && entry.attachTimer !== undefined) {
1285
+ window.clearTimeout(entry.attachTimer);
1286
+ delete entry.attachTimer;
1287
+ }
1288
+ if ((next.phase === "unavailable" || next.phase === "ended") && next.phase !== previous.phase) {
1289
+ this.#stopRoutedActivation(entry);
1290
+ }
1291
+ if (!previous.presentationReady && next.presentationReady) {
1292
+ entry.recoveryAttempts = 0;
1293
+ entry.protocolFailures.control = 0;
1294
+ entry.protocolFailures.frames = 0;
1295
+ }
1296
+ this.dispatchEvent(new CustomEvent("routed-activation-state", {
1297
+ detail: { sessionId: entry.sessionId, previous, current: next },
1298
+ }));
1299
+ if (previous.presentationReady !== next.presentationReady || previous.inputAllowed !== next.inputAllowed) {
1300
+ for (const viewId of entry.viewIds) {
1301
+ const view = this.#views.get(viewId);
1302
+ this.dispatchEvent(new CustomEvent("routed-view-readiness", {
1303
+ detail: {
1304
+ sessionId: entry.sessionId,
1305
+ viewId,
1306
+ presentationReady: next.presentationReady,
1307
+ inputAllowed: next.inputAllowed && view?.clientReadWrite === true && view.readWrite !== false,
1308
+ phase: next.phase,
1309
+ },
1310
+ }));
1311
+ }
1312
+ }
1313
+ }
1314
+ #handleRoutedControlEvent(event) {
1315
+ if (this.#disposed)
1316
+ return;
1317
+ if (event.type === "extension-message") {
1318
+ const host = this.#routedHost;
1319
+ if (!host?.onExtensionMessage)
1320
+ return;
1321
+ try {
1322
+ host.onExtensionMessage(event.message, event.context);
1323
+ }
1324
+ catch (error) {
1325
+ console.error("[terminal-runtime] routed extension handler failed", error);
1326
+ }
1327
+ return;
1328
+ }
1329
+ if (event.type === "transport-ready") {
1330
+ const deadline = event.accepted.protocolLimits.activationAttachDeadlineMs;
1331
+ this.#routedAttachDeadlineByCell.set(event.cellBootId, deadline);
1332
+ for (const entry of this.#routedBySession.values()) {
1333
+ if (entry.ticket?.route.cellBootId === event.cellBootId && entry.state.phase === "attaching") {
1334
+ this.#armRoutedAttachDeadline(entry, deadline);
1335
+ }
1336
+ }
1337
+ return;
1338
+ }
1339
+ if (event.type === "control-attached") {
1340
+ const entry = this.#routedByActivation.get(event.attached.activationId);
1341
+ if (!entry || event.attached.sessionId !== entry.sessionId)
1342
+ return;
1343
+ entry.preAuthRemints.control = 0;
1344
+ this.#transitionRouted(entry, {
1345
+ type: "control-attached",
1346
+ grantGeneration: event.attached.grantGenerationAccepted,
1347
+ rights: event.attached.rights,
1348
+ });
1349
+ const previousGeometry = this.#routedGeometry.get(entry.sessionId);
1350
+ if (!event.attached.rights.includes("geometry") &&
1351
+ previousGeometry?.holderViewId !== undefined &&
1352
+ entry.viewIds.has(previousGeometry.holderViewId)) {
1353
+ // The cell auto-releases a holder when renewal drops the geometry
1354
+ // right. Preserve its next CAS revision without retaining authority.
1355
+ this.#routedGeometry.set(entry.sessionId, {
1356
+ revision: previousGeometry.revision + 1,
1357
+ ...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
1358
+ ...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
1359
+ });
1360
+ }
1361
+ const geometry = this.#routedGeometry.get(entry.sessionId);
1362
+ for (const viewId of entry.viewIds) {
1363
+ const view = this.#views.get(viewId);
1364
+ if (view?.resizeControlRequested &&
1365
+ geometry?.holderViewId !== viewId &&
1366
+ view.desiredCols !== undefined &&
1367
+ view.desiredRows !== undefined) {
1368
+ this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
1369
+ }
1370
+ }
1371
+ return;
1372
+ }
1373
+ if (event.type === "attach-refused") {
1374
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1375
+ if (!entry)
1376
+ return;
1377
+ this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
1378
+ if (entry.state.phase === "recovering") {
1379
+ this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry");
1380
+ }
1381
+ return;
1382
+ }
1383
+ if (event.type === "cell-status") {
1384
+ const entry = this.#routedByActivation.get(event.status.activationId);
1385
+ if (!entry || event.status.sessionId !== entry.sessionId)
1386
+ return;
1387
+ this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
1388
+ const sequence = entry.state.lastCellStatusSequence;
1389
+ window.setTimeout(() => {
1390
+ if (entry.state.lastCellStatusSequence !== sequence)
1391
+ return;
1392
+ this.#transitionRouted(entry, { type: "cell-lease-expired" });
1393
+ }, event.status.leaseTtlMs);
1394
+ if (event.status.presentation.state === "revoked" &&
1395
+ (event.status.presentation.reason === "leg-dead" || event.status.presentation.reason === "stale-route")) {
1396
+ this.#recoverRoutedActivation(entry, event.status.presentation.reason === "stale-route" ? "route-stale" : "retry");
1397
+ }
1398
+ return;
1399
+ }
1400
+ if (event.type === "geometry-committed") {
1401
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1402
+ if (!entry)
1403
+ return;
1404
+ this.#routedGeometry.set(entry.sessionId, {
1405
+ holderViewId: event.committed.holder.viewId,
1406
+ holderGeneration: event.committed.holder.holderGeneration,
1407
+ revision: event.committed.geometryRevision,
1408
+ cols: event.committed.cols,
1409
+ rows: event.committed.rows,
1410
+ });
1411
+ this.dispatchEvent(new CustomEvent("routed-geometry", { detail: { sessionId: entry.sessionId, ...event } }));
1412
+ return;
1413
+ }
1414
+ if (event.type === "geometry-refused") {
1415
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1416
+ if (entry && event.refused.geometryRevision !== undefined) {
1417
+ const previous = this.#routedGeometry.get(entry.sessionId);
1418
+ const holder = event.refused.currentHolder;
1419
+ this.#routedGeometry.set(entry.sessionId, {
1420
+ revision: event.refused.geometryRevision,
1421
+ ...(holder === undefined ? {} : { holderViewId: holder.viewId, holderGeneration: holder.holderGeneration }),
1422
+ ...(previous?.cols === undefined ? {} : { cols: previous.cols }),
1423
+ ...(previous?.rows === undefined ? {} : { rows: previous.rows }),
1424
+ });
1425
+ }
1426
+ this.dispatchEvent(new CustomEvent("routed-geometry-refused", { detail: event }));
1427
+ return;
1428
+ }
1429
+ if (event.type === "transport-closed") {
1430
+ this.#routedAttachDeadlineByCell.delete(event.cellBootId);
1431
+ for (const activationId of event.activationIds) {
1432
+ const entry = this.#routedByActivation.get(activationId);
1433
+ if (!entry)
1434
+ continue;
1435
+ if (event.preAuth || event.refusal) {
1436
+ const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
1437
+ this.#transitionRouted(entry, {
1438
+ type: "transport-failed",
1439
+ ...(event.preAuth ? { preAuth: true } : {}),
1440
+ ...(event.refusal === undefined ? {} : { retryable: recoverable }),
1441
+ });
1442
+ if (entry.state.phase === "unavailable")
1443
+ continue;
1444
+ this.#recoverRoutedActivation(entry, event.preAuth ? "pre-auth" : "retry", "control");
1445
+ continue;
1446
+ }
1447
+ const routeStale = event.code === 4000 || event.code === 4001;
1448
+ if (event.code === 4002) {
1449
+ this.#transitionRouted(entry, { type: "replaced" });
1450
+ continue;
1451
+ }
1452
+ if (event.code === 4003) {
1453
+ if (entry.protocolFailures.control >= 1) {
1454
+ this.#transitionRouted(entry, { type: "leg-lost", channel: "control", resumeCapable: false });
1455
+ this.#transitionRouted(entry, {
1456
+ type: "transport-failed",
1457
+ recoveryExhausted: true,
1458
+ reason: "protocol",
1459
+ });
1460
+ this.dispatchEvent(new CustomEvent("routed-protocol-error", {
1461
+ detail: { sessionId: entry.sessionId, channel: "control", reason: event.reason },
1462
+ }));
1463
+ continue;
1464
+ }
1465
+ entry.protocolFailures.control += 1;
1466
+ }
1467
+ this.#transitionRouted(entry, {
1468
+ type: routeStale ? "route-stale" : "leg-lost",
1469
+ channel: "control",
1470
+ resumeCapable: false,
1471
+ });
1472
+ this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "control");
1473
+ }
1474
+ }
1475
+ }
1476
+ #handleRoutedFramesEvent(event) {
1477
+ if (event.type === "frames-attached") {
1478
+ const entry = this.#routedByActivation.get(event.attached.activationId);
1479
+ if (!entry || event.attached.sessionId !== entry.sessionId)
1480
+ return;
1481
+ entry.preAuthRemints.frames = 0;
1482
+ this.#transitionRouted(entry, {
1483
+ type: "frames-attached",
1484
+ outcome: event.attached.outcome,
1485
+ trfIdentity: event.attached.trfIdentity,
1486
+ resumeToken: event.attached.resumeToken,
1487
+ });
1488
+ return;
1489
+ }
1490
+ if (event.type === "attach-refused") {
1491
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1492
+ if (!entry)
1493
+ return;
1494
+ this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
1495
+ if (entry.state.phase === "recovering") {
1496
+ this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry", "frames");
1497
+ }
1498
+ return;
1499
+ }
1500
+ if (event.type === "frames-state") {
1501
+ const entry = this.#routedByActivation.get(event.activationId);
1502
+ if (!entry)
1503
+ return;
1504
+ this.#transitionRouted(entry, {
1505
+ type: "frames-state",
1506
+ state: {
1507
+ activationId: event.activationId,
1508
+ state: event.state,
1509
+ ...(event.resumeToken === undefined ? {} : { resumeToken: event.resumeToken }),
1510
+ ...(event.appliedContent === undefined ? {} : { appliedContent: event.appliedContent }),
1511
+ },
1512
+ });
1513
+ if (event.state === "active" && event.appliedContent) {
1514
+ this.#transitionRouted(entry, { type: "sync-complete", appliedContent: event.appliedContent });
1515
+ }
1516
+ else if (event.state === "failed") {
1517
+ this.#transitionRouted(entry, { type: "sync-failed" });
1518
+ }
1519
+ return;
1520
+ }
1521
+ if (event.type === "presentation-status") {
1522
+ const entry = this.#routedByActivation.get(event.status.activationId);
1523
+ if (!entry)
1524
+ return;
1525
+ this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
1526
+ const sequence = entry.state.lastWorkerStatusSequence;
1527
+ window.setTimeout(() => {
1528
+ if (entry.state.lastWorkerStatusSequence !== sequence)
1529
+ return;
1530
+ this.#transitionRouted(entry, { type: "worker-lease-expired" });
1531
+ }, event.status.leaseTtlMs);
1532
+ return;
1533
+ }
1534
+ for (const activationId of event.activationIds) {
1535
+ const entry = this.#routedByActivation.get(activationId);
1536
+ if (!entry)
1537
+ continue;
1538
+ if (event.preAuth || event.refusal) {
1539
+ const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
1540
+ this.#transitionRouted(entry, {
1541
+ type: "transport-failed",
1542
+ ...(event.preAuth ? { preAuth: true } : {}),
1543
+ ...(event.refusal === undefined ? {} : { retryable: recoverable }),
1544
+ });
1545
+ if (entry.state.phase === "unavailable")
1546
+ continue;
1547
+ this.#resumeRoutedFrames(entry, event.preAuth ? "pre-auth" : "retry");
1548
+ continue;
1549
+ }
1550
+ const routeStale = event.code === 4000 || event.code === 4001;
1551
+ if (event.code === 4002) {
1552
+ this.#transitionRouted(entry, { type: "replaced" });
1553
+ continue;
1554
+ }
1555
+ if (event.code === 4003) {
1556
+ if (entry.protocolFailures.frames >= 1) {
1557
+ this.#transitionRouted(entry, { type: "leg-lost", channel: "frames", resumeCapable: false });
1558
+ this.#transitionRouted(entry, {
1559
+ type: "transport-failed",
1560
+ recoveryExhausted: true,
1561
+ reason: "protocol",
1562
+ });
1563
+ this.dispatchEvent(new CustomEvent("routed-protocol-error", {
1564
+ detail: { sessionId: entry.sessionId, channel: "frames", reason: event.reason },
1565
+ }));
1566
+ continue;
1567
+ }
1568
+ entry.protocolFailures.frames += 1;
1569
+ }
1570
+ const activationFailed = event.code === 4002 || event.code === 4003;
1571
+ const canResume = !routeStale &&
1572
+ !activationFailed &&
1573
+ this.#routedCapabilities.includes("resume") &&
1574
+ entry.state.resumeToken !== undefined &&
1575
+ entry.state.appliedContent !== undefined &&
1576
+ entry.ticket?.endpoints !== undefined;
1577
+ this.#transitionRouted(entry, {
1578
+ type: routeStale ? "route-stale" : "leg-lost",
1579
+ channel: "frames",
1580
+ resumeCapable: canResume,
1581
+ });
1582
+ if (canResume) {
1583
+ this.#resumeRoutedFrames(entry, "retry");
1584
+ }
1585
+ else {
1586
+ this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "frames");
1587
+ }
1588
+ }
1589
+ }
1590
+ #resumeRoutedFrames(entry, reason) {
1591
+ if (entry.framesResume || this.#disposed)
1592
+ return;
1593
+ const task = (async () => {
1594
+ const host = this.#routedHost;
1595
+ const previousTicket = entry.ticket;
1596
+ const activationId = entry.state.activationId;
1597
+ const resumeToken = entry.state.resumeToken;
1598
+ const appliedContent = entry.state.appliedContent;
1599
+ if (!host || !previousTicket?.endpoints || !resumeToken || !appliedContent) {
1600
+ this.#recoverRoutedActivation(entry, reason, "frames");
1601
+ return;
1602
+ }
1603
+ if (reason === "pre-auth") {
1604
+ if (entry.preAuthRemints.frames >= 1) {
1605
+ this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
1606
+ return;
1607
+ }
1608
+ entry.preAuthRemints.frames += 1;
1609
+ }
1610
+ entry.recoveryAttempts += 1;
1611
+ if (entry.recoveryAttempts > 5) {
1612
+ this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
1613
+ return;
1614
+ }
1615
+ let ticket;
1616
+ try {
1617
+ ticket = await host.openTicket(entry.sessionId, { reason });
1618
+ }
1619
+ catch {
1620
+ this.#recoverRoutedActivation(entry, "retry", "frames");
1621
+ return;
1622
+ }
1623
+ if (this.#disposed ||
1624
+ this.#routedByActivation.get(activationId) !== entry ||
1625
+ entry.state.activationId !== activationId) {
1626
+ return;
1627
+ }
1628
+ if (!this.#routedTicketMatches(entry, ticket) || !ticket.endpoints) {
1629
+ this.#recoverRoutedActivation(entry, "route-stale", "frames");
1630
+ return;
1631
+ }
1632
+ const sameRoute = ticket.route.cellBootId === previousTicket.route.cellBootId &&
1633
+ ticket.route.routeRevision === previousTicket.route.routeRevision &&
1634
+ ticket.route.leaseEpoch === previousTicket.route.leaseEpoch;
1635
+ if (!sameRoute) {
1636
+ this.#transitionRouted(entry, { type: "route-stale" });
1637
+ this.#recoverRoutedActivation(entry, "route-stale", "frames");
1638
+ return;
1639
+ }
1640
+ entry.ticket = ticket;
1641
+ this.#routedControl?.renew(activationId, ticket.attachGrant);
1642
+ this.#scheduleRoutedRenewal(entry);
1643
+ this.#postWorker({
1644
+ type: "routed-frames-attach",
1645
+ request: {
1646
+ cellBootId: ticket.route.cellBootId,
1647
+ sessionHandle: entry.sessionHandle,
1648
+ framesUrl: ticket.endpoints.framesUrl,
1649
+ transportGrant: ticket.transportGrant,
1650
+ attachGrant: ticket.attachGrant,
1651
+ activationId,
1652
+ resume: { resumeToken, from: appliedContent },
1653
+ ...(this.#routedReceiverCapacities === undefined
1654
+ ? {}
1655
+ : { receiverCapacities: this.#routedReceiverCapacities }),
1656
+ capabilities: this.#routedCapabilities,
1657
+ },
1658
+ });
1659
+ })();
1660
+ entry.framesResume = task;
1661
+ void task.finally(() => {
1662
+ if (entry.framesResume === task)
1663
+ delete entry.framesResume;
1664
+ });
1665
+ }
1666
+ #recoverRoutedActivation(entry, reason, failedChannel) {
1667
+ if (this.#disposed || entry.viewIds.size === 0 || entry.state.phase === "ended")
1668
+ return;
1669
+ if (reason === "pre-auth") {
1670
+ const channel = failedChannel ?? "control";
1671
+ if (entry.preAuthRemints[channel] >= 1) {
1672
+ this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
1673
+ return;
1674
+ }
1675
+ entry.preAuthRemints[channel] += 1;
1676
+ }
1677
+ entry.recoveryAttempts += 1;
1678
+ if (entry.recoveryAttempts > 5) {
1679
+ this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
1680
+ return;
1681
+ }
1682
+ // Geometry belongs to the cell-side attach/client/view scope. Preserve it
1683
+ // across a same-route leg replacement, but never carry its revision or
1684
+ // holder generation to a newly routed cell.
1685
+ if (reason === "route-stale")
1686
+ this.#routedGeometry.delete(entry.sessionId);
1687
+ const previousActivationId = entry.state.activationId;
1688
+ this.#routedControl?.detach(previousActivationId);
1689
+ this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
1690
+ this.#routedByActivation.delete(previousActivationId);
1691
+ const nextActivationId = crypto.randomUUID();
1692
+ entry.replacesActivationId = previousActivationId;
1693
+ const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
1694
+ [...entry.viewIds].some((viewId) => {
1695
+ const view = this.#views.get(viewId);
1696
+ return view?.clientReadWrite === true && view.readWrite !== false;
1697
+ })
1698
+ ? "read-write"
1699
+ : "read-only";
1700
+ entry.state = {
1701
+ ...initialRoutedActivation(entry.sessionId, nextActivationId, inputPolicy),
1702
+ phase: "recovering",
1703
+ replacesActivationId: previousActivationId,
1704
+ preAuthRemintUsed: entry.preAuthRemints.control > 0 || entry.preAuthRemints.frames > 0,
1705
+ };
1706
+ this.#routedByActivation.set(nextActivationId, entry);
1707
+ if (entry.attachTimer !== undefined)
1708
+ window.clearTimeout(entry.attachTimer);
1709
+ delete entry.attachTimer;
1710
+ if (entry.renewalTimer !== undefined)
1711
+ window.clearTimeout(entry.renewalTimer);
1712
+ delete entry.renewalTimer;
1713
+ delete entry.ticket;
1714
+ const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
1715
+ window.setTimeout(() => {
1716
+ if (this.#routedByActivation.get(nextActivationId) !== entry)
1717
+ return;
1718
+ entry.start = this.#startRoutedActivation(entry, reason);
1719
+ void entry.start.finally(() => delete entry.start);
1720
+ }, delay);
1721
+ }
1722
+ #armRoutedAttachDeadline(entry, delayMs) {
1723
+ if (entry.attachTimer !== undefined)
1724
+ window.clearTimeout(entry.attachTimer);
1725
+ const activationId = entry.state.activationId;
1726
+ entry.attachTimer = window.setTimeout(() => {
1727
+ if (this.#routedByActivation.get(activationId) !== entry || entry.state.phase !== "attaching")
1728
+ return;
1729
+ this.#transitionRouted(entry, { type: "attach-deadline" });
1730
+ this.#recoverRoutedActivation(entry, "retry");
1731
+ }, Math.max(0, delayMs));
1732
+ }
1733
+ #scheduleRoutedRenewal(entry) {
1734
+ const ticket = entry.ticket;
1735
+ const host = this.#routedHost;
1736
+ if (!ticket || !host)
1737
+ return;
1738
+ if (entry.renewalTimer !== undefined)
1739
+ window.clearTimeout(entry.renewalTimer);
1740
+ const generation = ticket.attachGrant.claims.grantGeneration;
1741
+ const delay = Math.max(0, ticket.attachGrant.claims.expiresAt - Date.now() - 60_000);
1742
+ if (delay > MAX_BROWSER_TIMEOUT_MS) {
1743
+ entry.renewalTimer = window.setTimeout(() => {
1744
+ if (entry.ticket !== ticket || this.#disposed)
1745
+ return;
1746
+ this.#scheduleRoutedRenewal(entry);
1747
+ }, MAX_BROWSER_TIMEOUT_MS);
1748
+ return;
1749
+ }
1750
+ entry.renewalTimer = window.setTimeout(() => {
1751
+ this.#transitionRouted(entry, { type: "grant-expiring" });
1752
+ if (!host.renewAttach) {
1753
+ this.#transitionRouted(entry, { type: "renew-failed" });
1754
+ return;
1755
+ }
1756
+ const requestId = crypto.randomUUID();
1757
+ void host
1758
+ .renewAttach({ sessionId: entry.sessionId, expectGeneration: generation, requestId })
1759
+ .then(({ attachGrant }) => {
1760
+ if (entry.ticket !== ticket || !this.#routedRenewalMatches(entry, attachGrant, generation)) {
1761
+ this.#transitionRouted(entry, { type: "renew-failed" });
1762
+ return;
1763
+ }
1764
+ entry.ticket = { ...ticket, attachGrant };
1765
+ this.#routedControl?.renew(entry.state.activationId, attachGrant);
1766
+ this.#scheduleRoutedRenewal(entry);
1767
+ })
1768
+ .catch(() => this.#transitionRouted(entry, { type: "renew-failed" }));
1769
+ }, delay);
1770
+ }
1771
+ #routedDemand(entry) {
1772
+ let live = false;
1773
+ let urgent = false;
1774
+ for (const viewId of entry.viewIds) {
1775
+ const view = this.#views.get(viewId);
1776
+ live ||= view?.visible === true;
1777
+ urgent ||= view?.visible === true && this.#focusByView.get(viewId) === true;
1778
+ }
1779
+ return {
1780
+ mode: live ? "live" : "none",
1781
+ urgency: urgent ? "urgent" : "normal",
1782
+ };
1783
+ }
1784
+ #declareRoutedDemand(entry) {
1785
+ this.#routedControl?.declareDemand(entry.state.activationId, this.#routedDemand(entry));
1786
+ }
1787
+ #releaseRoutedView(sessionId, viewId) {
1788
+ const entry = this.#routedBySession.get(sessionId);
1789
+ if (!entry)
1790
+ return;
1791
+ entry.viewIds.delete(viewId);
1792
+ if (entry.viewIds.size > 0) {
1793
+ const anyWritable = this.#routedHost?.encodeInput !== undefined &&
1794
+ [...entry.viewIds].some((candidate) => {
1795
+ const view = this.#views.get(candidate);
1796
+ return view?.clientReadWrite === true && view.readWrite !== false;
1797
+ });
1798
+ this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
1799
+ this.#declareRoutedDemand(entry);
1800
+ return;
1801
+ }
1802
+ this.#transitionRouted(entry, { type: "detach" });
1803
+ this.#routedBySession.delete(sessionId);
1804
+ this.#routedByActivation.delete(entry.state.activationId);
1805
+ this.#routedGeometry.delete(sessionId);
1806
+ }
872
1807
  #createMountLease(mounted) {
873
1808
  let disposed = false;
874
1809
  return {
@@ -891,13 +1826,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
891
1826
  if (ownsWorkerSurface) {
892
1827
  this.#postWorker({ type: "unmount", surfaceId: mounted.viewId });
893
1828
  this.#mountGenerationBySurface.delete(mounted.viewId);
894
- this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
1829
+ if (this.#routedHost)
1830
+ this.#releaseRoutedView(mounted.sessionId, mounted.viewId);
1831
+ else
1832
+ this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
895
1833
  this.#views.delete(mounted.viewId);
896
1834
  this.#focusByView.delete(mounted.viewId);
897
1835
  }
898
1836
  this.#mountedCanvases.delete(mounted.canvas);
899
1837
  this.#mountedEntries.delete(mounted);
900
- this.#releaseFrameSubscription(mounted.sessionHandle);
1838
+ if (!this.#routedHost)
1839
+ this.#releaseFrameSubscription(mounted.sessionHandle);
901
1840
  }, 0);
902
1841
  },
903
1842
  };
@@ -950,8 +1889,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
950
1889
  this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
951
1890
  }
952
1891
  }
953
- // A cleared controller is the one case worth re-evaluating: the pane that
954
- // still holds focus may now take control back.
1892
+ // A cleared controller is the one case worth re-evaluating: a view with an
1893
+ // outstanding explicit resize-control request may now take the seat.
955
1894
  for (const viewId of this.#viewIdsForSession(sessionId))
956
1895
  this.#maybeReclaim(viewId);
957
1896
  }
@@ -963,10 +1902,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
963
1902
  }
964
1903
  /**
965
1904
  * The single funnel for taking resize control (§4.2.3). Every condition that
966
- * gates a claim re-enters here when it changes, because no one event is
967
- * enough: recovery marks a view attached before its session reaches live, and
968
- * the focus setter suppresses repeat `true` updates, so a claim keyed on
969
- * either alone would be skipped and never retried.
1905
+ * gates an explicit claim re-enters here when it changes, because no one
1906
+ * event is enough: recovery can mark a view attached before its session
1907
+ * reaches live, while the resize-control request already exists.
970
1908
  *
971
1909
  * At most one claim per attachment epoch, plus one more each time the
972
1910
  * controller is cleared at a newer revision.
@@ -980,15 +1918,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
980
1918
  */
981
1919
  #maybeReclaim(viewId) {
982
1920
  const view = this.#views.get(viewId);
983
- if (!view || view.readWrite === false)
1921
+ if (!view || view.readWrite === false || !view.clientReadWrite || !view.resizeControlRequested)
984
1922
  return;
985
1923
  const attachmentEpoch = view.attachmentEpoch;
986
1924
  if (attachmentEpoch === undefined)
987
1925
  return;
988
1926
  if (view.desiredCols === undefined || view.desiredRows === undefined)
989
1927
  return;
990
- if (this.#focusByView.get(viewId) !== true)
991
- return;
992
1928
  const remote = this.#remoteSessions.get(view.sessionId);
993
1929
  if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame))
994
1930
  return;
@@ -1181,7 +2117,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1181
2117
  */
1182
2118
  #sendViewInput(viewId, operation, silent = false) {
1183
2119
  const view = this.#views.get(viewId);
1184
- if (!view || view.readWrite === false)
2120
+ if (!view || view.readWrite === false || !view.clientReadWrite)
1185
2121
  return;
1186
2122
  const remote = this.#remoteSessions.get(view.sessionId);
1187
2123
  const attachmentEpoch = view.attachmentEpoch;
@@ -1206,36 +2142,100 @@ export class GhostteaTerminalRuntime extends EventTarget {
1206
2142
  #reportSuppressedInput(sessionId, viewId, state) {
1207
2143
  this.dispatchEvent(new CustomEvent("input-suppressed", { detail: { sessionId, viewId, state } }));
1208
2144
  }
2145
+ #sendRoutedInput(sessionId, viewId, operation, silent = false) {
2146
+ const host = this.#routedHost;
2147
+ const view = this.#views.get(viewId);
2148
+ const activation = this.#routedBySession.get(sessionId);
2149
+ if (!host ||
2150
+ !view ||
2151
+ view.sessionId !== sessionId ||
2152
+ view.readWrite === false ||
2153
+ !view.clientReadWrite ||
2154
+ !activation?.state.inputAllowed) {
2155
+ if (!silent) {
2156
+ this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
2157
+ detail: {
2158
+ sessionId,
2159
+ viewId,
2160
+ reason: !host?.encodeInput ? "wire-verb-unavailable" : "input-not-allowed",
2161
+ },
2162
+ }));
2163
+ }
2164
+ return false;
2165
+ }
2166
+ if (!host.encodeInput) {
2167
+ if (!silent) {
2168
+ this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
2169
+ detail: { sessionId, viewId, reason: "wire-verb-unavailable" },
2170
+ }));
2171
+ }
2172
+ return false;
2173
+ }
2174
+ view.inputSequence += 1;
2175
+ const message = host.encodeInput({
2176
+ sessionId,
2177
+ viewId,
2178
+ activationId: activation.state.activationId,
2179
+ leaseEpoch: activation.ticket?.attachGrant.claims.leaseEpoch ?? 0,
2180
+ inputSequence: view.inputSequence,
2181
+ operation,
2182
+ });
2183
+ return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
2184
+ }
1209
2185
  sendText(sessionId, viewId, text) {
2186
+ if (this.#routedHost) {
2187
+ this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
2188
+ return;
2189
+ }
1210
2190
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
1211
2191
  const handle = this.#handleBySessionId.get(sessionId);
1212
2192
  if (handle)
1213
2193
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
1214
2194
  }
1215
2195
  paste(sessionId, viewId, text) {
2196
+ if (this.#routedHost) {
2197
+ this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
2198
+ return;
2199
+ }
1216
2200
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "paste", sessionId, viewId, attachmentEpoch, inputSequence, text }));
1217
2201
  const handle = this.#handleBySessionId.get(sessionId);
1218
2202
  if (handle)
1219
2203
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
1220
2204
  }
1221
2205
  sendKey(sessionId, viewId, event) {
2206
+ if (this.#routedHost) {
2207
+ this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
2208
+ return;
2209
+ }
1222
2210
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-key", sessionId, viewId, attachmentEpoch, inputSequence, event }));
1223
2211
  const handle = this.#handleBySessionId.get(sessionId);
1224
2212
  if (handle)
1225
2213
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
1226
2214
  }
1227
2215
  sendMouse(sessionId, viewId, event) {
2216
+ if (this.#routedHost) {
2217
+ this.#sendRoutedInput(sessionId, viewId, { kind: "mouse", event }, true);
2218
+ return;
2219
+ }
1228
2220
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }), true);
1229
2221
  }
1230
2222
  scroll(sessionId, viewId, rows) {
1231
2223
  if (rows === 0)
1232
2224
  return;
2225
+ if (this.#routedHost) {
2226
+ this.#sendRoutedInput(sessionId, viewId, { kind: "scroll", rows }, true);
2227
+ return;
2228
+ }
1233
2229
  // Scrolling is host-side input, so it is simply inert while frozen.
1234
2230
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }), true);
1235
2231
  }
1236
2232
  scrollTo(sessionId, viewId, row) {
1237
2233
  if (!Number.isSafeInteger(row) || row < 0)
1238
2234
  return;
2235
+ if (this.#routedHost) {
2236
+ this.#sendRoutedInput(sessionId, viewId, { kind: "scroll-to", row }, true);
2237
+ return;
2238
+ }
1239
2239
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }), true);
1240
2240
  }
1241
2241
  scrollbar(sessionHandle) {
@@ -1246,6 +2246,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
1246
2246
  }
1247
2247
  setTheme(sessionHandle, theme, surfaceId) {
1248
2248
  this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
2249
+ // A surface-scoped theme is renderer-local. Updating the daemon's
2250
+ // session-wide palette here would repaint every mirrored viewer.
2251
+ if (surfaceId)
2252
+ return;
1249
2253
  const session = this.#sessionByHandle.get(sessionHandle);
1250
2254
  if (!session)
1251
2255
  return;
@@ -1270,6 +2274,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
1270
2274
  }
1271
2275
  setVisible(sessionHandle, visible, surfaceId) {
1272
2276
  this.#postWorker({ type: "visibility", sessionHandle, ...(surfaceId ? { surfaceId } : {}), visible });
2277
+ if (this.#routedHost) {
2278
+ const session = this.#sessionByHandle.get(sessionHandle);
2279
+ if (!session)
2280
+ return;
2281
+ if (surfaceId) {
2282
+ const view = this.#views.get(surfaceId);
2283
+ if (view)
2284
+ view.visible = visible;
2285
+ }
2286
+ else {
2287
+ for (const view of this.#views.values()) {
2288
+ if (view.sessionId === session.id)
2289
+ view.visible = visible;
2290
+ }
2291
+ }
2292
+ const entry = this.#routedBySession.get(session.id);
2293
+ if (entry)
2294
+ this.#declareRoutedDemand(entry);
2295
+ }
1273
2296
  }
1274
2297
  forceFullRedraw(sessionHandle) {
1275
2298
  this.#postWorker({ type: "force-full-redraw", sessionHandle });
@@ -1280,28 +2303,92 @@ export class GhostteaTerminalRuntime extends EventTarget {
1280
2303
  setPartialRenderingEnabled(enabled) {
1281
2304
  this.#postWorker({ type: "partial-rendering", enabled });
1282
2305
  }
2306
+ #claimRoutedGeometry(entry, viewId, cols, rows) {
2307
+ const ticket = entry.ticket;
2308
+ const view = this.#views.get(viewId);
2309
+ if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
2310
+ return;
2311
+ const geometry = this.#routedGeometry.get(entry.sessionId);
2312
+ this.#routedControl?.claimGeometry(entry.state.activationId, {
2313
+ sessionId: entry.sessionId,
2314
+ activationId: entry.state.activationId,
2315
+ leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
2316
+ claimant: { clientId: ticket.attachGrant.claims.clientId, viewId },
2317
+ cols,
2318
+ rows,
2319
+ expectRevision: geometry?.revision ?? 0,
2320
+ });
2321
+ }
1283
2322
  claimResizeControl(sessionHandle, viewId, cols, rows) {
1284
2323
  const view = this.#views.get(viewId);
1285
2324
  if (view) {
1286
2325
  view.desiredCols = cols;
1287
2326
  view.desiredRows = rows;
1288
- // An explicit claim is the funnel's outcome, not a competing path.
1289
- view.claimedEpoch = view.attachmentEpoch;
1290
- view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
2327
+ view.resizeControlRequested = true;
1291
2328
  }
1292
- const session = this.#sessionByHandle.get(sessionHandle);
1293
- if (!session)
2329
+ if (this.#routedHost) {
2330
+ const session = this.#sessionByHandle.get(sessionHandle);
2331
+ const entry = session ? this.#routedBySession.get(session.id) : undefined;
2332
+ if (entry)
2333
+ this.#claimRoutedGeometry(entry, viewId, cols, rows);
1294
2334
  return;
1295
- this.#sendViewInput(viewId, (attachmentEpoch) => {
1296
- this.#control?.notify({
1297
- type: "focus-and-resize",
1298
- sessionId: session.id,
1299
- viewId,
1300
- attachmentEpoch,
1301
- cols,
1302
- rows,
1303
- });
1304
- }, true);
2335
+ }
2336
+ if (!this.#sessionByHandle.has(sessionHandle))
2337
+ return;
2338
+ this.#maybeReclaim(viewId);
2339
+ }
2340
+ releaseResizeControl(viewId) {
2341
+ const view = this.#views.get(viewId);
2342
+ if (!view)
2343
+ return;
2344
+ view.resizeControlRequested = false;
2345
+ if (this.#routedHost) {
2346
+ const entry = this.#routedBySession.get(view.sessionId);
2347
+ const geometry = this.#routedGeometry.get(view.sessionId);
2348
+ const ticket = entry?.ticket;
2349
+ if (entry && ticket && geometry?.holderViewId === viewId && geometry.holderGeneration !== undefined) {
2350
+ const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
2351
+ sessionId: view.sessionId,
2352
+ activationId: entry.state.activationId,
2353
+ leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
2354
+ holder: {
2355
+ clientId: ticket.attachGrant.claims.clientId,
2356
+ viewId,
2357
+ holderGeneration: geometry.holderGeneration,
2358
+ },
2359
+ });
2360
+ if (sent) {
2361
+ // T1 sends no success body for release. The ordered control leg and
2362
+ // cell state machine make the next revision deterministic.
2363
+ this.#routedGeometry.set(view.sessionId, {
2364
+ revision: geometry.revision + 1,
2365
+ ...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
2366
+ ...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
2367
+ });
2368
+ }
2369
+ }
2370
+ return;
2371
+ }
2372
+ // The legacy protocol has no release verb. Clearing the local epoch still
2373
+ // closes every resize path immediately; a later explicit claim can renew it.
2374
+ view.controlEpoch = undefined;
2375
+ }
2376
+ setViewInputPolicy(viewId, readWrite) {
2377
+ const view = this.#views.get(viewId);
2378
+ if (!view)
2379
+ return;
2380
+ view.clientReadWrite = readWrite;
2381
+ if (!readWrite)
2382
+ view.pendingInput.length = 0;
2383
+ const entry = this.#routedBySession.get(view.sessionId);
2384
+ if (entry) {
2385
+ const anyWritable = this.#routedHost?.encodeInput !== undefined &&
2386
+ [...entry.viewIds].some((candidate) => {
2387
+ const candidateView = this.#views.get(candidate);
2388
+ return candidateView?.clientReadWrite === true && candidateView.readWrite !== false;
2389
+ });
2390
+ this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
2391
+ }
1305
2392
  }
1306
2393
  setFocused(sessionHandle, viewId, focused, cols, rows) {
1307
2394
  const view = this.#views.get(viewId);
@@ -1310,10 +2397,6 @@ export class GhostteaTerminalRuntime extends EventTarget {
1310
2397
  view.desiredRows = rows;
1311
2398
  }
1312
2399
  if (this.#focusByView.get(viewId) === focused) {
1313
- // Focus has not moved, so the claim below will not run — but an epoch or
1314
- // controller change since the last update may have made one possible,
1315
- // and nothing else would ever retry it after a resume.
1316
- this.#maybeReclaim(viewId);
1317
2400
  return;
1318
2401
  }
1319
2402
  this.#focusByView.set(viewId, focused);
@@ -1321,6 +2404,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
1321
2404
  const session = this.#sessionByHandle.get(sessionHandle);
1322
2405
  if (!session)
1323
2406
  return;
2407
+ if (this.#routedHost) {
2408
+ const entry = this.#routedBySession.get(session.id);
2409
+ if (entry)
2410
+ this.#declareRoutedDemand(entry);
2411
+ return;
2412
+ }
1324
2413
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
1325
2414
  this.#control?.notify({
1326
2415
  type: "focus",
@@ -1330,25 +2419,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
1330
2419
  inputSequence,
1331
2420
  focused,
1332
2421
  });
1333
- if (focused) {
1334
- // Taking focus is a deliberate claim, and counts as this epoch's.
1335
- if (view) {
1336
- view.claimedEpoch = attachmentEpoch;
1337
- view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
1338
- }
1339
- this.#control?.notify({
1340
- type: "focus-and-resize",
1341
- sessionId: session.id,
1342
- viewId,
1343
- attachmentEpoch,
1344
- cols,
1345
- rows,
1346
- });
1347
- }
1348
2422
  }, true);
1349
2423
  }
1350
2424
  async copySelection(sessionId, viewId, selection, selectAll = false) {
1351
2425
  await this.connect();
2426
+ if (this.#routedHost)
2427
+ return "";
1352
2428
  const view = this.#views.get(viewId);
1353
2429
  if (!view || view.sessionId !== sessionId)
1354
2430
  return "";
@@ -1383,6 +2459,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
1383
2459
  return response.text;
1384
2460
  }
1385
2461
  interrupt(sessionId, viewId) {
2462
+ if (this.#routedHost) {
2463
+ this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
2464
+ return;
2465
+ }
1386
2466
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "interrupt", sessionId, viewId, attachmentEpoch, inputSequence }));
1387
2467
  const handle = this.#handleBySessionId.get(sessionId);
1388
2468
  if (handle)
@@ -1431,6 +2511,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
1431
2511
  }
1432
2512
  }
1433
2513
  this.#sessionByHandle.delete(handle);
2514
+ this.#sessionGenerationByHandle.delete(handle);
2515
+ this.#appliedRoutedExitEvents.delete(handle);
1434
2516
  this.#handleBySessionId.delete(sessionId);
1435
2517
  this.#remoteSessions.delete(sessionId);
1436
2518
  this.#controlBySession.delete(sessionId);
@@ -1443,9 +2525,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
1443
2525
  this.#postWorker({ type: "drop-session", sessionHandle: handle });
1444
2526
  }
1445
2527
  unregisterSession(sessionId) {
2528
+ const routed = this.#routedBySession.get(sessionId);
2529
+ if (routed) {
2530
+ for (const viewId of [...routed.viewIds])
2531
+ this.#releaseRoutedView(sessionId, viewId);
2532
+ }
1446
2533
  this.#removeRegisteredSession(sessionId, true);
1447
2534
  }
1448
2535
  terminate(sessionId, source = "user") {
2536
+ if (this.#routedHost) {
2537
+ void this.#routedHost.terminate?.(sessionId, source);
2538
+ const entry = this.#routedBySession.get(sessionId);
2539
+ if (entry) {
2540
+ for (const viewId of [...entry.viewIds])
2541
+ this.#releaseRoutedView(sessionId, viewId);
2542
+ }
2543
+ this.#removeRegisteredSession(sessionId, false);
2544
+ return;
2545
+ }
1449
2546
  this.#control?.notify({ type: "terminate", sessionId, source });
1450
2547
  this.#removeRegisteredSession(sessionId, false);
1451
2548
  }
@@ -1455,6 +2552,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
1455
2552
  return;
1456
2553
  view.desiredCols = cols;
1457
2554
  view.desiredRows = rows;
2555
+ if (!view.resizeControlRequested)
2556
+ return;
2557
+ if (this.#routedHost) {
2558
+ const entry = this.#routedBySession.get(sessionId);
2559
+ if (entry)
2560
+ this.#claimRoutedGeometry(entry, viewId, cols, rows);
2561
+ return;
2562
+ }
1458
2563
  if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) {
1459
2564
  // Dimensions are one of the funnel's conditions: a pane that measured
1460
2565
  // itself while uncontrolled may now be able to take control.
@@ -1489,12 +2594,21 @@ export class GhostteaTerminalRuntime extends EventTarget {
1489
2594
  for (const timer of this.#metadataTimers.values())
1490
2595
  window.clearTimeout(timer);
1491
2596
  this.#metadataTimers.clear();
2597
+ this.#metadataRefreshPending.clear();
2598
+ this.#metadataRefreshes.clear();
2599
+ this.#sessionGenerationByHandle.clear();
2600
+ this.#appliedRoutedExitEvents.clear();
1492
2601
  this.#resync.dispose();
1493
2602
  for (const request of this.#performanceRequests.values()) {
1494
2603
  window.clearTimeout(request.timer);
1495
2604
  request.reject(new Error("Terminal runtime was disposed during a performance request"));
1496
2605
  }
1497
2606
  this.#performanceRequests.clear();
2607
+ for (const request of this.#counterRequests.values()) {
2608
+ window.clearTimeout(request.timer);
2609
+ request.reject(new Error("Terminal runtime was disposed during a counter request"));
2610
+ }
2611
+ this.#counterRequests.clear();
1498
2612
  this.#views.clear();
1499
2613
  this.#remoteSessions.clear();
1500
2614
  this.#controlBySession.clear();
@@ -1515,15 +2629,28 @@ export class GhostteaTerminalRuntime extends EventTarget {
1515
2629
  }
1516
2630
  this.#control?.dispose();
1517
2631
  this.#control = undefined;
2632
+ this.#routedControl?.dispose();
2633
+ for (const entry of this.#routedBySession.values()) {
2634
+ if (entry.attachTimer !== undefined)
2635
+ window.clearTimeout(entry.attachTimer);
2636
+ if (entry.renewalTimer !== undefined)
2637
+ window.clearTimeout(entry.renewalTimer);
2638
+ }
2639
+ this.#routedBySession.clear();
2640
+ this.#routedByActivation.clear();
2641
+ this.#routedGeometry.clear();
2642
+ this.#routedAttachDeadlineByCell.clear();
1518
2643
  this.#serverProtocolMinor = 0;
1519
2644
  this.#worker.terminate();
1520
- void this.#ports.then((ports) => {
1521
- ports.control.close();
1522
- ports.frames.close();
1523
- }, () => undefined);
2645
+ if (this.#ports) {
2646
+ void this.#ports.then((ports) => {
2647
+ ports.control.close();
2648
+ ports.frames.close();
2649
+ }, () => undefined);
2650
+ }
1524
2651
  }
1525
2652
  #sendResize(viewId, view, cols, rows) {
1526
- if (view.attachmentEpoch === undefined || view.controlEpoch === undefined)
2653
+ if (!view.resizeControlRequested || view.attachmentEpoch === undefined || view.controlEpoch === undefined)
1527
2654
  return;
1528
2655
  view.resizeSequence += 1;
1529
2656
  this.#control?.notify({