tmux-ide 2.9.10 → 2.9.12

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 (81) hide show
  1. package/bin/cli.js +4321 -2596
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +4 -0
  4. package/packages/contracts/src/pane-stream.ts +72 -0
  5. package/packages/contracts/src/terminal-delivery-binary-chunk.ts +55 -0
  6. package/packages/contracts/src/terminal-delivery.ts +17 -2
  7. package/packages/contracts/src/terminal-graphics.ts +68 -0
  8. package/packages/contracts/src/terminal-projection.ts +153 -0
  9. package/packages/core/src/fixtures/terminal-projection-vectors.json +585 -0
  10. package/packages/core/src/index.ts +2 -0
  11. package/packages/core/src/terminal-delivery-binary.ts +160 -0
  12. package/packages/core/src/terminal-delivery.ts +371 -16
  13. package/packages/core/src/terminal-projection-vectors.mjs +82 -0
  14. package/packages/core/src/terminal-projection.ts +476 -0
  15. package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
  16. package/packages/core/src/terminal-replica.ts +1 -1
  17. package/packages/daemon/dist/lib/daemon-embed.js +39 -3
  18. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
  19. package/packages/daemon/dist/lib/tmux-server-owner.js +1 -0
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  21. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  22. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  23. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  24. package/packages/daemon/dist/terminal/graphics/local-image-owner.js +330 -0
  25. package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
  26. package/packages/daemon/dist/terminal/mirror/mirror-service.js +12 -2
  27. package/packages/daemon/dist/terminal/mirror/session-channel.js +46 -23
  28. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +190 -19
  29. package/packages/daemon/dist/terminal/pane-stream/runtime.js +1 -0
  30. package/packages/daemon/dist/terminal/session-runtime/registry.js +19 -6
  31. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
  32. package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
  33. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
  34. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +5 -0
  35. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
  36. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
  37. package/packages/daemon/dist/tui/mirror/runtime/application-generation-starter.js +16 -1
  38. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agent-navigation.js +13 -0
  39. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +12 -4
  40. package/packages/daemon/dist/tui/mirror/runtime/application-retained-agent.js +18 -0
  41. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +90 -5
  42. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
  43. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
  44. package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
  45. package/packages/daemon/dist/tui/mirror/runtime/runtime-layout-presentation.js +6 -3
  46. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +7 -2
  47. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -0
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
  49. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
  50. package/packages/daemon/src/lib/daemon-embed.ts +38 -2
  51. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
  52. package/packages/daemon/src/lib/tmux-server-owner.ts +3 -0
  53. package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
  54. package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -0
  55. package/packages/daemon/src/terminal/mirror/mirror-service.ts +19 -2
  56. package/packages/daemon/src/terminal/mirror/session-channel.ts +57 -9
  57. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +248 -16
  58. package/packages/daemon/src/terminal/pane-stream/runtime.ts +3 -0
  59. package/packages/daemon/src/terminal/session-runtime/registry.ts +25 -3
  60. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
  61. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
  62. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
  63. package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
  64. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
  65. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +6 -0
  66. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +3 -0
  67. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +6 -0
  68. package/packages/daemon/src/tui/mirror/runtime/application-generation-starter.ts +17 -1
  69. package/packages/daemon/src/tui/mirror/runtime/application-machine-agent-navigation.ts +15 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +18 -4
  71. package/packages/daemon/src/tui/mirror/runtime/application-retained-agent.ts +29 -0
  72. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +89 -6
  73. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
  74. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
  75. package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
  76. package/packages/daemon/src/tui/mirror/runtime/runtime-layout-presentation.ts +7 -4
  77. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +9 -1
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +15 -0
  79. package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
  80. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
  81. package/packages/daemon-client/src/pane-stream-client.ts +92 -1
@@ -1,3 +1,5 @@
1
+ import { rendererProbe } from "./renderer-probe.js";
2
+ import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.js";
1
3
  import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
2
4
  import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.js";
3
5
  import { createHash, randomUUID } from "node:crypto";
