@vibecook/ghosttea-react 0.10.0 → 0.11.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 (40) hide show
  1. package/README.md +50 -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 +69 -0
  18. package/dist/routed-control.d.ts.map +1 -0
  19. package/dist/routed-control.js +383 -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 +73 -4
  26. package/dist/runtime.d.ts.map +1 -1
  27. package/dist/runtime.js +1034 -53
  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;
@@ -90,13 +104,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
90
104
  #resync;
91
105
  #performanceRequestId = 1;
92
106
  #performanceRequests = new Map();
107
+ #counterRequests = new Map();
93
108
  #disposed = false;
94
109
  constructor(options) {
95
110
  super();
96
111
  this.#worker =
97
112
  options.workerFactory?.() ??
98
113
  new Worker(new URL("./terminal-render.worker.js", import.meta.url), { type: "module" });
99
- this.#ports = Promise.resolve(options.ports);
114
+ this.#ports = options.transport === "routed" ? undefined : Promise.resolve(options.ports);
115
+ this.#routedHost = options.transport === "routed" ? options.host : undefined;
116
+ this.#routedReceiverCapacities = options.transport === "routed" ? options.receiverCapacities : undefined;
117
+ this.#routedCapabilities = options.transport === "routed" ? (options.capabilities ?? ["resume"]) : [];
118
+ this.#routedControl =
119
+ options.transport === "routed"
120
+ ? new RoutedControlTransport({
121
+ ...(options.websocketFactory === undefined ? {} : { socketFactory: options.websocketFactory }),
122
+ emit: (event) => this.#handleRoutedControlEvent(event),
123
+ })
124
+ : undefined;
100
125
  this.#platform = options.platform;
101
126
  this.#clientBuild = options.clientBuild ?? "ghosttea-react";
102
127
  this.#sessionOwnerId = options.sessionOwnerId;
@@ -152,6 +177,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
152
177
  else if (data.type === "performance-result") {
153
178
  this.#resolvePerformanceRequest(data.requestId, data.snapshot);
154
179
  }
180
+ else if (data.type === "performance-counters") {
181
+ const pending = this.#counterRequests.get(data.requestId);
182
+ if (!pending)
183
+ return;
184
+ window.clearTimeout(pending.timer);
185
+ this.#counterRequests.delete(data.requestId);
186
+ pending.resolve(data.snapshot);
187
+ }
188
+ else if (data.type === "routed-frames-event") {
189
+ this.#handleRoutedFramesEvent(data.event);
190
+ }
155
191
  else if (data.type === "renderer-reload-required") {
156
192
  console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
157
193
  this.#platform.setForceCanvasFallback(true);
@@ -171,6 +207,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
171
207
  get rendererBackend() {
172
208
  return this.#rendererBackend;
173
209
  }
210
+ /** Current main-authority state for a routed session. */
211
+ routedActivation(sessionId) {
212
+ return this.#routedBySession.get(sessionId)?.state;
213
+ }
214
+ routedViewInputAllowed(viewId) {
215
+ const view = this.#views.get(viewId);
216
+ if (!view?.clientReadWrite || view.readWrite === false)
217
+ return false;
218
+ return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
219
+ }
174
220
  #resolvePerformanceRequest(requestId, value) {
175
221
  const pending = this.#performanceRequests.get(requestId);
176
222
  if (!pending)
@@ -203,6 +249,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
203
249
  throw new Error("Terminal render worker returned no performance snapshot");
204
250
  return result;
205
251
  }
252
+ /** Reads monotonic production counters without starting a sample window or draining the GPU. */
253
+ readPerformanceCounters(timeoutMs = 2_000) {
254
+ if (this.#disposed)
255
+ return Promise.reject(new Error("Terminal runtime is disposed"));
256
+ const requestId = this.#performanceRequestId++;
257
+ return new Promise((resolve, reject) => {
258
+ const timer = window.setTimeout(() => {
259
+ this.#counterRequests.delete(requestId);
260
+ reject(new Error(`Terminal render counter request ${requestId} timed out`));
261
+ }, timeoutMs);
262
+ this.#counterRequests.set(requestId, { resolve, reject, timer });
263
+ this.#postWorker({ type: "performance-counters", requestId });
264
+ });
265
+ }
206
266
  connect() {
207
267
  if (this.#disposed)
208
268
  return Promise.reject(new Error("Terminal runtime is disposed"));
@@ -210,6 +270,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
210
270
  return this.#ready;
211
271
  }
212
272
  async #connect() {
273
+ if (this.#routedHost)
274
+ return;
213
275
  const ports = await this.#ports;
214
276
  if (this.#disposed) {
215
277
  ports.control.close();
@@ -342,6 +404,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
342
404
  }
343
405
  async reloadConfig() {
344
406
  await this.connect();
407
+ if (this.#routedHost)
408
+ throw new Error("Configuration reload is not part of the routed host contract");
345
409
  const response = await this.#control.request({ type: "reload-config" });
346
410
  if (response.type !== "config")
347
411
  throw new Error("ghosttead returned an unexpected configuration response");
@@ -608,6 +672,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
608
672
  }
609
673
  async createSession(options) {
610
674
  await this.connect();
675
+ if (this.#routedHost) {
676
+ if (!this.#routedHost.createSession)
677
+ throw new Error("The routed host does not provide session creation");
678
+ const session = await this.#routedHost.createSession(options);
679
+ this.registerSession(session);
680
+ return session;
681
+ }
611
682
  if (options.scrollbackBytes !== undefined) {
612
683
  if (!isValidScrollbackBytes(options.scrollbackBytes)) {
613
684
  throw new RangeError("scrollbackBytes must be a non-negative safe integer");
@@ -628,6 +699,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
628
699
  }
629
700
  async listSessions() {
630
701
  await this.connect();
702
+ if (this.#routedHost) {
703
+ const sessions = this.#routedHost.listSessions
704
+ ? await this.#routedHost.listSessions()
705
+ : [...this.#sessionByHandle.values()];
706
+ for (const session of sessions)
707
+ this.registerSession(session);
708
+ return sessions;
709
+ }
631
710
  const response = await this.#control.request({ type: "list-sessions" });
632
711
  if (response.type !== "sessions")
633
712
  throw new Error("ghosttead returned an unexpected response");
@@ -706,6 +785,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
706
785
  }
707
786
  async listRemoteHosts() {
708
787
  await this.connect();
788
+ if (this.#routedHost)
789
+ throw new Error("Remote-host discovery is not part of the routed host contract");
709
790
  const response = await this.#control.request({ type: "list-remote-hosts" });
710
791
  if (response.type !== "remote-hosts")
711
792
  throw new Error("ghosttead returned an unexpected response");
@@ -713,6 +794,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
713
794
  }
714
795
  async listRemoteSessions(deviceId) {
715
796
  await this.connect();
797
+ if (this.#routedHost)
798
+ throw new Error("Remote-session discovery is not part of the routed host contract");
716
799
  const response = await this.#control.request({ type: "list-remote-sessions", deviceId }, 35_000);
717
800
  if (response.type !== "remote-sessions" || response.deviceId !== deviceId)
718
801
  throw new Error("ghosttead returned an unexpected response");
@@ -720,6 +803,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
720
803
  }
721
804
  async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
722
805
  await this.connect();
806
+ if (this.#routedHost)
807
+ throw new Error("Remote-session opening is not part of the routed host contract");
723
808
  const response = await this.#control.request({
724
809
  type: "open-remote-session",
725
810
  deviceId,
@@ -767,6 +852,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
767
852
  mount(sessionId, sessionHandle, viewId, canvas) {
768
853
  if (this.#disposed)
769
854
  throw new Error("Cannot mount a disposed terminal runtime");
855
+ if (this.#routedHost)
856
+ return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
770
857
  const mounted = this.#mountedCanvases.get(canvas);
771
858
  if (mounted) {
772
859
  if (!mounted.active)
@@ -801,6 +888,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
801
888
  const view = {
802
889
  sessionId,
803
890
  sessionHandle,
891
+ clientReadWrite: true,
892
+ resizeControlRequested: false,
893
+ visible: true,
804
894
  inputSequence: 0,
805
895
  resizeSequence: 0,
806
896
  controlEpoch: undefined,
@@ -869,6 +959,711 @@ export class GhostteaTerminalRuntime extends EventTarget {
869
959
  .catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
870
960
  return this.#createMountLease(entry);
871
961
  }
962
+ #mountRouted(sessionId, sessionHandle, viewId, canvas) {
963
+ const mounted = this.#mountedCanvases.get(canvas);
964
+ if (mounted) {
965
+ if (!mounted.active)
966
+ throw new Error("A released terminal canvas cannot be remounted");
967
+ if (mounted.sessionHandle !== sessionHandle) {
968
+ throw new Error("A terminal canvas cannot be reassigned to another session");
969
+ }
970
+ mounted.references += 1;
971
+ if (mounted.disposeTimer !== undefined) {
972
+ window.clearTimeout(mounted.disposeTimer);
973
+ mounted.disposeTimer = undefined;
974
+ }
975
+ return this.#createMountLease(mounted);
976
+ }
977
+ const offscreen = canvas.transferControlToOffscreen();
978
+ const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
979
+ this.#mountGenerationBySurface.set(viewId, generation);
980
+ this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
981
+ const entry = {
982
+ canvas,
983
+ sessionHandle,
984
+ sessionId,
985
+ viewId,
986
+ generation,
987
+ references: 1,
988
+ disposeTimer: undefined,
989
+ active: true,
990
+ };
991
+ this.#mountedCanvases.set(canvas, entry);
992
+ this.#mountedEntries.add(entry);
993
+ const session = this.#sessionByHandle.get(sessionHandle);
994
+ this.#views.set(viewId, {
995
+ sessionId,
996
+ sessionHandle,
997
+ ...(session === undefined ? {} : { readWrite: session.readWrite }),
998
+ clientReadWrite: true,
999
+ resizeControlRequested: false,
1000
+ visible: true,
1001
+ inputSequence: 0,
1002
+ resizeSequence: 0,
1003
+ controlEpoch: undefined,
1004
+ desiredCols: undefined,
1005
+ desiredRows: undefined,
1006
+ pendingInput: [],
1007
+ lastViewStateSeq: undefined,
1008
+ lastAttachmentEpoch: undefined,
1009
+ claimedEpoch: undefined,
1010
+ claimedRevision: 0,
1011
+ });
1012
+ const activation = this.#routedBySession.get(sessionId);
1013
+ if (activation)
1014
+ activation.viewIds.add(viewId);
1015
+ void this.#ensureRoutedActivation(sessionId, sessionHandle, viewId).catch((error) => {
1016
+ if (!this.#disposed)
1017
+ console.error(`[terminal-runtime] routed activation failed for ${sessionId}`, error);
1018
+ });
1019
+ return this.#createMountLease(entry);
1020
+ }
1021
+ async #ensureRoutedActivation(sessionId, sessionHandle, viewId) {
1022
+ const existing = this.#routedBySession.get(sessionId);
1023
+ if (existing) {
1024
+ existing.viewIds.add(viewId);
1025
+ if (existing.start)
1026
+ await existing.start;
1027
+ const anyWritable = this.#routedHost?.encodeInput !== undefined &&
1028
+ [...existing.viewIds].some((candidate) => {
1029
+ const view = this.#views.get(candidate);
1030
+ return view?.clientReadWrite === true && view.readWrite !== false;
1031
+ });
1032
+ this.#transitionRouted(existing, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
1033
+ this.#declareRoutedDemand(existing);
1034
+ return;
1035
+ }
1036
+ const activationId = crypto.randomUUID();
1037
+ const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
1038
+ this.#views.get(viewId)?.clientReadWrite !== false &&
1039
+ this.#views.get(viewId)?.readWrite !== false
1040
+ ? "read-write"
1041
+ : "read-only";
1042
+ const entry = {
1043
+ sessionId,
1044
+ sessionHandle,
1045
+ state: initialRoutedActivation(sessionId, activationId, inputPolicy),
1046
+ viewIds: new Set([viewId]),
1047
+ recoveryAttempts: 0,
1048
+ preAuthRemints: { control: 0, frames: 0 },
1049
+ protocolFailures: { control: 0, frames: 0 },
1050
+ };
1051
+ this.#routedBySession.set(sessionId, entry);
1052
+ this.#routedByActivation.set(activationId, entry);
1053
+ entry.start = this.#startRoutedActivation(entry, "mount");
1054
+ try {
1055
+ await entry.start;
1056
+ }
1057
+ finally {
1058
+ delete entry.start;
1059
+ }
1060
+ }
1061
+ #routedTicketMatches(entry, ticket) {
1062
+ return (isRoutedTerminalOpenTicket(ticket) &&
1063
+ ticket.route.cellBootId === ticket.transportGrant.claims.audienceCellBootId &&
1064
+ ticket.route.cellBootId === ticket.attachGrant.claims.audienceCellBootId &&
1065
+ ticket.route.cellBootId === ticket.transportGrant.protected.kid.cellBootId &&
1066
+ ticket.route.cellBootId === ticket.attachGrant.protected.kid.cellBootId &&
1067
+ ticket.transportGrant.claims.clientId === ticket.attachGrant.claims.clientId &&
1068
+ ticket.transportGrant.claims.allowedChannels.includes("control") &&
1069
+ ticket.transportGrant.claims.allowedChannels.includes("frames") &&
1070
+ ticket.attachGrant.claims.sessionId === entry.sessionId &&
1071
+ ticket.attachGrant.claims.routeRevision === ticket.route.routeRevision &&
1072
+ (ticket.route.leaseEpoch === undefined ||
1073
+ ticket.attachGrant.claims.leaseEpoch === undefined ||
1074
+ ticket.route.leaseEpoch === ticket.attachGrant.claims.leaseEpoch));
1075
+ }
1076
+ #routedRenewalMatches(entry, value, previousGeneration) {
1077
+ const ticket = entry.ticket;
1078
+ return (ticket !== undefined &&
1079
+ isRoutedSessionAttachGrant(value) &&
1080
+ value.protected.kid.cellBootId === ticket.route.cellBootId &&
1081
+ value.claims.audienceCellBootId === ticket.route.cellBootId &&
1082
+ value.claims.clientId === ticket.attachGrant.claims.clientId &&
1083
+ value.claims.sessionId === entry.sessionId &&
1084
+ value.claims.routeRevision === ticket.route.routeRevision &&
1085
+ value.claims.leaseEpoch === ticket.attachGrant.claims.leaseEpoch &&
1086
+ value.claims.grantGeneration > previousGeneration);
1087
+ }
1088
+ async #startRoutedActivation(entry, reason) {
1089
+ const host = this.#routedHost;
1090
+ if (!host || this.#disposed || entry.viewIds.size === 0)
1091
+ return;
1092
+ let ticket;
1093
+ try {
1094
+ ticket = await host.openTicket(entry.sessionId, { reason });
1095
+ }
1096
+ catch (error) {
1097
+ this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
1098
+ return;
1099
+ }
1100
+ if (this.#routedBySession.get(entry.sessionId) !== entry || this.#disposed)
1101
+ return;
1102
+ if (!this.#routedTicketMatches(entry, ticket)) {
1103
+ this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
1104
+ return;
1105
+ }
1106
+ this.#transitionRouted(entry, { type: "ticket-minted", endpointsPresent: ticket.endpoints !== undefined });
1107
+ if (!ticket.endpoints)
1108
+ return;
1109
+ entry.ticket = ticket;
1110
+ this.#transitionRouted(entry, { type: "transport-ready" });
1111
+ this.#routedControl.attach({
1112
+ cellBootId: ticket.route.cellBootId,
1113
+ controlUrl: ticket.endpoints.controlUrl,
1114
+ transportGrant: ticket.transportGrant,
1115
+ attachGrant: ticket.attachGrant,
1116
+ activationId: entry.state.activationId,
1117
+ ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
1118
+ initialDemand: this.#routedDemand(entry),
1119
+ capabilities: this.#routedCapabilities,
1120
+ });
1121
+ this.#postWorker({
1122
+ type: "routed-frames-attach",
1123
+ request: {
1124
+ cellBootId: ticket.route.cellBootId,
1125
+ sessionHandle: entry.sessionHandle,
1126
+ framesUrl: ticket.endpoints.framesUrl,
1127
+ transportGrant: ticket.transportGrant,
1128
+ attachGrant: ticket.attachGrant,
1129
+ activationId: entry.state.activationId,
1130
+ ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
1131
+ ...(this.#routedReceiverCapacities === undefined ? {} : { receiverCapacities: this.#routedReceiverCapacities }),
1132
+ capabilities: this.#routedCapabilities,
1133
+ },
1134
+ });
1135
+ this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
1136
+ DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
1137
+ this.#scheduleRoutedRenewal(entry);
1138
+ }
1139
+ #stopRoutedActivation(entry) {
1140
+ this.#routedControl?.detach(entry.state.activationId);
1141
+ this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
1142
+ if (entry.attachTimer !== undefined)
1143
+ window.clearTimeout(entry.attachTimer);
1144
+ delete entry.attachTimer;
1145
+ if (entry.renewalTimer !== undefined)
1146
+ window.clearTimeout(entry.renewalTimer);
1147
+ delete entry.renewalTimer;
1148
+ delete entry.ticket;
1149
+ }
1150
+ #transitionRouted(entry, event) {
1151
+ const previous = entry.state;
1152
+ const next = reduceRoutedActivation(previous, event);
1153
+ if (next === previous)
1154
+ return;
1155
+ entry.state = next;
1156
+ if (next.phase !== "attaching" && entry.attachTimer !== undefined) {
1157
+ window.clearTimeout(entry.attachTimer);
1158
+ delete entry.attachTimer;
1159
+ }
1160
+ if ((next.phase === "unavailable" || next.phase === "ended") && next.phase !== previous.phase) {
1161
+ this.#stopRoutedActivation(entry);
1162
+ }
1163
+ if (!previous.presentationReady && next.presentationReady) {
1164
+ entry.recoveryAttempts = 0;
1165
+ entry.protocolFailures.control = 0;
1166
+ entry.protocolFailures.frames = 0;
1167
+ }
1168
+ this.dispatchEvent(new CustomEvent("routed-activation-state", {
1169
+ detail: { sessionId: entry.sessionId, previous, current: next },
1170
+ }));
1171
+ if (previous.presentationReady !== next.presentationReady || previous.inputAllowed !== next.inputAllowed) {
1172
+ for (const viewId of entry.viewIds) {
1173
+ const view = this.#views.get(viewId);
1174
+ this.dispatchEvent(new CustomEvent("routed-view-readiness", {
1175
+ detail: {
1176
+ sessionId: entry.sessionId,
1177
+ viewId,
1178
+ presentationReady: next.presentationReady,
1179
+ inputAllowed: next.inputAllowed && view?.clientReadWrite === true && view.readWrite !== false,
1180
+ phase: next.phase,
1181
+ },
1182
+ }));
1183
+ }
1184
+ }
1185
+ }
1186
+ #handleRoutedControlEvent(event) {
1187
+ if (this.#disposed)
1188
+ return;
1189
+ if (event.type === "transport-ready") {
1190
+ const deadline = event.accepted.protocolLimits.activationAttachDeadlineMs;
1191
+ this.#routedAttachDeadlineByCell.set(event.cellBootId, deadline);
1192
+ for (const entry of this.#routedBySession.values()) {
1193
+ if (entry.ticket?.route.cellBootId === event.cellBootId && entry.state.phase === "attaching") {
1194
+ this.#armRoutedAttachDeadline(entry, deadline);
1195
+ }
1196
+ }
1197
+ return;
1198
+ }
1199
+ if (event.type === "control-attached") {
1200
+ const entry = this.#routedByActivation.get(event.attached.activationId);
1201
+ if (!entry || event.attached.sessionId !== entry.sessionId)
1202
+ return;
1203
+ entry.preAuthRemints.control = 0;
1204
+ this.#transitionRouted(entry, {
1205
+ type: "control-attached",
1206
+ grantGeneration: event.attached.grantGenerationAccepted,
1207
+ rights: event.attached.rights,
1208
+ });
1209
+ const previousGeometry = this.#routedGeometry.get(entry.sessionId);
1210
+ if (!event.attached.rights.includes("geometry") &&
1211
+ previousGeometry?.holderViewId !== undefined &&
1212
+ entry.viewIds.has(previousGeometry.holderViewId)) {
1213
+ // The cell auto-releases a holder when renewal drops the geometry
1214
+ // right. Preserve its next CAS revision without retaining authority.
1215
+ this.#routedGeometry.set(entry.sessionId, {
1216
+ revision: previousGeometry.revision + 1,
1217
+ ...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
1218
+ ...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
1219
+ });
1220
+ }
1221
+ const geometry = this.#routedGeometry.get(entry.sessionId);
1222
+ for (const viewId of entry.viewIds) {
1223
+ const view = this.#views.get(viewId);
1224
+ if (view?.resizeControlRequested &&
1225
+ geometry?.holderViewId !== viewId &&
1226
+ view.desiredCols !== undefined &&
1227
+ view.desiredRows !== undefined) {
1228
+ this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
1229
+ }
1230
+ }
1231
+ return;
1232
+ }
1233
+ if (event.type === "attach-refused") {
1234
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1235
+ if (!entry)
1236
+ return;
1237
+ this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
1238
+ if (entry.state.phase === "recovering") {
1239
+ this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry");
1240
+ }
1241
+ return;
1242
+ }
1243
+ if (event.type === "cell-status") {
1244
+ const entry = this.#routedByActivation.get(event.status.activationId);
1245
+ if (!entry || event.status.sessionId !== entry.sessionId)
1246
+ return;
1247
+ this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
1248
+ const sequence = entry.state.lastCellStatusSequence;
1249
+ window.setTimeout(() => {
1250
+ if (entry.state.lastCellStatusSequence !== sequence)
1251
+ return;
1252
+ this.#transitionRouted(entry, { type: "cell-lease-expired" });
1253
+ }, event.status.leaseTtlMs);
1254
+ if (event.status.presentation.state === "revoked" &&
1255
+ (event.status.presentation.reason === "leg-dead" || event.status.presentation.reason === "stale-route")) {
1256
+ this.#recoverRoutedActivation(entry, event.status.presentation.reason === "stale-route" ? "route-stale" : "retry");
1257
+ }
1258
+ return;
1259
+ }
1260
+ if (event.type === "geometry-committed") {
1261
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1262
+ if (!entry)
1263
+ return;
1264
+ this.#routedGeometry.set(entry.sessionId, {
1265
+ holderViewId: event.committed.holder.viewId,
1266
+ holderGeneration: event.committed.holder.holderGeneration,
1267
+ revision: event.committed.geometryRevision,
1268
+ cols: event.committed.cols,
1269
+ rows: event.committed.rows,
1270
+ });
1271
+ this.dispatchEvent(new CustomEvent("routed-geometry", { detail: { sessionId: entry.sessionId, ...event } }));
1272
+ return;
1273
+ }
1274
+ if (event.type === "geometry-refused") {
1275
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1276
+ if (entry && event.refused.geometryRevision !== undefined) {
1277
+ const previous = this.#routedGeometry.get(entry.sessionId);
1278
+ const holder = event.refused.currentHolder;
1279
+ this.#routedGeometry.set(entry.sessionId, {
1280
+ revision: event.refused.geometryRevision,
1281
+ ...(holder === undefined ? {} : { holderViewId: holder.viewId, holderGeneration: holder.holderGeneration }),
1282
+ ...(previous?.cols === undefined ? {} : { cols: previous.cols }),
1283
+ ...(previous?.rows === undefined ? {} : { rows: previous.rows }),
1284
+ });
1285
+ }
1286
+ this.dispatchEvent(new CustomEvent("routed-geometry-refused", { detail: event }));
1287
+ return;
1288
+ }
1289
+ if (event.type === "transport-closed") {
1290
+ this.#routedAttachDeadlineByCell.delete(event.cellBootId);
1291
+ for (const activationId of event.activationIds) {
1292
+ const entry = this.#routedByActivation.get(activationId);
1293
+ if (!entry)
1294
+ continue;
1295
+ if (event.preAuth || event.refusal) {
1296
+ const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
1297
+ this.#transitionRouted(entry, {
1298
+ type: "transport-failed",
1299
+ ...(event.preAuth ? { preAuth: true } : {}),
1300
+ ...(event.refusal === undefined ? {} : { retryable: recoverable }),
1301
+ });
1302
+ if (entry.state.phase === "unavailable")
1303
+ continue;
1304
+ this.#recoverRoutedActivation(entry, event.preAuth ? "pre-auth" : "retry", "control");
1305
+ continue;
1306
+ }
1307
+ const routeStale = event.code === 4000 || event.code === 4001;
1308
+ if (event.code === 4002) {
1309
+ this.#transitionRouted(entry, { type: "replaced" });
1310
+ continue;
1311
+ }
1312
+ if (event.code === 4003) {
1313
+ if (entry.protocolFailures.control >= 1) {
1314
+ this.#transitionRouted(entry, { type: "leg-lost", channel: "control", resumeCapable: false });
1315
+ this.#transitionRouted(entry, {
1316
+ type: "transport-failed",
1317
+ recoveryExhausted: true,
1318
+ reason: "protocol",
1319
+ });
1320
+ this.dispatchEvent(new CustomEvent("routed-protocol-error", {
1321
+ detail: { sessionId: entry.sessionId, channel: "control", reason: event.reason },
1322
+ }));
1323
+ continue;
1324
+ }
1325
+ entry.protocolFailures.control += 1;
1326
+ }
1327
+ this.#transitionRouted(entry, {
1328
+ type: routeStale ? "route-stale" : "leg-lost",
1329
+ channel: "control",
1330
+ resumeCapable: false,
1331
+ });
1332
+ this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "control");
1333
+ }
1334
+ }
1335
+ }
1336
+ #handleRoutedFramesEvent(event) {
1337
+ if (event.type === "frames-attached") {
1338
+ const entry = this.#routedByActivation.get(event.attached.activationId);
1339
+ if (!entry || event.attached.sessionId !== entry.sessionId)
1340
+ return;
1341
+ entry.preAuthRemints.frames = 0;
1342
+ this.#transitionRouted(entry, {
1343
+ type: "frames-attached",
1344
+ outcome: event.attached.outcome,
1345
+ trfIdentity: event.attached.trfIdentity,
1346
+ resumeToken: event.attached.resumeToken,
1347
+ });
1348
+ return;
1349
+ }
1350
+ if (event.type === "attach-refused") {
1351
+ const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1352
+ if (!entry)
1353
+ return;
1354
+ this.#transitionRouted(entry, { type: "attach-refused", code: event.code, retryable: event.retryable });
1355
+ if (entry.state.phase === "recovering") {
1356
+ this.#recoverRoutedActivation(entry, event.code === "STALE_ROUTE" || event.code === "FENCED" ? "route-stale" : "retry", "frames");
1357
+ }
1358
+ return;
1359
+ }
1360
+ if (event.type === "frames-state") {
1361
+ const entry = this.#routedByActivation.get(event.activationId);
1362
+ if (!entry)
1363
+ return;
1364
+ this.#transitionRouted(entry, {
1365
+ type: "frames-state",
1366
+ state: {
1367
+ activationId: event.activationId,
1368
+ state: event.state,
1369
+ ...(event.resumeToken === undefined ? {} : { resumeToken: event.resumeToken }),
1370
+ ...(event.appliedContent === undefined ? {} : { appliedContent: event.appliedContent }),
1371
+ },
1372
+ });
1373
+ if (event.state === "active" && event.appliedContent) {
1374
+ this.#transitionRouted(entry, { type: "sync-complete", appliedContent: event.appliedContent });
1375
+ }
1376
+ else if (event.state === "failed") {
1377
+ this.#transitionRouted(entry, { type: "sync-failed" });
1378
+ }
1379
+ return;
1380
+ }
1381
+ if (event.type === "presentation-status") {
1382
+ const entry = this.#routedByActivation.get(event.status.activationId);
1383
+ if (!entry)
1384
+ return;
1385
+ this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
1386
+ const sequence = entry.state.lastWorkerStatusSequence;
1387
+ window.setTimeout(() => {
1388
+ if (entry.state.lastWorkerStatusSequence !== sequence)
1389
+ return;
1390
+ this.#transitionRouted(entry, { type: "worker-lease-expired" });
1391
+ }, event.status.leaseTtlMs);
1392
+ return;
1393
+ }
1394
+ for (const activationId of event.activationIds) {
1395
+ const entry = this.#routedByActivation.get(activationId);
1396
+ if (!entry)
1397
+ continue;
1398
+ if (event.preAuth || event.refusal) {
1399
+ const recoverable = event.refusal ? routedConnectionRefusalIsRecoverable(event.refusal) : true;
1400
+ this.#transitionRouted(entry, {
1401
+ type: "transport-failed",
1402
+ ...(event.preAuth ? { preAuth: true } : {}),
1403
+ ...(event.refusal === undefined ? {} : { retryable: recoverable }),
1404
+ });
1405
+ if (entry.state.phase === "unavailable")
1406
+ continue;
1407
+ this.#resumeRoutedFrames(entry, event.preAuth ? "pre-auth" : "retry");
1408
+ continue;
1409
+ }
1410
+ const routeStale = event.code === 4000 || event.code === 4001;
1411
+ if (event.code === 4002) {
1412
+ this.#transitionRouted(entry, { type: "replaced" });
1413
+ continue;
1414
+ }
1415
+ if (event.code === 4003) {
1416
+ if (entry.protocolFailures.frames >= 1) {
1417
+ this.#transitionRouted(entry, { type: "leg-lost", channel: "frames", resumeCapable: false });
1418
+ this.#transitionRouted(entry, {
1419
+ type: "transport-failed",
1420
+ recoveryExhausted: true,
1421
+ reason: "protocol",
1422
+ });
1423
+ this.dispatchEvent(new CustomEvent("routed-protocol-error", {
1424
+ detail: { sessionId: entry.sessionId, channel: "frames", reason: event.reason },
1425
+ }));
1426
+ continue;
1427
+ }
1428
+ entry.protocolFailures.frames += 1;
1429
+ }
1430
+ const activationFailed = event.code === 4002 || event.code === 4003;
1431
+ const canResume = !routeStale &&
1432
+ !activationFailed &&
1433
+ this.#routedCapabilities.includes("resume") &&
1434
+ entry.state.resumeToken !== undefined &&
1435
+ entry.state.appliedContent !== undefined &&
1436
+ entry.ticket?.endpoints !== undefined;
1437
+ this.#transitionRouted(entry, {
1438
+ type: routeStale ? "route-stale" : "leg-lost",
1439
+ channel: "frames",
1440
+ resumeCapable: canResume,
1441
+ });
1442
+ if (canResume) {
1443
+ this.#resumeRoutedFrames(entry, "retry");
1444
+ }
1445
+ else {
1446
+ this.#recoverRoutedActivation(entry, routeStale ? "route-stale" : "retry", "frames");
1447
+ }
1448
+ }
1449
+ }
1450
+ #resumeRoutedFrames(entry, reason) {
1451
+ if (entry.framesResume || this.#disposed)
1452
+ return;
1453
+ const task = (async () => {
1454
+ const host = this.#routedHost;
1455
+ const previousTicket = entry.ticket;
1456
+ const activationId = entry.state.activationId;
1457
+ const resumeToken = entry.state.resumeToken;
1458
+ const appliedContent = entry.state.appliedContent;
1459
+ if (!host || !previousTicket?.endpoints || !resumeToken || !appliedContent) {
1460
+ this.#recoverRoutedActivation(entry, reason, "frames");
1461
+ return;
1462
+ }
1463
+ if (reason === "pre-auth") {
1464
+ if (entry.preAuthRemints.frames >= 1) {
1465
+ this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
1466
+ return;
1467
+ }
1468
+ entry.preAuthRemints.frames += 1;
1469
+ }
1470
+ entry.recoveryAttempts += 1;
1471
+ if (entry.recoveryAttempts > 5) {
1472
+ this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
1473
+ return;
1474
+ }
1475
+ let ticket;
1476
+ try {
1477
+ ticket = await host.openTicket(entry.sessionId, { reason });
1478
+ }
1479
+ catch {
1480
+ this.#recoverRoutedActivation(entry, "retry", "frames");
1481
+ return;
1482
+ }
1483
+ if (this.#disposed ||
1484
+ this.#routedByActivation.get(activationId) !== entry ||
1485
+ entry.state.activationId !== activationId) {
1486
+ return;
1487
+ }
1488
+ if (!this.#routedTicketMatches(entry, ticket) || !ticket.endpoints) {
1489
+ this.#recoverRoutedActivation(entry, "route-stale", "frames");
1490
+ return;
1491
+ }
1492
+ const sameRoute = ticket.route.cellBootId === previousTicket.route.cellBootId &&
1493
+ ticket.route.routeRevision === previousTicket.route.routeRevision &&
1494
+ ticket.route.leaseEpoch === previousTicket.route.leaseEpoch;
1495
+ if (!sameRoute) {
1496
+ this.#transitionRouted(entry, { type: "route-stale" });
1497
+ this.#recoverRoutedActivation(entry, "route-stale", "frames");
1498
+ return;
1499
+ }
1500
+ entry.ticket = ticket;
1501
+ this.#routedControl?.renew(activationId, ticket.attachGrant);
1502
+ this.#scheduleRoutedRenewal(entry);
1503
+ this.#postWorker({
1504
+ type: "routed-frames-attach",
1505
+ request: {
1506
+ cellBootId: ticket.route.cellBootId,
1507
+ sessionHandle: entry.sessionHandle,
1508
+ framesUrl: ticket.endpoints.framesUrl,
1509
+ transportGrant: ticket.transportGrant,
1510
+ attachGrant: ticket.attachGrant,
1511
+ activationId,
1512
+ resume: { resumeToken, from: appliedContent },
1513
+ ...(this.#routedReceiverCapacities === undefined
1514
+ ? {}
1515
+ : { receiverCapacities: this.#routedReceiverCapacities }),
1516
+ capabilities: this.#routedCapabilities,
1517
+ },
1518
+ });
1519
+ })();
1520
+ entry.framesResume = task;
1521
+ void task.finally(() => {
1522
+ if (entry.framesResume === task)
1523
+ delete entry.framesResume;
1524
+ });
1525
+ }
1526
+ #recoverRoutedActivation(entry, reason, failedChannel) {
1527
+ if (this.#disposed || entry.viewIds.size === 0 || entry.state.phase === "ended")
1528
+ return;
1529
+ if (reason === "pre-auth") {
1530
+ const channel = failedChannel ?? "control";
1531
+ if (entry.preAuthRemints[channel] >= 1) {
1532
+ this.#transitionRouted(entry, { type: "transport-failed", preAuth: true, recoveryExhausted: true });
1533
+ return;
1534
+ }
1535
+ entry.preAuthRemints[channel] += 1;
1536
+ }
1537
+ entry.recoveryAttempts += 1;
1538
+ if (entry.recoveryAttempts > 5) {
1539
+ this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
1540
+ return;
1541
+ }
1542
+ // Geometry belongs to the cell-side attach/client/view scope. Preserve it
1543
+ // across a same-route leg replacement, but never carry its revision or
1544
+ // holder generation to a newly routed cell.
1545
+ if (reason === "route-stale")
1546
+ this.#routedGeometry.delete(entry.sessionId);
1547
+ const previousActivationId = entry.state.activationId;
1548
+ this.#routedControl?.detach(previousActivationId);
1549
+ this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
1550
+ this.#routedByActivation.delete(previousActivationId);
1551
+ const nextActivationId = crypto.randomUUID();
1552
+ entry.replacesActivationId = previousActivationId;
1553
+ const inputPolicy = this.#routedHost?.encodeInput !== undefined &&
1554
+ [...entry.viewIds].some((viewId) => {
1555
+ const view = this.#views.get(viewId);
1556
+ return view?.clientReadWrite === true && view.readWrite !== false;
1557
+ })
1558
+ ? "read-write"
1559
+ : "read-only";
1560
+ entry.state = {
1561
+ ...initialRoutedActivation(entry.sessionId, nextActivationId, inputPolicy),
1562
+ phase: "recovering",
1563
+ replacesActivationId: previousActivationId,
1564
+ preAuthRemintUsed: entry.preAuthRemints.control > 0 || entry.preAuthRemints.frames > 0,
1565
+ };
1566
+ this.#routedByActivation.set(nextActivationId, entry);
1567
+ if (entry.attachTimer !== undefined)
1568
+ window.clearTimeout(entry.attachTimer);
1569
+ delete entry.attachTimer;
1570
+ if (entry.renewalTimer !== undefined)
1571
+ window.clearTimeout(entry.renewalTimer);
1572
+ delete entry.renewalTimer;
1573
+ delete entry.ticket;
1574
+ const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
1575
+ window.setTimeout(() => {
1576
+ if (this.#routedByActivation.get(nextActivationId) !== entry)
1577
+ return;
1578
+ entry.start = this.#startRoutedActivation(entry, reason);
1579
+ void entry.start.finally(() => delete entry.start);
1580
+ }, delay);
1581
+ }
1582
+ #armRoutedAttachDeadline(entry, delayMs) {
1583
+ if (entry.attachTimer !== undefined)
1584
+ window.clearTimeout(entry.attachTimer);
1585
+ const activationId = entry.state.activationId;
1586
+ entry.attachTimer = window.setTimeout(() => {
1587
+ if (this.#routedByActivation.get(activationId) !== entry || entry.state.phase !== "attaching")
1588
+ return;
1589
+ this.#transitionRouted(entry, { type: "attach-deadline" });
1590
+ this.#recoverRoutedActivation(entry, "retry");
1591
+ }, Math.max(0, delayMs));
1592
+ }
1593
+ #scheduleRoutedRenewal(entry) {
1594
+ const ticket = entry.ticket;
1595
+ const host = this.#routedHost;
1596
+ if (!ticket || !host)
1597
+ return;
1598
+ if (entry.renewalTimer !== undefined)
1599
+ window.clearTimeout(entry.renewalTimer);
1600
+ const generation = ticket.attachGrant.claims.grantGeneration;
1601
+ const delay = Math.max(0, ticket.attachGrant.claims.expiresAt - Date.now() - 60_000);
1602
+ if (delay > MAX_BROWSER_TIMEOUT_MS) {
1603
+ entry.renewalTimer = window.setTimeout(() => {
1604
+ if (entry.ticket !== ticket || this.#disposed)
1605
+ return;
1606
+ this.#scheduleRoutedRenewal(entry);
1607
+ }, MAX_BROWSER_TIMEOUT_MS);
1608
+ return;
1609
+ }
1610
+ entry.renewalTimer = window.setTimeout(() => {
1611
+ this.#transitionRouted(entry, { type: "grant-expiring" });
1612
+ if (!host.renewAttach) {
1613
+ this.#transitionRouted(entry, { type: "renew-failed" });
1614
+ return;
1615
+ }
1616
+ const requestId = crypto.randomUUID();
1617
+ void host
1618
+ .renewAttach({ sessionId: entry.sessionId, expectGeneration: generation, requestId })
1619
+ .then(({ attachGrant }) => {
1620
+ if (entry.ticket !== ticket || !this.#routedRenewalMatches(entry, attachGrant, generation)) {
1621
+ this.#transitionRouted(entry, { type: "renew-failed" });
1622
+ return;
1623
+ }
1624
+ entry.ticket = { ...ticket, attachGrant };
1625
+ this.#routedControl?.renew(entry.state.activationId, attachGrant);
1626
+ this.#scheduleRoutedRenewal(entry);
1627
+ })
1628
+ .catch(() => this.#transitionRouted(entry, { type: "renew-failed" }));
1629
+ }, delay);
1630
+ }
1631
+ #routedDemand(entry) {
1632
+ let live = false;
1633
+ let urgent = false;
1634
+ for (const viewId of entry.viewIds) {
1635
+ const view = this.#views.get(viewId);
1636
+ live ||= view?.visible === true;
1637
+ urgent ||= view?.visible === true && this.#focusByView.get(viewId) === true;
1638
+ }
1639
+ return {
1640
+ mode: live ? "live" : "none",
1641
+ urgency: urgent ? "urgent" : "normal",
1642
+ };
1643
+ }
1644
+ #declareRoutedDemand(entry) {
1645
+ this.#routedControl?.declareDemand(entry.state.activationId, this.#routedDemand(entry));
1646
+ }
1647
+ #releaseRoutedView(sessionId, viewId) {
1648
+ const entry = this.#routedBySession.get(sessionId);
1649
+ if (!entry)
1650
+ return;
1651
+ entry.viewIds.delete(viewId);
1652
+ if (entry.viewIds.size > 0) {
1653
+ const anyWritable = this.#routedHost?.encodeInput !== undefined &&
1654
+ [...entry.viewIds].some((candidate) => {
1655
+ const view = this.#views.get(candidate);
1656
+ return view?.clientReadWrite === true && view.readWrite !== false;
1657
+ });
1658
+ this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
1659
+ this.#declareRoutedDemand(entry);
1660
+ return;
1661
+ }
1662
+ this.#transitionRouted(entry, { type: "detach" });
1663
+ this.#routedBySession.delete(sessionId);
1664
+ this.#routedByActivation.delete(entry.state.activationId);
1665
+ this.#routedGeometry.delete(sessionId);
1666
+ }
872
1667
  #createMountLease(mounted) {
