@vibecook/ghosttea-react 0.6.2 → 0.8.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 (73) hide show
  1. package/README.md +8 -0
  2. package/dist/TerminalSurface.d.ts +4 -1
  3. package/dist/TerminalSurface.d.ts.map +1 -1
  4. package/dist/TerminalSurface.js +6 -3
  5. package/dist/TerminalSurface.js.map +1 -1
  6. package/dist/bindings/action-route.d.ts.map +1 -1
  7. package/dist/bindings/action-route.js +4 -0
  8. package/dist/bindings/action-route.js.map +1 -1
  9. package/dist/bindings/ghostty-bindings.d.ts +13 -0
  10. package/dist/bindings/ghostty-bindings.d.ts.map +1 -1
  11. package/dist/bindings/ghostty-bindings.js +57 -0
  12. package/dist/bindings/ghostty-bindings.js.map +1 -1
  13. package/dist/bindings/index.d.ts +1 -1
  14. package/dist/bindings/index.d.ts.map +1 -1
  15. package/dist/bindings/index.js +1 -1
  16. package/dist/bindings/index.js.map +1 -1
  17. package/dist/config.d.ts +7 -0
  18. package/dist/config.d.ts.map +1 -0
  19. package/dist/config.js +21 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/renderers/types.d.ts +6 -0
  26. package/dist/renderers/types.d.ts.map +1 -1
  27. package/dist/renderers/types.js +3 -0
  28. package/dist/renderers/types.js.map +1 -1
  29. package/dist/renderers/webgpu-renderer.d.ts.map +1 -1
  30. package/dist/renderers/webgpu-renderer.js +33 -4
  31. package/dist/renderers/webgpu-renderer.js.map +1 -1
  32. package/dist/runtime.d.ts +40 -3
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +458 -32
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/terminal-bindings.d.ts +2 -2
  37. package/dist/terminal-bindings.d.ts.map +1 -1
  38. package/dist/terminal-bindings.js +2 -1
  39. package/dist/terminal-bindings.js.map +1 -1
  40. package/dist/terminal-render.worker.js +93 -7
  41. package/dist/terminal-render.worker.js.map +2 -2
  42. package/dist/worker-messages.d.ts +26 -1
  43. package/dist/worker-messages.d.ts.map +1 -1
  44. package/dist/workspace/RemoteSessionBanner.d.ts +22 -0
  45. package/dist/workspace/RemoteSessionBanner.d.ts.map +1 -0
  46. package/dist/workspace/RemoteSessionBanner.js +108 -0
  47. package/dist/workspace/RemoteSessionBanner.js.map +1 -0
  48. package/dist/workspace/RemoteSessionPalette.d.ts +5 -1
  49. package/dist/workspace/RemoteSessionPalette.d.ts.map +1 -1
  50. package/dist/workspace/RemoteSessionPalette.js +19 -15
  51. package/dist/workspace/RemoteSessionPalette.js.map +1 -1
  52. package/dist/workspace/Workspace.d.ts +25 -2
  53. package/dist/workspace/Workspace.d.ts.map +1 -1
  54. package/dist/workspace/Workspace.js +125 -27
  55. package/dist/workspace/Workspace.js.map +1 -1
  56. package/dist/workspace/index.d.ts +4 -2
  57. package/dist/workspace/index.d.ts.map +1 -1
  58. package/dist/workspace/index.js +3 -1
  59. package/dist/workspace/index.js.map +1 -1
  60. package/dist/workspace/pane-layout.d.ts +9 -2
  61. package/dist/workspace/pane-layout.d.ts.map +1 -1
  62. package/dist/workspace/pane-layout.js +37 -11
  63. package/dist/workspace/pane-layout.js.map +1 -1
  64. package/dist/workspace/remote-banner.d.ts +36 -0
  65. package/dist/workspace/remote-banner.d.ts.map +1 -0
  66. package/dist/workspace/remote-banner.js +108 -0
  67. package/dist/workspace/remote-banner.js.map +1 -0
  68. package/dist/workspace/workspace-model.d.ts +2 -0
  69. package/dist/workspace/workspace-model.d.ts.map +1 -1
  70. package/dist/workspace/workspace-model.js +6 -1
  71. package/dist/workspace/workspace-model.js.map +1 -1
  72. package/dist/workspace.css +93 -0
  73. package/package.json +4 -4
package/dist/runtime.js CHANGED
@@ -10,10 +10,23 @@ function sameSessionActivity(left, right) {
10
10
  left.foregroundProcessGroupId === right.foregroundProcessGroupId &&
11
11
  left.observedAtMs === right.observedAtMs);
12
12
  }
13
+ /** States that leave a screen on display which the host is no longer updating. */
14
+ export function showsStaleScreen(state) {
15
+ return state !== "live" && state !== "opening";
16
+ }
17
+ /** Whether a pane is showing stale content: frozen outright, or live but not yet redrawn. */
18
+ export function sessionIsFrozen(state) {
19
+ return showsStaleScreen(state.state) || state.awaitingRecoveryFrame;
20
+ }
13
21
  const FRAME_SUBSCRIPTION_ACK_TIMEOUT_MS = 10_000;
