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,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.
|
|
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
|
-
|
|
2502
|
-
|
|
2503
|
-
//
|
|
2504
|
-
|
|
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(() =>
|
|
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();
|