@vibecook/ghosttea-react 0.11.1 → 0.13.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 (90) hide show
  1. package/README.md +86 -4
  2. package/dist/TerminalSurface.d.ts +53 -0
  3. package/dist/TerminalSurface.d.ts.map +1 -1
  4. package/dist/TerminalSurface.js +106 -12
  5. package/dist/TerminalSurface.js.map +1 -1
  6. package/dist/appearance/AppearanceSettings.d.ts +5 -2
  7. package/dist/appearance/AppearanceSettings.d.ts.map +1 -1
  8. package/dist/appearance/AppearanceSettings.js +14 -3
  9. package/dist/appearance/AppearanceSettings.js.map +1 -1
  10. package/dist/appearance/BackdropBlurControl.d.ts +5 -0
  11. package/dist/appearance/BackdropBlurControl.d.ts.map +1 -0
  12. package/dist/appearance/BackdropBlurControl.js +42 -0
  13. package/dist/appearance/BackdropBlurControl.js.map +1 -0
  14. package/dist/appearance/types.d.ts +10 -0
  15. package/dist/appearance/types.d.ts.map +1 -1
  16. package/dist/catalog-budget.d.ts +2 -2
  17. package/dist/catalog-budget.d.ts.map +1 -1
  18. package/dist/catalog-budget.js +2 -2
  19. package/dist/catalog-budget.js.map +1 -1
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +2 -0
  22. package/dist/config.js.map +1 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/link-input.d.ts +18 -0
  28. package/dist/link-input.d.ts.map +1 -0
  29. package/dist/link-input.js +27 -0
  30. package/dist/link-input.js.map +1 -0
  31. package/dist/renderers/background-geometry.d.ts +16 -0
  32. package/dist/renderers/background-geometry.d.ts.map +1 -0
  33. package/dist/renderers/background-geometry.js +20 -0
  34. package/dist/renderers/background-geometry.js.map +1 -0
  35. package/dist/renderers/glyph-atlas.d.ts +43 -0
  36. package/dist/renderers/glyph-atlas.d.ts.map +1 -0
  37. package/dist/renderers/glyph-atlas.js +175 -0
  38. package/dist/renderers/glyph-atlas.js.map +1 -0
  39. package/dist/renderers/light-adaptation.d.ts +36 -0
  40. package/dist/renderers/light-adaptation.d.ts.map +1 -0
  41. package/dist/renderers/light-adaptation.js +332 -0
  42. package/dist/renderers/light-adaptation.js.map +1 -0
  43. package/dist/renderers/types.d.ts +23 -0
  44. package/dist/renderers/types.d.ts.map +1 -1
  45. package/dist/renderers/types.js.map +1 -1
  46. package/dist/renderers/webgpu-renderer.d.ts +4 -0
  47. package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
  48. package/dist/renderers/webgpu-renderer.js +331 -239
  49. package/dist/renderers/webgpu-renderer.js.map +1 -1
  50. package/dist/routed-control.d.ts +26 -2
  51. package/dist/routed-control.d.ts.map +1 -1
  52. package/dist/routed-control.js +145 -27
  53. package/dist/routed-control.js.map +1 -1
  54. package/dist/routed-frames.d.ts +1 -1
  55. package/dist/routed-frames.d.ts.map +1 -1
  56. package/dist/routed-frames.js +3 -7
  57. package/dist/routed-frames.js.map +1 -1
  58. package/dist/row-damage.d.ts +21 -0
  59. package/dist/row-damage.d.ts.map +1 -0
  60. package/dist/row-damage.js +76 -0
  61. package/dist/row-damage.js.map +1 -0
  62. package/dist/runtime.d.ts +97 -3
  63. package/dist/runtime.d.ts.map +1 -1
  64. package/dist/runtime.js +489 -49
  65. package/dist/runtime.js.map +1 -1
  66. package/dist/styles.css +24 -0
  67. package/dist/terminal-render.worker.js +979 -297
  68. package/dist/terminal-render.worker.js.map +4 -4
  69. package/dist/use-terminal-links.d.ts +17 -0
  70. package/dist/use-terminal-links.d.ts.map +1 -0
  71. package/dist/use-terminal-links.js +101 -0
  72. package/dist/use-terminal-links.js.map +1 -0
  73. package/dist/worker-messages.d.ts +27 -1
  74. package/dist/worker-messages.d.ts.map +1 -1
  75. package/dist/workspace/Workspace.d.ts +19 -3
  76. package/dist/workspace/Workspace.d.ts.map +1 -1
  77. package/dist/workspace/Workspace.js +53 -20
  78. package/dist/workspace/Workspace.js.map +1 -1
  79. package/dist/workspace/index.d.ts +2 -2
  80. package/dist/workspace/index.d.ts.map +1 -1
  81. package/dist/workspace/index.js.map +1 -1
  82. package/dist/workspace/pane-focus.d.ts +8 -0
  83. package/dist/workspace/pane-focus.d.ts.map +1 -0
  84. package/dist/workspace/pane-focus.js +24 -0
  85. package/dist/workspace/pane-focus.js.map +1 -0
  86. package/dist/workspace/pane-layout.d.ts +5 -2
  87. package/dist/workspace/pane-layout.d.ts.map +1 -1
  88. package/dist/workspace/pane-layout.js +45 -6
  89. package/dist/workspace/pane-layout.js.map +1 -1
  90. package/package.json +4 -4
package/dist/runtime.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { terminalLinkUrl } from "@vibecook/ghosttea-protocol";
1
2
  import { ControlClient } from "@vibecook/ghosttea";
2
3
  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
4
  import { FRAME_MAGIC, FrameFlag } from "@vibecook/ghosttea-frame";