14
22
  const FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR = 7;
15
23
  const FRAME_BRIDGE_CAPABILITY_VERSION = 1;
24
+ const CONFIG_PROTOCOL_MINOR = 10;
25
+ const REMOTE_LIFECYCLE_PROTOCOL_MINOR = 12;
26
+ const CONTROL_REVISION_CAS_PROTOCOL_MINOR = 13;
16
27
  const DEFAULT_FRAME_SUBSCRIPTION_GRACE_MS = 1_000;
28
+ /** A one-shot resume covers a 20 s dial plus the attach handshake. */
29
+ const RECONNECT_REQUEST_TIMEOUT_MS = 60_000;
17
30
  export function waitForGhostteaRendererPorts(timeoutMs = 10_000) {
18
31
  return new Promise((resolve, reject) => {
19
32
  const timeout = window.setTimeout(() => {
@@ -61,7 +74,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
61
74
  #scrollbarByHandle = new Map();
62
75
  #focusByView = new Map();
63
76
  #views = new Map();
77
+ #remoteSessions = new Map();
78
+ #controlBySession = new Map();
79
+ /** Sessions whose current outage episode has already produced a full snapshot. */
80
+ #recoveredSessions = new Set();
81
+ #remoteLifecycleSupported = false;
82
+ #controlRevisionCasSupported = false;
64
83
  #rendererBackend = "starting";
84
+ #configSnapshot;
85
+ #configProtocolSupported = false;
65
86
  #metadataTimers = new Map();
66
87
  #resync;
67
88
  #performanceRequestId = 1;
@@ -112,6 +133,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
112
133
  else if (data.type === "frame-resync-complete") {
113
134
  this.#resync.complete(data.sessionHandle);
114
135
  }
136
+ else if (data.type === "frame-committed") {
137
+ this.#recordCommittedFrame(data.sessionHandle, data.fullSnapshot);
138
+ }
115
139
  else if (data.type === "catalog-pressure") {
116
140
  console.warn(`[terminal-runtime] native text catalog budget exceeded for ${data.sessionHandle}; using bounded fallback text`);
117
141
  this.dispatchEvent(new CustomEvent("catalog-pressure", { detail: data }));
@@ -229,20 +253,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
229
253
  });
230
254
  this.#control.addEventListener("control-changed", (event) => {
231
255
  const detail = event.detail;
232
- for (const [viewId, view] of this.#views) {
233
- if (view.sessionId !== detail.sessionId)
234
- continue;
235
- if (viewId !== detail.controllerViewId) {
236
- view.controlEpoch = undefined;
237
- continue;
238
- }
239
- view.controlEpoch = detail.controlEpoch;
240
- if (view.desiredCols !== undefined &&
241
- view.desiredRows !== undefined &&
242
- (view.desiredCols !== detail.cols || view.desiredRows !== detail.rows)) {
243
- this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
244
- }
245
- }
256
+ this.#applyController(detail.sessionId, { viewId: detail.controllerViewId, controlEpoch: detail.controlEpoch }, detail.cols, detail.rows);
257
+ });
258
+ this.#control.addEventListener("control-state", (event) => {
259
+ const detail = event.detail;
260
+ this.#applyController(detail.sessionId, detail.controller, detail.cols, detail.rows, detail.controlRevision);
261
+ });
262
+ this.#control.addEventListener("remote-session-state-changed", (event) => {
263
+ const detail = event.detail;
264
+ this.#applyRemoteSessionState(detail.sessionId, detail, false);
265
+ });
266
+ this.#control.addEventListener("view-state-changed", (event) => {
267
+ const detail = event.detail;
268
+ this.#applyViewState(detail.viewId, detail);
269
+ });
270
+ this.#control.addEventListener("config-changed", (event) => {
271
+ const detail = event.detail;
272
+ this.#installConfig(detail.config);
246
273
  });
247
274
  this.#frames = ports.frames;
248
275
  this.#frames.onmessage = ({ data }) => {
@@ -292,9 +319,44 @@ export class GhostteaTerminalRuntime extends EventTarget {
292
319
  if (hello.type !== "hello" || hello.protocolMajor !== PROTOCOL_MAJOR)
293
320
  throw new Error("ghosttead protocol mismatch");
294
321
  this.#frameSubscriptionAcksSupported = hello.protocolMinor >= FRAME_SUBSCRIPTION_ACK_PROTOCOL_MINOR;
322
+ this.#configProtocolSupported = hello.protocolMinor >= CONFIG_PROTOCOL_MINOR;
323
+ this.#remoteLifecycleSupported = hello.protocolMinor >= REMOTE_LIFECYCLE_PROTOCOL_MINOR;
324
+ this.#controlRevisionCasSupported = hello.protocolMinor >= CONTROL_REVISION_CAS_PROTOCOL_MINOR;
325
+ if (this.#configProtocolSupported && hello.configRevision !== undefined) {
326
+ await this.#refreshConfig();
327
+ }
295
328
  await this.#queueFrameSubscriptionSync();
296
329
  console.info("[terminal-runtime] authenticated ghosttead protocol");
297
330
  }