@@ -117,6 +119,10 @@ export function createApplicationTerminalInteractionController(options) {
117
119
  left.generation === right.generation &&
118
120
  left.workspace === right.workspace;
119
121
  let selectedControl = null;
122
+ const selectionWaiter = createTerminalSelectionWaiter();
123
+ let observedLayoutEpoch = null;
124
+ let selectionGeneration = null;
125
+ let selectionLayout = options.layout();
120
126
  const waitingInput = new Set();
121
127
  let waitingInputBytes = 0;
122
128
  let releasingInput = null;
@@ -522,8 +528,28 @@ export function createApplicationTerminalInteractionController(options) {
522
528
  const paneInput = new TerminalPaneInputRouter({
523
529
  select: async (paneId) => {
524
530
  finishLinkSelection(false);
531
+ const initial = options.generation();
532
+ const selectionControl = controlIdentity(initial);
533
+ const selectionVersion = paneInput.selectionVersion;
534
+ const selectionCurrent = () => paneInput.selectionVersion === selectionVersion &&
535
+ sameControl(selectionControl, controlIdentity(selectionGeneration));
536
+ const panePresent = () => selectionLayout.windows.some((window) => window.panes.some((pane) => pane.pane === paneId));
537
+ if (initial?.status === "rebinding") {
538
+ // Reserve the optimistic target now, but send its FIRST mutation only
539
+ // after this same control connection has a coherent terminal runtime.
540
+ const ready = await selectionWaiter.wait(() => {
541
+ if (!selectionCurrent())
542
+ return false;
543
+ const active = selectionGeneration;
544
+ if (active?.status !== "live" || observedLayoutEpoch !== active.rendererEpoch)
545
+ return "pending";
546
+ return panePresent();
547
+ });
548
+ if (!ready || !selectionCurrent())
549
+ return false;
550
+ }
525
551
  const expected = liveSelectionTarget();
526
- const selectionControl = controlIdentity(options.generation());
552
+ let dispatchedRendererEpoch;
527
553
  const selecting = pendingWindowSwitch;
528
554
  const operationId = selecting?.traceId;
529
555
  const selectionFailure = { current: null };
@@ -536,8 +562,36 @@ export function createApplicationTerminalInteractionController(options) {
536
562
  : null;
537
563
  }, paneId, operationId, (failure) => {
538
564
  selectionFailure.current = failure;
539
- }, windowLinkForPane(options.layout(), paneId) ?? undefined)
565
+ }, windowLinkForPane(options.layout(), paneId) ?? undefined, () => {
566
+ dispatchedRendererEpoch = options.generation()?.rendererEpoch;
567
+ })
540
568
  : null;
569
+ if (receipt === null &&
570
+ selectionFailure.current?.reason === "topology-changed" &&
571
+ selectionCurrent()) {
572
+ // The daemon may have selected the pane before retiring its stream.
573
+ // Observe the replacement runtime; never resubmit an uncertain command
574
+ // or manufacture an operation receipt. Input still waits on this promise.
575
+ const confirmed = await selectionWaiter.wait(() => {
576
+ if (!selectionCurrent())
577
+ return false;
578
+ const active = selectionGeneration;
579
+ if (active?.status !== "live" ||
580
+ active.rendererEpoch === dispatchedRendererEpoch ||
581
+ observedLayoutEpoch !== active.rendererEpoch)
582
+ return "pending";
583
+ if (!panePresent())
584
+ return false;
585
+ const current = selectionLayout.current;
586
+ const activePanes = current?.panes.filter((pane) => pane.active) ?? [];
587
+ if (!current?.currentWindow || activePanes.length !== 1)
588
+ return "pending";
589
+ return activePanes[0]?.pane === paneId ? true : "pending";
590
+ });
591
+ if (selecting && pendingWindowSwitch === selecting)
592
+ pendingWindowSwitch = null;
593
+ return confirmed && selectionCurrent();
594
+ }
541
595
  if (selecting && pendingWindowSwitch === selecting) {
542
596
  const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
543
597
  if (selecting.timing)
@@ -685,22 +739,30 @@ export function createApplicationTerminalInteractionController(options) {
685
739
  }
686
740
  return outcome.status === "sent";
687
741
  },
688
- onFocusedPane: options.setFocusedPane,
742
+ onFocusedPane: (pane) => {
743
+ rendererProbe()?.cancel("selection-changed", nowMicros());
744
+ options.setFocusedPane(pane);
745
+ },
689
746
  });