@@ -24,6 +25,57 @@ export function showsStaleScreen(state) {
24
25
  export function sessionIsFrozen(state) {
25
26
  return showsStaleScreen(state.state) || state.awaitingRecoveryFrame;
26
27
  }
28
+ function geometryHolderRecord(holder) {
29
+ return { clientId: holder.clientId, viewId: holder.viewId, holderGeneration: holder.holderGeneration };
30
+ }
31
+ function sameGeometryHolder(a, b) {
32
+ if (a === undefined || b === undefined)
33
+ return a === b;
34
+ return a.clientId === b.clientId && a.viewId === b.viewId && a.holderGeneration === b.holderGeneration;
35
+ }
36
+ /** A take's context on the control leg: which view and take it is, and the seat state it was built on. */
37
+ class RoutedGeometryTake {
38
+ viewId;
39
+ /** The view's request serial this take was sent under. */
40
+ viewSerial;
41
+ /** The session's take serial. */
42
+ serial;
43
+ attempt;
44
+ basisHolder;
45
+ basisRevision;
46
+ constructor(viewId, viewSerial, serial, attempt, basis) {
47
+ this.viewId = viewId;
48
+ this.viewSerial = viewSerial;
49
+ this.serial = serial;
50
+ this.attempt = attempt;
51
+ this.basisHolder = basis?.holder;
52
+ this.basisRevision = basis?.revision ?? 0;
53
+ }
54
+ }
55
+ /** A plain claim's context: the view that sent it, under which serial, and whether it re-sends a grid that did not land. */
56
+ class RoutedGeometryClaim {
57
+ viewId;
58
+ viewSerial;
59
+ trailing;
60
+ constructor(viewId, viewSerial, trailing) {
61
+ this.viewId = viewId;
62
+ this.viewSerial = viewSerial;
63
+ this.trailing = trailing;
64
+ }
65
+ }
66
+ /** Refusals that describe the seat as it is, so a take built on older news can retry against them. */
67
+ const RETRYABLE_TAKE_REFUSALS = new Set(["SEAT_HELD", "STALE_REVISION", "NOT_HOLDER"]);
68
+ /**
69
+ * Whether a take's refusal names a seat other than the one the take was built
70
+ * on. A refusal that repeats the take's own view of the seat (a cell that does
71
+ * not allow the take at all) would only be refused again.
72
+ */
73
+ function takeRefusalNamesNews(refused, take) {
74
+ if (!RETRYABLE_TAKE_REFUSALS.has(refused.code) || refused.geometryRevision === undefined)
75
+ return false;
76
+ const holder = refused.currentHolder === undefined ? undefined : geometryHolderRecord(refused.currentHolder);
77
+ return refused.geometryRevision !== take.basisRevision || !sameGeometryHolder(holder, take.basisHolder);
78
+ }
27
79
  const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000;
28
80
  const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7;
29
81
  const FRAME_BRIDGE_CAPABILITY_VERSION = 1;
@@ -52,6 +104,9 @@ export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
52
104
  }
53
105
  export class GhostteaTerminalRuntime extends EventTarget {
54
106
  #worker;
107
+ #sessionsCreatedHere = new Set();
108
+ #sessionsWithInput = new Set();
109
+ #sessionsDrawn = new Set();
55
110
  #ports;
56
111
  #routedHost;
57
112
  #routedReceiverCapacities;
@@ -110,6 +165,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
110
165
  #performanceRequestId = 1;
111
166
  #performanceRequests = new Map();
112
167
  #counterRequests = new Map();
168
+ #linksByHandle = new Map();
113
169
  #disposed = false;
114
170
  constructor(options) {
115
171
  super();
@@ -155,6 +211,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
155
211
  else if (data.type === "clipboard-write") {
156
212
  this.#platform.writeClipboard(data.text);
157
213
  }
214
+ else if (data.type === "link-targets") {
215
+ this.#linksByHandle.set(data.sessionHandle, data.links.filter((link) => terminalLinkUrl(link.uri) !== null));
216
+ this.dispatchEvent(new CustomEvent("link-targets", { detail: { sessionHandle: data.sessionHandle } }));
217
+ }
158
218
  else if (data.type === "scrollbar-state") {
159
219
  this.#scrollbarByHandle.set(data.sessionHandle, data.scrollbar);
160
220
  this.dispatchEvent(new CustomEvent("scrollbar-state", {
@@ -172,6 +232,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
172
232
  this.#scheduleMetadataRefresh(data.sessionHandle);
173
233
  this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
174
234
  }
235
+ else if (data.type === "session-drawn") {
236
+ this.#recordSessionDrawn(data.sessionHandle);
237
+ }
175
238
  else if (data.type === "catalog-pressure") {
176
239
  console.warn(`[terminal-runtime] native text catalog budget exceeded for ${data.sessionHandle}; using bounded fallback text`);
177
240
  this.dispatchEvent(new CustomEvent("catalog-pressure", { detail: data }));
@@ -196,6 +259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
196
259
  else if (data.type === "routed-frames-event") {
197
260
  this.#handleRoutedFramesEvent(data.event);
198
261
  }
262
+ else if (data.type === "terminal-modes") {
263
+ this.#noteMouseTracking(data.sessionHandle, data.mouseTracking);
264
+ }
199
265
  else if (data.type === "renderer-reload-required") {
200
266
  console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
201
267
  this.#platform.setForceCanvasFallback(true);
@@ -212,6 +278,26 @@ export class GhostteaTerminalRuntime extends EventTarget {
212
278
  return;
213
279
  this.#worker.postMessage(message, transfer);
214
280
  }
281
+ /** Whether this renderer has attempted terminal input during this session's lifetime.
282
+ * Restored or externally created sessions must never be treated as unused from this alone. */
283
+ sessionHasInput(sessionId) {
284
+ return this.#sessionsWithInput.has(sessionId);
285
+ }
286
+ /**
287
+ * Whether a committed frame of this session has put something on screen in
288
+ * this renderer: what a host that just started a program waits for before it
289
+ * reveals the terminal. Once true it stays true while the session is
290
+ * registered here (a restart in place is the same session and does not draw
291
+ * "first" again); a `session-drawn` event (`detail: { sessionId }`) announces
292
+ * the change, and a session registered again announces again.
293
+ */
294
+ sessionHasDrawn(sessionId) {
295
+ return this.#sessionsDrawn.has(sessionId);
296
+ }
297
+ /** A local birth receipt, never inferred from registration or an empty input history. */
298
+ sessionCreatedHere(sessionId) {
299
+ return this.#sessionsCreatedHere.has(sessionId);
300
+ }
215
301
  get rendererBackend() {
216
302
  return this.#rendererBackend;
217
303
  }
@@ -225,6 +311,48 @@ export class GhostteaTerminalRuntime extends EventTarget {
225
311
  return false;
226
312
  return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
227
313
  }
314
+ /**
315
+ * The routed session's geometry seat as this runtime last saw it, or
316
+ * `undefined` before anything was seen (and always on the port-pair
317
+ * transport). A change of holder also fires `geometry-holder-changed`; a
318
+ * resize by the same holder only moves `revision`, without an event.
319
+ */
320
+ geometryHolder(sessionHandle) {
321
+ const session = this.#sessionByHandle.get(sessionHandle);
322
+ const entry = session ? this.#routedBySession.get(session.id) : undefined;
323
+ const geometry = entry ? this.#routedGeometry.get(entry.sessionId) : undefined;
324
+ return entry && geometry ? this.#geometryHolderState(entry, geometry) : undefined;
325
+ }
326
+ #geometryHolderState(entry, geometry) {
327
+ const holder = geometry.holder;
328
+ return {
329
+ holder: holder === undefined ? null : { ...holder },
330
+ revision: geometry.revision,
331
+ local: holder !== undefined && holder.clientId === entry.clientId,
332
+ };
333
+ }
334
+ /** Whether this view, under this runtime's client, holds the seat as last seen. */
335
+ #routedViewHolds(entry, geometry, viewId) {
336
+ const holder = geometry?.holder;
337
+ return holder !== undefined && holder.viewId === viewId && holder.clientId === entry.clientId;
338
+ }
339
+ /** Every write of a session's seat state goes through here, so a holder change always fires its event. */
340
+ #setRoutedGeometry(entry, next) {
341
+ const previous = this.#routedGeometry.get(entry.sessionId);
342
+ if (next === undefined)
343
+ this.#routedGeometry.delete(entry.sessionId);
344
+ else
345
+ this.#routedGeometry.set(entry.sessionId, next);
346
+ if ((previous === undefined) === (next === undefined) && sameGeometryHolder(previous?.holder, next?.holder))
347
+ return;
348
+ const detail = {
349
+ sessionId: entry.sessionId,
350
+ sessionHandle: entry.sessionHandle,
351
+ previous: previous === undefined ? undefined : this.#geometryHolderState(entry, previous),
352
+ current: next === undefined ? undefined : this.#geometryHolderState(entry, next),
353
+ };
354
+ this.dispatchEvent(new CustomEvent("geometry-holder-changed", { detail }));
355
+ }
228
356
  #resolvePerformanceRequest(requestId, value) {
229
357
  const pending = this.#performanceRequests.get(requestId);
230
358
  if (!pending)
@@ -352,11 +480,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
352
480
  this.#returnFrameCredit(data.byteLength);
353
481
  return;
354
482
  }
355
- const tracking = (view.getUint16(6, true) & FrameFlag.MouseTracking) !== 0;
356
- if (this.#mouseTrackingByHandle.get(sessionHandle) !== tracking) {
357
- this.#mouseTrackingByHandle.set(sessionHandle, tracking);
358
- this.dispatchEvent(new CustomEvent("terminal-modes", { detail: { sessionHandle, mouseTracking: tracking } }));
359
- }
483
+ this.#noteMouseTracking(sessionHandle, (view.getUint16(6, true) & FrameFlag.MouseTracking) !== 0);
360
484
  this.#scheduleMetadataRefresh(sessionHandle);
361
485
  this.#postWorker({ type: "frame", packet: data }, [data]);
362
486
  };
@@ -804,6 +928,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
804
928
  if (!this.#routedHost.createSession)
805
929
  throw new Error("The routed host does not provide session creation");
806
930
  const session = await this.#routedHost.createSession(options);
931
+ this.#sessionsCreatedHere.add(session.id);
807
932
  this.registerSession(session);
808
933
  return session;
809
934
  }
@@ -821,6 +946,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
821
946
  });