331
+ get configSnapshot() {
332
+ return this.#configSnapshot;
333
+ }
334
+ async getConfig() {
335
+ await this.connect();
336
+ return this.#configSnapshot;
337
+ }
338
+ async reloadConfig() {
339
+ await this.connect();
340
+ const response = await this.#control.request({ type: "reload-config" });
341
+ if (response.type !== "config")
342
+ throw new Error("ghosttead returned an unexpected configuration response");
343
+ this.#installConfig(response.config);
344
+ return response.config;
345
+ }
346
+ #installConfig(config) {
347
+ if (this.#configSnapshot?.revision === config.revision)
348
+ return;
349
+ this.#configSnapshot = config;
350
+ this.dispatchEvent(new CustomEvent("config-changed", { detail: config }));
351
+ }
352
+ async #refreshConfig() {
353
+ if (!this.#configProtocolSupported || !this.#control)
354
+ return;
355
+ const response = await this.#control.request({ type: "get-config" });
356
+ if (response.type !== "config")
357
+ throw new Error("ghosttead returned an unexpected configuration response");
358
+ this.#installConfig(response.config);
359
+ }
298
360
  #handleFrameChannelControl(message) {
299
361
  if (!message || typeof message !== "object")
300
362
  return false;
@@ -573,7 +635,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
573
635
  for (const session of this.#sessionByHandle.values()) {
574
636
  before.set(session.id, session);
575
637
  }
576
- const sessions = await this.listSessions();
638
+ const [sessions] = await Promise.all([
639
+ this.listSessions(),
640
+ this.#refreshConfig(),
641
+ this.#reconcileRemoteSessions(),
642
+ ]);
577
643
  const known = new Set();
578
644
  for (const session of sessions) {
579
645
  known.add(session.id);
@@ -622,7 +688,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
622
688
  }
623
689
  catch (error) {
624
690
  if (!this.#disposed)
625
- console.error("[terminal-runtime] failed to resynchronize sessions after lost events", error);
691
+ console.error("[terminal-runtime] failed to resynchronize state after lost events", error);
626
692
  }
627
693
  }
628
694
  async listRemoteHosts() {
@@ -639,7 +705,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
639
705
  throw new Error("ghosttead returned an unexpected response");
640
706
  return response.sessions;
641
707
  }
642
- async openRemoteSession(deviceId, remoteSessionId, cols, rows) {
708
+ async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
643
709
  await this.connect();
644
710
  const response = await this.#control.request({
645
711
  type: "open-remote-session",
@@ -652,6 +718,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
652
718
  if (response.type !== "session-created")
653
719
  throw new Error("ghosttead could not open the remote session");
654
720
  this.registerSession(response.session);
721
+ // The session counts as remote from here, before any daemon event, so its
722
+ // input is never queued. A daemon on this minor reports `opening` until
723
+ // the first snapshot lands, so start there and let its events take over;
724
+ // an older daemon reports nothing at all, and seeding `opening` against it
725
+ // would block input forever. The sequence below is under every real event.
726
+ this.#remoteSessions.set(response.session.id, {
727
+ sessionId: response.session.id,
728
+ state: this.#remoteLifecycleSupported ? "opening" : "live",
729
+ reason: null,
730
+ exit: null,
731
+ lifecycleSeq: -1,
732
+ deviceId,
733
+ deviceName,
734
+ attempt: null,
735
+ nextRetryMs: null,
736
+ lastContactMs: null,
737
+ observedAt: performance.now(),
738
+ awaitingRecoveryFrame: false,
739
+ });
655
740
  return response.session;
656
741
  }
657
742
  async #refreshSession(sessionHandle) {
@@ -660,7 +745,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
660
745
  return;
661
746
  if (!this.#control)
662
747
  throw new Error(`Session ${sessionHandle} is not ready for frame resynchronization`);
663
- const response = await this.#control.request({ type: "refresh-session", sessionId: session.id });
748
+ // A refresh of a remote session re-attaches and resynchronizes over the
749
+ // network, so it needs the same budget as a one-shot reconnect.
750
+ const response = await this.#control.request({ type: "refresh-session", sessionId: session.id }, RECONNECT_REQUEST_TIMEOUT_MS);
664
751
  if (response.type !== "ok")
665
752
  throw new Error("ghosttead rejected frame resynchronization");
666
753
  }
@@ -707,8 +794,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
707
794
  desiredCols: undefined,
708
795
  desiredRows: undefined,
709
796
  pendingInput: [],
797
+ lastViewStateSeq: undefined,
798
+ lastAttachmentEpoch: undefined,
799
+ claimedEpoch: undefined,
800
+ claimedRevision: 0,
710
801
  };
