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,308 @@
1
+ import { NativeSurfaceDemand } from "./native-surface-demand.ts";
2
+ import {
3
+ SessionRuntimeGenerationSchemaZ,
4
+ TerminalSurfaceAuditTokenSchemaZ,
5
+ TerminalSurfaceOfferSchemaZ,
6
+ TerminalSurfaceReceiverSchemaZ,
7
+ TERMINAL_SURFACE_MAX_BYTES,
8
+ TERMINAL_SURFACE_MAX_OUTSTANDING,
9
+ TERMINAL_SURFACE_MAX_CONTROL_BYTES,
10
+ type TerminalSurfaceReceiver,
11
+ type TerminalSurfaceOffer,
12
+ type TerminalSurfacePeer,
13
+ type TerminalSurfaceFallback,
14
+ } from "@tmux-ide/contracts";
15
+
16
+ type Reason = TerminalSurfaceFallback["reason"];
17
+ type Control = TerminalSurfaceOffer | TerminalSurfacePeer | TerminalSurfaceFallback;
18
+ /** Native provider owns the actual kernel proof, immutable backing and GPU-safe
19
+ * reuse policy. These callbacks must enqueue bounded work, never block Node. */
20
+ export interface NativeSurfaceProducerConnection {
21
+ start(): void;
22
+ retire(sequence: number): void;
23
+ fallback(): void;
24
+ close(): void;
25
+ }
26
+ export interface NativeSurfaceProvider {
27
+ readonly auditToken: readonly number[];
28
+ open(context: {
29
+ readonly receiver: TerminalSurfaceReceiver;
30
+ readonly panes: readonly string[];
31
+ /** Reserve and send control before transferring a native right. False means
32
+ * no transfer is allowed; retry/backpressure belongs to the native owner. */
33
+ offer(value: unknown): boolean;
34
+ }): NativeSurfaceProducerConnection;
35
+ }
36
+ export interface NativeSurfaceSession {
37
+ retire(sequence: number): boolean;
38
+ fallback(reason: Reason): void;
39
+ close(): void;
40
+ readonly outstanding: number;
41
+ }
42
+ interface Registration {
43
+ readonly provider: NativeSurfaceProvider;
44
+ readonly auditToken: number[];
45
+ readonly sessions: Set<Session>;
46
+ }
47
+
48
+ /** Internal daemon registration only. Never construct a provider from terminal
49
+ * content, request-supplied paths, a PID lookup or a loopback address. The native
50
+ * owner must be authenticated before registration; kernel proof still follows. */
51
+ export class NativeSurfaceProviderRegistry {
52
+ readonly demand = new NativeSurfaceDemand();
53
+ readonly generation: string;
54
+ #registration: Registration | null = null;
55
+ #sessions = new Set<Session>();
56
+ #bytes = 0;
57
+ #frames = 0;
58
+ #revision = 0;
59
+ #closed = false;
60
+ #watchers = new Set<(available: boolean) => void>();
61
+ #notificationQueued = false;
62
+ constructor(generation: string) {
63
+ this.generation = SessionRuntimeGenerationSchemaZ.parse(generation);
64
+ }
65
+ get revision(): number {
66
+ return this.#revision;
67
+ }
68
+ get available(): boolean {
69
+ return this.#registration !== null;
70
+ }
71
+ watchAvailability(listener: (available: boolean) => void): () => void {
72
+ if (this.#closed || this.#watchers.size >= 256)
73
+ throw new Error("Native surface availability subscription unavailable");
74
+ this.#watchers.add(listener);
75
+ this.#notifyAvailability();
76
+ return () => this.#watchers.delete(listener);
77
+ }
78
+ #notifyAvailability(): void {
79
+ if (this.#notificationQueued) return;
80
+ this.#notificationQueued = true;
81
+ // Provider registration must finish before a viewer can open its channel.
82
+ queueMicrotask(() => {
83
+ this.#notificationQueued = false;
84
+ for (const listener of [...this.#watchers]) {
85
+ if (!this.#watchers.has(listener)) continue;
86
+ try {
87
+ listener(this.available);
88
+ } catch {
89
+ this.#watchers.delete(listener);
90
+ }
91
+ }
92
+ if (this.#closed) this.#watchers.clear();
93
+ });
94
+ }
95
+ register(provider: NativeSurfaceProvider): () => void {
96
+ if (this.#closed) throw new Error("Native surface registry closed");
97
+ if (this.#registration) throw new Error("Native surface provider already registered");
98
+ const registration: Registration = {
99
+ provider,
100
+ auditToken: TerminalSurfaceAuditTokenSchemaZ.parse(provider.auditToken),
101
+ sessions: new Set(),
102
+ };
103
+ this.#registration = registration;
104
+ this.#revision++;
105
+ this.#notifyAvailability();
106
+ return () => {
107
+ if (this.#registration !== registration) return;
108
+ this.#registration = null;
109
+ this.#revision++;
110
+ // Existing original sockets may still ACK retained GPU storage. Replacing
111
+ // a provider never redirects those ACKs to the replacement process.
112
+ for (const session of registration.sessions) session.fallback("unsupported");
113
+ this.#notifyAvailability();
114
+ };
115
+ }
116
+ open(
117
+ receiver: TerminalSurfaceReceiver,
118
+ panes: readonly string[],
119
+ send: (frame: Control) => boolean,
120
+ deliveryNonce?: (pane: string, generation: string) => string | null,
121
+ ): NativeSurfaceSession | null {
122
+ receiver = TerminalSurfaceReceiverSchemaZ.parse(receiver);
123
+ const registration = this.#registration;
124
+ if (!registration || receiver.generation !== this.generation || this.#sessions.size >= 32)
125
+ return null;
126
+ const session = new Session(
127
+ receiver,
128
+ panes,
129
+ registration,
130
+ send,
131
+ deliveryNonce,
132
+ (bytes) => {
133
+ if (this.#frames >= 32 || this.#bytes + bytes > 256 * 1024 * 1024) return false;
134
+ this.#frames++;
135
+ this.#bytes += bytes;
136
+ return true;
137
+ },
138
+ (bytes) => {
139
+ this.#frames--;
140
+ this.#bytes -= bytes;
141
+ },
142
+ () => {
143
+ registration.sessions.delete(session);
144
+ this.#sessions.delete(session);
145
+ },
146
+ );
147
+ registration.sessions.add(session);
148
+ this.#sessions.add(session);
149
+ session.start();
150
+ return session;
151
+ }
152
+ close(): void {
153
+ if (this.#closed) return;
154
+ this.#closed = true;
155
+ this.demand.close();
156
+ this.#registration = null;
157
+ this.#revision++;
158
+ for (const session of [...this.#sessions]) session.close();
159
+ this.#notifyAvailability();
160
+ }
161
+ }
162
+
163
+ class Session implements NativeSurfaceSession {
164
+ #producer: NativeSurfaceProducerConnection | null = null;
165
+ #outstanding = new Map<number, number>();
166
+ #bytes = 0;
167
+ #lastSequence = 0;
168
+ #stopped = false;
169
+ #closed = false;
170
+ readonly #panes: ReadonlySet<string>;
171
+ constructor(
172
+ readonly receiver: TerminalSurfaceReceiver,
173
+ panes: readonly string[],
174
+ readonly registration: Registration,
175
+ readonly send: (frame: Control) => boolean,
176
+ readonly deliveryNonce: ((pane: string, generation: string) => string | null) | undefined,
177
+ readonly reserve: (bytes: number) => boolean,
178
+ readonly release: (bytes: number) => void,
179
+ readonly disposed: () => void,
180
+ ) {
181
+ this.#panes = new Set(panes);
182
+ }
183
+ get outstanding(): number {
184
+ return this.#outstanding.size;
185
+ }
186
+ start(): void {
187
+ try {
188
+ const producer = this.registration.provider.open({
189
+ receiver: structuredClone(this.receiver),
190
+ panes: [...this.#panes],
191
+ offer: (value) => this.#offer(value),
192
+ });
193
+ if (this.#stopped || this.#closed) {
194
+ try {
195
+ producer.close();
196
+ } catch {
197
+ /* Registration already revoked this connection. */
198
+ }
199
+ this.close();
200
+ return;
201
+ }
202
+ this.#producer = producer;
203
+ if (
204
+ !this.#send({
205
+ type: "terminal-surface-peer",
206
+ version: 1,
207
+ generation: this.receiver.generation,
208
+ connectionNonce: this.receiver.connectionNonce,
209
+ auditToken: [...this.registration.auditToken],
210
+ })
211
+ ) {
212
+ this.close();
213
+ return;
214
+ }
215
+ if (!this.#stopped && !this.#closed) this.#producer?.start();
216
+ } catch {
217
+ this.fallback("storage");
218
+ }
219
+ }
220
+ #offer(value: unknown): boolean {
221
+ if (this.#closed || this.#stopped || !this.#producer) return false;
222
+ const parsed = TerminalSurfaceOfferSchemaZ.safeParse(value);
223
+ if (!parsed.success) return false;
224
+ let offer = parsed.data;
225
+ // A producer's own read-only subscription has a different delivery nonce.
226
+ // Only the daemon owning the target viewer may bind that nonce. Preserve
227
+ // canonical revision/hash/incarnation exactly; the receiver verifies them.
228
+ if (this.deliveryNonce) {
229
+ const nonce = this.deliveryNonce(offer.pane, offer.generation);
230
+ if (!nonce) return false;
231
+ const rebound = TerminalSurfaceOfferSchemaZ.safeParse({ ...offer, deliveryNonce: nonce });
232
+ if (!rebound.success) return false;
233
+ offer = rebound.data;
234
+ }
235
+ if (
236
+ offer.generation !== this.receiver.generation ||
237
+ !this.#panes.has(offer.pane) ||
238
+ offer.sequence <= this.#lastSequence ||
239
+ this.#outstanding.size >= TERMINAL_SURFACE_MAX_OUTSTANDING ||
240
+ this.#bytes + offer.allocationBytes > TERMINAL_SURFACE_MAX_BYTES ||
241
+ Buffer.byteLength(JSON.stringify(offer)) > TERMINAL_SURFACE_MAX_CONTROL_BYTES ||
242
+ !this.reserve(offer.allocationBytes)
243
+ )
244
+ return false;
245
+ this.#lastSequence = offer.sequence;
246
+ this.#outstanding.set(offer.sequence, offer.allocationBytes);
247
+ this.#bytes += offer.allocationBytes;
248
+ if (this.#send(offer)) return !this.#closed && !this.#stopped;
249
+ this.close();
250
+ return false;
251
+ }
252
+ retire(sequence: number): boolean {
253
+ const bytes = this.#outstanding.get(sequence);
254
+ if (this.#closed || bytes === undefined) return false;
255
+ this.#outstanding.delete(sequence);
256
+ this.#bytes -= bytes;
257
+ this.release(bytes);
258
+ try {
259
+ this.#producer?.retire(sequence);
260
+ } catch {
261
+ this.fallback("storage");
262
+ }
263
+ return true;
264
+ }
265
+ fallback(reason: Reason): void {
266
+ if (this.#closed || this.#stopped) return;
267
+ this.#stopped = true;
268
+ try {
269
+ this.#producer?.fallback();
270
+ } catch {
271
+ /* Native owner cannot authorize new transfers. */
272
+ }
273
+ if (
274
+ !this.#send({
275
+ type: "terminal-surface-fallback",
276
+ version: 1,
277
+ generation: this.receiver.generation,
278
+ connectionNonce: this.receiver.connectionNonce,
279
+ reason,
280
+ })
281
+ )
282
+ this.close();
283
+ }
284
+ close(): void {
285
+ if (this.#closed) return;
286
+ this.#closed = true;
287
+ // Closing releases accounting/reference ownership, never grants buffer reuse.
288
+ // Native receivers retain their own backing/GPU leases after this connection.
289
+ for (const bytes of this.#outstanding.values()) this.release(bytes);
290
+ this.#outstanding.clear();
291
+ this.#bytes = 0;
292
+ try {
293
+ this.#producer?.close();
294
+ } catch {
295
+ /* All daemon-side admissions remain revoked. */
296
+ } finally {
297
+ this.#producer = null;
298
+ this.disposed();
299
+ }
300
+ }
301
+ #send(frame: Control): boolean {
302
+ try {
303
+ return this.send(frame);
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+ }
@@ -0,0 +1,154 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ TerminalSurfaceAvailable,
4
+ TerminalSurfaceFallback,
5
+ TerminalSurfaceOffer,
6
+ TerminalSurfacePeer,
7
+ TerminalSurfaceReceiver,
8
+ TerminalSurfaceRetired,
9
+ } from "@tmux-ide/contracts";
10
+ import type {
11
+ NativeSurfaceProviderRegistry,
12
+ NativeSurfaceSession,
13
+ } from "./native-surface-provider.ts";
14
+
15
+ type Output =
16
+ | TerminalSurfaceAvailable
17
+ | TerminalSurfaceFallback
18
+ | TerminalSurfaceOffer
19
+ | TerminalSurfacePeer;
20
+ type Entry = { receiver: TerminalSurfaceReceiver; session: NativeSurfaceSession };
21
+ const MAX_RETAINED = 8;
22
+
23
+ /** One terminal socket owns every negotiation and its remaining GPU retirements. */
24
+ export class NativeSurfaceViewer {
25
+ #stop: (() => void) | null = null;
26
+ #closed = false;
27
+ #revision = -1;
28
+ #nonce: string | null = null;
29
+ #used = false;
30
+ #active: Entry | null = null;
31
+ #retained = new Map<string, Entry>();
32
+ constructor(
33
+ readonly registry: NativeSurfaceProviderRegistry,
34
+ readonly panes: readonly string[],
35
+ readonly send: (frame: Output) => boolean,
36
+ readonly deliveryNonce: (pane: string, generation: string) => string | null,
37
+ ) {}
38
+ start(): void {
39
+ if (this.#closed || this.#stop) return;
40
+ try {
41
+ this.#stop = this.registry.watchAvailability((available) => {
42
+ if (this.#closed || this.#revision === this.registry.revision) return;
43
+ this.#revision = this.registry.revision;
44
+ this.#active?.session.fallback("unsupported");
45
+ this.#active = null;
46
+ this.#prune();
47
+ this.#nonce = available ? randomUUID() : null;
48
+ this.#used = false;
49
+ this.#send({
50
+ type: "terminal-surface-available",
51
+ version: 1,
52
+ generation: this.registry.generation,
53
+ connectionNonce: this.#nonce,
54
+ });
55
+ });
56
+ } catch {
57
+ this.#send({
58
+ type: "terminal-surface-available",
59
+ version: 1,
60
+ generation: this.registry.generation,
61
+ connectionNonce: null,
62
+ });
63
+ this.close();
64
+ }
65
+ }
66
+ receive(frame: TerminalSurfaceReceiver): boolean {
67
+ if (
68
+ this.#closed ||
69
+ frame.generation !== this.registry.generation ||
70
+ frame.connectionNonce !== this.#nonce
71
+ )
72
+ return false;
73
+ const previous = this.#retained.get(frame.connectionNonce);
74
+ if (previous) return JSON.stringify(previous.receiver) === JSON.stringify(frame);
75
+ if (this.#used) return false;
76
+ this.#used = true;
77
+ this.#prune();
78
+ if (this.#retained.size >= MAX_RETAINED) {
79
+ this.#fallback(frame, "budget");
80
+ return true;
81
+ }
82
+ const session = this.registry.open(
83
+ frame,
84
+ this.panes,
85
+ (control) => this.#send(control),
86
+ this.deliveryNonce,
87
+ );
88
+ if (!session) {
89
+ this.#fallback(frame, "unsupported");
90
+ return true;
91
+ }
92
+ if (this.#closed) {
93
+ session.close();
94
+ return true;
95
+ }
96
+ const entry = { receiver: frame, session };
97
+ this.#retained.set(frame.connectionNonce, entry);
98
+ this.#active = entry;
99
+ return true;
100
+ }
101
+ retire(frame: TerminalSurfaceRetired): boolean {
102
+ if (this.#closed || frame.generation !== this.registry.generation) return false;
103
+ const entry = this.#retained.get(frame.connectionNonce);
104
+ if (!entry?.session.retire(frame.sequence)) return false;
105
+ this.#prune();
106
+ return true;
107
+ }
108
+ fallback(frame: TerminalSurfaceFallback): boolean {
109
+ if (this.#closed || frame.generation !== this.registry.generation) return false;
110
+ const entry = this.#retained.get(frame.connectionNonce);
111
+ if (!entry) return this.#used && frame.connectionNonce === this.#nonce;
112
+ entry.session.fallback(frame.reason);
113
+ if (this.#active === entry) this.#active = null;
114
+ this.#prune();
115
+ return true;
116
+ }
117
+ #fallback(receiver: TerminalSurfaceReceiver, reason: TerminalSurfaceFallback["reason"]): void {
118
+ this.#send({
119
+ type: "terminal-surface-fallback",
120
+ version: 1,
121
+ generation: receiver.generation,
122
+ connectionNonce: receiver.connectionNonce,
123
+ reason,
124
+ });
125
+ }
126
+ #prune(): void {
127
+ for (const [nonce, entry] of this.#retained) {
128
+ if (entry !== this.#active && entry.session.outstanding === 0) {
129
+ entry.session.close();
130
+ this.#retained.delete(nonce);
131
+ }
132
+ }
133
+ }
134
+ #send(frame: Output): boolean {
135
+ if (this.#closed) return false;
136
+ try {
137
+ if (this.send(frame)) return !this.#closed;
138
+ } catch {
139
+ /* Revoke a failed sink. */
140
+ }
141
+ this.close();
142
+ return false;
143
+ }
144
+ close(): void {
145
+ if (this.#closed) return;
146
+ this.#closed = true;
147
+ this.#stop?.();
148
+ this.#stop = null;
149
+ for (const entry of this.#retained.values()) entry.session.close();
150
+ this.#retained.clear();
151
+ this.#active = null;
152
+ this.#nonce = null;
153
+ }
154
+ }
@@ -92,6 +92,8 @@ export interface MirrorServiceOptions {
92
92
  controlModeOwnershipRegistry?: ControlModeOwnershipRegistry;
93
93
  /** Emitted only after an event-triggered list-clients proof of a native client. */
94
94
  onNativeClientActivity?: (session: string) => void;
95
+ /** Proven native viewer presence; null preserves fallback while unknown. */
96
+ onNativeClientPresence?: (session: string, present: boolean | null) => void;
95
97
  captureInputWrite?: (
96
98
  session: string,
97
99
  ...args: Parameters<NonNullable<SessionChannelOptions["captureInputWrite"]>>
@@ -813,6 +815,9 @@ export class MirrorService {
813
815
  }
814
816
  },
815
817
  onNativeClientActivity: () => this.opts.onNativeClientActivity?.(session),
818
+ onNativeClientPresence: this.opts.onNativeClientPresence
819
+ ? (present) => this.opts.onNativeClientPresence?.(session, present)
820
+ : undefined,
816
821
  captureInputWrite: (action, semanticPaneId) =>
817
822
  this.opts.captureInputWrite?.(session, action, semanticPaneId),
818
823
  onInputWrite: (action, startedAtMicros, endedAtMicros, pendingBeforeSend) =>
@@ -272,6 +272,8 @@ export interface SessionChannelOptions {
272
272
  onExit?: () => void;
273
273
  /** Event-driven proof that a non-control tmux client is actively attached. */
274
274
  onNativeClientActivity?: () => void;
275
+ /** null means unproven: consumers must preserve ordinary terminal output. */
276
+ onNativeClientPresence?: (present: boolean | null) => void;
275
277
  /** Optional diagnostic capture before dispatch; completion must not affect input. */
276
278
  captureInputWrite?: (
277
279
  action: InputAction,
@@ -514,6 +516,8 @@ export class SessionChannel {
514
516
  private disposed = false;
515
517
  private readonly nativeGrid: NativeGridCaptureReader;
516
518
  private nativeClientProbePending = false;
519
+ private nativeClientProbeDirty = false;
520
+ private nativeClientProbeClosed = false;
517
521
  // Native captures fence every geometry event; inventory fences identity only.
518
522
  private windowAuthorityOrdinal = 0;
519
523
  private readonly layoutNotificationOrdinals = new Map<string, number>();
@@ -751,7 +755,7 @@ export class SessionChannel {
751
755
  this.io.send("refresh-client -B 'tmux-ide-copy-keys:@*:#{mode-keys}'");
752
756
  this.io.send("refresh-client -B 'tmux-ide-pane-history:%*:#{history_size}'");
753
757
  this.io.send("refresh-client -B 'tmux-ide-scroll-on-clear:%*:#{scroll-on-clear}'");
754
- if (this.opts.onNativeClientActivity) {
758
+ if (this.opts.onNativeClientActivity || this.opts.onNativeClientPresence) {
755
759
  // tmux does not guarantee `%client-attached` is broadcast to an
756
760
  // existing control client. A format subscription is the documented,
757
761
  // event-driven observation seam; its notification only schedules the
@@ -760,6 +764,7 @@ export class SessionChannel {
760
764
  }
761
765
  await this.syncNow();
762
766
  await this.firstJoin;
767
+ if (this.opts.onNativeClientPresence) this.probeNativeClientActivity();
763
768
  }
764
769
 
765
770
  // ── Public surface (semantic ids only) ──────────────────────────────────
@@ -1489,6 +1494,7 @@ export class SessionChannel {
1489
1494
  async dispose(): Promise<void> {
1490
1495
  if (this.disposed) return;
1491
1496
  this.disposed = true;
1497
+ this.opts.onNativeClientPresence?.(null);
1492
1498
  this.snapshotActive?.cleanup?.();
1493
1499
  this.snapshotActive?.cancelFence?.();
1494
1500
  this.snapshotActive = null;
@@ -2951,7 +2957,7 @@ export class SessionChannel {
2951
2957
  // before the once-per-second subscription notification. The inventory
2952
2958
  // (not either notification) remains the proof.
2953
2959
  if (
2954
- this.opts.onNativeClientActivity &&
2960
+ (this.opts.onNativeClientActivity || this.opts.onNativeClientPresence) &&
2955
2961
  (NATIVE_CLIENT_NOTIFICATIONS.has(name) || name === "layout-change")
2956
2962
  ) {
2957
2963
  this.probeNativeClientActivity();
@@ -3142,23 +3148,36 @@ export class SessionChannel {
3142
3148
  }
3143
3149
 
3144
3150
  private probeNativeClientActivity(): void {
3145
- if (this.nativeClientProbePending || this.disposed) return;
3151
+ if (this.disposed || this.nativeClientProbeClosed) return;
3152
+ this.opts.onNativeClientPresence?.(null);
3153
+ if (this.nativeClientProbePending) {
3154
+ this.nativeClientProbeDirty ||= this.opts.onNativeClientPresence !== undefined;
3155
+ return;
3156
+ }
3146
3157
  this.nativeClientProbePending = true;
3158
+ this.nativeClientProbeDirty = false;
3147
3159
  void this.io
3148
3160
  .request(
3149
3161
  `list-clients -t "${this.opts.session}" -F "#{client_control_mode}\t#{client_activity}"`,
3150
3162
  )
3151
3163
  .then((lines) => {
3152
- // The daemon's own mirror is a control-mode client. Only a tmux-owned
3153
- // attached client (control mode = 0) is honest evidence for yielding
3154
- // geometry; notification names alone can include our own lifecycle.
3155
- if (lines.some((line) => /^0\t\d+$/u.test(line.trim()))) {
3156
- this.opts.onNativeClientActivity?.();
3157
- }
3164
+ if (this.disposed || this.nativeClientProbeClosed || this.nativeClientProbeDirty) return;
3165
+ // Control-mode clients include our own mirrors. Only complete valid
3166
+ // inventory proves absence; malformed replies must preserve fallback.
3167
+ const valid = lines.every((line) => /^[01]\t\d+$/u.test(line.trim()));
3168
+ const present = lines.some((line) => /^0\t\d+$/u.test(line.trim()));
3169
+ this.opts.onNativeClientPresence?.(present ? true : valid ? false : null);
3170
+ if (present) this.opts.onNativeClientActivity?.();
3171
+ })
3172
+ .catch(() => {
3173
+ if (!this.disposed && !this.nativeClientProbeClosed)
3174
+ this.opts.onNativeClientPresence?.(null);
3158
3175
  })
3159
- .catch(() => undefined)
3160
3176
  .finally(() => {
3161
3177
  this.nativeClientProbePending = false;
3178
+ // An attach/detach while the query ran invalidated its answer. Keep one
3179
+ // follow-up request, rather than dropping the event or growing a queue.
3180
+ if (this.nativeClientProbeDirty) this.probeNativeClientActivity();
3162
3181
  });
3163
3182
  }
3164
3183
 
@@ -4209,6 +4228,8 @@ export class SessionChannel {
4209
4228
 
4210
4229
  private onChannelExit(): void {
4211
4230
  if (this.disposed) return;
4231
+ this.nativeClientProbeClosed = true;
4232
+ this.opts.onNativeClientPresence?.(null);
4212
4233
  this.snapshotActive?.cancelFence?.();
4213
4234
  this.snapshotActive = null;
4214
4235
  this.snapshotQueue.clear();