690
747
  return {
691
748
  cancelPendingInput() {
749
+ rendererProbe()?.cancel("input-cancelled", nowMicros());
750
+ selectionWaiter.cancel();
692
751
  finishLinkSelection(false);
693
752
  paneInput.invalidateSelection();
694
753
  for (const entry of waitingInput)
695
754
  entry.settle(false);
696
755
  },
697
756
  adoptGeneration(snapshot) {
757
+ selectionGeneration = snapshot;
698
758
  const control = controlIdentity(snapshot);
699
759
  if (!sameControl(selectedControl, control)) {
760
+ rendererProbe()?.cancel("control-changed", nowMicros());
700
761
  finishLinkSelection(false);
701
762
  paneInput.invalidateSelection();
702
763
  }
703
764
  selectedControl = control;
765
+ selectionWaiter.reconcile();
704
766
  if (waitingInput.size > 0 && snapshot?.status === "live" && snapshot.fastLane) {
705
767
  // Old waiters resume first; input admitted synchronously by the next
706
768
  // layout publication must not overtake them in this microtask turn.
@@ -751,6 +813,7 @@ export function createApplicationTerminalInteractionController(options) {
751
813
  next.fastLane !== inputAuthorityIdentity.fastLane ||
752
814
  next.adapter !== inputAuthorityIdentity.adapter);
753
815
  if (next === null || replaced) {
816
+ rendererProbe()?.cancel("generation-changed", nowMicros());
754
817
  finishLinkSelection(false);
755
818
  pendingWindowSwitch = null;
756
819
  pendingWindowRename = null;
@@ -764,6 +827,9 @@ export function createApplicationTerminalInteractionController(options) {
764
827
  inputAuthorityIdentity = next;
765
828
  },
766
829
  adoptLayout(snapshot) {
830
+ selectionLayout = snapshot;
831
+ observedLayoutEpoch = snapshot.rendererEpoch ?? null;
832
+ selectionWaiter.reconcile();
767
833
  reconcileLinkSelection(snapshot);
768
834
  paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
769
835
  const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
@@ -850,6 +916,7 @@ export function createApplicationTerminalInteractionController(options) {
850
916
  selectPane: (paneId) => {
851
917
  finishLinkSelection(false);
852
918
  paneInput.selectPane(paneId);
919
+ selectionWaiter.reconcile();
853
920
  },
854
921
  async unlinkWindowLink(target) {
855
922
  const expected = liveSelectionTarget();
@@ -979,9 +1046,27 @@ export function createApplicationTerminalInteractionController(options) {
979
1046
  }
980
1047
  },
981
1048
  sendInput: async (input, parserOrigin) => {
982
- await paneInput.sendInput({ input, parserOrigin });
1049
+ const probe = rendererProbe();
1050
+ const state = probe
1051
+ ? (options.generation()?.fastLane?.lane.paneState(paneInput.focusedPane ?? "") ?? null)
1052
+ : null;
1053
+ const measured = probe?.admit(input, state, nowMicros());
1054
+ const sent = await paneInput.sendInput({ input: measured ?? input, parserOrigin });
1055
+ if (measured && !sent)
1056
+ probe?.cancel("dispatch-rejected", nowMicros());
1057
+ },
1058
+ sendInputToPane: async (paneId, input, parserOrigin) => {
1059
+ const probe = rendererProbe();
1060
+ const state = probe ? (options.generation()?.fastLane?.lane.paneState(paneId) ?? null) : null;
1061
+ const measured = probe?.admit(input, state, nowMicros());
1062
+ const sent = await paneInput.sendInputToPane(paneId, {
1063
+ input: measured ?? input,
1064
+ parserOrigin,
1065
+ });
1066
+ if (measured && !sent)
1067
+ probe?.cancel("dispatch-rejected", nowMicros());
1068
+ return sent;
983
1069
  },
984
- sendInputToPane: (paneId, input, parserOrigin) => paneInput.sendInputToPane(paneId, { input, parserOrigin }),
985
1070
  beginResizePointerIngress(input) {
986
1071
  if (!options.diagnosticsEnabled)
987
1072
  return null;
@@ -1,3 +1,4 @@
1
+ import { rendererProbe } from "./renderer-probe.js";
1
2
  import { paneInteractionDisplayDestination } from "../ui/pane-interaction-presentation.js";
2
3
  import { interactionPaneEndpointKey } from "@tmux-ide/core";
3
4
  import { interactionForCurrentPane } from "../ui/pane-interaction-presentation.js";
@@ -935,6 +936,7 @@ export function ApplicationTerminalWorkspace(props) {
935
936
  return;
936
937
  }
937
938
  if (event.type === "scroll") {
939
+ const probeAt = rendererProbe() ? Math.floor(performance.now() * 1000) : 0;
938
940
  // A held selection owns the gesture even when the pointer crosses a sibling.
939
941
  const activeSelection = selecting;
940
942
  const selectionCell = activeSelection &&
@@ -942,7 +944,9 @@ export function ApplicationTerminalWorkspace(props) {
942
944
  const hit = activeSelection && selectionCell
943
945
  ? { frame: activeSelection.frame, ...selectionCell }
944
946
  : paneContentAt(point);
945
- const before = props.onWheelObservation && hit ? scrollback.offset(hit.frame.paneId) : null;
947
+ const before = (props.onWheelObservation || rendererProbe()) && hit
948
+ ? scrollback.offset(hit.frame.paneId)
949
+ : null;
946
950
  const observe = (route) => {
947
951
  props.onWheelObservation?.({
948
952
  paneId: hit?.frame.paneId ?? null,
@@ -1021,6 +1025,14 @@ export function ApplicationTerminalWorkspace(props) {
1021
1025
  }
1022
1026
  else
1023
1027
  scrollback.move(hit.frame.paneId, motion.lines);
1028
+ if (rendererProbe() && identity && before !== null)
1029
+ rendererProbe()?.scroll({
1030
+ pane: hit.frame.paneId,
1031
+ generation: identity.generation,
1032
+ incarnation: identity.incarnation,
1033
+ cols: snapshot.cols,
1034
+ rows: snapshot.rows,
1035
+ }, snapshot, before, scrollback.offset(hit.frame.paneId), probeAt);
1024
1036
  observe("local-history");
1025
1037
  if (selecting?.paneId === hit.frame.paneId)
1026
1038
  updatePointerSelection(selecting.pointer);
@@ -371,7 +371,7 @@ export function createOpenTuiGenerationHost(sessionName, presentation, overrides
371
371
  // desired state against the fresh socket on the following live publish.
372
372
  publish({ ...snapshot, status: "rebinding" });
373
373
  owner.stopPresentation?.();
374
- owner.stopPresentation = runtime ? presentation.adopt(runtime) : null;
374
+ owner.stopPresentation = runtime ? presentation.adopt(runtime, rendererEpoch + 1) : null;
375
375
  owner.runtime = runtime;
376
376
  owner.authorityClient = runtime ? runtimeAuthorityClient(owner.bundle, runtime) : null;
377
377
  if (!runtime)
@@ -394,7 +394,7 @@ export function createOpenTuiGenerationHost(sessionName, presentation, overrides
394
394
  if (candidate !== owner)
395
395
  return;
396
396
  if (runtime) {
397
- owner.stopPresentation = presentation.adopt(runtime);
397
+ owner.stopPresentation = presentation.adopt(runtime, rendererEpoch + 1);
398
398
  diagnose?.("host-internal-snapshot-publication", {
399
399
  publicationPhase: "presentation-adopted",
400
400
  daemonGeneration: owner.bundle.connection.target.daemon.instanceId,
@@ -0,0 +1,195 @@
1
+ import { visibleTerminalRowAt } from "../semantic-pane-render-source.js";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { isAbsolute } from "node:path";
4
+ function scope(state) {
5
+ return state.snapshot
6
+ ? {
7
+ pane: state.semanticPaneId,
8
+ generation: state.generation,
9
+ incarnation: state.incarnation,
10
+ cols: state.snapshot.cols,
11
+ rows: state.snapshot.rows,
12
+ }
13
+ : null;
14
+ }
15
+ const key = (s) => JSON.stringify([s.pane, s.generation, s.incarnation, s.cols, s.rows]);
16
+ export class RendererProbe {
17
+ trials;
18
+ emit;
19
+ #next = 0;
20
+ #pending = null;
21
+ constructor(trials, emit) {
22
+ this.trials = trials;
23
+ this.emit = emit;
24
+ if (trials.length > 64 ||
25
+ new Set(trials.map((t) => t.nonce)).size !== trials.length ||
26
+ trials.some((t) => !t.id ||
27
+ t.id.length > 128 ||
28
+ !/^[a-f0-9]{32}$/.test(t.nonce) ||
29
+ (t.kind !== undefined && !["input", "scroll", "output"].includes(t.kind))))
30
+ throw new Error("Invalid renderer probe trials");
31
+ }
32
+ get pending() {
33
+ return this.#pending !== null;
34
+ }
35
+ admit(input, state, at) {
36
+ if (input.kind !== "text" ||
37
+ input.data !== "!" ||
38
+ !state?.snapshot ||
39
+ this.#pending ||
40
+ !this.trials[this.#next])
41
+ return null;
42
+ if (this.trials[this.#next]?.kind === "scroll")
43
+ return { kind: "text", data: `PROBE-PREPARE-SCROLL:${this.trials[this.#next].nonce}\n` };
44
+ const trial = this.trials[this.#next++], marker = `PROBE-OUTPUT:${trial.nonce}`, identity = scope(state);
45
+ this.#pending = { trial, scope: identity, at, marker, offset: 0, sourceOffset: 0 };
46
+ if (state.snapshot.cols < marker.length ||
47
+ state.snapshot.grid.some((r) => r.cells
48
+ .map((c) => c.grapheme)
49
+ .join("")
50
+ .includes(marker))) {
51
+ this.cancel("negative-control", at);
52
+ return null;
53
+ }
54
+ return {
55
+ kind: "text",
56
+ data: `${trial.kind === "output" ? "PROBE-BURST" : "PROBE-IN"}:${trial.nonce}\n`,
57
+ };
58
+ }
59
+ scroll(identity, snapshot, before, after, at) {
60
+ const trial = this.trials[this.#next];
61
+ if (this.#pending || trial?.kind !== "scroll" || before === after)
62
+ return;
63
+ const row = visibleTerminalRowAt(snapshot, after, 0);
64
+ const text = row?.cells
65
+ .map((c) => c.grapheme)
66
+ .join("")
67
+ .trimEnd() ?? "";
68
+ if (!new RegExp(`^PROBE-ROW:${trial.nonce}:[0-9]{4}$`).test(text))
69
+ return;
70
+ for (let y = 0; y < snapshot.rows; y++)
71
+ if (visibleTerminalRowAt(snapshot, before, y)
72
+ ?.cells.map((c) => c.grapheme)
73
+ .join("")
74
+ .trimEnd() === text)
75
+ return;
76
+ this.#next++;
77
+ this.#pending = {
78
+ trial,
79
+ scope: identity,
80
+ at,
81
+ marker: text,
82
+ offset: after,
83
+ sourceOffset: before,
84
+ };
85
+ }
86
+ tick(at) {
87
+ if (this.#pending && at - this.#pending.at > 5_000_000)
88
+ this.cancel("timeout", at);
89
+ }
90
+ cancel(reason, at) {
91
+ const p = this.#pending;
92
+ if (!p)
93
+ return;
94
+ this.#pending = null;
95
+ this.emit({
96
+ ...p.trial,
97
+ side: "tui",
98
+ kind: p.trial.kind ?? "input",
99
+ sourceScope: p.scope,
100
+ expectedScope: p.scope,
101
+ sourceOffset: p.sourceOffset,
102
+ expectedOffset: p.offset,
103
+ admittedAtMicros: p.at,
104
+ completedAtMicros: at,
105
+ endpoint: "input-admission-to-renderer-work-complete",
106
+ status: "failed",
107
+ reason,
108
+ });
109
+ }
110
+ observe(state, at) {
111
+ if (!this.#pending)
112
+ return;
113
+ if (state && state.semanticPaneId !== this.#pending.scope.pane)
114
+ return;
115
+ if (!state || !scope(state) || key(scope(state)) !== key(this.#pending.scope))
116
+ this.cancel("scope-changed", at);
117
+ else if (at - this.#pending.at > 5_000_000)
118
+ this.cancel("timeout", at);
119
+ }
120
+ row(state, row, viewportRow, width, height, left, scrollOffset, at) {
121
+ this.observe(state, at);
122
+ const p = this.#pending;
123
+ if (!p || state.semanticPaneId !== p.scope.pane)
124
+ return;
125
+ if (scrollOffset !== p.offset ||
126
+ left !== 0 ||
127
+ viewportRow !== 0 ||
128
+ height < 1 ||
129
+ width < p.marker.length ||
130
+ !row)
131
+ return;
132
+ const cells = row.cells.slice(0, p.marker.length);
133
+ if (cells.some((c) => c.width !== 1) || cells.map((c) => c.grapheme).join("") !== p.marker)
134
+ return;
135
+ if (!Number.isSafeInteger(at) || at < p.at) {
136
+ this.cancel("clock-invalid", at);
137
+ return;
138
+ }
139
+ this.#pending = null;
140
+ this.emit({
141
+ ...p.trial,
142
+ side: "tui",
143
+ kind: p.trial.kind ?? "input",
144
+ sourceScope: p.scope,
145
+ expectedScope: p.scope,
146
+ sourceOffset: p.sourceOffset,
147
+ expectedOffset: p.offset,
148
+ admittedAtMicros: p.at,
149
+ completedAtMicros: at,
150
+ endpoint: "input-admission-to-renderer-work-complete",
151
+ status: "completed",
152
+ durationMicros: at - p.at,
153
+ witness: {
154
+ row: viewportRow,
155
+ column: 0,
156
+ marker: p.marker,
157
+ revision: state.revision,
158
+ stateHash: state.hash,
159
+ },
160
+ });
161
+ }
162
+ }
163
+ let singleton;
164
+ /** All filesystem work is asynchronous and deferred; at most64 short receipts. */
165
+ export function rendererProbe() {
166
+ if (singleton !== undefined)
167
+ return singleton;
168
+ singleton = null;
169
+ if (process.env.TMUX_IDE_RENDERER_FIXTURE !== "1")
170
+ return null;
171
+ const raw = process.env.TMUX_IDE_RENDERER_PROBE_TRIALS, path = process.env.TMUX_IDE_RENDERER_PROBE_LOG;
172
+ if (!raw || raw.length > 16384 || !path || !isAbsolute(path))
173
+ return null;
174
+ try {
175
+ const trials = JSON.parse(raw);
176
+ if (!Array.isArray(trials))
177
+ return null;
178
+ let writes = Promise.resolve(), failed = false;
179
+ singleton = new RendererProbe(trials, (record) => {
180
+ if (failed)
181
+ return;
182
+ writes = writes
183
+ .then(() => appendFile(path, JSON.stringify(record) + "\n", { mode: 0o600 }))
184
+ .catch(() => {
185
+ failed = true;
186
+ });
187
+ });
188
+ const timer = setInterval(() => singleton?.tick(Math.floor(performance.now() * 1000)), 250);
189
+ timer.unref();
190
+ }
191
+ catch {
192
+ singleton = null;
193
+ }
194
+ return singleton;
195
+ }
@@ -52,7 +52,7 @@ export function createOpenTuiRuntimeLayoutPresentation() {
52
52
  listener(windows);
53
53
  return () => windowListeners.delete(listener);
54
54
  },
55
- adopt(port) {
55
+ adopt(port, rendererEpoch) {
56
56
  if (disposed)
57
57
  return () => undefined;
58
58
  let candidate = true;
@@ -60,15 +60,18 @@ export function createOpenTuiRuntimeLayoutPresentation() {
60
60
  candidateStop = port.onLayout((layout) => {
61
61
  if (disposed || !candidate || !layout.current)
62
62
  return;
63
+ // A port may reuse its snapshot object. Own the provenance wrapper so
64
+ // a preparing runtime cannot relabel the retained coherent frame.
65
+ const presented = Object.freeze({ ...layout, rendererEpoch });
63
66
  if (currentPort !== port) {
64
67
  const previousStop = stopLayout;
65
68
  currentPort = port;
66
69
  stopLayout = candidateStop;
67
- publish(layout);
70
+ publish(presented);
68
71
  previousStop?.();
69
72
  return;
70
73
  }
71
- publish(layout);
74
+ publish(presented);
72
75
  });
73
76
  // `onLayout` intentionally seeds synchronously. Capture the disposer
74
77
  // after that first callback has promoted this candidate.
@@ -1,5 +1,5 @@
1
1
  /** Acquire controller authority before issuing the canonical pane-select intent. */
2
- export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure, windowLink) {
2
+ export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure, windowLink, onDispatch) {
3
3
  const fail = (failure) => {
4
4
  try {
5
5
  onFailure?.(failure);
@@ -20,7 +20,11 @@ export async function selectTerminalPane(expected, current, semanticPaneId, oper
20
20
  : undefined;
21
21
  return fail({
22
22
  stage,
23
- reason: code === "operation-timeout" ? "operation-timeout" : "transport-rejected",
23
+ reason: code === "operation-timeout"
24
+ ? "operation-timeout"
25
+ : code === "topology-changed" && stage === "dispatch"
26
+ ? "topology-changed"
27
+ : "transport-rejected",
24
28
  ...(backendReason ? { backendReason } : {}),
25
29
  });
26
30
  };
@@ -50,6 +54,7 @@ export async function selectTerminalPane(expected, current, semanticPaneId, oper
50
54
  return fail({ stage: "pre-dispatch", reason: "generation-replaced" });
51
55
  let result;
52
56
  try {
57
+ onDispatch?.();
53
58
  result = await expected.client.dispatch({
54
59
  kind: "semantic-intent",
55
60
  ...(operationId ? { operationId } : {}),
@@ -1,3 +1,4 @@
1
+ import { rendererProbe } from "./renderer-probe.js";
1
2
  import { terminalReplicaRowsEqual } from "@tmux-ide/core";
2
3
  import { reflowTerminalPosition, reflowRetainedTerminalSnapshot, retainNativeTerminalBacking, } from "../terminal-viewport.js";
3
4
  import { blitSemanticRow, changedTerminalRows, visibleTerminalRowAt, } from "../semantic-pane-render-source.js";
@@ -481,6 +482,8 @@ export class TerminalFastLaneRendererAdapter {
481
482
  if (interest.retainedView && next)
482
483
  this.#resizeRetainedView(interest, next);
483
484
  interest.state = publication.state;
485
+ if (rendererProbe()?.pending)
486
+ rendererProbe()?.observe(publication.state, Math.floor(performance.now() * 1000));
484
487
  // Preserve coordinates for an unchanged prefix or an exact retained suffix.
485
488
  // The suffix offset is fixed by the length difference; never search repeated
486
489
  // log lines for a guessed overlap. Changed grids/reflow need a separate map.
@@ -810,6 +813,9 @@ export class TerminalFastLaneRendererAdapter {
810
813
  if (interest?.dirtyRows.has(canonicalRow))
811
814
  paintedCanonicalChange = true;
812
815
  blit(sourceRow, buffers, row, width, defaultFg, defaultBg, options.graphemes, options.palette, options.viewportOrigin?.x ?? 0);
816
+ if (interest?.state && rendererProbe()?.pending) {
817
+ rendererProbe()?.row(interest.retainedView?.state ?? interest.state, sourceRow, row, width, height, options.viewportOrigin?.x ?? 0, options.viewportOrigin ? Math.max(0, -options.viewportOrigin.y) : scrollOffset, Math.floor(performance.now() * 1_000));
818
+ }
813
819
  options.dirtyRows.push(row);
814
820
  paintedRows[row] = sourceRow;
815
821
  }
@@ -0,0 +1,36 @@
1
+ /** One bounded selection observation; never submits or repeats a mutation. */
2
+ export function createTerminalSelectionWaiter() {
3
+ let pending = null;
4
+ const cancel = () => pending?.finish(false);
5
+ const reconcile = () => {
6
+ const waiting = pending;
7
+ if (!waiting)
8
+ return;
9
+ const result = waiting.check();
10
+ if (result !== "pending")
11
+ waiting.finish(result);
12
+ };
13
+ return {
14
+ cancel,
15
+ reconcile,
16
+ wait(check) {
17
+ cancel();
18
+ return new Promise((resolve) => {
19
+ const timer = setTimeout(() => waiting.finish(false), 5000);
20
+ timer.unref?.();
21
+ const waiting = {
22
+ check,
23
+ finish(selected) {
24
+ if (pending !== waiting)
25
+ return;
26
+ pending = null;
27
+ clearTimeout(timer);
28
+ resolve(selected);
29
+ },
30
+ };
31
+ pending = waiting;
32
+ reconcile();
33
+ });
34
+ },
35
+ };
36
+ }
@@ -95,6 +95,10 @@ export class SemanticPaneReplica {
95
95
  : [...this.#dirtyRows].sort((a, b) => a - b);
96
96
  }
97
97
  accept(message) {
98
+ if (message.type === "terminal.projection") {
99
+ this.#fail("protocol-violation", message.transactionId);
100
+ return;
101
+ }
98
102
  if (message.type === "terminal.delivery.fault") {
99
103
  this.#assembler = null;
100
104
  this.#snapshot = null;
@@ -8,7 +8,8 @@ import { createGuardedNativeSplitResize } from "./guarded-native-split-resize.ts
8
8
  import { WindowLinkTargetSchemaZ } from "@tmux-ide/contracts";
9
9
  import { WindowSplitLayoutUnavailable } from "../terminal/mirror/window-split-authority.ts";
10
10
  import { requireSupportedTmuxVersion } from "./tmux-version.ts";
11
- import { join } from "node:path";
11
+ import { dirname, join } from "node:path";
12
+ import { LocalImageOwner, cleanupLocalImageOwner } from "../terminal/graphics/local-image-owner.ts";
12
13
  import { createClaudeTeamMembershipReader } from "../terminal/attachments/claude-team-names.ts";
13
14
  import { PaneSourceDiscovery } from "./pane-source-discovery.ts";
14
15
  import { createBackgroundNativeCapture } from "./background-native-capture.ts";
@@ -159,6 +160,7 @@ import { captureDaemonProvenance } from "./daemon-provenance.ts";
159
160
  import { registerLogSecret, setLogIdentity } from "./log.ts";
160
161
  import {
161
162
  canonicalDaemonUrl,
163
+ getCanonicalDaemonInfoPath,
162
164
  clearCanonicalDaemonInfoIfOwned,
163
165
  clearCanonicalDaemonInfoIfUnchanged,
164
166
  inspectCanonicalDaemonInfo,
@@ -1399,6 +1401,7 @@ async function startEmbeddedDaemonGeneration(
1399
1401
  };
1400
1402
  let terminalAttachmentRuntime: NativeTerminalAttachmentRuntime | null = null;
1401
1403
  let paneStreamRuntime: PaneStreamRuntime | null = null;
1404
+ let localImages: LocalImageOwner | undefined;
1402
1405
  let serverOwners: Awaited<ReturnType<typeof createEmbeddedTmuxServerOwners>> | null = null;
1403
1406
  let defaultEditorContextRetired = false;
1404
1407
  const defaultDiagnosticsLifetime = new AbortController();
@@ -1732,7 +1735,34 @@ async function startEmbeddedDaemonGeneration(
1732
1735
  };
1733
1736
  const peekTerminalAttachmentRuntime = (): NativeTerminalAttachmentRuntime | null =>
1734
1737
  terminalAttachmentRuntime;
1738
+ // The held canonical claim has already proven and hardened this private root.
1739
+ // Local images are optional: unavailable storage retains inline graphics.
1740
+ if (process.platform !== "win32") {
1741
+ const root = dirname(getCanonicalDaemonInfoPath());
1742
+ try {
1743
+ if (existingCanonical.status === "valid") {
1744
+ await cleanupLocalImageOwner({
1745
+ root,
1746
+ owner: {
1747
+ generation: existingCanonical.info.instanceId,
1748
+ pid: existingCanonical.info.pid,
1749
+ startedAt: existingCanonical.info.startedAt,
1750
+ },
1751
+ proveDead: () => isCanonicalDaemonRecordOwnerProvenDead(existingCanonical),
1752
+ });
1753
+ }
1754
+ localImages = await LocalImageOwner.create({
1755
+ root,
1756
+ generation: instanceId,
1757
+ pid: process.pid,
1758
+ startedAt,
1759
+ });
1760
+ } catch {
1761
+ localImages = undefined;
1762
+ }
1763
+ }
1735
1764
  paneStreamRuntime = createPaneStreamRuntime({
1765
+ localImages,
1736
1766
  daemonInstanceId: instanceId,
1737
1767
  webSocketUrl: paneStreamWebSocketUrl(bindHostname, port),
1738
1768
  sessionRuntimeRegistry,
@@ -1758,6 +1788,7 @@ async function startEmbeddedDaemonGeneration(
1758
1788
  });
1759
1789
  await externalInteractionObserver.start();
1760
1790
  serverOwners = await createEmbeddedTmuxServerOwners({
1791
+ localImages,
1761
1792
  environmentId,
1762
1793
  onDefaultScope: async (scope) => {
1763
1794
  interactionEvidence = new InteractionEvidenceAuthority(environmentId, scope);
@@ -2075,7 +2106,10 @@ async function startEmbeddedDaemonGeneration(
2075
2106
  // The pane-stream coordinator may still hold runtime consumers while it
2076
2107
  // drains. Preserve the normal shutdown order on startup rollback too:
2077
2108
  // transports first, then the one session/control authority.
2078
- await Promise.allSettled([sessionRuntimeRegistry?.dispose() ?? Promise.resolve()]);
2109
+ await Promise.allSettled([
2110
+ sessionRuntimeRegistry?.dispose() ?? Promise.resolve(),
2111
+ localImages?.dispose() ?? Promise.resolve(),
2112
+ ]);
2079
2113
  throw error;
2080
2114
  }
2081
2115
  const {
@@ -2108,6 +2142,7 @@ async function startEmbeddedDaemonGeneration(
2108
2142
  )),
2109
2143
  ...(await retirePaneStreamTransport()),
2110
2144
  ];
2145
+ await localImages?.dispose();
2111
2146
  terminalInventoryRuntime.dispose();
2112
2147
  const paneDisposal = Promise.resolve().then(() => workspacePaneCreation.dispose());
2113
2148
  const workspaceOpenDisposal = Promise.resolve().then(() => workspaceOpen.dispose());
@@ -2375,6 +2410,7 @@ async function startEmbeddedDaemonGeneration(
2375
2410
  )),
2376
2411
  );
2377
2412
  failures.push(...(await retirePaneStreamTransport()));
2413
+ await capture(() => localImages?.dispose());
2378
2414
  await capture(() => terminalInventoryRuntime.dispose());
2379
2415
  await capture(() => workspacePaneCreation.dispose());
2380
2416
  await capture(() => workspaceOpen.dispose());
@@ -1,3 +1,4 @@
1
+ import type { LocalImageOwner } from "../terminal/graphics/local-image-owner.ts";
1
2
  import type { Server, IncomingMessage } from "node:http";
2
3
  import type { Socket } from "node:net";
3
4
  import { randomUUID } from "node:crypto";
@@ -22,6 +23,7 @@ import {
22
23
 
23
24
  /** Adapts the established default owner without duplicating its control clients. */
24
25
  export async function createEmbeddedTmuxServerOwners(options: {
26
+ readonly localImages?: LocalImageOwner;
25
27
  readonly environmentId: string;
26
28
  readonly onDefaultScope?: (scope: TmuxServerScope) => Promise<void>;
27
29
  readonly defaultAuthority: WorkspacePaneTmuxAuthority;
@@ -89,6 +91,7 @@ export async function createEmbeddedTmuxServerOwners(options: {
89
91
  if (registration.serverId === defaultRegistration.serverId) await retireDefault();
90
92
  const route = tmuxServerPaneStreamPath(scope);
91
93
  const owner = await createNativeTmuxServerOwner({
94
+ localImages: options.localImages,
92
95
  environmentId: options.environmentId,
93
96
  ...scope,
94
97
  tmuxAuthority: observation.authority,