@vibecook/ghosttea-react 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +59 -4
  2. package/dist/TerminalSurface.d.ts +53 -0
  3. package/dist/TerminalSurface.d.ts.map +1 -1
  4. package/dist/TerminalSurface.js +76 -9
  5. package/dist/TerminalSurface.js.map +1 -1
  6. package/dist/appearance/AppearanceSettings.d.ts +2 -0
  7. package/dist/appearance/AppearanceSettings.d.ts.map +1 -1
  8. package/dist/appearance/AppearanceSettings.js +12 -2
  9. package/dist/appearance/AppearanceSettings.js.map +1 -1
  10. package/dist/appearance/types.d.ts +4 -0
  11. package/dist/appearance/types.d.ts.map +1 -1
  12. package/dist/catalog-budget.d.ts +2 -2
  13. package/dist/catalog-budget.d.ts.map +1 -1
  14. package/dist/catalog-budget.js +2 -2
  15. package/dist/catalog-budget.js.map +1 -1
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +2 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/renderers/glyph-atlas.d.ts +43 -0
  24. package/dist/renderers/glyph-atlas.d.ts.map +1 -0
  25. package/dist/renderers/glyph-atlas.js +175 -0
  26. package/dist/renderers/glyph-atlas.js.map +1 -0
  27. package/dist/renderers/light-adaptation.d.ts +36 -0
  28. package/dist/renderers/light-adaptation.d.ts.map +1 -0
  29. package/dist/renderers/light-adaptation.js +332 -0
  30. package/dist/renderers/light-adaptation.js.map +1 -0
  31. package/dist/renderers/types.d.ts +21 -0
  32. package/dist/renderers/types.d.ts.map +1 -1
  33. package/dist/renderers/types.js.map +1 -1
  34. package/dist/renderers/webgpu-renderer.d.ts +4 -0
  35. package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
  36. package/dist/renderers/webgpu-renderer.js +321 -237
  37. package/dist/renderers/webgpu-renderer.js.map +1 -1
  38. package/dist/routed-control.d.ts +26 -2
  39. package/dist/routed-control.d.ts.map +1 -1
  40. package/dist/routed-control.js +145 -27
  41. package/dist/routed-control.js.map +1 -1
  42. package/dist/routed-frames.d.ts +1 -1
  43. package/dist/routed-frames.d.ts.map +1 -1
  44. package/dist/routed-frames.js +3 -7
  45. package/dist/routed-frames.js.map +1 -1
  46. package/dist/row-damage.d.ts +21 -0
  47. package/dist/row-damage.d.ts.map +1 -0
  48. package/dist/row-damage.js +76 -0
  49. package/dist/row-damage.js.map +1 -0
  50. package/dist/runtime.d.ts +92 -3
  51. package/dist/runtime.d.ts.map +1 -1
  52. package/dist/runtime.js +439 -43
  53. package/dist/runtime.js.map +1 -1
  54. package/dist/styles.css +11 -0
  55. package/dist/terminal-render.worker.js +908 -288
  56. package/dist/terminal-render.worker.js.map +4 -4
  57. package/dist/worker-messages.d.ts +22 -1
  58. package/dist/worker-messages.d.ts.map +1 -1
  59. package/dist/workspace/Workspace.d.ts +15 -2
  60. package/dist/workspace/Workspace.d.ts.map +1 -1
  61. package/dist/workspace/Workspace.js +36 -12
  62. package/dist/workspace/Workspace.js.map +1 -1
  63. package/dist/workspace/pane-layout.d.ts +3 -1
  64. package/dist/workspace/pane-layout.d.ts.map +1 -1
  65. package/dist/workspace/pane-layout.js +16 -4
  66. package/dist/workspace/pane-layout.js.map +1 -1
  67. package/package.json +4 -4
package/dist/runtime.js CHANGED
@@ -25,6 +25,57 @@ export function showsStaleScreen(state) {
25
25
  export function sessionIsFrozen(state) {
26
26
  return showsStaleScreen(state.state) || state.awaitingRecoveryFrame;
27
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
+ }
28
79
  const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000;
29
80
  const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7;
30
81
  const FRAME_BRIDGE_CAPABILITY_VERSION = 1;
@@ -53,6 +104,9 @@ export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
53
104
  }