873
1668
  let disposed = false;
874
1669
  return {
@@ -891,13 +1686,17 @@ export class GhostteaTerminalRuntime extends EventTarget {
891
1686
  if (ownsWorkerSurface) {
892
1687
  this.#postWorker({ type: "unmount", surfaceId: mounted.viewId });
893
1688
  this.#mountGenerationBySurface.delete(mounted.viewId);
894
- this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
1689
+ if (this.#routedHost)
1690
+ this.#releaseRoutedView(mounted.sessionId, mounted.viewId);
1691
+ else
1692
+ this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
895
1693
  this.#views.delete(mounted.viewId);
896
1694
  this.#focusByView.delete(mounted.viewId);
897
1695
  }
898
1696
  this.#mountedCanvases.delete(mounted.canvas);
899
1697
  this.#mountedEntries.delete(mounted);
900
- this.#releaseFrameSubscription(mounted.sessionHandle);
1698
+ if (!this.#routedHost)
1699
+ this.#releaseFrameSubscription(mounted.sessionHandle);
901
1700
  }, 0);
902
1701
  },
903
1702
  };
@@ -950,8 +1749,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
950
1749
  this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
951
1750
  }
952
1751
  }
953
- // A cleared controller is the one case worth re-evaluating: the pane that
954
- // still holds focus may now take control back.
1752
+ // A cleared controller is the one case worth re-evaluating: a view with an
1753
+ // outstanding explicit resize-control request may now take the seat.
955
1754
  for (const viewId of this.#viewIdsForSession(sessionId))
956
1755
  this.#maybeReclaim(viewId);
957
1756
  }
