@vibecook/ghosttea-react 0.6.1 → 0.7.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 (64) 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 +526 -30
  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.map +1 -1
  53. package/dist/workspace/Workspace.js +96 -18
  54. package/dist/workspace/Workspace.js.map +1 -1
  55. package/dist/workspace/index.d.ts +2 -0
  56. package/dist/workspace/index.d.ts.map +1 -1
  57. package/dist/workspace/index.js +2 -0
  58. package/dist/workspace/index.js.map +1 -1
  59. package/dist/workspace/remote-banner.d.ts +36 -0
  60. package/dist/workspace/remote-banner.d.ts.map +1 -0
  61. package/dist/workspace/remote-banner.js +108 -0
  62. package/dist/workspace/remote-banner.js.map +1 -0
  63. package/dist/workspace.css +93 -0
  64. 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 }));
@@ -210,6 +234,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
210
234
  this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
211
235
  this.dispatchEvent(new CustomEvent("session-exited", { detail }));
212
236
  });
237
+ this.#control.addEventListener("events-lost", () => {
238
+ // The daemon dropped events faster than this client drained them. Any
239
+ // of them could have been a session-exited, so reconcile against the
240
+ // daemon's authoritative session list.
241
+ void this.#resyncAfterLostEvents();
242
+ });
213
243
  this.#control.addEventListener("session-activity-changed", (event) => {
214
244
  const detail = event.detail;
215
245
  const handle = this.#handleBySessionId.get(detail.sessionId);
@@ -223,20 +253,23 @@ export class GhostteaTerminalRuntime extends EventTarget {
223
253
  });
224
254
  this.#control.addEventListener("control-changed", (event) => {
225
255
  const detail = event.detail;
226
- for (const [viewId, view] of this.#views) {
227
- if (view.sessionId !== detail.sessionId)
228
- continue;
229
- if (viewId !== detail.controllerViewId) {
230
- view.controlEpoch = undefined;
231
- continue;
232
- }
233
- view.controlEpoch = detail.controlEpoch;
234
- if (view.desiredCols !== undefined &&
235
- view.desiredRows !== undefined &&
236
- (view.desiredCols !== detail.cols || view.desiredRows !== detail.rows)) {
237
- this.#sendResize(viewId, view, view.desiredCols, view.desiredRows);
238
- }
239
- }
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);
240
273
  });
241
274
  this.#frames = ports.frames;
242
275
  this.#frames.onmessage = ({ data }) => {
@@ -286,9 +319,44 @@ export class GhostteaTerminalRuntime extends EventTarget {
286
319
  if (hello.type !== "hello" || hello.protocolMajor !== PROTOCOL_MAJOR)
287
320
  throw new Error("ghosttead protocol mismatch");
288
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
+ }
289
328
  await this.#queueFrameSubscriptionSync();
290
329
  console.info("[terminal-runtime] authenticated ghosttead protocol");
291
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
+ }
292
360
  #handleFrameChannelControl(message) {
293
361
  if (!message || typeof message !== "object")
294
362
  return false;
@@ -555,6 +623,74 @@ export class GhostteaTerminalRuntime extends EventTarget {
555
623
  }
556
624
  return response.sessions;
557
625
  }
626
+ /**
627
+ * Reconcile tracked sessions against the daemon's authoritative list after
628
+ * an events-lost notice. A session still listed but now exited had its exit
629
+ * event lost; a session absent from the list entirely exited and left the
630
+ * registry, so its exit details are gone and are reported as unknown.
631
+ */
632
+ async #resyncAfterLostEvents() {
633
+ try {
634
+ const before = new Map();
635
+ for (const session of this.#sessionByHandle.values()) {
636
+ before.set(session.id, session);
637
+ }
638
+ const [sessions] = await Promise.all([
639
+ this.listSessions(),
640
+ this.#refreshConfig(),
641
+ this.#reconcileRemoteSessions(),
642
+ ]);
643
+ const known = new Set();
644
+ for (const session of sessions) {
645
+ known.add(session.id);
646
+ const previous = before.get(session.id);
647
+ if (!previous || previous.exited || !session.exited)
648
+ continue;
649
+ this.#cancelMetadataRefresh(session.handle);
650
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: session }));
651
+ this.dispatchEvent(new CustomEvent("session-exited", {
652
+ detail: {
653
+ requestId: 0,
654
+ type: "session-exited",
655
+ sessionId: session.id,
656
+ exitCode: session.exitCode,
657
+ exitSignal: session.exitSignal,
658
+ requestedTermination: session.requestedTermination,
659
+ exitOutcome: session.exitOutcome ?? "unknown",
660
+ },
661
+ }));
662
+ }
663
+ for (const [handle, session] of this.#sessionByHandle) {
664
+ if (session.exited || known.has(session.id))
665
+ continue;
666
+ const detail = {
667
+ requestId: 0,
668
+ type: "session-exited",
669
+ sessionId: session.id,
670
+ exitCode: null,
671
+ exitSignal: null,
672
+ requestedTermination: null,
673
+ exitOutcome: "unknown",
674
+ };
675
+ this.#cancelMetadataRefresh(handle);
676
+ const exited = {
677
+ ...session,
678
+ exited: true,
679
+ exitCode: null,
680
+ exitSignal: null,
681
+ requestedTermination: null,
682
+ exitOutcome: "unknown",
683
+ };
684
+ this.#sessionByHandle.set(handle, exited);
685
+ this.dispatchEvent(new CustomEvent("session-metadata", { detail: exited }));
686
+ this.dispatchEvent(new CustomEvent("session-exited", { detail }));
687
+ }
688
+ }
689
+ catch (error) {
690
+ if (!this.#disposed)
691
+ console.error("[terminal-runtime] failed to resynchronize state after lost events", error);
692
+ }
693
+ }
558
694
  async listRemoteHosts() {
559
695
  await this.connect();
560
696
  const response = await this.#control.request({ type: "list-remote-hosts" });
@@ -569,7 +705,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
569
705
  throw new Error("ghosttead returned an unexpected response");
570
706
  return response.sessions;
571
707
  }