54
105
  export class GhostteaTerminalRuntime extends EventTarget {
55
106
  #worker;
107
+ #sessionsCreatedHere = new Set();
108
+ #sessionsWithInput = new Set();
109
+ #sessionsDrawn = new Set();
56
110
  #ports;
57
111
  #routedHost;
58
112
  #routedReceiverCapacities;
@@ -178,6 +232,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
178
232
  this.#scheduleMetadataRefresh(data.sessionHandle);
179
233
  this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
180
234
  }
235
+ else if (data.type === "session-drawn") {
236
+ this.#recordSessionDrawn(data.sessionHandle);
237
+ }
181
238
  else if (data.type === "catalog-pressure") {
182
239
  console.warn(`[terminal-runtime] native text catalog budget exceeded for ${data.sessionHandle}; using bounded fallback text`);
183
240
  this.dispatchEvent(new CustomEvent("catalog-pressure", { detail: data }));
@@ -202,6 +259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
202
259
  else if (data.type === "routed-frames-event") {
203
260
  this.#handleRoutedFramesEvent(data.event);
204
261
  }
262
+ else if (data.type === "terminal-modes") {
263
+ this.#noteMouseTracking(data.sessionHandle, data.mouseTracking);
264
+ }
205
265
  else if (data.type === "renderer-reload-required") {
206
266
  console.error(`[terminal-runtime] renderer requested reload: ${String(data.reason ?? "unknown")}`);
207
267
  this.#platform.setForceCanvasFallback(true);
@@ -218,6 +278,26 @@ export class GhostteaTerminalRuntime extends EventTarget {
218
278
  return;
219
279
  this.#worker.postMessage(message, transfer);
220
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
+ }
221
301
  get rendererBackend() {
222
302
  return this.#rendererBackend;
223
303
  }
@@ -231,6 +311,48 @@ export class GhostteaTerminalRuntime extends EventTarget {
231
311
  return false;
232
312
  return this.#routedBySession.get(view.sessionId)?.state.inputAllowed ?? false;
233
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
+ }
234
356
  #resolvePerformanceRequest(requestId, value) {
235
357
  const pending = this.#performanceRequests.get(requestId);
236
358
  if (!pending)
@@ -358,11 +480,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
358
480
  this.#returnFrameCredit(data.byteLength);
359
481
  return;
360
482
  }
361
- const tracking = (view.getUint16(6, true) & FrameFlag.MouseTracking) !== 0;
362
- if (this.#mouseTrackingByHandle.get(sessionHandle) !== tracking) {
363
- this.#mouseTrackingByHandle.set(sessionHandle, tracking);
364
- this.dispatchEvent(new CustomEvent("terminal-modes", { detail: { sessionHandle, mouseTracking: tracking } }));
365
- }
483
+ this.#noteMouseTracking(sessionHandle, (view.getUint16(6, true) & FrameFlag.MouseTracking) !== 0);
366
484
  this.#scheduleMetadataRefresh(sessionHandle);
367
485
  this.#postWorker({ type: "frame", packet: data }, [data]);
368
486
  };
@@ -810,6 +928,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
810
928
  if (!this.#routedHost.createSession)
811
929
  throw new Error("The routed host does not provide session creation");
812
930
  const session = await this.#routedHost.createSession(options);
931
+ this.#sessionsCreatedHere.add(session.id);
813
932
  this.registerSession(session);
814
933
  return session;
815
934
  }
@@ -827,6 +946,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
827
946
  });
828
947
  if (response.type !== "session-created")
829
948
  throw new Error("ghosttead returned an unexpected response");
949
+ this.#sessionsCreatedHere.add(response.session.id);
830
950
  this.registerSession(response.session);
831
951
  console.info(`[terminal-runtime] created session ${response.session.id}`);
832
952
  return response.session;
@@ -983,11 +1103,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
983
1103
  if (response.type !== "ok")
984
1104
  throw new Error("ghosttead rejected frame resynchronization");
985
1105
  }