@@ -963,10 +1762,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
963
1762
  }
964
1763
  /**
965
1764
  * 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.
1765
+ * gates an explicit claim re-enters here when it changes, because no one
1766
+ * event is enough: recovery can mark a view attached before its session
1767
+ * reaches live, while the resize-control request already exists.
970
1768
  *
971
1769
  * At most one claim per attachment epoch, plus one more each time the
972
1770
  * controller is cleared at a newer revision.
@@ -980,15 +1778,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
980
1778
  */
981
1779
  #maybeReclaim(viewId) {
982
1780
  const view = this.#views.get(viewId);
983
- if (!view || view.readWrite === false)
1781
+ if (!view || view.readWrite === false || !view.clientReadWrite || !view.resizeControlRequested)
984
1782
  return;
985
1783
  const attachmentEpoch = view.attachmentEpoch;
986
1784
  if (attachmentEpoch === undefined)
987
1785
  return;
988
1786
  if (view.desiredCols === undefined || view.desiredRows === undefined)
989
1787
  return;
990
- if (this.#focusByView.get(viewId) !== true)
991
- return;
992
1788
  const remote = this.#remoteSessions.get(view.sessionId);
993
1789
  if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame))
994
1790
  return;
@@ -1181,7 +1977,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1181
1977
  */