711
802
  this.#views.set(viewId, view);
803
+ const remote = this.#remoteSessions.get(sessionId);
804
+ if (remote && remote.state !== "live")
805
+ this.#setCursorFrozen(sessionId, true);
712
806
  void subscription.ready
713
807
  .then(() => {
714
808
  const current = this.#views.get(viewId);
@@ -727,8 +821,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
727
821
  const current = this.#views.get(viewId);
728
822
  if (current !== view)
729
823
  return;
730
- current.attachmentEpoch = response.attachmentEpoch;
731
- current.readWrite = response.readWrite;
824
+ const applied = this.#applyViewState(viewId, {
825
+ ...(response.viewStateSeq !== undefined ? { viewStateSeq: response.viewStateSeq } : {}),
826
+ viewState: "attached",
827
+ attachmentEpoch: response.attachmentEpoch,
828
+ readWrite: response.readWrite,
829
+ error: null,
830
+ retryable: null,
831
+ });
832
+ if (!applied)
833
+ return;
732
834
  this.dispatchEvent(new CustomEvent("view-attached", {
733
835
  detail: { sessionId, sessionHandle, viewId, readWrite: response.readWrite },
734
836
  }));
@@ -782,17 +884,301 @@ export class GhostteaTerminalRuntime extends EventTarget {
782
884
  },
783
885
  };
784
886
  }
785
- #sendViewInput(viewId, operation) {
887
+ get remoteLifecycleSupported() {
888
+ return this.#remoteLifecycleSupported;
889
+ }
890
+ /** Last known lifecycle of a remote session; undefined for local sessions. */
891
+ remoteSession(sessionId) {
892
+ return this.#remoteSessions.get(sessionId);
893
+ }
894
+ #applyController(sessionId, controller, cols, rows, revision = 0) {
895
+ const previous = this.#controlBySession.get(sessionId);
896
+ // A revisioned announcement older than what is cached is a queued view of
897
+ // the past, and must be dropped whole. Keeping its payload while raising
898
+ // the revision would manufacture a state that never existed — "nobody
899
+ // holds control, as of the newest revision" — and that reads as an empty
900
+ // seat the funnel may claim. The compare-and-swap would not catch it
901
+ // either: the expectation would be genuinely current, so the host would
902
+ // accept a claim that takes control from the real holder.
903
+ if (revision >= 1 && previous !== undefined && revision < previous.revision)
904
+ return;
905
+ this.#controlBySession.set(sessionId, {
906
+ controller,
907
+ // A legacy `control-changed` carries no revision; keep the last one so a
908
+ // downgrade never looks like the controller was cleared at a newer one.
909
+ // Unrevisioned announcements cannot be ordered against each other, so
910
+ // they stay last-write-wins — the only semantics available for them.
911
+ revision: Math.max(revision, previous?.revision ?? 0),
912
+ });
913
+ for (const [viewId, view] of this.#views) {
914
+ if (view.sessionId !== sessionId)
915
+ continue;
916
+ if (!controller || controller.viewId !== viewId) {
917
+ view.controlEpoch = undefined;
918
+ continue;
919
+ }
920
+ view.controlEpoch = controller.controlEpoch;
921
+ if (view.desiredCols !== undefined &&
922
+ view.desiredRows !== undefined &&
923
+ (view.desiredCols !== cols || view.desiredRows !== rows)) {
924
+ this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
925
+ }
926
+ }
927
+ // A cleared controller is the one case worth re-evaluating: the pane that
928
+ // still holds focus may now take control back.
929
+ for (const viewId of this.#viewIdsForSession(sessionId))
930
+ this.#maybeReclaim(viewId);
931
+ }
932
+ *#viewIdsForSession(sessionId) {
933
+ for (const [viewId, view] of this.#views) {
934
+ if (view.sessionId === sessionId)
935
+ yield viewId;
936
+ }
937
+ }
938
+ /**
939
+ * The single funnel for taking resize control (§4.2.3). Every condition that
940
+ * gates a claim re-enters here when it changes, because no one event is
941
+ * enough: recovery marks a view attached before its session reaches live, and
942
+ * the focus setter suppresses repeat `true` updates, so a claim keyed on
943
+ * either alone would be skipped and never retried.
944
+ *
945
+ * At most one claim per attachment epoch, plus one more each time the
946
+ * controller is cleared at a newer revision.
947
+ *
948
+ * A host that reports real revisions gets a compare-and-swap, so a claim or
949
+ * a clear that landed since this client last looked rejects the attempt
950
+ * instead of silently overwriting it. The outcome is asymmetric and needs no
951
+ * loop of its own: the daemon announces the resulting controller state, this
952
+ * funnel re-runs on it, and another pane holding control simply fails the
953
+ * guard above while an empty seat at a newer revision passes the one below.
954
+ */
955
+ #maybeReclaim(viewId) {
786
956
  const view = this.#views.get(viewId);