986
- mount(sessionId, sessionHandle, viewId, canvas) {
1106
+ mount(sessionId, sessionHandle, viewId, canvas, initialSize) {
987
1107
  if (this.#disposed)
988
1108
  throw new Error("Cannot mount a disposed terminal runtime");
989
1109
  if (this.#routedHost)
990
- return this.#mountRouted(sessionId, sessionHandle, viewId, canvas);
1110
+ return this.#mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize);
991
1111
  const mounted = this.#mountedCanvases.get(canvas);
992
1112
  if (mounted) {
993
1113
  if (!mounted.active)
@@ -1002,11 +1122,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
1002
1122
  }
1003
1123
  return this.#createMountLease(mounted);
1004
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));
1005
1134
  const offscreen = canvas.transferControlToOffscreen();
1006
1135
  const subscription = this.#retainFrameSubscription(sessionHandle);
1007
1136
  const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
1008
1137
  this.#mountGenerationBySurface.set(viewId, generation);
1009
- this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
1138
+ this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
1010
1139
  const entry = {
1011
1140
  canvas,
1012
1141
  sessionHandle,
@@ -1035,6 +1164,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1035
1164
  lastAttachmentEpoch: undefined,
1036
1165
  claimedEpoch: undefined,
1037
1166
  claimedRevision: 0,
1167
+ routedGeometrySerial: 0,
1038
1168
  };
1039
1169
  this.#views.set(viewId, view);
1040
1170
  const remote = this.#remoteSessions.get(sessionId);
@@ -1093,7 +1223,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1093
1223
  .catch((error) => console.error(`[terminal-runtime] failed to attach view ${viewId}`, error));
1094
1224
  return this.#createMountLease(entry);
1095
1225
  }
1096
- #mountRouted(sessionId, sessionHandle, viewId, canvas) {
1226
+ #mountRouted(sessionId, sessionHandle, viewId, canvas, initialSize) {
1097
1227
  const mounted = this.#mountedCanvases.get(canvas);
1098
1228
  if (mounted) {
1099
1229
  if (!mounted.active)
@@ -1108,10 +1238,19 @@ export class GhostteaTerminalRuntime extends EventTarget {
1108
1238
  }
1109
1239
  return this.#createMountLease(mounted);
1110
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));
1111
1250
  const offscreen = canvas.transferControlToOffscreen();
1112
1251
  const generation = (this.#mountGenerationBySurface.get(viewId) ?? 0) + 1;
1113
1252
  this.#mountGenerationBySurface.set(viewId, generation);
1114
- this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen }, [offscreen]);
1253
+ this.#postWorker({ type: "mount", surfaceId: viewId, sessionHandle, canvas: offscreen, size }, [offscreen]);
1115
1254
  const entry = {
1116
1255
  canvas,
1117
1256
  sessionHandle,
@@ -1142,6 +1281,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1142
1281
  lastAttachmentEpoch: undefined,
1143
1282
  claimedEpoch: undefined,
1144
1283
  claimedRevision: 0,
1284
+ routedGeometrySerial: 0,
1145
1285
  });
1146
1286
  const activation = this.#routedBySession.get(sessionId);
1147
1287
  if (activation)
@@ -1178,6 +1318,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1178
1318
  sessionHandle,
1179
1319
  state: initialRoutedActivation(sessionId, activationId, inputPolicy),
1180
1320
  viewIds: new Set([viewId]),
1321
+ takeSerial: 0,
1181
1322
  recoveryAttempts: 0,
1182
1323
  preAuthRemints: { control: 0, frames: 0 },
1183
1324
  protocolFailures: { control: 0, frames: 0 },
@@ -1223,15 +1364,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
1223
1364
  const host = this.#routedHost;