1182
1978
  #sendViewInput(viewId, operation, silent = false) {
1183
1979
  const view = this.#views.get(viewId);
1184
- if (!view || view.readWrite === false)
1980
+ if (!view || view.readWrite === false || !view.clientReadWrite)
1185
1981
  return;
1186
1982
  const remote = this.#remoteSessions.get(view.sessionId);
1187
1983
  const attachmentEpoch = view.attachmentEpoch;
@@ -1206,36 +2002,100 @@ export class GhostteaTerminalRuntime extends EventTarget {
1206
2002
  #reportSuppressedInput(sessionId, viewId, state) {
1207
2003
  this.dispatchEvent(new CustomEvent("input-suppressed", { detail: { sessionId, viewId, state } }));
1208
2004
  }
2005
+ #sendRoutedInput(sessionId, viewId, operation, silent = false) {
2006
+ const host = this.#routedHost;
2007
+ const view = this.#views.get(viewId);
2008
+ const activation = this.#routedBySession.get(sessionId);
2009
+ if (!host ||
2010
+ !view ||
2011
+ view.sessionId !== sessionId ||
2012
+ view.readWrite === false ||
2013
+ !view.clientReadWrite ||
2014
+ !activation?.state.inputAllowed) {
2015
+ if (!silent) {
2016
+ this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
2017
+ detail: {
2018
+ sessionId,
2019
+ viewId,
2020
+ reason: !host?.encodeInput ? "wire-verb-unavailable" : "input-not-allowed",
2021
+ },
2022
+ }));
2023
+ }
2024
+ return false;
2025
+ }
2026
+ if (!host.encodeInput) {
2027
+ if (!silent) {
2028
+ this.dispatchEvent(new CustomEvent("routed-input-suppressed", {
2029
+ detail: { sessionId, viewId, reason: "wire-verb-unavailable" },
2030
+ }));
2031
+ }
2032
+ return false;
2033
+ }
2034
+ view.inputSequence += 1;
2035
+ const message = host.encodeInput({
2036
+ sessionId,
2037
+ viewId,
2038
+ activationId: activation.state.activationId,
2039
+ leaseEpoch: activation.ticket?.attachGrant.claims.leaseEpoch ?? 0,
2040
+ inputSequence: view.inputSequence,
2041
+ operation,
2042
+ });
2043
+ return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
2044
+ }
1209
2045
  sendText(sessionId, viewId, text) {
2046
+ if (this.#routedHost) {
2047
+ this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
2048
+ return;
2049
+ }
1210
2050
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
1211
2051
  const handle = this.#handleBySessionId.get(sessionId);
1212
2052
  if (handle)
1213
2053
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
1214
2054
  }