787
957
  if (!view || view.readWrite === false)
788
958
  return;
789
- if (view.attachmentEpoch === undefined) {
959
+ const attachmentEpoch = view.attachmentEpoch;
960
+ if (attachmentEpoch === undefined)
961
+ return;
962
+ if (view.desiredCols === undefined || view.desiredRows === undefined)
963
+ return;
964
+ if (this.#focusByView.get(viewId) !== true)
965
+ return;
966
+ const remote = this.#remoteSessions.get(view.sessionId);
967
+ if (remote && (remote.state !== "live" || remote.awaitingRecoveryFrame))
968
+ return;
969
+ const control = this.#controlBySession.get(view.sessionId);
970
+ const controller = control?.controller ?? null;
971
+ // Never take control from another pane. Our own name on the record is not
972
+ // a reason to stop: after a resume it is the previous incarnation's, and
973
+ // the new attachment epoch below is what decides.
974
+ if (controller && controller.viewId !== viewId)
975
+ return;
976
+ const revision = control?.revision ?? 0;
977
+ // One claim per attachment epoch. A controller *cleared* at a newer
978
+ // revision earns one more; the seat being confirmed as ours does not.
979
+ if (view.claimedEpoch === attachmentEpoch && (controller !== null || view.claimedRevision >= revision))
980
+ return;
981
+ const session = this.#sessionByHandle.get(view.sessionHandle);
982
+ if (!session)
983
+ return;
984
+ view.claimedEpoch = attachmentEpoch;
985
+ view.claimedRevision = revision;
986
+ this.#control?.notify({
987
+ type: "focus-and-resize",
988
+ sessionId: session.id,
989
+ viewId,
990
+ attachmentEpoch,
991
+ cols: view.desiredCols,
992
+ rows: view.desiredRows,
993
+ // Revision 0 is the "legacy, unknown" sentinel, never a real observation
994
+ // to swap against: a host without revisions only understands the
995
+ // unconditional claim, so the field stays off. The negotiated minor is
996
+ // checked too, because a cached revision could outlive a daemon
997
+ // downgrade and a daemon that predates the field would ignore it —
998
+ // turning a swap the caller believes in into a silent overwrite.
999
+ ...(this.#controlRevisionCasSupported && revision >= 1 ? { expectedControlRevision: revision } : {}),
1000
+ });
1001
+ }
1002
+ /**
1003
+ * The only path that mutates per-view attachment state, whether the update
1004
+ * arrived as an event, a reconciliation, or an attach response. A sequence
1005
+ * at or below the last applied one is a delayed transition from an abandoned
1006
+ * attempt and is dropped; an update without a sequence comes from a daemon
1007
+ * older than the fence and applies directly.
1008
+ */
1009
+ #applyViewState(viewId, update) {
1010
+ const view = this.#views.get(viewId);
1011
+ if (!view)
1012
+ return false;
1013
+ if (update.viewStateSeq !== undefined) {
1014
+ if (view.lastViewStateSeq !== undefined && update.viewStateSeq <= view.lastViewStateSeq)
1015
+ return false;
1016
+ view.lastViewStateSeq = update.viewStateSeq;
1017
+ }
1018
+ const remote = this.#remoteSessions.has(view.sessionId);
1019
+ if (update.viewState === "attached" && update.attachmentEpoch !== null) {
1020
+ // Nothing typed against the previous incarnation may ride out on a
1021
+ // freshly armed epoch.
1022
+ if (remote)
1023
+ view.pendingInput.length = 0;
1024
+ view.attachmentEpoch = update.attachmentEpoch;
1025
+ view.lastAttachmentEpoch = update.attachmentEpoch;
1026
+ if (update.readWrite !== null)
1027
+ view.readWrite = update.readWrite;
1028
+ this.#maybeReclaim(viewId);
1029
+ return true;
1030
+ }
1031
+ view.pendingInput.length = 0;
1032
+ view.attachmentEpoch = undefined;
1033
+ return true;
1034
+ }
1035
+ #applyRemoteSessionState(sessionId, lifecycle, authoritative) {
1036
+ const previous = this.#remoteSessions.get(sessionId);
1037
+ if (previous) {
1038
+ // A reconciliation is the source of truth and may restate its sequence;
1039
+ // a pushed event at a sequence already seen is stale.
1040
+ const stale = authoritative
1041
+ ? lifecycle.lifecycleSeq < previous.lifecycleSeq
1042
+ : lifecycle.lifecycleSeq <= previous.lifecycleSeq;
1043
+ if (stale)
1044
+ return false;
1045
+ }
1046
+ // Lifecycle events and frames travel independent channels, so a recovery
1047
+ // snapshot can commit either side of the live transition. An episode's
1048
+ // latch is cleared when it begins and set by any full snapshot, which lets
1049
+ // both arrival orders resolve without either one having to come first.
1050
+ if (showsStaleScreen(lifecycle.state) && (previous === undefined || !showsStaleScreen(previous.state))) {
1051
+ this.#recoveredSessions.delete(sessionId);
1052
+ }
1053
+ // Coming back from a frozen state, the screen on display is still the one
1054
+ // from before the outage. Keep it marked stale until the recovered stream
1055
+ // has committed a full frame; `opening` never had a screen to distrust.
1056
+ const awaitingRecoveryFrame = lifecycle.state === "live" &&
1057
+ previous !== undefined &&
1058
+ (previous.awaitingRecoveryFrame || showsStaleScreen(previous.state)) &&
1059
+ !this.#recoveredSessions.has(sessionId);
1060
+ const state = {
1061
+ ...lifecycle,
1062
+ sessionId,
1063
+ observedAt: performance.now(),
1064
+ awaitingRecoveryFrame,
1065
+ };
1066
+ this.#remoteSessions.set(sessionId, state);
1067
+ if (state.state !== "live") {
1068
+ for (const view of this.#views.values()) {
1069
+ if (view.sessionId === sessionId)
1070
+ view.pendingInput.length = 0;
1071
+ }
1072
+ }
1073
+ this.#setCursorFrozen(sessionId, sessionIsFrozen(state));
1074
+ this.dispatchEvent(new CustomEvent("remote-session-state", { detail: state }));
1075
+ for (const viewId of this.#viewIdsForSession(sessionId))
1076
+ this.#maybeReclaim(viewId);
1077
+ return true;
1078
+ }
1079
+ /**
1080
+ * A committed frame is only evidence of recovery if it carries a whole
1081
+ * screen: a partial update repaints part of the stale one, which is exactly
1082
+ * what would thaw a pane too early.
1083
+ */
1084
+ #recordCommittedFrame(sessionHandle, fullSnapshot) {
1085
+ if (!fullSnapshot)
1086
+ return;
1087
+ const sessionId = this.#sessionByHandle.get(sessionHandle)?.id;
1088
+ if (sessionId === undefined || !this.#remoteSessions.has(sessionId))
1089
+ return;
1090
+ this.#recoveredSessions.add(sessionId);
1091
+ this.#thawRecoveredSession(sessionId);
1092
+ }
1093
+ /** What the pane shows is current again, so stop marking it stale. */
1094
+ #thawRecoveredSession(sessionId) {
1095
+ const state = this.#remoteSessions.get(sessionId);
1096
+ if (!state || !state.awaitingRecoveryFrame || !this.#recoveredSessions.has(sessionId))
1097
+ return;
1098
+ const thawed = { ...state, awaitingRecoveryFrame: false };
1099
+ this.#remoteSessions.set(sessionId, thawed);
1100
+ this.#setCursorFrozen(sessionId, sessionIsFrozen(thawed));
1101
+ this.dispatchEvent(new CustomEvent("remote-session-state", { detail: thawed }));
1102
+ for (const viewId of this.#viewIdsForSession(sessionId))
1103
+ this.#maybeReclaim(viewId);
1104
+ }
1105
+ /** Hold the cursor steady while the replica is frozen, without disturbing focus. */
1106
+ #setCursorFrozen(sessionId, frozen) {
1107
+ for (const [viewId, view] of this.#views) {
1108
+ if (view.sessionId === sessionId)
1109
+ this.#postWorker({ type: "cursor-frozen", surfaceId: viewId, frozen });
1110
+ }
1111
+ }
1112
+ async #remoteSessionStateRequest(command, timeoutMs) {
1113
+ await this.connect();
1114
+ if (!this.#remoteLifecycleSupported)
1115
+ return undefined;
1116
+ const response = await this.#control.request(command, timeoutMs);
1117
+ if (response.type !== "remote-session-state") {
1118
+ throw new Error("ghosttead returned an unexpected remote session state");
1119
+ }
1120
+ this.#applyRemoteSessionState(command.sessionId, response, true);
1121
+ this.#applyController(command.sessionId, response.controller, response.cols, response.rows, response.controlRevision);
1122
+ for (const view of response.views)
1123
+ this.#applyViewState(view.viewId, view);
1124
+ return this.#remoteSessions.get(command.sessionId);
1125
+ }
1126
+ /** Rebuild this client's state for one remote session from the daemon. */
1127
+ async getRemoteSessionState(sessionId) {
1128
+ return this.#remoteSessionStateRequest({ type: "get-remote-session-state", sessionId }, 10_000);
1129
+ }
1130
+ async reconnectRemoteSession(sessionId) {
1131
+ return this.#remoteSessionStateRequest({ type: "reconnect-remote-session", sessionId }, RECONNECT_REQUEST_TIMEOUT_MS);
1132
+ }
1133
+ async retryRemoteView(sessionId, viewId) {
1134
+ await this.connect();
1135
+ if (!this.#remoteLifecycleSupported)
1136
+ return;
1137
+ const response = await this.#control.request({ type: "retry-remote-view", sessionId, viewId }, RECONNECT_REQUEST_TIMEOUT_MS);
1138
+ if (response.type !== "view-state")
1139
+ throw new Error("ghosttead returned an unexpected view state");
1140
+ this.#applyViewState(response.viewId, response);
1141
+ }
1142
+ #reconcileRemoteSessions() {
1143
+ if (!this.#remoteLifecycleSupported)
1144
+ return Promise.resolve();
1145
+ return Promise.all([...this.#remoteSessions.keys()].map((sessionId) => this.getRemoteSessionState(sessionId).catch((error) => {
1146
+ if (!this.#disposed) {
1147
+ console.warn(`[terminal-runtime] could not reconcile remote session ${sessionId}`, error);
1148
+ }
1149
+ })));
1150
+ }
1151
+ /**
1152
+ * `silent` marks operations that drop without the keystroke hint: focus and
1153
+ * geometry updates are cosmetic, and pointer gestures are already answered
1154
+ * by the pane's frozen treatment.
1155
+ */
1156
+ #sendViewInput(viewId, operation, silent = false) {
1157
+ const view = this.#views.get(viewId);
1158
+ if (!view || view.readWrite === false)
1159
+ return;
1160
+ const remote = this.#remoteSessions.get(view.sessionId);
1161
+ const attachmentEpoch = view.attachmentEpoch;
1162
+ // Input for a remote session is dropped with feedback rather than queued:
1163
+ // replaying a keystroke across an outage would deliver it to a screen the
1164
+ // user has never seen. The queue below survives only for a local session's
1165
+ // mount-to-attach gap, which is a local IPC round-trip.
1166
+ if (remote && (remote.state !== "live" || attachmentEpoch === undefined)) {
1167
+ view.pendingInput.length = 0;
1168
+ if (!silent)
1169
+ this.#reportSuppressedInput(view.sessionId, viewId, remote.state);
1170
+ return;
1171
+ }
1172
+ if (attachmentEpoch === undefined) {
790
1173
  if (view.pendingInput.length < 256)
791
1174
  view.pendingInput.push(operation);
792
1175
  return;
793
1176
  }