822
947
  if (response.type !== "session-created")
823
948
  throw new Error("ghosttead returned an unexpected response");
949
+ this.#sessionsCreatedHere.add(response.session.id);
824
950
  this.registerSession(response.session);
825
951
  console.info(`[terminal-runtime] created session ${response.session.id}`);
826
952
  return response.session;
@@ -977,11 +1103,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
977
1103
  if (response.type !== "ok")
978
1104
  throw new Error("ghosttead rejected frame resynchronization");
979
1105
  }
980
- mount(sessionId, sessionHandle, viewId, canvas) {
1106
+ mount(sessionId, sessionHandle, viewId, canvas, initialSize) {
981
1107
  if (this.#disposed)
982
1108
  throw new Error("Cannot mount a disposed terminal runtime");
983
1109
  if (this.#routedHost)
984
- return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
1110
+ return this.#mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize);
985
1111
  const mounted = this.#mountedCanvases.get(canvas);
986
1112
  if (mounted) {
987
1113
  if (!mounted.active)
@@ -996,11 +1122,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
996
1122
  }
997
1123
  return this.#createMountLease(mounted);
998
1124
  }
1125
+ const size = initialSize ?? {
1126
+ width: canvas.clientWidth || 0,
1127
+ height: canvas.clientHeight || 0,
1128
+ dpr: window.devicePixelRatio || 1,
1129
+ };
1130
+ // Allocate the first render target at its actual size, too: the backend
1131
+ // must not build a default 300x150 scene texture and immediately replace it.
1132
+ canvas.width = Math.max(1, Math.round(size.width * size.dpr));
1133
+ canvas.height = Math.max(1, Math.round(size.height * size.dpr));
999
1134
  const offscreen = canvas.transferControlToOffscreen();
1000
1135
  const subscription = this.#retainFrameSubscription(sessionHandle);
1001
1136
  const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
1002
1137
  this.#mountGenerationBySurface.set(viewId, generation);
1003
- this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
1138
+ this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
1004
1139
  const entry = {
1005
1140
  canvas,
1006
1141
  sessionHandle,
@@ -1029,6 +1164,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1029
1164
  lastAttachmentEpoch: undefined,
1030
1165
  claimedEpoch: undefined,
1031
1166
  claimedRevision: 0,
1167
+ routedGeometrySerial: 0,
1032
1168
  };
1033
1169
  this.#views.set(viewId, view);
1034
1170
  const remote = this.#remoteSessions.get(sessionId);
@@ -1087,7 +1223,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1087
1223
  .catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
1088
1224
  return this.#createMountLease(entry);
1089
1225
  }
1090
- #mountRouted(sessionId, sessionHandle, viewId, canvas) {
1226
+ #mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize) {
1091
1227
  const mounted = this.#mountedCanvases.get(canvas);
1092
1228
  if (mounted) {
1093
1229
  if (!mounted.active)
@@ -1102,10 +1238,19 @@ export class GhostteaTerminalRuntime extends EventTarget {
1102
1238
  }
1103
1239
  return this.#createMountLease(mounted);
1104
1240
  }
1241
+ const size = initialSize ?? {
1242
+ width: canvas.clientWidth || 0,
1243
+ height: canvas.clientHeight || 0,
1244
+ dpr: window.devicePixelRatio || 1,
1245
+ };
1246
+ // Allocate the first render target at its actual size, too: the backend
1247
+ // must not build a default 300x150 scene texture and immediately replace it.
1248
+ canvas.width = Math.max(1, Math.round(size.width * size.dpr));
1249
+ canvas.height = Math.max(1, Math.round(size.height * size.dpr));
1105
1250
  const offscreen = canvas.transferControlToOffscreen();
1106
1251
  const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
1107
1252
  this.#mountGenerationBySurface.set(viewId, generation);
1108
- this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
1253
+ this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
1109
1254
  const entry = {
1110
1255
  canvas,
1111
1256
  sessionHandle,
@@ -1136,6 +1281,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1136
1281
  lastAttachmentEpoch: undefined,
1137
1282
  claimedEpoch: undefined,
1138
1283
  claimedRevision: 0,
1284
+ routedGeometrySerial: 0,
1139
1285
  });
1140
1286
  const activation = this.#routedBySession.get(sessionId);
1141
1287
  if (activation)
@@ -1172,6 +1318,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1172
1318
  sessionHandle,
1173
1319
  state: initialRoutedActivation(sessionId, activationId, inputPolicy),
1174
1320
  viewIds: new Set([viewId]),
1321
+ takeSerial: 0,
1175
1322
  recoveryAttempts: 0,
1176
1323
  preAuthRemints: { control: 0, frames: 0 },
1177
1324
  protocolFailures: { control: 0, frames: 0 },
@@ -1217,15 +1364,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
1217
1364
  const host = this.#routedHost;
1218
1365
  if (!host || this.#disposed || entry.viewIds.size === 0)
1219
1366
  return;
1367
+ const activationId = entry.state.activationId;
1368
+ const isCurrent = () => !this.#disposed &&
1369
+ entry.viewIds.size > 0 &&
1370
+ entry.state.activationId === activationId &&
1371
+ this.#routedByActivation.get(activationId) === entry &&
1372
+ this.#routedBySession.get(entry.sessionId) === entry;
1220
1373
  let ticket;
1221
1374
  try {
1222
1375
  ticket = await host.openTicket(entry.sessionId, { reason });
1223
1376
  }
1224
1377
  catch (error) {
1225
- this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
1378
+ if (isCurrent())
1379
+ this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
1226
1380
  return;
1227
1381
  }
1228
- if (this.#routedBySession.get(entry.sessionId) !== entry || this.#disposed)
1382
+ if (!isCurrent())
1229
1383
  return;
1230
1384
  if (!this.#routedTicketMatches(entry, ticket)) {
1231
1385
  this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
@@ -1235,6 +1389,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1235
1389
  if (!ticket.endpoints)
1236
1390
  return;
1237
1391
  entry.ticket = ticket;
1392
+ entry.clientId = ticket.attachGrant.claims.clientId;
1238
1393
  this.#transitionRouted(entry, { type: "transport-ready" });
1239
1394
  this.#routedControl.attach({
1240
1395
  cellBootId: ticket.route.cellBootId,
@@ -1242,10 +1397,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
1242
1397
  transportGrant: ticket.transportGrant,
1243
1398
  attachGrant: ticket.attachGrant,
1244
1399
  activationId: entry.state.activationId,
1400
+ replacePreviousActivation: true,
1245
1401
  ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
1246
1402
  initialDemand: this.#routedDemand(entry),
1247
1403
  capabilities: this.#routedCapabilities,
1248
1404
  });
1405
+ this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
1406
+ DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
1407
+ this.#scheduleRoutedRenewal(entry);
1408
+ }
1409
+ #startRoutedFrames(entry) {
1410
+ const ticket = entry.ticket;
1411
+ if (!ticket?.endpoints || !entry.state.controlAttached || entry.framesAttachStarted || entry.viewIds.size === 0)
1412
+ return;
1413
+ // Control creates the native activation. The frames socket may already be
1414
+ // warm; sending on both legs at once races that creation on every remount.
1415
+ entry.framesAttachStarted = true;
1249
1416
  this.#postWorker({
1250
1417
  type: "routed-frames-attach",
1251
1418
  request: {
@@ -1260,19 +1427,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
1260
1427
  capabilities: this.#routedCapabilities,
1261
1428
  },
1262
1429
  });
