tmux-ide 2.9.12 → 2.9.13

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 (48) hide show
  1. package/bin/cli.js +3438 -2062
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +2 -0
  4. package/packages/contracts/src/pane-stream.ts +17 -0
  5. package/packages/contracts/src/terminal-delivery.ts +2 -1
  6. package/packages/contracts/src/terminal-graphics-compressed.ts +66 -0
  7. package/packages/contracts/src/terminal-graphics.ts +5 -0
  8. package/packages/contracts/src/terminal-surfaces.ts +137 -0
  9. package/packages/core/src/terminal-delivery.ts +8 -2
  10. package/packages/core/src/terminal-replica.ts +33 -0
  11. package/packages/daemon/dist/command-center/resources/saved-machine-route.js +2 -0
  12. package/packages/daemon/dist/command-center/resources/saved-machine-watch.js +117 -0
  13. package/packages/daemon/dist/command-center/server.js +13 -0
  14. package/packages/daemon/dist/command-center/tmux-servers.js +9 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +32 -1
  16. package/packages/daemon/dist/lib/native-surface-context.js +96 -0
  17. package/packages/daemon/dist/lib/tmux-server-owner.js +9 -0
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  21. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  22. package/packages/daemon/dist/server/native-surface-provider-upgrade.js +88 -0
  23. package/packages/daemon/dist/terminal/graphics/compress-chunk.js +40 -0
  24. package/packages/daemon/dist/terminal/graphics/native-surface-demand.js +99 -0
  25. package/packages/daemon/dist/terminal/graphics/native-surface-provider-channel.js +162 -0
  26. package/packages/daemon/dist/terminal/graphics/native-surface-provider.js +275 -0
  27. package/packages/daemon/dist/terminal/graphics/native-surface-viewer.js +146 -0
  28. package/packages/daemon/dist/terminal/mirror/mirror-service.js +3 -0
  29. package/packages/daemon/dist/terminal/mirror/session-channel.js +32 -9
  30. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +196 -3
  31. package/packages/daemon/dist/terminal/pane-stream/runtime.js +9 -0
  32. package/packages/daemon/src/command-center/resources/saved-machine-route.ts +4 -0
  33. package/packages/daemon/src/command-center/resources/saved-machine-watch.ts +132 -0
  34. package/packages/daemon/src/command-center/server.ts +18 -0
  35. package/packages/daemon/src/command-center/tmux-servers.ts +14 -0
  36. package/packages/daemon/src/lib/daemon-embed.ts +43 -1
  37. package/packages/daemon/src/lib/native-surface-context.ts +123 -0
  38. package/packages/daemon/src/lib/tmux-server-owner.ts +9 -0
  39. package/packages/daemon/src/server/native-surface-provider-upgrade.ts +103 -0
  40. package/packages/daemon/src/terminal/graphics/compress-chunk.ts +49 -0
  41. package/packages/daemon/src/terminal/graphics/native-surface-demand.ts +88 -0
  42. package/packages/daemon/src/terminal/graphics/native-surface-provider-channel.ts +161 -0
  43. package/packages/daemon/src/terminal/graphics/native-surface-provider.ts +308 -0
  44. package/packages/daemon/src/terminal/graphics/native-surface-viewer.ts +154 -0
  45. package/packages/daemon/src/terminal/mirror/mirror-service.ts +5 -0
  46. package/packages/daemon/src/terminal/mirror/session-channel.ts +31 -10
  47. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +229 -2
  48. package/packages/daemon/src/terminal/pane-stream/runtime.ts +11 -1