794
1177
  view.inputSequence += 1;
795
- operation(view.attachmentEpoch, view.inputSequence);
1178
+ operation(attachmentEpoch, view.inputSequence);
1179
+ }
1180
+ #reportSuppressedInput(sessionId, viewId, state) {
1181
+ this.dispatchEvent(new CustomEvent("input-suppressed", { detail: { sessionId, viewId, state } }));
796
1182
  }
797
1183
  sendText(sessionId, viewId, text) {
798
1184
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
@@ -813,17 +1199,18 @@ export class GhostteaTerminalRuntime extends EventTarget {
813
1199
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
814
1200
  }
815
1201
  sendMouse(sessionId, viewId, event) {
816
- this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }));
1202
+ this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-mouse", sessionId, viewId, attachmentEpoch, inputSequence, event }), true);
817
1203
  }
818
1204
  scroll(sessionId, viewId, rows) {
819
1205
  if (rows === 0)
820
1206
  return;
821
- this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }));
1207
+ // Scrolling is host-side input, so it is simply inert while frozen.
1208
+ this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll", sessionId, viewId, attachmentEpoch, inputSequence, rows }), true);
822
1209
  }
823
1210
  scrollTo(sessionId, viewId, row) {
824
1211
  if (!Number.isSafeInteger(row) || row < 0)
825
1212
  return;
826
- this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }));
1213
+ this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "scroll-to", sessionId, viewId, attachmentEpoch, inputSequence, row }), true);
827
1214
  }