572
- async openRemoteSession(deviceId, remoteSessionId, cols, rows) {
708
+ async openRemoteSession(deviceId, remoteSessionId, cols, rows, deviceName = deviceId) {
573
709
  await this.connect();
574
710
  const response = await this.#control.request({
575
711
  type: "open-remote-session",
@@ -582,6 +718,25 @@ export class GhostteaTerminalRuntime extends EventTarget {
582
718
  if (response.type !== "session-created")
583
719
  throw new Error("ghosttead could not open the remote session");
584
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
+ });
585
740
  return response.session;
586
741
  }
587
742
  async #refreshSession(sessionHandle) {
@@ -590,7 +745,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
590
745
  return;
591
746
  if (!this.#control)
592
747
  throw new Error(`Session ${sessionHandle} is not ready for frame resynchronization`);
593
- 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);
594
751
  if (response.type !== "ok")
595
752
  throw new Error("ghosttead rejected frame resynchronization");
596
753
  }
@@ -637,8 +794,15 @@ export class GhostteaTerminalRuntime extends EventTarget {
637
794
  desiredCols: undefined,
638
795
  desiredRows: undefined,
639
796
  pendingInput: [],
797
+ lastViewStateSeq: undefined,
798
+ lastAttachmentEpoch: undefined,
799
+ claimedEpoch: undefined,
800
+ claimedRevision: 0,
640
801
  };
641
802
  this.#views.set(viewId, view);
803
+ const remote = this.#remoteSessions.get(sessionId);
804
+ if (remote && remote.state !== "live")
805
+ this.#setCursorFrozen(sessionId, true);
642
806
  void subscription.ready
643
807
  .then(() => {
644
808
  const current = this.#views.get(viewId);
@@ -657,8 +821,16 @@ export class GhostteaTerminalRuntime extends EventTarget {
657
821
  const current = this.#views.get(viewId);
658
822
  if (current !== view)
659
823
  return;
660
- current.attachmentEpoch = response.attachmentEpoch;
661
- 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;
662
834
  this.dispatchEvent(new CustomEvent("view-attached", {
663
835
  detail: { sessionId, sessionHandle, viewId, readWrite: response.readWrite },
664
836
  }));
@@ -712,17 +884,301 @@ export class GhostteaTerminalRuntime extends EventTarget {
712
884
  },
713
885
  };
714
886
  }
715
- #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) {
956
+ const view = this.#views.get(viewId);
957
+ if (!view || view.readWrite === false)
958
+ return;
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) {
716
1157
  const view = this.#views.get(viewId);
717
1158
  if (!view || view.readWrite === false)
718
1159
  return;
719
- if (view.attachmentEpoch === undefined) {
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) {
720
1173
  if (view.pendingInput.length < 256)
721
1174
  view.pendingInput.push(operation);
722
1175
  return;
723
1176
  }
724
1177
  view.inputSequence += 1;
725
- 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 } }));
726
1182
  }
727
1183
  sendText(sessionId, viewId, text) {
728
1184
  this.#sendViewInput(viewId, (attachmentEpoch, inputSequence) => this.#control?.notify({ type: "send-text", sessionId, viewId, attachmentEpoch, inputSequence, text }));
@@ -743,17 +1199,18 @@ export class GhostteaTerminalRuntime extends EventTarget {
743
1199
  this.#postWorker({ type: "cursor-activity", sessionHandle: handle });
744
1200
  }
745
1201
  sendMouse(sessionId, viewId, event) {
746
- 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);
747
1203
  }
748
1204
  scroll(sessionId, viewId, rows) {
749
1205
  if (rows === 0)
750
1206
  return;
751
- 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);
752
1209
  }