@@ -0,0 +1,275 @@
1
+ import { NativeSurfaceDemand } from "./native-surface-demand.js";
2
+ import { SessionRuntimeGenerationSchemaZ, TerminalSurfaceAuditTokenSchemaZ, TerminalSurfaceOfferSchemaZ, TerminalSurfaceReceiverSchemaZ, TERMINAL_SURFACE_MAX_BYTES, TERMINAL_SURFACE_MAX_OUTSTANDING, TERMINAL_SURFACE_MAX_CONTROL_BYTES, } from "@tmux-ide/contracts";
3
+ /** Internal daemon registration only. Never construct a provider from terminal
4
+ * content, request-supplied paths, a PID lookup or a loopback address. The native
5
+ * owner must be authenticated before registration; kernel proof still follows. */
6
+ export class NativeSurfaceProviderRegistry {
7
+ demand = new NativeSurfaceDemand();
8
+ generation;
9
+ #registration = null;
10
+ #sessions = new Set();
11
+ #bytes = 0;
12
+ #frames = 0;
13
+ #revision = 0;
14
+ #closed = false;
15
+ #watchers = new Set();
16
+ #notificationQueued = false;
17
+ constructor(generation) {
18
+ this.generation = SessionRuntimeGenerationSchemaZ.parse(generation);
19
+ }
20
+ get revision() {
21
+ return this.#revision;
22
+ }
23
+ get available() {
24
+ return this.#registration !== null;
25
+ }
26
+ watchAvailability(listener) {
27
+ if (this.#closed || this.#watchers.size >= 256)
28
+ throw new Error("Native surface availability subscription unavailable");
29
+ this.#watchers.add(listener);
30
+ this.#notifyAvailability();
31
+ return () => this.#watchers.delete(listener);
32
+ }
33
+ #notifyAvailability() {
34
+ if (this.#notificationQueued)
35
+ return;
36
+ this.#notificationQueued = true;
37
+ // Provider registration must finish before a viewer can open its channel.
38
+ queueMicrotask(() => {
39
+ this.#notificationQueued = false;
40
+ for (const listener of [...this.#watchers]) {
41
+ if (!this.#watchers.has(listener))
42
+ continue;
43
+ try {
44
+ listener(this.available);
45
+ }
46
+ catch {
47
+ this.#watchers.delete(listener);
48
+ }
49
+ }
50
+ if (this.#closed)
51
+ this.#watchers.clear();
52
+ });
53
+ }
54
+ register(provider) {
55
+ if (this.#closed)
56
+ throw new Error("Native surface registry closed");
57
+ if (this.#registration)
58
+ throw new Error("Native surface provider already registered");
59
+ const registration = {
60
+ provider,
61
+ auditToken: TerminalSurfaceAuditTokenSchemaZ.parse(provider.auditToken),
62
+ sessions: new Set(),
63
+ };
64
+ this.#registration = registration;
65
+ this.#revision++;
66
+ this.#notifyAvailability();
67
+ return () => {
68
+ if (this.#registration !== registration)
69
+ return;
70
+ this.#registration = null;
71
+ this.#revision++;
72
+ // Existing original sockets may still ACK retained GPU storage. Replacing
73
+ // a provider never redirects those ACKs to the replacement process.
74
+ for (const session of registration.sessions)
75
+ session.fallback("unsupported");
76
+ this.#notifyAvailability();
77
+ };
78
+ }
79
+ open(receiver, panes, send, deliveryNonce) {
80
+ receiver = TerminalSurfaceReceiverSchemaZ.parse(receiver);
81
+ const registration = this.#registration;
82
+ if (!registration || receiver.generation !== this.generation || this.#sessions.size >= 32)
83
+ return null;
84
+ const session = new Session(receiver, panes, registration, send, deliveryNonce, (bytes) => {
85
+ if (this.#frames >= 32 || this.#bytes + bytes > 256 * 1024 * 1024)
86
+ return false;
87
+ this.#frames++;
88
+ this.#bytes += bytes;
89
+ return true;
90
+ }, (bytes) => {
91
+ this.#frames--;
92
+ this.#bytes -= bytes;
93
+ }, () => {
94
+ registration.sessions.delete(session);
95
+ this.#sessions.delete(session);
96
+ });
97
+ registration.sessions.add(session);
98
+ this.#sessions.add(session);
99
+ session.start();
100
+ return session;
101
+ }
102
+ close() {
103
+ if (this.#closed)
104
+ return;
105
+ this.#closed = true;
106
+ this.demand.close();
107
+ this.#registration = null;
108
+ this.#revision++;
109
+ for (const session of [...this.#sessions])
110
+ session.close();
111
+ this.#notifyAvailability();
112
+ }
113
+ }
114
+ class Session {
115
+ receiver;
116
+ registration;
117
+ send;
118
+ deliveryNonce;
119
+ reserve;
120
+ release;
121
+ disposed;
122
+ #producer = null;
123
+ #outstanding = new Map();
124
+ #bytes = 0;
125
+ #lastSequence = 0;
126
+ #stopped = false;
127
+ #closed = false;
128
+ #panes;
129
+ constructor(receiver, panes, registration, send, deliveryNonce, reserve, release, disposed) {
130
+ this.receiver = receiver;
131
+ this.registration = registration;
132
+ this.send = send;
133
+ this.deliveryNonce = deliveryNonce;
134
+ this.reserve = reserve;
135
+ this.release = release;
136
+ this.disposed = disposed;
137
+ this.#panes = new Set(panes);
138
+ }
139
+ get outstanding() {
140
+ return this.#outstanding.size;
141
+ }
142
+ start() {
143
+ try {
144
+ const producer = this.registration.provider.open({
145
+ receiver: structuredClone(this.receiver),
146
+ panes: [...this.#panes],
147
+ offer: (value) => this.#offer(value),
148
+ });
149
+ if (this.#stopped || this.#closed) {
150
+ try {
151
+ producer.close();
152
+ }
153
+ catch {
154
+ /* Registration already revoked this connection. */
155
+ }
156
+ this.close();
157
+ return;
158
+ }
159
+ this.#producer = producer;
160
+ if (!this.#send({
161
+ type: "terminal-surface-peer",
162
+ version: 1,
163
+ generation: this.receiver.generation,
164
+ connectionNonce: this.receiver.connectionNonce,
165
+ auditToken: [...this.registration.auditToken],
166
+ })) {
167
+ this.close();
168
+ return;
169
+ }
170
+ if (!this.#stopped && !this.#closed)
171
+ this.#producer?.start();
172
+ }
173
+ catch {
174
+ this.fallback("storage");
175
+ }
176
+ }
177
+ #offer(value) {
178
+ if (this.#closed || this.#stopped || !this.#producer)
179
+ return false;
180
+ const parsed = TerminalSurfaceOfferSchemaZ.safeParse(value);
181
+ if (!parsed.success)
182
+ return false;
183
+ let offer = parsed.data;
184
+ // A producer's own read-only subscription has a different delivery nonce.
185
+ // Only the daemon owning the target viewer may bind that nonce. Preserve
186
+ // canonical revision/hash/incarnation exactly; the receiver verifies them.
187
+ if (this.deliveryNonce) {
188
+ const nonce = this.deliveryNonce(offer.pane, offer.generation);
189
+ if (!nonce)
190
+ return false;
191
+ const rebound = TerminalSurfaceOfferSchemaZ.safeParse({ ...offer, deliveryNonce: nonce });
192
+ if (!rebound.success)
193
+ return false;
194
+ offer = rebound.data;
195
+ }
196
+ if (offer.generation !== this.receiver.generation ||
197
+ !this.#panes.has(offer.pane) ||
198
+ offer.sequence <= this.#lastSequence ||
199
+ this.#outstanding.size >= TERMINAL_SURFACE_MAX_OUTSTANDING ||
200
+ this.#bytes + offer.allocationBytes > TERMINAL_SURFACE_MAX_BYTES ||
201
+ Buffer.byteLength(JSON.stringify(offer)) > TERMINAL_SURFACE_MAX_CONTROL_BYTES ||
202
+ !this.reserve(offer.allocationBytes))
203
+ return false;
204
+ this.#lastSequence = offer.sequence;
205
+ this.#outstanding.set(offer.sequence, offer.allocationBytes);
206
+ this.#bytes += offer.allocationBytes;
207
+ if (this.#send(offer))
208
+ return !this.#closed && !this.#stopped;
209
+ this.close();
210
+ return false;
211
+ }
212
+ retire(sequence) {
213
+ const bytes = this.#outstanding.get(sequence);
214
+ if (this.#closed || bytes === undefined)
215
+ return false;
216
+ this.#outstanding.delete(sequence);
217
+ this.#bytes -= bytes;
218
+ this.release(bytes);
219
+ try {
220
+ this.#producer?.retire(sequence);
221
+ }
222
+ catch {
223
+ this.fallback("storage");
224
+ }
225
+ return true;
226
+ }
227
+ fallback(reason) {
228
+ if (this.#closed || this.#stopped)
229
+ return;
230
+ this.#stopped = true;
231
+ try {
232
+ this.#producer?.fallback();
233
+ }
234
+ catch {
235
+ /* Native owner cannot authorize new transfers. */
236
+ }
237
+ if (!this.#send({
238
+ type: "terminal-surface-fallback",
239
+ version: 1,
240
+ generation: this.receiver.generation,
241
+ connectionNonce: this.receiver.connectionNonce,
242
+ reason,
243
+ }))
244
+ this.close();
245
+ }
246
+ close() {
247
+ if (this.#closed)
248
+ return;
249
+ this.#closed = true;
250
+ // Closing releases accounting/reference ownership, never grants buffer reuse.
251
+ // Native receivers retain their own backing/GPU leases after this connection.
252
+ for (const bytes of this.#outstanding.values())
253
+ this.release(bytes);
254
+ this.#outstanding.clear();
255
+ this.#bytes = 0;
256
+ try {
257
+ this.#producer?.close();
258
+ }
259
+ catch {
260
+ /* All daemon-side admissions remain revoked. */
261
+ }
262
+ finally {
263
+ this.#producer = null;
264
+ this.disposed();
265
+ }
266
+ }
267
+ #send(frame) {
268
+ try {
269
+ return this.send(frame);
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const MAX_RETAINED = 8;
3
+ /** One terminal socket owns every negotiation and its remaining GPU retirements. */
4
+ export class NativeSurfaceViewer {
5
+ registry;
6
+ panes;
7
+ send;
8
+ deliveryNonce;
9
+ #stop = null;
10
+ #closed = false;
11
+ #revision = -1;
12
+ #nonce = null;
13
+ #used = false;
14
+ #active = null;
15
+ #retained = new Map();
16
+ constructor(registry, panes, send, deliveryNonce) {
17
+ this.registry = registry;
18
+ this.panes = panes;
19
+ this.send = send;
20
+ this.deliveryNonce = deliveryNonce;
21
+ }
22
+ start() {
23
+ if (this.#closed || this.#stop)
24
+ return;
25
+ try {
26
+ this.#stop = this.registry.watchAvailability((available) => {
27
+ if (this.#closed || this.#revision === this.registry.revision)
28
+ return;
29
+ this.#revision = this.registry.revision;
30
+ this.#active?.session.fallback("unsupported");
31
+ this.#active = null;
32
+ this.#prune();
33
+ this.#nonce = available ? randomUUID() : null;
34
+ this.#used = false;
35
+ this.#send({
36
+ type: "terminal-surface-available",
37
+ version: 1,
38
+ generation: this.registry.generation,
39
+ connectionNonce: this.#nonce,
40
+ });
41
+ });
42
+ }
43
+ catch {
44
+ this.#send({
45
+ type: "terminal-surface-available",
46
+ version: 1,
47
+ generation: this.registry.generation,
48
+ connectionNonce: null,
49
+ });
50
+ this.close();
51
+ }
52
+ }
53
+ receive(frame) {
54
+ if (this.#closed ||
55
+ frame.generation !== this.registry.generation ||
56
+ frame.connectionNonce !== this.#nonce)
57
+ return false;
58
+ const previous = this.#retained.get(frame.connectionNonce);
59
+ if (previous)
60
+ return JSON.stringify(previous.receiver) === JSON.stringify(frame);
61
+ if (this.#used)
62
+ return false;
63
+ this.#used = true;
64
+ this.#prune();
65
+ if (this.#retained.size >= MAX_RETAINED) {
66
+ this.#fallback(frame, "budget");
67
+ return true;
68
+ }
69
+ const session = this.registry.open(frame, this.panes, (control) => this.#send(control), this.deliveryNonce);
70
+ if (!session) {
71
+ this.#fallback(frame, "unsupported");
72
+ return true;
73
+ }
74
+ if (this.#closed) {
75
+ session.close();
76
+ return true;
77
+ }
78
+ const entry = { receiver: frame, session };
79
+ this.#retained.set(frame.connectionNonce, entry);
80
+ this.#active = entry;
81
+ return true;
82
+ }
83
+ retire(frame) {
84
+ if (this.#closed || frame.generation !== this.registry.generation)
85
+ return false;
86
+ const entry = this.#retained.get(frame.connectionNonce);
87
+ if (!entry?.session.retire(frame.sequence))
88
+ return false;
89
+ this.#prune();
90
+ return true;
91
+ }
92
+ fallback(frame) {
93
+ if (this.#closed || frame.generation !== this.registry.generation)
94
+ return false;
95
+ const entry = this.#retained.get(frame.connectionNonce);
96
+ if (!entry)
97
+ return this.#used && frame.connectionNonce === this.#nonce;
98
+ entry.session.fallback(frame.reason);
99
+ if (this.#active === entry)
100
+ this.#active = null;
101
+ this.#prune();
102
+ return true;
103
+ }
104
+ #fallback(receiver, reason) {
105
+ this.#send({
106
+ type: "terminal-surface-fallback",
107
+ version: 1,
108
+ generation: receiver.generation,
109
+ connectionNonce: receiver.connectionNonce,
110
+ reason,
111
+ });
112
+ }
113
+ #prune() {
114
+ for (const [nonce, entry] of this.#retained) {
115
+ if (entry !== this.#active && entry.session.outstanding === 0) {
116
+ entry.session.close();
117
+ this.#retained.delete(nonce);
118
+ }
119
+ }
120
+ }
121
+ #send(frame) {
122
+ if (this.#closed)
123
+ return false;
124
+ try {
125
+ if (this.send(frame))
126
+ return !this.#closed;
127
+ }
128
+ catch {
129
+ /* Revoke a failed sink. */
130
+ }
131
+ this.close();
132
+ return false;
133
+ }
134
+ close() {
135
+ if (this.#closed)
136
+ return;
137
+ this.#closed = true;
138
+ this.#stop?.();
139
+ this.#stop = null;
140
+ for (const entry of this.#retained.values())
141
+ entry.session.close();
142
+ this.#retained.clear();
143
+ this.#active = null;
144
+ this.#nonce = null;
145
+ }
146
+ }
@@ -591,6 +591,9 @@ export class MirrorService {
591
591
  }
592
592
  },
593
593
  onNativeClientActivity: () => this.opts.onNativeClientActivity?.(session),
594
+ onNativeClientPresence: this.opts.onNativeClientPresence
595
+ ? (present) => this.opts.onNativeClientPresence?.(session, present)
596
+ : undefined,
594
597
  captureInputWrite: (action, semanticPaneId) => this.opts.captureInputWrite?.(session, action, semanticPaneId),
595
598
  onInputWrite: (action, startedAtMicros, endedAtMicros, pendingBeforeSend) => this.opts.onInputWrite?.(session, action, startedAtMicros, endedAtMicros, pendingBeforeSend),
596
599
  onInputAccepted: (action, acceptedAtMicros, ok) => this.opts.onInputAccepted?.(session, action, acceptedAtMicros, ok),
@@ -184,6 +184,8 @@ export class SessionChannel {
184
184
  disposed = false;
185
185
  nativeGrid;
186
186
  nativeClientProbePending = false;
187
+ nativeClientProbeDirty = false;
188
+ nativeClientProbeClosed = false;
187
189
  // Native captures fence every geometry event; inventory fences identity only.
188
190
  windowAuthorityOrdinal = 0;
189
191
  layoutNotificationOrdinals = new Map();
@@ -367,7 +369,7 @@ export class SessionChannel {
367
369
  this.io.send("refresh-client -B 'tmux-ide-copy-keys:@*:#{mode-keys}'");
368
370
  this.io.send("refresh-client -B 'tmux-ide-pane-history:%*:#{history_size}'");
369
371
  this.io.send("refresh-client -B 'tmux-ide-scroll-on-clear:%*:#{scroll-on-clear}'");
370
- if (this.opts.onNativeClientActivity) {
372
+ if (this.opts.onNativeClientActivity || this.opts.onNativeClientPresence) {
371
373
  // tmux does not guarantee `%client-attached` is broadcast to an
372
374
  // existing control client. A format subscription is the documented,
373
375
  // event-driven observation seam; its notification only schedules the
@@ -376,6 +378,8 @@ export class SessionChannel {
376
378
  }
377
379
  await this.syncNow();
378
380
  await this.firstJoin;
381
+ if (this.opts.onNativeClientPresence)
382
+ this.probeNativeClientActivity();
379
383
  }
380
384
  // ── Public surface (semantic ids only) ──────────────────────────────────
381
385
  describe() {
@@ -1001,6 +1005,7 @@ export class SessionChannel {
1001
1005
  if (this.disposed)
1002
1006
  return;
1003
1007
  this.disposed = true;
1008
+ this.opts.onNativeClientPresence?.(null);
1004
1009
  this.snapshotActive?.cleanup?.();
1005
1010
  this.snapshotActive?.cancelFence?.();
1006
1011
  this.snapshotActive = null;
@@ -2308,7 +2313,7 @@ export class SessionChannel {
2308
2313
  // Layout changes remain a second honest wake-up: a native resize can arrive
2309
2314
  // before the once-per-second subscription notification. The inventory
2310
2315
  // (not either notification) remains the proof.
2311
- if (this.opts.onNativeClientActivity &&
2316
+ if ((this.opts.onNativeClientActivity || this.opts.onNativeClientPresence) &&
2312
2317
  (NATIVE_CLIENT_NOTIFICATIONS.has(name) || name === "layout-change")) {
2313
2318
  this.probeNativeClientActivity();
2314
2319
  }
@@ -2492,22 +2497,38 @@ export class SessionChannel {
2492
2497
  this.scheduleSync();
2493
2498
  }
2494
2499
  probeNativeClientActivity() {
2495
- if (this.nativeClientProbePending || this.disposed)
2500
+ if (this.disposed || this.nativeClientProbeClosed)
2501
+ return;
2502
+ this.opts.onNativeClientPresence?.(null);
2503
+ if (this.nativeClientProbePending) {
2504
+ this.nativeClientProbeDirty ||= this.opts.onNativeClientPresence !== undefined;
2496
2505
  return;
2506
+ }
2497
2507
  this.nativeClientProbePending = true;
2508
+ this.nativeClientProbeDirty = false;
2498
2509
  void this.io
2499
2510
  .request(`list-clients -t "${this.opts.session}" -F "#{client_control_mode}\t#{client_activity}"`)
2500
2511
  .then((lines) => {
2501
- // The daemon's own mirror is a control-mode client. Only a tmux-owned
2502
- // attached client (control mode = 0) is honest evidence for yielding
2503
- // geometry; notification names alone can include our own lifecycle.
2504
- if (lines.some((line) => /^0\t\d+$/u.test(line.trim()))) {
2512
+ if (this.disposed || this.nativeClientProbeClosed || this.nativeClientProbeDirty)
2513
+ return;
2514
+ // Control-mode clients include our own mirrors. Only complete valid
2515
+ // inventory proves absence; malformed replies must preserve fallback.
2516
+ const valid = lines.every((line) => /^[01]\t\d+$/u.test(line.trim()));
2517
+ const present = lines.some((line) => /^0\t\d+$/u.test(line.trim()));
2518
+ this.opts.onNativeClientPresence?.(present ? true : valid ? false : null);
2519
+ if (present)
2505
2520
  this.opts.onNativeClientActivity?.();
2506
- }
2507
2521
  })
2508
- .catch(() => undefined)
2522
+ .catch(() => {
2523
+ if (!this.disposed && !this.nativeClientProbeClosed)
2524
+ this.opts.onNativeClientPresence?.(null);
2525
+ })
2509
2526
  .finally(() => {
2510
2527
  this.nativeClientProbePending = false;
2528
+ // An attach/detach while the query ran invalidated its answer. Keep one
2529
+ // follow-up request, rather than dropping the event or growing a queue.
2530
+ if (this.nativeClientProbeDirty)
2531
+ this.probeNativeClientActivity();
2511
2532
  });
2512
2533
  }
2513
2534
  releasePendingLayout(windowRuntimeId, syncOrdinal) {
@@ -3401,6 +3422,8 @@ export class SessionChannel {
3401
3422
  onChannelExit() {
3402
3423
  if (this.disposed)
3403
3424
  return;
3425
+ this.nativeClientProbeClosed = true;
3426
+ this.opts.onNativeClientPresence?.(null);
3404
3427
  this.snapshotActive?.cancelFence?.();
3405
3428
  this.snapshotActive = null;
3406
3429
  this.snapshotQueue.clear();