1263
- this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
1264
- DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
1265
- this.#scheduleRoutedRenewal(entry);
1266
1430
  }
1267
1431
  #stopRoutedActivation(entry) {
1268
1432
  this.#routedControl?.detach(entry.state.activationId);
1269
1433
  this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
1434
+ delete entry.framesAttachStarted;
1270
1435
  if (entry.attachTimer !== undefined)
1271
1436
  window.clearTimeout(entry.attachTimer);
1272
1437
  delete entry.attachTimer;
1273
1438
  if (entry.renewalTimer !== undefined)
1274
1439
  window.clearTimeout(entry.renewalTimer);
1275
1440
  delete entry.renewalTimer;
1441
+ if (entry.cellLeaseTimer !== undefined)
1442
+ window.clearTimeout(entry.cellLeaseTimer);
1443
+ delete entry.cellLeaseTimer;
1444
+ if (entry.workerLeaseTimer !== undefined)
1445
+ window.clearTimeout(entry.workerLeaseTimer);
1446
+ delete entry.workerLeaseTimer;
1276
1447
  delete entry.ticket;
1277
1448
  }
1278
1449
  #transitionRouted(entry, event) {
@@ -1346,23 +1517,36 @@ export class GhostteaTerminalRuntime extends EventTarget {
1346
1517
  grantGeneration: event.attached.grantGenerationAccepted,
1347
1518
  rights: event.attached.rights,
1348
1519
  });
1520
+ this.#startRoutedFrames(entry);
1349
1521
  const previousGeometry = this.#routedGeometry.get(entry.sessionId);
1522
+ const previousHolder = previousGeometry?.holder;
1350
1523
  if (!event.attached.rights.includes("geometry") &&
1351
- previousGeometry?.holderViewId !== undefined &&
1352
- entry.viewIds.has(previousGeometry.holderViewId)) {
1524
+ previousGeometry !== undefined &&
1525
+ previousHolder !== undefined &&
1526
+ this.#routedViewHolds(entry, previousGeometry, previousHolder.viewId) &&
1527
+ entry.viewIds.has(previousHolder.viewId)) {
1353
1528
  // The cell auto-releases a holder when renewal drops the geometry
1354
1529
  // right. Preserve its next CAS revision without retaining authority.
1355
- this.#routedGeometry.set(entry.sessionId, {
1530
+ this.#setRoutedGeometry(entry, {
1356
1531
  revision: previousGeometry.revision + 1,
1357
1532
  ...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
1358
1533
  ...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
1359
1534
  });
1360
1535
  }
1536
+ // A take asked before the leg could carry it goes out now. It decides
1537
+ // the seat, so the plain claims below would only draw refusals.
1538
+ const queuedTake = entry.queuedTake;
1539
+ delete entry.queuedTake;
1540
+ if (queuedTake !== undefined &&
1541
+ queuedTake.serial === entry.takeSerial &&
1542
+ this.#sendRoutedTake(entry, queuedTake.viewId, queuedTake.serial, 0)) {
1543
+ return;
1544
+ }
1361
1545
  const geometry = this.#routedGeometry.get(entry.sessionId);