1215
2055
  paste(sessionId, viewId, text) {
2056
+ if (this.#routedHost) {
2057
+ this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
2058
+ return;
2059
+ }
1216
2060
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "paste", sessionId, viewId, attachmentEpoch, inputSequence, text }));
1217
2061
  const handle = this.#handleBySessionId.get(sessionId);
1218
2062
  if (handle)
1219
2063
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
1220
2064
  }
1221
2065
  sendKey(sessionId, viewId, event) {
2066
+ if (this.#routedHost) {
2067
+ this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
2068
+ return;
2069
+ }
1222
2070
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-key", sessionId, viewId, attachmentEpoch, inputSequence, event }));
1223
2071
  const handle = this.#handleBySessionId.get(sessionId);
1224
2072
  if (handle)
1225
2073
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
1226
2074
  }
1227
2075
  sendMouse(sessionId, viewId, event) {
2076
+ if (this.#routedHost) {
2077
+ this.#sendRoutedInput(sessionId, viewId, { kind: "mouse", event }, true);
2078
+ return;
2079
+ }
1228
2080
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }), true);
1229
2081
  }
1230
2082
  scroll(sessionId, viewId, rows) {
1231
2083
  if (rows === 0)
1232
2084
  return;
2085
+ if (this.#routedHost) {
2086
+ this.#sendRoutedInput(sessionId, viewId, { kind: "scroll", rows }, true);
2087
+ return;
2088
+ }
1233
2089
  // Scrolling is host-side input, so it is simply inert while frozen.
1234
2090
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }), true);
1235
2091
  }
