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.
- package/bin/cli.js +3438 -2062
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +2 -0
- package/packages/contracts/src/pane-stream.ts +17 -0
- package/packages/contracts/src/terminal-delivery.ts +2 -1
- package/packages/contracts/src/terminal-graphics-compressed.ts +66 -0
- package/packages/contracts/src/terminal-graphics.ts +5 -0
- package/packages/contracts/src/terminal-surfaces.ts +137 -0
- package/packages/core/src/terminal-delivery.ts +8 -2
- package/packages/core/src/terminal-replica.ts +33 -0
- package/packages/daemon/dist/command-center/resources/saved-machine-route.js +2 -0
- package/packages/daemon/dist/command-center/resources/saved-machine-watch.js +117 -0
- package/packages/daemon/dist/command-center/server.js +13 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +9 -0
- package/packages/daemon/dist/lib/daemon-embed.js +32 -1
- package/packages/daemon/dist/lib/native-surface-context.js +96 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +9 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/server/native-surface-provider-upgrade.js +88 -0
- package/packages/daemon/dist/terminal/graphics/compress-chunk.js +40 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-demand.js +99 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-provider-channel.js +162 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-provider.js +275 -0
- package/packages/daemon/dist/terminal/graphics/native-surface-viewer.js +146 -0
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +3 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +32 -9
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +196 -3
- package/packages/daemon/dist/terminal/pane-stream/runtime.js +9 -0
- package/packages/daemon/src/command-center/resources/saved-machine-route.ts +4 -0
- package/packages/daemon/src/command-center/resources/saved-machine-watch.ts +132 -0
- package/packages/daemon/src/command-center/server.ts +18 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +14 -0
- package/packages/daemon/src/lib/daemon-embed.ts +43 -1
- package/packages/daemon/src/lib/native-surface-context.ts +123 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +9 -0
- package/packages/daemon/src/server/native-surface-provider-upgrade.ts +103 -0
- package/packages/daemon/src/terminal/graphics/compress-chunk.ts +49 -0
- package/packages/daemon/src/terminal/graphics/native-surface-demand.ts +88 -0
- package/packages/daemon/src/terminal/graphics/native-surface-provider-channel.ts +161 -0
- package/packages/daemon/src/terminal/graphics/native-surface-provider.ts +308 -0
- package/packages/daemon/src/terminal/graphics/native-surface-viewer.ts +154 -0
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +5 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +31 -10
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +229 -2
- 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.
|
|
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
|
-
|
|
3153
|
-
//
|
|
3154
|
-
//
|
|
3155
|
-
|
|
3156
|
-
|
|
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();
|