753
1210
  scrollTo(sessionId, viewId, row) {
754
1211
  if (!Number.isSafeInteger(row) || row < 0)
755
1212
  return;
756
- 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);
757
1214
  }
758
1215
  scrollbar(sessionHandle) {
759
1216
  return this.#scrollbarByHandle.get(sessionHandle);
@@ -779,6 +1236,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
779
1236
  cursor: rgb(theme.cursor),
780
1237
  });
781
1238
  }
1239
+ setEffects(sessionHandle, effects, surfaceId) {
1240
+ this.#postWorker({ type: "effects", sessionHandle, ...(surfaceId ? { surfaceId } : {}), effects });
1241
+ }
782
1242
  setSelection(sessionHandle, selection, surfaceId) {
783
1243
  this.#postWorker({ type: "selection", sessionHandle, ...(surfaceId ? { surfaceId } : {}), selection });
784
1244
  }
@@ -799,6 +1259,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
799
1259
  if (view) {
800
1260
  view.desiredCols = cols;
801
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;
802
1265
  }
803
1266
  const session = this.#sessionByHandle.get(sessionHandle);
804
1267
  if (!session)
@@ -812,7 +1275,7 @@ export class GhostteaTerminalRuntime extends EventTarget {
812
1275
  cols,
813
1276
  rows,
814
1277
  });
815
- });
1278
+ }, true);
816
1279
  }
817
1280
  setFocused(sessionHandle, viewId, focused, cols, rows) {
818
1281
  const view = this.#views.get(viewId);
@@ -820,8 +1283,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
820
1283
  view.desiredCols = cols;
821
1284
  view.desiredRows = rows;
822
1285
  }
823
- 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);
824
1291
  return;
1292
+ }
825
1293
  this.#focusByView.set(viewId, focused);
826
1294
  this.#postWorker({ type: "focus", surfaceId: viewId, sessionHandle, focused });
827
1295
  const session = this.#sessionByHandle.get(sessionHandle);
@@ -837,6 +1305,11 @@ export class GhostteaTerminalRuntime extends EventTarget {
837
1305
  focused,
838
1306
  });
839
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
+ }
840
1313
  this.#control?.notify({
841
1314
  type: "focus-and-resize",
842
1315
  sessionId: session.id,
@@ -846,18 +1319,24 @@ export class GhostteaTerminalRuntime extends EventTarget {
846
1319
  rows,
847
1320
  });
848
1321
  }
849
- });
1322
+ }, true);
850
1323
  }
851
1324
  async copySelection(sessionId, viewId, selection, selectAll = false) {
852
1325
  await this.connect();
853
1326
  const view = this.#views.get(viewId);
854
- 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)
855
1334
  return "";
856
1335
  const response = await this.#control.request({
857
1336
  type: "selection-text",
858
1337
  sessionId,
859
1338
  viewId,
860
- attachmentEpoch: view.attachmentEpoch,
1339
+ attachmentEpoch,
861
1340
  startColumn: selection.anchor.column,
862
1341
  startRow: selection.anchor.row,
863
1342
  endColumn: selection.focus.column,
@@ -866,6 +1345,13 @@ export class GhostteaTerminalRuntime extends EventTarget {
866
1345
  });
867
1346
  if (response.type !== "selection-text")
868
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
+ }
869
1355
  if (response.text)
870
1356
  this.#platform.writeClipboard(response.text);
871
1357
  return response.text;
@@ -920,6 +1406,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
920
1406
  }
921
1407
  this.#sessionByHandle.delete(handle);
922
1408
  this.#handleBySessionId.delete(sessionId);
1409
+ this.#remoteSessions.delete(sessionId);
1410
+ this.#controlBySession.delete(sessionId);
1411
+ this.#recoveredSessions.delete(sessionId);
923
1412
  if (subscriptionChanged) {
924
1413
  this.#frameSubscriptionVersion += 1;
925
1414
  this.#syncFrameSubscriptionsInBackground();
@@ -940,8 +1429,12 @@ export class GhostteaTerminalRuntime extends EventTarget {
940
1429
  return;
941
1430
  view.desiredCols = cols;
942
1431
  view.desiredRows = rows;
943
- 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);
944
1436
  return;
1437
+ }
945
1438
  this.#sendResize(viewId, view, cols, rows);
946
1439
  }
947
1440
  dispose() {
@@ -977,6 +1470,9 @@ export class GhostteaTerminalRuntime extends EventTarget {
977
1470
  }
978
1471
  this.#performanceRequests.clear();
979
1472
  this.#views.clear();
1473
+ this.#remoteSessions.clear();
1474
+ this.#controlBySession.clear();
1475
+ this.#recoveredSessions.clear();
980
1476
  this.#focusByView.clear();
981
1477
  this.#mountGenerationBySurface.clear();
982
1478
  this.#mouseTrackingByHandle.clear();