1236
2092
  scrollTo(sessionId, viewId, row) {
1237
2093
  if (!Number.isSafeInteger(row) || row < 0)
1238
2094
  return;
2095
+ if (this.#routedHost) {
2096
+ this.#sendRoutedInput(sessionId, viewId, { kind: "scroll-to", row }, true);
2097
+ return;
2098
+ }
1239
2099
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }), true);
1240
2100
  }
1241
2101
  scrollbar(sessionHandle) {
@@ -1246,6 +2106,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
1246
2106
  }
1247
2107
  setTheme(sessionHandle, theme, surfaceId) {
1248
2108
  this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
2109
+ // A surface-scoped theme is renderer-local. Updating the daemon's
2110
+ // session-wide palette here would repaint every mirrored viewer.
2111
+ if (surfaceId)
2112
+ return;
1249
2113
  const session = this.#sessionByHandle.get(sessionHandle);
1250
2114
  if (!session)
1251
2115
  return;
@@ -1270,6 +2134,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
1270
2134
  }
1271
2135
  setVisible(sessionHandle, visible, surfaceId) {
1272
2136
  this.#postWorker({ type: "visibility", sessionHandle, ...(surfaceId ? { surfaceId } : {}), visible });
2137
+ if (this.#routedHost) {
2138
+ const session = this.#sessionByHandle.get(sessionHandle);
2139
+ if (!session)
2140
+ return;
2141
+ if (surfaceId) {
2142
+ const view = this.#views.get(surfaceId);
2143
+ if (view)
2144
+ view.visible = visible;
2145
+ }
2146
+ else {
2147
+ for (const view of this.#views.values()) {
2148
+ if (view.sessionId === session.id)
2149
+ view.visible = visible;
2150
+ }
2151
+ }
2152
+ const entry = this.#routedBySession.get(session.id);
2153
+ if (entry)
2154
+ this.#declareRoutedDemand(entry);
2155
+ }
1273
2156
  }