828
1215
  scrollbar(sessionHandle) {
829
1216
  return this.#scrollbarByHandle.get(sessionHandle);
@@ -849,6 +1236,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
849
1236
  cursor: rgb(theme.cursor),
850
1237
  });
851
1238
  }
1239
+ setEffects(sessionHandle, effects, surfaceId) {
1240
+ this.#postWorker({ type: "effects", sessionHandle, ...(surfaceId ? { surfaceId } : {}), effects });
1241
+ }
852
1242
  setSelection(sessionHandle, selection, surfaceId) {
853
1243
  this.#postWorker({ type: "selection", sessionHandle, ...(surfaceId ? { surfaceId } : {}), selection });
854
1244
  }
@@ -869,6 +1259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
869
1259
  if (view) {
870
1260
  view.desiredCols = cols;
871
1261
  view.desiredRows = rows;
1262
+ // An explicit claim is the funnel's outcome, not a competing path.
1263
+ view.claimedEpoch = view.attachmentEpoch;
1264
+ view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
872
1265
  }
873
1266
  const session = this.#sessionByHandle.get(sessionHandle);
874
1267
  if (!session)
@@ -882,7 +1275,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
882
1275
  cols,
883
1276
  rows,
884
1277
  });
885
- });
1278
+ }, true);
886
1279
  }
887
1280
  setFocused(sessionHandle, viewId, focused, cols, rows) {
888
1281
  const view = this.#views.get(viewId);
@@ -890,8 +1283,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
890
1283
  view.desiredCols = cols;
891
1284
  view.desiredRows = rows;
892
1285
  }