1362
1546
  for (const viewId of entry.viewIds) {
1363
1547
  const view = this.#views.get(viewId);
1364
1548
  if (view?.resizeControlRequested &&
1365
- geometry?.holderViewId !== viewId &&
1549
+ !this.#routedViewHolds(entry, geometry, viewId) &&
1366
1550
  view.desiredCols !== undefined &&
1367
1551
  view.desiredRows !== undefined) {
1368
1552
  this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
@@ -1384,9 +1568,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
1384
1568
  const entry = this.#routedByActivation.get(event.status.activationId);
1385
1569
  if (!entry || event.status.sessionId !== entry.sessionId)
1386
1570
  return;
1571
+ const previousSequence = entry.state.lastCellStatusSequence;
1387
1572
  this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
1388
1573
  const sequence = entry.state.lastCellStatusSequence;
1389
- window.setTimeout(() => {
1574
+ if (sequence === previousSequence)
1575
+ return;
1576
+ if (entry.cellLeaseTimer !== undefined)
1577
+ window.clearTimeout(entry.cellLeaseTimer);
1578
+ entry.cellLeaseTimer = window.setTimeout(() => {
1579
+ delete entry.cellLeaseTimer;
1390
1580
  if (entry.state.lastCellStatusSequence !== sequence)
1391
1581
  return;
1392
1582
  this.#transitionRouted(entry, { type: "cell-lease-expired" });
@@ -1401,14 +1591,21 @@ export class GhostteaTerminalRuntime extends EventTarget {
1401
1591
  const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1402
1592
  if (!entry)
1403
1593
  return;
1404
- this.#routedGeometry.set(entry.sessionId, {
1405
- holderViewId: event.committed.holder.viewId,
1406
- holderGeneration: event.committed.holder.holderGeneration,
1594
+ this.#setRoutedGeometry(entry, {
1595
+ holder: geometryHolderRecord(event.committed.holder),
1407
1596
  revision: event.committed.geometryRevision,
1408
1597
  cols: event.committed.cols,
1409
1598
  rows: event.committed.rows,
1410
1599
  });
1411
- this.dispatchEvent(new CustomEvent("routed-geometry", { detail: { sessionId: entry.sessionId, ...event } }));
1600
+ this.#trailRoutedResize(entry, event.request?.context, undefined);
1601
+ this.dispatchEvent(new CustomEvent("routed-geometry", {
1602
+ detail: {
1603
+ sessionId: entry.sessionId,
1604
+ type: event.type,
1605
+ ...(event.activationId === undefined ? {} : { activationId: event.activationId }),
1606
+ committed: event.committed,
1607
+ },
1608
+ }));
1412
1609
  return;
1413
1610
  }
1414
1611
  if (event.type === "geometry-refused") {
@@ -1416,14 +1613,32 @@ export class GhostteaTerminalRuntime extends EventTarget {
1416
1613
  if (entry && event.refused.geometryRevision !== undefined) {
1417
1614
  const previous = this.#routedGeometry.get(entry.sessionId);
1418
1615
  const holder = event.refused.currentHolder;
1419
- this.#routedGeometry.set(entry.sessionId, {
1616
+ this.#setRoutedGeometry(entry, {
1420
1617
  revision: event.refused.geometryRevision,
1421
- ...(holder === undefined ? {} : { holderViewId: holder.viewId, holderGeneration: holder.holderGeneration }),
1618
+ ...(holder === undefined ? {} : { holder: geometryHolderRecord(holder) }),
1422
1619
  ...(previous?.cols === undefined ? {} : { cols: previous.cols }),
1423
1620
  ...(previous?.rows === undefined ? {} : { rows: previous.rows }),
1424
1621
  });
1425
1622
  }
1426
- this.dispatchEvent(new CustomEvent("routed-geometry-refused", { detail: event }));
1623
+ // A take refused on stale news retries once against the seat the
1624
+ // refusal names; only the session's latest take does.
1625
+ const take = event.request?.context instanceof RoutedGeometryTake ? event.request.context : undefined;
1626
+ if (entry &&
1627
+ take &&
1628
+ take.attempt === 0 &&
1629
+ take.serial === entry.takeSerial &&
1630
+ takeRefusalNamesNews(event.refused, take)) {
1631
+ this.#sendRoutedTake(entry, take.viewId, take.serial, 1);
1632
+ }
1633
+ if (entry)
1634
+ this.#trailRoutedResize(entry, event.request?.context, event.refused.code);
1635
+ this.dispatchEvent(new CustomEvent("routed-geometry-refused", {
1636
+ detail: {
1637
+ type: event.type,
1638
+ ...(event.activationId === undefined ? {} : { activationId: event.activationId }),
1639
+ refused: event.refused,
1640
+ },
1641
+ }));
1427
1642
  return;
1428
1643
  }
1429
1644
  if (event.type === "transport-closed") {
@@ -1522,9 +1737,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
1522
1737
  const entry = this.#routedByActivation.get(event.status.activationId);
1523
1738
  if (!entry)
1524
1739
  return;
1740
+ const previousSequence = entry.state.lastWorkerStatusSequence;
1525
1741
  this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
1526
1742
  const sequence = entry.state.lastWorkerStatusSequence;
1527
- window.setTimeout(() => {
1743
+ if (sequence === previousSequence)
1744
+ return;
1745
+ if (entry.workerLeaseTimer !== undefined)
1746
+ window.clearTimeout(entry.workerLeaseTimer);
1747
+ entry.workerLeaseTimer = window.setTimeout(() => {
1748
+ delete entry.workerLeaseTimer;
1528
1749
  if (entry.state.lastWorkerStatusSequence !== sequence)
1529
1750
  return;
1530
1751
  this.#transitionRouted(entry, { type: "worker-lease-expired" });
@@ -1638,6 +1859,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1638
1859
  return;
1639
1860
  }
1640
1861
  entry.ticket = ticket;
1862
+ entry.clientId = ticket.attachGrant.claims.clientId;
1641
1863
  this.#routedControl?.renew(activationId, ticket.attachGrant);
1642
1864
  this.#scheduleRoutedRenewal(entry);
1643
1865
  this.#postWorker({
@@ -1679,14 +1901,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
1679
1901
  this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
1680
1902
  return;
1681
1903
  }
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
1904
  const previousActivationId = entry.state.activationId;
1688
1905
  this.#routedControl?.detach(previousActivationId);
1689
1906
  this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
1907
+ delete entry.framesAttachStarted;
1690
1908
  this.#routedByActivation.delete(previousActivationId);
1691
1909
  const nextActivationId = crypto.randomUUID();
1692
1910
  entry.replacesActivationId = previousActivationId;
@@ -1710,7 +1928,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
1710
1928
  if (entry.renewalTimer !== undefined)
1711
1929
  window.clearTimeout(entry.renewalTimer);
1712
1930
  delete entry.renewalTimer;
1931
+ if (entry.cellLeaseTimer !== undefined)
1932
+ window.clearTimeout(entry.cellLeaseTimer);
1933
+ delete entry.cellLeaseTimer;
1934
+ if (entry.workerLeaseTimer !== undefined)
1935
+ window.clearTimeout(entry.workerLeaseTimer);
1936
+ delete entry.workerLeaseTimer;
1713
1937
  delete entry.ticket;
1938
+ // Geometry belongs to the cell-side attach/client/view scope. Preserve it
1939
+ // across a same-route leg replacement, but never carry its revision or
1940
+ // holder generation to a newly routed cell. Forgotten only now, once the
1941
+ // old activation is gone, so a listener of the holder event cannot act
1942
+ // on it.
1943
+ if (reason === "route-stale")
1944
+ this.#setRoutedGeometry(entry, undefined);
1714
1945
  const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
1715
1946
  window.setTimeout(() => {
1716
1947
  if (this.#routedByActivation.get(nextActivationId) !== entry)
@@ -1789,6 +2020,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
1789
2020
  if (!entry)
1790
2021
  return;
1791
2022
  entry.viewIds.delete(viewId);
2023
+ if (entry.queuedTake?.viewId === viewId)
2024
+ delete entry.queuedTake;
1792
2025
  if (entry.viewIds.size > 0) {
1793
2026
  const anyWritable = this.#routedHost?.encodeInput !== undefined &&
1794
2027
  [...entry.viewIds].some((candidate) => {
@@ -1802,7 +2035,30 @@ export class GhostteaTerminalRuntime extends EventTarget {
1802
2035
  this.#transitionRouted(entry, { type: "detach" });
1803
2036
  this.#routedBySession.delete(sessionId);
1804
2037
  this.#routedByActivation.delete(entry.state.activationId);
1805
- this.#routedGeometry.delete(sessionId);
2038
+ this.#setRoutedGeometry(entry, undefined);
2039
+ }
2040
+ /**
2041
+ * Detach one of our views and let the seat go with it.
2042
+ *
2043
+ * The daemon clears the resize controller when its holder detaches, but a
2044
+ * local session announces control only through the legacy `control-changed`
2045
+ * frame, which cannot say "no controller": the clear never reaches this
2046
+ * client, and a pane that remounts (a split re-parents its surface) would
2047
+ * see its own previous incarnation on the record forever and never claim.
2048
+ * Our own detach is the one clear this client can predict, so the record is
2049
+ * cleared here at the same revision and the session's remaining views get
2050
+ * their one look at the empty seat (§4.2.3).
2051
+ */
2052
+ #detachView(sessionId, viewId) {
2053
+ this.#control?.notify({ type: "detach-session", sessionId, viewId });
2054
+ const control = this.#controlBySession.get(sessionId);
2055
+ if (!control?.controller || control.controller.viewId !== viewId)
2056
+ return;
2057
+ this.#controlBySession.set(sessionId, { controller: null, revision: control.revision });
2058
+ for (const other of this.#viewIdsForSession(sessionId)) {
2059
+ if (other !== viewId)
2060
+ this.#maybeReclaim(other);
2061
+ }
1806
2062
  }
1807
2063
  #createMountLease(mounted) {
1808
2064
  let disposed = false;
@@ -1829,7 +2085,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1829
2085
  if (this.#routedHost)
1830
2086
  this.#releaseRoutedView(mounted.sessionId, mounted.viewId);
1831
2087
  else
1832
- this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
2088
+ this.#detachView(mounted.sessionId, mounted.viewId);
1833
2089
  this.#views.delete(mounted.viewId);
1834
2090
  this.#focusByView.delete(mounted.viewId);
1835
2091
  }
@@ -2043,6 +2299,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
2043
2299
  * screen: a partial update repaints part of the stale one, which is exactly
2044
2300
  * what would thaw a pane too early.
2045
2301
  */
2302
+ #recordSessionDrawn(sessionHandle) {
2303
+ const sessionId = this.#sessionByHandle.get(sessionHandle)?.id;
2304
+ if (sessionId === undefined || this.#sessionsDrawn.has(sessionId))
2305
+ return;
2306
+ this.#sessionsDrawn.add(sessionId);
2307
+ this.dispatchEvent(new CustomEvent("session-drawn", { detail: { sessionId } }));
2308
+ }
2046
2309
  #recordCommittedFrame(sessionHandle, fullSnapshot) {
2047
2310
  if (!fullSnapshot)
2048
2311
  return;
@@ -2183,6 +2446,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2183
2446
  return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
2184
2447
  }
2185
2448
  sendText(sessionId, viewId, text) {
2449
+ if (text.length > 0)
2450
+ this.#sessionsWithInput.add(sessionId);
2186
2451
  if (this.#routedHost) {
2187
2452
  this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
2188
2453
  return;
@@ -2193,6 +2458,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2193
2458
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
2194
2459
  }
2195
2460
  paste(sessionId, viewId, text) {
2461
+ if (text.length > 0)
2462
+ this.#sessionsWithInput.add(sessionId);
2196
2463
  if (this.#routedHost) {
2197
2464
  this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
2198
2465
  return;
@@ -2203,6 +2470,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2203
2470
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
2204
2471
  }
2205
2472
  sendKey(sessionId, viewId, event) {
2473
+ if (event.type === "down" && !["Shift", "Control", "Alt", "Meta"].includes(event.key))
2474
+ this.#sessionsWithInput.add(sessionId);
2206
2475
  if (this.#routedHost) {
2207
2476
  this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
2208
2477
  return;
@@ -2244,6 +2513,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
2244
2513
  isMouseTracking(sessionHandle) {
2245
2514
  return this.#mouseTrackingByHandle.get(sessionHandle) ?? false;
2246
2515
  }
2516
+ /** A session's mouse modes, from whichever path carried its frame: the legacy
2517
+ * bridge's header, read here, or the worker's word for a routed frame. */
2518
+ #noteMouseTracking(sessionHandle, tracking) {
2519
+ if (this.#mouseTrackingByHandle.get(sessionHandle) === tracking)
2520
+ return;
2521
+ this.#mouseTrackingByHandle.set(sessionHandle, tracking);
2522
+ this.dispatchEvent(new CustomEvent("terminal-modes", { detail: { sessionHandle, mouseTracking: tracking } }));
2523
+ }
2247
2524
  setTheme(sessionHandle, theme, surfaceId) {
2248
2525
  this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
2249
2526
  // A surface-scoped theme is renderer-local. Updating the daemon's
@@ -2269,6 +2546,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
2269
2546
  setEffects(sessionHandle, effects, surfaceId) {
2270
2547
  this.#postWorker({ type: "effects", sessionHandle, ...(surfaceId ? { surfaceId } : {}), effects });
2271
2548
  }
2549
+ links(sessionHandle) {
2550
+ return this.#platform.openExternal
2551
+ ? (this.#linksByHandle.get(sessionHandle) ?? []).filter((link) => link.explicit || this.#configSnapshot?.renderer.linkUrl !== false)
2552
+ : [];
2553
+ }
2554
+ async openLink(uri) {
2555
+ const url = terminalLinkUrl(uri);
2556
+ if (url)
2557
+ await this.#platform.openExternal?.(url);
2558
+ }
2272
2559
  setSelection(sessionHandle, selection, surfaceId) {
2273
2560
  this.#postWorker({ type: "selection", sessionHandle, ...(surfaceId ? { surfaceId } : {}), selection });
2274
2561
  }
@@ -2303,13 +2590,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
2303
2590
  setPartialRenderingEnabled(enabled) {
2304
2591
  this.#postWorker({ type: "partial-rendering", enabled });
2305
2592
  }
2306
- #claimRoutedGeometry(entry, viewId, cols, rows) {
2593
+ #claimRoutedGeometry(entry, viewId, cols, rows, trailing = false) {
2307
2594
  const ticket = entry.ticket;
2308
2595
  const view = this.#views.get(viewId);
2309
2596
  if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
2310
2597
  return;
2311
2598
  const geometry = this.#routedGeometry.get(entry.sessionId);
2312
- this.#routedControl?.claimGeometry(entry.state.activationId, {
2599
+ const viewSerial = view.routedGeometrySerial + 1;
2600
+ const sent = this.#routedControl?.claimGeometry(entry.state.activationId, {
2313
2601
  sessionId: entry.sessionId,
2314
2602
  activationId: entry.state.activationId,
2315
2603
  leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
@@ -2317,7 +2605,139 @@ export class GhostteaTerminalRuntime extends EventTarget {
2317
2605
  cols,
2318
2606
  rows,
2319
2607
  expectRevision: geometry?.revision ?? 0,
2320
- });
2608
+ }, new RoutedGeometryClaim(viewId, viewSerial, trailing));
2609
+ if (sent)
2610
+ view.routedGeometrySerial = viewSerial;
2611
+ }
2612
+ /**
2613
+ * Overlapping claims from one view name the same revision, so the cell
2614
+ * commits the first and refuses the rest as stale: on a slow control leg
2615
+ * the last grid of a resize would never land. When the reply to a view's
2616
+ * latest claim or take (a commit, or a STALE_REVISION refusal) leaves the
2617
+ * seat with this view at a grid other than the one it shows, that grid goes
2618
+ * once more. Only for the holder, and never after a re-sent claim is
2619
+ * refused.
2620
+ */
2621
+ #trailRoutedResize(entry, context, refusal) {
2622
+ const request = context instanceof RoutedGeometryClaim || context instanceof RoutedGeometryTake ? context : undefined;
2623
+ if (request === undefined)
2624
+ return;
2625
+ if (refusal !== undefined) {
2626
+ if (refusal !== "STALE_REVISION")
2627
+ return;
2628
+ if (request instanceof RoutedGeometryClaim && request.trailing)
2629
+ return;
2630
+ }
2631
+ const view = this.#views.get(request.viewId);
2632
+ if (!view?.resizeControlRequested || view.routedGeometrySerial !== request.viewSerial)
2633
+ return;
2634
+ if (view.desiredCols === undefined || view.desiredRows === undefined)
2635
+ return;
2636
+ const geometry = this.#routedGeometry.get(entry.sessionId);
2637
+ if (!geometry || !this.#routedViewHolds(entry, geometry, request.viewId))
2638
+ return;
2639
+ if (geometry.cols === view.desiredCols && geometry.rows === view.desiredRows)
2640
+ return;
2641
+ this.#claimRoutedGeometry(entry, request.viewId, view.desiredCols, view.desiredRows, true);
2642
+ }
2643
+ /**
2644
+ * Send a take of the seat for `viewId`, built on the holder this runtime
2645
+ * last saw. False when it cannot go out: no ticket or attached control leg,
2646
+ * no `geometry` right, or the view no longer asks for resize control.
2647
+ */
2648
+ #sendRoutedTake(entry, viewId, serial, attempt) {
2649
+ const ticket = entry.ticket;
2650
+ const view = this.#views.get(viewId);
2651
+ if (!ticket ||
2652
+ !entry.state.controlAttached ||
2653
+ !entry.state.rights.includes("geometry") ||
2654
+ !entry.viewIds.has(viewId) ||
2655
+ !view?.resizeControlRequested ||
2656
+ view.desiredCols === undefined ||
2657
+ view.desiredRows === undefined) {
2658
+ return false;
2659
+ }
2660
+ const clientId = ticket.attachGrant.claims.clientId;
2661
+ const geometry = this.#routedGeometry.get(entry.sessionId);
2662
+ const holder = geometry?.holder;
2663
+ const viewSerial = view.routedGeometrySerial + 1;
2664
+ const context = new RoutedGeometryTake(viewId, viewSerial, serial, attempt, geometry);
2665
+ const lease = {
2666
+ sessionId: entry.sessionId,
2667
+ activationId: entry.state.activationId,
2668
+ leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
2669
+ };
2670
+ const expectRevision = geometry?.revision ?? 0;
2671
+ const cols = view.desiredCols;
2672
+ const rows = view.desiredRows;
2673
+ // An empty seat is claimed; a claim of the view's own seat is committed
2674
+ // by the cell as a resize (at the same size, the PTY is left alone).
2675
+ const sent = holder === undefined || (holder.clientId === clientId && holder.viewId === viewId)
2676
+ ? this.#routedControl?.claimGeometry(entry.state.activationId, { ...lease, claimant: { clientId, viewId }, cols, rows, expectRevision }, context)
2677
+ : this.#routedControl?.transferGeometry(entry.state.activationId, { ...lease, from: { ...holder }, to: { clientId, viewId }, expectRevision, cols, rows }, context);
2678
+ if (sent)
2679
+ view.routedGeometrySerial = viewSerial;
2680
+ return sent ?? false;
2681
+ }
2682
+ /**
2683
+ * Take the session's geometry seat for `viewId`: the host's policy verb.
2684
+ *
2685
+ * Focus is a scheduling fact. The runtime never turns it into geometry;
2686
+ * the host decides when a view takes the seat. VibeField's rule (DS-D5) is
2687
+ * that the pane focused last holds the PTY's cols×rows, on any device, last
2688
+ * write wins, and `TerminalSurface`'s `takeGeometryOnFocus` applies it.
2689
+ *
2690
+ * On the routed transport the take is built on the holder this runtime last
2691
+ * saw (`geometryHolder`):
2692
+ * - an empty seat draws a `ClaimGeometry`;
2693
+ * - a seat this view holds draws a `ClaimGeometry` of its own seat, which
2694
+ * the cell commits as a resize;
2695
+ * - a seat held by another view, of this client or another, draws one
2696
+ * `TransferGeometry {from: holder, to: {clientId, viewId}, expectRevision,
2697
+ * cols, rows}`, with `clientId` from this session's attach grant.
2698
+ *
2699
+ * A refusal is the only announcement a take gets. The cell answers only the
2700
+ * caller's control leg, so a pane whose seat another client took still
2701
+ * believes it holds it, and its take is a resize that the cell refuses. A
2702
+ * `SEAT_HELD`, `STALE_REVISION` or `NOT_HOLDER` refusal that names a holder
2703
+ * or revision other than the one the take was built on is retried once,
2704
+ * against what it names: one `TransferGeometry` from the named holder at
2705
+ * the named revision; a claim of the view's own seat if it names this
2706
+ * view; a `ClaimGeometry` if it names an empty seat. A refusal that repeats
2707
+ * the take's own view of the seat, or names no seat, is not retried, and
2708
+ * neither is a second refusal. The runtime owns that one retry: a host
2709
+ * calls this once per focus gain of a controller view and never retries
2710
+ * it. Only the session's latest take retries. A take asked before the
2711
+ * control leg attaches is sent when it attaches, unless the view's focus
2712
+ * ends first. Without the grant's `geometry` right nothing is sent.
2713
+ *
2714
+ * On the port-pair transport this is `claimResizeControl`, which never
2715
+ * displaces another view.
2716
+ *
2717
+ * Like `claimResizeControl`, it leaves the view asking for resize control:
2718
+ * its later grid changes resize the PTY while it holds the seat, until
2719
+ * `releaseResizeControl`.
2720
+ */
2721
+ takeResizeControl(sessionHandle, viewId, cols, rows) {
2722
+ if (!this.#routedHost) {
2723
+ this.claimResizeControl(sessionHandle, viewId, cols, rows);
2724
+ return;
2725
+ }
2726
+ const view = this.#views.get(viewId);
2727
+ const session = this.#sessionByHandle.get(sessionHandle);
2728
+ const entry = session ? this.#routedBySession.get(session.id) : undefined;
2729
+ if (!view || !entry || view.sessionId !== entry.sessionId || !entry.viewIds.has(viewId))
2730
+ return;
2731
+ view.desiredCols = cols;
2732
+ view.desiredRows = rows;
2733
+ view.resizeControlRequested = true;
2734
+ entry.takeSerial += 1;
2735
+ const serial = entry.takeSerial;
2736
+ delete entry.queuedTake;
2737
+ if (this.#sendRoutedTake(entry, viewId, serial, 0))
2738
+ return;
2739
+ if (!entry.state.controlAttached)
2740
+ entry.queuedTake = { viewId, serial };
2321
2741
  }
2322
2742
  claimResizeControl(sessionHandle, viewId, cols, rows) {
2323
2743
  const view = this.#views.get(viewId);
@@ -2344,9 +2764,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
2344
2764
  view.resizeControlRequested = false;
2345
2765
  if (this.#routedHost) {
2346
2766
  const entry = this.#routedBySession.get(view.sessionId);
2767
+ if (entry?.queuedTake?.viewId === viewId)
2768
+ delete entry.queuedTake;
2347
2769
  const geometry = this.#routedGeometry.get(view.sessionId);
2348
2770
  const ticket = entry?.ticket;
2349
- if (entry && ticket && geometry?.holderViewId === viewId && geometry.holderGeneration !== undefined) {
2771
+ const holder = geometry?.holder;
2772
+ if (entry && ticket && geometry && holder && this.#routedViewHolds(entry, geometry, viewId)) {
2350
2773
  const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
2351
2774
  sessionId: view.sessionId,
2352
2775
  activationId: entry.state.activationId,
@@ -2354,13 +2777,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
2354
2777
  holder: {
2355
2778
  clientId: ticket.attachGrant.claims.clientId,
2356
2779
  viewId,
2357
- holderGeneration: geometry.holderGeneration,
2780
+ holderGeneration: holder.holderGeneration,
2358
2781
  },
2359
2782
  });
2360
2783
  if (sent) {
2361
2784
  // 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, {
2785
+ // cell state machine make the next revision deterministic; if the
2786
+ // seat had moved on, the cell's refusal names the holder instead.
2787
+ this.#setRoutedGeometry(entry, {
2364
2788
  revision: geometry.revision + 1,
2365
2789
  ...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
2366
2790
  ...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
@@ -2369,9 +2793,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
2369
2793
  }
2370
2794
  return;
2371
2795
  }
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;
2796
+ // The legacy protocol has no release verb, so the daemon keeps this view
2797
+ // seated. Dropping the request is what closes every resize path; the epoch
2798
+ // stays, because it is still the seat's truth and a later explicit claim
2799
+ // resumes on it. Clearing it here would leave the view with the record in
2800
+ // its own name and no epoch: the funnel's one-claim-per-attachment guard
2801
+ // refuses that claim, and a pane hidden by a zoom never resizes again.
2375
2802
  }
2376
2803
  setViewInputPolicy(viewId, readWrite) {
2377
2804
  const view = this.#views.get(viewId);
@@ -2406,8 +2833,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
2406
2833
  return;
2407
2834
  if (this.#routedHost) {
2408
2835
  const entry = this.#routedBySession.get(session.id);
2409
- if (entry)
2410
- this.#declareRoutedDemand(entry);
2836
+ if (!entry)
2837
+ return;
2838
+ // The focus that asked for a take still waiting on the leg has ended.
2839
+ if (!focused && entry.queuedTake?.viewId === viewId)
2840
+ delete entry.queuedTake;
2841
+ this.#declareRoutedDemand(entry);
2411
2842
  return;
2412
2843
  }
2413
2844
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
@@ -2459,6 +2890,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2459
2890
  return response.text;
2460
2891
  }
2461
2892
  interrupt(sessionId, viewId) {
2893
+ this.#sessionsWithInput.add(sessionId);
2462
2894
  if (this.#routedHost) {
2463
2895
  this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
2464
2896
  return;
@@ -2480,6 +2912,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2480
2912
  this.#sessionMountReferences.delete(handle);
2481
2913
  const subscriptionChanged = this.#subscribedSessionHandles.delete(handle);
2482
2914
  this.#cancelMetadataRefresh(handle);
2915
+ this.#linksByHandle.delete(handle);
2483
2916
  this.#mouseTrackingByHandle.delete(handle);
2484
2917
  this.#scrollbarByHandle.delete(handle);
2485
2918
  for (const mounted of [...this.#mountedEntries]) {
@@ -2494,7 +2927,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2494
2927
  this.#postWorker({ type: "unmount", surfaceId: mounted.viewId });
2495
2928
  this.#mountGenerationBySurface.delete(mounted.viewId);
2496
2929
  if (detachViews)
2497
- this.#control?.notify({ type: "detach-session", sessionId: mounted.sessionId, viewId: mounted.viewId });
2930
+ this.#detachView(mounted.sessionId, mounted.viewId);
2498
2931
  this.#views.delete(mounted.viewId);
2499
2932
  this.#focusByView.delete(mounted.viewId);
2500
2933
  }
@@ -2505,7 +2938,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2505
2938
  if (view.sessionId === sessionId) {
2506
2939
  view.pendingInput.length = 0;
2507
2940
  if (detachViews)
2508
- this.#control?.notify({ type: "detach-session", sessionId, viewId });
2941
+ this.#detachView(sessionId, viewId);
2509
2942
  this.#views.delete(viewId);
2510
2943
  this.#focusByView.delete(viewId);
2511
2944
  }
@@ -2517,6 +2950,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2517
2950
  this.#remoteSessions.delete(sessionId);
2518
2951
  this.#controlBySession.delete(sessionId);
2519
2952
  this.#recoveredSessions.delete(sessionId);
2953
+ // The worker forgets the handle's first frame with its snapshot (`drop-session`).
2954
+ this.#sessionsDrawn.delete(sessionId);
2520
2955
  if (subscriptionChanged) {
2521
2956
  this.#frameSubscriptionVersion += 1;
2522
2957
  this.#syncFrameSubscriptionsInBackground();
@@ -2615,6 +3050,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2615
3050
  this.#recoveredSessions.clear();
2616
3051
  this.#focusByView.clear();
2617
3052
  this.#mountGenerationBySurface.clear();
3053
+ this.#linksByHandle.clear();
2618
3054
  this.#mouseTrackingByHandle.clear();
2619
3055
  this.#scrollbarByHandle.clear();
2620
3056
  this.#subscribedSessionHandles.clear();
@@ -2635,6 +3071,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
2635
3071
  window.clearTimeout(entry.attachTimer);
2636
3072
  if (entry.renewalTimer !== undefined)
2637
3073
  window.clearTimeout(entry.renewalTimer);
3074
+ if (entry.cellLeaseTimer !== undefined)
3075
+ window.clearTimeout(entry.cellLeaseTimer);
3076
+ if (entry.workerLeaseTimer !== undefined)
3077
+ window.clearTimeout(entry.workerLeaseTimer);
2638
3078
  }
2639
3079
  this.#routedBySession.clear();
2640
3080
  this.#routedByActivation.clear();