1274
2157
  forceFullRedraw(sessionHandle) {
1275
2158
  this.#postWorker({ type: "force-full-redraw", sessionHandle });
@@ -1280,28 +2163,92 @@ export class GhostteaTerminalRuntime extends EventTarget {
1280
2163
  setPartialRenderingEnabled(enabled) {
1281
2164
  this.#postWorker({ type: "partial-rendering", enabled });
1282
2165
  }
2166
+ #claimRoutedGeometry(entry, viewId, cols, rows) {
2167
+ const ticket = entry.ticket;
2168
+ const view = this.#views.get(viewId);
2169
+ if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
2170
+ return;
2171
+ const geometry = this.#routedGeometry.get(entry.sessionId);
2172
+ this.#routedControl?.claimGeometry(entry.state.activationId, {
2173
+ sessionId: entry.sessionId,
2174
+ activationId: entry.state.activationId,
2175
+ leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
2176
+ claimant: { clientId: ticket.attachGrant.claims.clientId, viewId },
2177
+ cols,
2178
+ rows,
2179
+ expectRevision: geometry?.revision ?? 0,
2180
+ });
2181
+ }
1283
2182
  claimResizeControl(sessionHandle, viewId, cols, rows) {
1284
2183
  const view = this.#views.get(viewId);
1285
2184
  if (view) {
1286
2185
  view.desiredCols = cols;
1287
2186
  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;
2187
+ view.resizeControlRequested = true;
1291
2188
  }
1292
- const session = this.#sessionByHandle.get(sessionHandle);
1293
- if (!session)
2189
+ if (this.#routedHost) {
2190
+ const session = this.#sessionByHandle.get(sessionHandle);
2191
+ const entry = session ? this.#routedBySession.get(session.id) : undefined;
2192
+ if (entry)
2193
+ this.#claimRoutedGeometry(entry, viewId, cols, rows);
1294
2194
  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);
2195
+ }
2196
+ if (!this.#sessionByHandle.has(sessionHandle))
2197
+ return;
2198
+ this.#maybeReclaim(viewId);
2199
+ }
2200
+ releaseResizeControl(viewId) {
2201
+ const view = this.#views.get(viewId);
2202
+ if (!view)
2203
+ return;
2204
+ view.resizeControlRequested = false;
2205
+ if (this.#routedHost) {
2206
+ const entry = this.#routedBySession.get(view.sessionId);
2207
+ const geometry = this.#routedGeometry.get(view.sessionId);
2208
+ const ticket = entry?.ticket;
2209
+ if (entry && ticket && geometry?.holderViewId === viewId && geometry.holderGeneration !== undefined) {
2210
+ const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
2211
+ sessionId: view.sessionId,
2212
+ activationId: entry.state.activationId,
2213
+ leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
2214
+ holder: {
2215
+ clientId: ticket.attachGrant.claims.clientId,
2216
+ viewId,
2217
+ holderGeneration: geometry.holderGeneration,
2218
+ },
2219
+ });
2220
+ if (sent) {
2221
+ // T1 sends no success body for release. The ordered control leg and
2222
+ // cell state machine make the next revision deterministic.
2223
+ this.#routedGeometry.set(view.sessionId, {
2224
+ revision: geometry.revision + 1,
2225
+ ...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
2226
+ ...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
2227
+ });
2228
+ }
2229
+ }
2230
+ return;
2231
+ }
2232
+ // The legacy protocol has no release verb. Clearing the local epoch still
2233
+ // closes every resize path immediately; a later explicit claim can renew it.
2234
+ view.controlEpoch = undefined;
2235
+ }
2236
+ setViewInputPolicy(viewId, readWrite) {
2237
+ const view = this.#views.get(viewId);
2238
+ if (!view)
2239
+ return;
2240
+ view.clientReadWrite = readWrite;
2241
+ if (!readWrite)
2242
+ view.pendingInput.length = 0;
2243
+ const entry = this.#routedBySession.get(view.sessionId);
2244
+ if (entry) {
2245
+ const anyWritable = this.#routedHost?.encodeInput !== undefined &&
2246
+ [...entry.viewIds].some((candidate) => {
2247
+ const candidateView = this.#views.get(candidate);
2248
+ return candidateView?.clientReadWrite === true && candidateView.readWrite !== false;
2249
+ });
2250
+ this.#transitionRouted(entry, { type: "input-policy", policy: anyWritable ? "read-write" : "read-only" });
2251
+ }
1305
2252
  }