1224
1365
  if (!host || this.#disposed || entry.viewIds.size === 0)
1225
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;
1226
1373
  let ticket;
1227
1374
  try {
1228
1375
  ticket = await host.openTicket(entry.sessionId, { reason });
1229
1376
  }
1230
1377
  catch (error) {
1231
- this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
1378
+ if (isCurrent())
1379
+ this.#transitionRouted(entry, { type: "no-route", reason: String(error) });
1232
1380
  return;
1233
1381
  }
1234
- if (this.#routedBySession.get(entry.sessionId) !== entry || this.#disposed)
1382
+ if (!isCurrent())
1235
1383
  return;
1236
1384
  if (!this.#routedTicketMatches(entry, ticket)) {
1237
1385
  this.#transitionRouted(entry, { type: "no-route", reason: "ticket-binding-mismatch" });
@@ -1241,6 +1389,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1241
1389
  if (!ticket.endpoints)
1242
1390
  return;
1243
1391
  entry.ticket = ticket;
1392
+ entry.clientId = ticket.attachGrant.claims.clientId;
1244
1393
  this.#transitionRouted(entry, { type: "transport-ready" });
1245
1394
  this.#routedControl.attach({
1246
1395
  cellBootId: ticket.route.cellBootId,
@@ -1248,10 +1397,22 @@ export class GhostteaTerminalRuntime extends EventTarget {
1248
1397
  transportGrant: ticket.transportGrant,
1249
1398
  attachGrant: ticket.attachGrant,
1250
1399
  activationId: entry.state.activationId,
1400
+ replacePreviousActivation: true,
1251
1401
  ...(entry.replacesActivationId === undefined ? {} : { replacesActivationId: entry.replacesActivationId }),
1252
1402
  initialDemand: this.#routedDemand(entry),
1253
1403
  capabilities: this.#routedCapabilities,
1254
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;
1255
1416
  this.#postWorker({
1256
1417
  type: "routed-frames-attach",
1257
1418
  request: {
@@ -1266,19 +1427,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
1266
1427
  capabilities: this.#routedCapabilities,
1267
1428
  },
1268
1429
  });
1269
- this.#armRoutedAttachDeadline(entry, this.#routedAttachDeadlineByCell.get(ticket.route.cellBootId) ??
1270
- DEFAULT_ROUTED_PROTOCOL_LIMITS.activationAttachDeadlineMs);
1271
- this.#scheduleRoutedRenewal(entry);
1272
1430
  }
1273
1431
  #stopRoutedActivation(entry) {
1274
1432
  this.#routedControl?.detach(entry.state.activationId);
1275
1433
  this.#postWorker({ type: "routed-frames-detach", activationId: entry.state.activationId });
1434
+ delete entry.framesAttachStarted;
1276
1435
  if (entry.attachTimer !== undefined)
1277
1436
  window.clearTimeout(entry.attachTimer);
1278
1437
  delete entry.attachTimer;
1279
1438
  if (entry.renewalTimer !== undefined)
1280
1439
  window.clearTimeout(entry.renewalTimer);
1281
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;
1282
1447
  delete entry.ticket;
1283
1448
  }
1284
1449
  #transitionRouted(entry, event) {
@@ -1352,23 +1517,36 @@ export class GhostteaTerminalRuntime extends EventTarget {
1352
1517
  grantGeneration: event.attached.grantGenerationAccepted,
1353
1518
  rights: event.attached.rights,
1354
1519
  });
1520
+ this.#startRoutedFrames(entry);
1355
1521
  const previousGeometry = this.#routedGeometry.get(entry.sessionId);
1522
+ const previousHolder = previousGeometry?.holder;
1356
1523
  if (!event.attached.rights.includes("geometry") &&
1357
- previousGeometry?.holderViewId !== undefined &&
1358
- entry.viewIds.has(previousGeometry.holderViewId)) {
1524
+ previousGeometry !== undefined &&
1525
+ previousHolder !== undefined &&
1526
+ this.#routedViewHolds(entry, previousGeometry, previousHolder.viewId) &&
1527
+ entry.viewIds.has(previousHolder.viewId)) {
1359
1528
  // The cell auto-releases a holder when renewal drops the geometry
1360
1529
  // right. Preserve its next CAS revision without retaining authority.
1361
- this.#routedGeometry.set(entry.sessionId, {
1530
+ this.#setRoutedGeometry(entry, {
1362
1531
  revision: previousGeometry.revision + 1,
1363
1532
  ...(previousGeometry.cols === undefined ? {} : { cols: previousGeometry.cols }),
1364
1533
  ...(previousGeometry.rows === undefined ? {} : { rows: previousGeometry.rows }),
1365
1534
  });
1366
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
+ }
1367
1545
  const geometry = this.#routedGeometry.get(entry.sessionId);