893
- if (this.#focusByView.get(viewId) === focused)
1286
+ if (this.#focusByView.get(viewId) === focused) {
1287
+ // Focus has not moved, so the claim below will not run — but an epoch or
1288
+ // controller change since the last update may have made one possible,
1289
+ // and nothing else would ever retry it after a resume.
1290
+ this.#maybeReclaim(viewId);
894
1291
  return;
1292
+ }
895
1293
  this.#focusByView.set(viewId, focused);
896
1294
  this.#postWorker({ type: "focus", surfaceId: viewId, sessionHandle, focused });
897
1295
  const session = this.#sessionByHandle.get(sessionHandle);
@@ -907,6 +1305,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
907
1305
  focused,
908
1306
  });
909
1307
  if (focused) {
1308
+ // Taking focus is a deliberate claim, and counts as this epoch's.
1309
+ if (view) {
1310
+ view.claimedEpoch = attachmentEpoch;
1311
+ view.claimedRevision = this.#controlBySession.get(view.sessionId)?.revision ?? 0;
1312
+ }
910
1313
  this.#control?.notify({
911
1314
  type: "focus-and-resize",
912
1315
  sessionId: session.id,
@@ -916,18 +1319,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
916
1319
  rows,
917
1320
  });
918
1321
  }
919
- });
1322
+ }, true);
920
1323
  }
921
1324
  async copySelection(sessionId, viewId, selection, selectAll = false) {
922
1325
  await this.connect();
923
1326
  const view = this.#views.get(viewId);
924
- if (!view || view.sessionId !== sessionId || view.attachmentEpoch === undefined)
1327
+ if (!view || view.sessionId !== sessionId)
1328
+ return "";
1329
+ // A frozen replica stays copyable: offline the daemon answers from the
1330
+ // retained snapshot and authorizes by ownership, so the last epoch this
1331
+ // view held is enough to name the attachment.
1332
+ const attachmentEpoch = view.attachmentEpoch ?? view.lastAttachmentEpoch;
1333
+ if (attachmentEpoch === undefined)
925
1334
  return "";
926
1335
  const response = await this.#control.request({
927
1336
  type: "selection-text",
928
1337
  sessionId,
929
1338
  viewId,
930
- attachmentEpoch: view.attachmentEpoch,
1339
+ attachmentEpoch,
931
1340
  startColumn: selection.anchor.column,
932
1341
  startRow: selection.anchor.row,
933
1342
  endColumn: selection.focus.column,
@@ -936,6 +1345,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
936
1345
  });
937
1346
  if (response.type !== "selection-text")
938
1347
  throw new Error("ghosttead returned an unexpected selection response");
1348
+ // Offline the daemon can only reach the retained screen, so say so rather
1349
+ // than letting a short copy read as the whole scrollback.
1350
+ if (response.scope !== undefined) {
1351
+ this.dispatchEvent(new CustomEvent("selection-scope", {
1352
+ detail: { sessionId, viewId, scope: response.scope },
1353
+ }));
1354
+ }
939
1355
  if (response.text)
940
1356
  this.#platform.writeClipboard(response.text);
941
1357
  return response.text;
@@ -990,6 +1406,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
990
1406
  }
991
1407
  this.#sessionByHandle.delete(handle);
992
1408
  this.#handleBySessionId.delete(sessionId);
1409
+ this.#remoteSessions.delete(sessionId);
1410
+ this.#controlBySession.delete(sessionId);
1411
+ this.#recoveredSessions.delete(sessionId);
993
1412
  if (subscriptionChanged) {
994
1413
  this.#frameSubscriptionVersion += 1;
995
1414
  this.#syncFrameSubscriptionsInBackground();
@@ -1010,8 +1429,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
1010
1429
  return;
1011
1430
  view.desiredCols = cols;
1012
1431
  view.desiredRows = rows;
1013
- if (view.attachmentEpoch === undefined || view.controlEpoch === undefined)
1432
+ if (view.attachmentEpoch === undefined || view.controlEpoch === undefined) {
1433
+ // Dimensions are one of the funnel's conditions: a pane that measured
1434
+ // itself while uncontrolled may now be able to take control.
1435
+ this.#maybeReclaim(viewId);
1014
1436
  return;
1437
+ }
1015
1438
  this.#sendResize(viewId, view, cols, rows);
1016
1439
  }
1017
1440
  dispose() {
@@ -1047,6 +1470,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
1047
1470
  }
1048
1471
  this.#performanceRequests.clear();
1049
1472
  this.#views.clear();
1473
+ this.#remoteSessions.clear();
1474
+ this.#controlBySession.clear();
1475
+ this.#recoveredSessions.clear();
1050
1476
  this.#focusByView.clear();
1051
1477
  this.#mountGenerationBySurface.clear();
1052
1478
  this.#mouseTrackingByHandle.clear();