1306
2253
  setFocused(sessionHandle, viewId, focused, cols, rows) {
1307
2254
  const view = this.#views.get(viewId);
@@ -1310,10 +2257,6 @@ export class GhostteaTerminalRuntime extends EventTarget {
1310
2257
  view.desiredRows = rows;
1311
2258
  }
1312
2259
  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
2260
  return;
1318
2261
  }
1319
2262
  this.#focusByView.set(viewId, focused);
@@ -1321,6 +2264,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
1321
2264
  const session = this.#sessionByHandle.get(sessionHandle);
1322
2265
  if (!session)
1323
2266
  return;
2267
+ if (this.#routedHost) {
2268
+ const entry = this.#routedBySession.get(session.id);
2269
+ if (entry)
2270
+ this.#declareRoutedDemand(entry);
2271
+ return;
2272
+ }
1324
2273
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
1325
2274
  this.#control?.notify({
1326
2275
  type: "focus",
@@ -1330,25 +2279,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
1330
2279
  inputSequence,
1331
2280
  focused,
1332
2281
  });
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
2282
  }, true);
1349
2283
  }
1350
2284
  async copySelection(sessionId, viewId, selection, selectAll = false) {
1351
2285
  await this.connect();
2286
+ if (this.#routedHost)
2287
+ return "";
1352
2288
  const view = this.#views.get(viewId);
1353
2289
  if (!view || view.sessionId !== sessionId)
1354
2290
  return "";
@@ -1383,6 +2319,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
1383
2319
  return response.text;
1384
2320
  }
1385
2321
  interrupt(sessionId, viewId) {
2322
+ if (this.#routedHost) {
2323
+ this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
2324
+ return;
2325
+ }
1386
2326
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "interrupt", sessionId, viewId, attachmentEpoch, inputSequence }));
1387
2327
  const handle = this.#handleBySessionId.get(sessionId);
1388
2328
  if (handle)
@@ -1443,9 +2383,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
1443
2383
  this.#postWorker({ type: "drop-session", sessionHandle: handle });
1444
2384
  }
1445
2385
  unregisterSession(sessionId) {
2386
+ const routed = this.#routedBySession.get(sessionId);
2387
+ if (routed) {
2388
+ for (const viewId of [...routed.viewIds])
2389
+ this.#releaseRoutedView(sessionId, viewId);
2390
+ }
1446
2391
  this.#removeRegisteredSession(sessionId, true);
1447
2392
  }
1448
2393
  terminate(sessionId, source = "user") {
2394
+ if (this.#routedHost) {
2395
+ void this.#routedHost.terminate?.(sessionId, source);
2396
+ const entry = this.#routedBySession.get(sessionId);
2397
+ if (entry) {
2398
+ for (const viewId of [...entry.viewIds])
2399
+ this.#releaseRoutedView(sessionId, viewId);
2400
+ }
2401
+ this.#removeRegisteredSession(sessionId, false);
2402
+ return;
2403
+ }
1449
2404
  this.#control?.notify({ type: "terminate", sessionId, source });
1450
2405
  this.#removeRegisteredSession(sessionId, false);
1451
2406
  }
@@ -1455,6 +2410,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
1455
2410
  return;
1456
2411
  view.desiredCols = cols;
1457
2412
  view.desiredRows = rows;
2413
+ if (!view.resizeControlRequested)
2414
+ return;
2415
+ if (this.#routedHost) {
2416
+ const entry = this.#routedBySession.get(sessionId);
2417
+ if (entry)
2418
+ this.#claimRoutedGeometry(entry, viewId, cols, rows);
2419
+ return;
2420
+ }
1458
2421
  if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) {
1459
2422
  // Dimensions are one of the funnel's conditions: a pane that measured
1460
2423
  // itself while uncontrolled may now be able to take control.
@@ -1495,6 +2458,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
1495
2458
  request.reject(new Error("Terminal runtime was disposed during a performance request"));
1496
2459
  }
1497
2460
  this.#performanceRequests.clear();
2461
+ for (const request of this.#counterRequests.values()) {
2462
+ window.clearTimeout(request.timer);
2463
+ request.reject(new Error("Terminal runtime was disposed during a counter request"));
2464
+ }
2465
+ this.#counterRequests.clear();
1498
2466
  this.#views.clear();
1499
2467
  this.#remoteSessions.clear();
1500
2468
  this.#controlBySession.clear();
@@ -1515,15 +2483,28 @@ export class GhostteaTerminalRuntime extends EventTarget {
1515
2483
  }
1516
2484
  this.#control?.dispose();
1517
2485
  this.#control = undefined;
2486
+ this.#routedControl?.dispose();
2487
+ for (const entry of this.#routedBySession.values()) {
2488
+ if (entry.attachTimer !== undefined)
2489
+ window.clearTimeout(entry.attachTimer);
2490
+ if (entry.renewalTimer !== undefined)
2491
+ window.clearTimeout(entry.renewalTimer);
2492
+ }
2493
+ this.#routedBySession.clear();
2494
+ this.#routedByActivation.clear();
2495
+ this.#routedGeometry.clear();
2496
+ this.#routedAttachDeadlineByCell.clear();
1518
2497
  this.#serverProtocolMinor = 0;
1519
2498
  this.#worker.terminate();
1520
- void this.#ports.then((ports) => {
1521
- ports.control.close();
1522
- ports.frames.close();
1523
- }, () => undefined);
2499
+ if (this.#ports) {
2500
+ void this.#ports.then((ports) => {
2501
+ ports.control.close();
2502
+ ports.frames.close();
2503
+ }, () => undefined);
2504
+ }
1524
2505
  }
1525
2506
  #sendResize(viewId, view, cols, rows) {
1526
- if (view.attachmentEpoch === undefined || view.controlEpoch === undefined)
2507
+ if (!view.resizeControlRequested || view.attachmentEpoch === undefined || view.controlEpoch === undefined)
1527
2508
  return;
1528
2509
  view.resizeSequence += 1;
1529
2510
  this.#control?.notify({