1368
1546
  for (const viewId of entry.viewIds) {
1369
1547
  const view = this.#views.get(viewId);
1370
1548
  if (view?.resizeControlRequested &&
1371
- geometry?.holderViewId !== viewId &&
1549
+ !this.#routedViewHolds(entry, geometry, viewId) &&
1372
1550
  view.desiredCols !== undefined &&
1373
1551
  view.desiredRows !== undefined) {
1374
1552
  this.#claimRoutedGeometry(entry, viewId, view.desiredCols, view.desiredRows);
@@ -1390,9 +1568,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
1390
1568
  const entry = this.#routedByActivation.get(event.status.activationId);
1391
1569
  if (!entry || event.status.sessionId !== entry.sessionId)
1392
1570
  return;
1571
+ const previousSequence = entry.state.lastCellStatusSequence;
1393
1572
  this.#transitionRouted(entry, { type: "cell-status", status: event.status, now: performance.now() });
1394
1573
  const sequence = entry.state.lastCellStatusSequence;
1395
- 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;
1396
1580
  if (entry.state.lastCellStatusSequence !== sequence)
1397
1581
  return;
1398
1582
  this.#transitionRouted(entry, { type: "cell-lease-expired" });
@@ -1407,14 +1591,21 @@ export class GhostteaTerminalRuntime extends EventTarget {
1407
1591
  const entry = event.activationId ? this.#routedByActivation.get(event.activationId) : undefined;
1408
1592
  if (!entry)
1409
1593
  return;
1410
- this.#routedGeometry.set(entry.sessionId, {
1411
- holderViewId: event.committed.holder.viewId,
1412
- holderGeneration: event.committed.holder.holderGeneration,
1594
+ this.#setRoutedGeometry(entry, {
1595
+ holder: geometryHolderRecord(event.committed.holder),
1413
1596
  revision: event.committed.geometryRevision,
1414
1597
  cols: event.committed.cols,
1415
1598
  rows: event.committed.rows,
1416
1599
  });
1417
- 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
+ }));
1418
1609
  return;
1419
1610
  }
1420
1611
  if (event.type === "geometry-refused") {
@@ -1422,14 +1613,32 @@ export class GhostteaTerminalRuntime extends EventTarget {
1422
1613
  if (entry && event.refused.geometryRevision !== undefined) {
1423
1614
  const previous = this.#routedGeometry.get(entry.sessionId);
1424
1615
  const holder = event.refused.currentHolder;
1425
- this.#routedGeometry.set(entry.sessionId, {
1616
+ this.#setRoutedGeometry(entry, {
1426
1617
  revision: event.refused.geometryRevision,
1427
- ...(holder === undefined ? {} : { holderViewId: holder.viewId, holderGeneration: holder.holderGeneration }),
1618
+ ...(holder === undefined ? {} : { holder: geometryHolderRecord(holder) }),
1428
1619
  ...(previous?.cols === undefined ? {} : { cols: previous.cols }),
1429
1620
  ...(previous?.rows === undefined ? {} : { rows: previous.rows }),
1430
1621
  });
1431
1622
  }
1432
- 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
+ }));
1433
1642
  return;
1434
1643
  }
1435
1644
  if (event.type === "transport-closed") {
@@ -1528,9 +1737,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
1528
1737
  const entry = this.#routedByActivation.get(event.status.activationId);
1529
1738
  if (!entry)
1530
1739
  return;
1740
+ const previousSequence = entry.state.lastWorkerStatusSequence;
1531
1741
  this.#transitionRouted(entry, { type: "presentation-status", status: event.status, now: performance.now() });
1532
1742
  const sequence = entry.state.lastWorkerStatusSequence;
1533
- 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;
1534
1749
  if (entry.state.lastWorkerStatusSequence !== sequence)
1535
1750
  return;
1536
1751
  this.#transitionRouted(entry, { type: "worker-lease-expired" });
@@ -1644,6 +1859,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1644
1859
  return;
1645
1860
  }
1646
1861
  entry.ticket = ticket;
1862
+ entry.clientId = ticket.attachGrant.claims.clientId;
1647
1863
  this.#routedControl?.renew(activationId, ticket.attachGrant);
1648
1864
  this.#scheduleRoutedRenewal(entry);
1649
1865
  this.#postWorker({
@@ -1685,14 +1901,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
1685
1901
  this.#transitionRouted(entry, { type: "transport-failed", recoveryExhausted: true });
1686
1902
  return;
1687
1903
  }
1688
- // Geometry belongs to the cell-side attach/client/view scope. Preserve it
1689
- // across a same-route leg replacement, but never carry its revision or
1690
- // holder generation to a newly routed cell.
1691
- if (reason === "route-stale")
1692
- this.#routedGeometry.delete(entry.sessionId);
1693
1904
  const previousActivationId = entry.state.activationId;
1694
1905
  this.#routedControl?.detach(previousActivationId);
1695
1906
  this.#postWorker({ type: "routed-frames-detach", activationId: previousActivationId });
1907
+ delete entry.framesAttachStarted;
1696
1908
  this.#routedByActivation.delete(previousActivationId);
1697
1909
  const nextActivationId = crypto.randomUUID();
1698
1910
  entry.replacesActivationId = previousActivationId;
@@ -1716,7 +1928,20 @@ export class GhostteaTerminalRuntime extends EventTarget {
1716
1928
  if (entry.renewalTimer !== undefined)
1717
1929
  window.clearTimeout(entry.renewalTimer);
1718
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;
1719
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);
1720
1945
  const delay = Math.min(2_000, 100 * 2 ** Math.max(0, entry.recoveryAttempts - 1));
1721
1946
  window.setTimeout(() => {
1722
1947
  if (this.#routedByActivation.get(nextActivationId) !== entry)
@@ -1795,6 +2020,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
1795
2020
  if (!entry)
1796
2021
  return;
1797
2022
  entry.viewIds.delete(viewId);
2023
+ if (entry.queuedTake?.viewId === viewId)
2024
+ delete entry.queuedTake;
1798
2025
  if (entry.viewIds.size > 0) {
1799
2026
  const anyWritable = this.#routedHost?.encodeInput !== undefined &&
1800
2027
  [...entry.viewIds].some((candidate) => {
@@ -1808,7 +2035,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
1808
2035
  this.#transitionRouted(entry, { type: "detach" });
1809
2036
  this.#routedBySession.delete(sessionId);
1810
2037
  this.#routedByActivation.delete(entry.state.activationId);
1811
- this.#routedGeometry.delete(sessionId);
2038
+ this.#setRoutedGeometry(entry, undefined);
1812
2039
  }
1813
2040
  /**
1814
2041
  * Detach one of our views and let the seat go with it.
@@ -2072,6 +2299,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
2072
2299
  * screen: a partial update repaints part of the stale one, which is exactly
2073
2300
  * what would thaw a pane too early.
2074
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
+ }
2075
2309
  #recordCommittedFrame(sessionHandle, fullSnapshot) {
2076
2310
  if (!fullSnapshot)
2077
2311
  return;
@@ -2212,6 +2446,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2212
2446
  return message !== null && this.#routedControl.sendExtension(activation.state.activationId, message);
2213
2447
  }
2214
2448
  sendText(sessionId, viewId, text) {
2449
+ if (text.length > 0)
2450
+ this.#sessionsWithInput.add(sessionId);
2215
2451
  if (this.#routedHost) {
2216
2452
  this.#sendRoutedInput(sessionId, viewId, { kind: "text", text });
2217
2453
  return;
@@ -2222,6 +2458,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2222
2458
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
2223
2459
  }
2224
2460
  paste(sessionId, viewId, text) {
2461
+ if (text.length > 0)
2462
+ this.#sessionsWithInput.add(sessionId);
2225
2463
  if (this.#routedHost) {
2226
2464
  this.#sendRoutedInput(sessionId, viewId, { kind: "paste", text });
2227
2465
  return;
@@ -2232,6 +2470,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2232
2470
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
2233
2471
  }
2234
2472
  sendKey(sessionId, viewId, event) {
2473
+ if (event.type === "down" && !["Shift", "Control", "Alt", "Meta"].includes(event.key))
2474
+ this.#sessionsWithInput.add(sessionId);
2235
2475
  if (this.#routedHost) {
2236
2476
  this.#sendRoutedInput(sessionId, viewId, { kind: "key", event });
2237
2477
  return;
@@ -2273,6 +2513,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
2273
2513
  isMouseTracking(sessionHandle) {
2274
2514
  return this.#mouseTrackingByHandle.get(sessionHandle) ?? false;
2275
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
+ }
2276
2524
  setTheme(sessionHandle, theme, surfaceId) {
2277
2525
  this.#postWorker({ type: "theme", sessionHandle, ...(surfaceId ? { surfaceId } : {}), theme });
2278
2526
  // A surface-scoped theme is renderer-local. Updating the daemon's
@@ -2342,13 +2590,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
2342
2590
  setPartialRenderingEnabled(enabled) {
2343
2591
  this.#postWorker({ type: "partial-rendering", enabled });
2344
2592
  }
2345
- #claimRoutedGeometry(entry, viewId, cols, rows) {
2593
+ #claimRoutedGeometry(entry, viewId, cols, rows, trailing = false) {
2346
2594
  const ticket = entry.ticket;
2347
2595
  const view = this.#views.get(viewId);
2348
2596
  if (!ticket || !view?.resizeControlRequested || !entry.state.rights.includes("geometry"))
2349
2597
  return;
2350
2598
  const geometry = this.#routedGeometry.get(entry.sessionId);
2351
- this.#routedControl?.claimGeometry(entry.state.activationId, {
2599
+ const viewSerial = view.routedGeometrySerial + 1;
2600
+ const sent = this.#routedControl?.claimGeometry(entry.state.activationId, {
2352
2601
  sessionId: entry.sessionId,
2353
2602
  activationId: entry.state.activationId,
2354
2603
  leaseEpoch: ticket.attachGrant.claims.leaseEpoch ?? 0,
@@ -2356,7 +2605,139 @@ export class GhostteaTerminalRuntime extends EventTarget {
2356
2605
  cols,
2357
2606
  rows,
2358
2607
  expectRevision: geometry?.revision ?? 0,
2359
- });
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 };
2360
2741
  }
2361
2742
  claimResizeControl(sessionHandle, viewId, cols, rows) {
2362
2743
  const view = this.#views.get(viewId);
@@ -2383,9 +2764,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
2383
2764
  view.resizeControlRequested = false;
2384
2765
  if (this.#routedHost) {
2385
2766
  const entry = this.#routedBySession.get(view.sessionId);
2767
+ if (entry?.queuedTake?.viewId === viewId)
2768
+ delete entry.queuedTake;
2386
2769
  const geometry = this.#routedGeometry.get(view.sessionId);
2387
2770
  const ticket = entry?.ticket;
2388
- 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)) {
2389
2773
  const sent = this.#routedControl?.releaseGeometry(entry.state.activationId, {
2390
2774
  sessionId: view.sessionId,
2391
2775
  activationId: entry.state.activationId,
@@ -2393,13 +2777,14 @@ export class GhostteaTerminalRuntime extends EventTarget {
2393
2777
  holder: {
2394
2778
  clientId: ticket.attachGrant.claims.clientId,
2395
2779
  viewId,
2396
- holderGeneration: geometry.holderGeneration,
2780
+ holderGeneration: holder.holderGeneration,
2397
2781
  },
2398
2782
  });
2399
2783
  if (sent) {
2400
2784
  // T1 sends no success body for release. The ordered control leg and
2401
- // cell state machine make the next revision deterministic.
2402
- 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, {
2403
2788
  revision: geometry.revision + 1,
2404
2789
  ...(geometry.cols === undefined ? {} : { cols: geometry.cols }),
2405
2790
  ...(geometry.rows === undefined ? {} : { rows: geometry.rows }),
@@ -2448,8 +2833,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
2448
2833
  return;
2449
2834
  if (this.#routedHost) {
2450
2835
  const entry = this.#routedBySession.get(session.id);
2451
- if (entry)
2452
- 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);
2453
2842
  return;
2454
2843
  }
2455
2844
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => {
@@ -2501,6 +2890,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
2501
2890
  return response.text;
2502
2891
  }
2503
2892
  interrupt(sessionId, viewId) {
2893
+ this.#sessionsWithInput.add(sessionId);
2504
2894
  if (this.#routedHost) {
2505
2895
  this.#sendRoutedInput(sessionId, viewId, { kind: "interrupt" });
2506
2896
  return;
@@ -2560,6 +2950,8 @@ export class GhostteaTerminalRuntime extends EventTarget {
2560
2950
  this.#remoteSessions.delete(sessionId);
2561
2951
  this.#controlBySession.delete(sessionId);
2562
2952
  this.#recoveredSessions.delete(sessionId);
2953
+ // The worker forgets the handle's first frame with its snapshot (`drop-session`).
2954
+ this.#sessionsDrawn.delete(sessionId);
2563
2955
  if (subscriptionChanged) {
2564
2956
  this.#frameSubscriptionVersion += 1;
2565
2957
  this.#syncFrameSubscriptionsInBackground();
@@ -2679,6 +3071,10 @@ export class GhostteaTerminalRuntime extends EventTarget {
2679
3071
  window.clearTimeout(entry.attachTimer);
2680
3072
  if (entry.renewalTimer !== undefined)
2681
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);
2682
3078
  }
2683
3079
  this.#routedBySession.clear();
2684
3080
  this.#routedByActivation.clear();