tmux-ide 2.9.11 → 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 (84) hide show
  1. package/bin/cli.js +6036 -3047
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +6 -0
  4. package/packages/contracts/src/pane-stream.ts +77 -0
  5. package/packages/contracts/src/terminal-delivery-binary-chunk.ts +55 -0
  6. package/packages/contracts/src/terminal-delivery.ts +19 -3
  7. package/packages/contracts/src/terminal-graphics-compressed.ts +66 -0
  8. package/packages/contracts/src/terminal-graphics.ts +73 -0
  9. package/packages/contracts/src/terminal-projection.ts +153 -0
  10. package/packages/contracts/src/terminal-surfaces.ts +137 -0
  11. package/packages/core/src/fixtures/terminal-projection-vectors.json +585 -0
  12. package/packages/core/src/index.ts +2 -0
  13. package/packages/core/src/terminal-delivery-binary.ts +160 -0
  14. package/packages/core/src/terminal-delivery.ts +377 -16
  15. package/packages/core/src/terminal-projection-vectors.mjs +82 -0
  16. package/packages/core/src/terminal-projection.ts +476 -0
  17. package/packages/core/src/terminal-replica.ts +34 -1
  18. package/packages/daemon/dist/command-center/resources/saved-machine-route.js +2 -0
  19. package/packages/daemon/dist/command-center/resources/saved-machine-watch.js +117 -0
  20. package/packages/daemon/dist/command-center/server.js +13 -0
  21. package/packages/daemon/dist/command-center/tmux-servers.js +9 -0
  22. package/packages/daemon/dist/lib/daemon-embed.js +71 -4
  23. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
  24. package/packages/daemon/dist/lib/native-surface-context.js +96 -0
  25. package/packages/daemon/dist/lib/tmux-server-owner.js +10 -0
  26. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  27. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  28. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  29. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  30. package/packages/daemon/dist/server/native-surface-provider-upgrade.js +88 -0
  31. package/packages/daemon/dist/terminal/graphics/compress-chunk.js +40 -0
  32. package/packages/daemon/dist/terminal/graphics/local-image-owner.js +330 -0
  33. package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
  34. package/packages/daemon/dist/terminal/graphics/native-surface-demand.js +99 -0
  35. package/packages/daemon/dist/terminal/graphics/native-surface-provider-channel.js +162 -0
  36. package/packages/daemon/dist/terminal/graphics/native-surface-provider.js +275 -0
  37. package/packages/daemon/dist/terminal/graphics/native-surface-viewer.js +146 -0
  38. package/packages/daemon/dist/terminal/mirror/mirror-service.js +3 -0
  39. package/packages/daemon/dist/terminal/mirror/session-channel.js +32 -9
  40. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +369 -16
  41. package/packages/daemon/dist/terminal/pane-stream/runtime.js +10 -0
  42. package/packages/daemon/dist/terminal/session-runtime/registry.js +2 -0
  43. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
  44. package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
  45. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +3 -4
  46. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
  47. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +28 -3
  48. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
  49. package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
  50. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -0
  51. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
  52. package/packages/daemon/src/command-center/resources/saved-machine-route.ts +4 -0
  53. package/packages/daemon/src/command-center/resources/saved-machine-watch.ts +132 -0
  54. package/packages/daemon/src/command-center/server.ts +18 -0
  55. package/packages/daemon/src/command-center/tmux-servers.ts +14 -0
  56. package/packages/daemon/src/lib/daemon-embed.ts +81 -3
  57. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
  58. package/packages/daemon/src/lib/native-surface-context.ts +123 -0
  59. package/packages/daemon/src/lib/tmux-server-owner.ts +12 -0
  60. package/packages/daemon/src/server/native-surface-provider-upgrade.ts +103 -0
  61. package/packages/daemon/src/terminal/graphics/compress-chunk.ts +49 -0
  62. package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
  63. package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -0
  64. package/packages/daemon/src/terminal/graphics/native-surface-demand.ts +88 -0
  65. package/packages/daemon/src/terminal/graphics/native-surface-provider-channel.ts +161 -0
  66. package/packages/daemon/src/terminal/graphics/native-surface-provider.ts +308 -0
  67. package/packages/daemon/src/terminal/graphics/native-surface-viewer.ts +154 -0
  68. package/packages/daemon/src/terminal/mirror/mirror-service.ts +5 -0
  69. package/packages/daemon/src/terminal/mirror/session-channel.ts +31 -10
  70. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +446 -15
  71. package/packages/daemon/src/terminal/pane-stream/runtime.ts +14 -1
  72. package/packages/daemon/src/terminal/session-runtime/registry.ts +2 -0
  73. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
  74. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
  75. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
  76. package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
  77. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +3 -4
  78. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +4 -0
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +26 -4
  80. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
  81. package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
  82. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +15 -0
  83. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
  84. package/packages/daemon-client/src/pane-stream-client.ts +75 -1
@@ -0,0 +1,286 @@
1
+ import { randomUUID, webcrypto } from "node:crypto";
2
+ import { constants, type Stats } from "node:fs";
3
+ import { chmod, link, lstat, mkdtemp, open, realpath, rmdir, unlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ interface Scope {
7
+ readonly generation: string;
8
+ readonly client: string;
9
+ }
10
+ export interface LocalImageLease extends Scope {
11
+ readonly id: string;
12
+ readonly digest: string;
13
+ readonly path: string;
14
+ readonly bytes: number;
15
+ readonly expiresAt: number;
16
+ }
17
+ interface Entry {
18
+ readonly path: string;
19
+ readonly bytes: number;
20
+ readonly identity: Stats;
21
+ }
22
+ interface Options {
23
+ /** Existing private, daemon-owned directory; never an application-provided path. */
24
+ readonly parent: string;
25
+ readonly generation: string;
26
+ readonly maxBytes?: number;
27
+ readonly maxFiles?: number;
28
+ readonly maxLeases?: number;
29
+ readonly ttlMs?: number;
30
+ readonly now?: () => number;
31
+ }
32
+ const IMAGE_LIMIT = 64 * 1024 * 1024;
33
+ const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
34
+ const privateDirectory = (s: Stats) =>
35
+ s.isDirectory() && s.uid === process.getuid?.() && (s.mode & 0o077) === 0;
36
+ const bounded = (value: number, max: number) =>
37
+ Number.isSafeInteger(value) && value > 0 && value <= max;
38
+ const identity = (value: string) =>
39
+ value.length > 0 &&
40
+ value.length <= 512 &&
41
+ ![...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127);
42
+
43
+ /** Immutable copy-reduced transport files, not a persistent asset cache. Only owner
44
+ * metadata crosses this API. A live daemon expires crashed clients' leases; its
45
+ * owner must clean abandoned instance directories after a daemon process crash.
46
+ * The private parent is trusted: Node has no descriptor-relative unlink primitive.
47
+ */
48
+ export class LocalImageStore {
49
+ readonly #entries = new Map<string, Entry>();
50
+ readonly #leases = new Map<string, LocalImageLease>();
51
+ readonly #maxBytes: number;
52
+ readonly #maxFiles: number;
53
+ readonly #maxLeases: number;
54
+ readonly #ttl: number;
55
+ readonly #now: () => number;
56
+ readonly #timer: ReturnType<typeof setInterval>;
57
+ #tail: Promise<unknown> = Promise.resolve();
58
+ #bytes = 0;
59
+ #pendingBytes = 0;
60
+ #pending = 0;
61
+ #closed = false;
62
+ private constructor(
63
+ readonly directory: string,
64
+ readonly generation: string,
65
+ readonly directoryIdentity: Stats,
66
+ options: Options,
67
+ ) {
68
+ this.#maxBytes = options.maxBytes ?? 256 * 1024 * 1024;
69
+ this.#maxFiles = options.maxFiles ?? 512;
70
+ this.#maxLeases = options.maxLeases ?? 1024;
71
+ this.#ttl = options.ttlMs ?? 30_000;
72
+ this.#now = options.now ?? Date.now;
73
+ this.#timer = setInterval(() => void this.sweep().catch(() => {}), Math.min(this.#ttl, 1000));
74
+ this.#timer.unref();
75
+ }
76
+ static async create(options: Options): Promise<LocalImageStore> {
77
+ if (
78
+ !identity(options.generation) ||
79
+ !bounded(options.maxBytes ?? 256 * 1024 * 1024, 256 * 1024 * 1024) ||
80
+ !bounded(options.maxFiles ?? 512, 512) ||
81
+ !bounded(options.maxLeases ?? 1024, 1024) ||
82
+ !bounded(options.ttlMs ?? 30_000, 60_000)
83
+ )
84
+ throw new Error("Invalid local image store bounds");
85
+ const parentIdentity = await lstat(options.parent);
86
+ if (!privateDirectory(parentIdentity)) throw new Error("Local image parent is not private");
87
+ const parent = await realpath(options.parent);
88
+ if (!same(parentIdentity, await lstat(parent))) throw new Error("Local image parent replaced");
89
+ const directory = await mkdtemp(join(parent, "local-images-"));
90
+ await chmod(directory, 0o700);
91
+ const stat = await lstat(directory);
92
+ if (!privateDirectory(stat)) throw new Error("Local image directory is not private");
93
+ return new LocalImageStore(directory, options.generation, stat, options);
94
+ }
95
+ #serial<T>(run: () => Promise<T>): Promise<T> {
96
+ const result = this.#tail.then(run);
97
+ this.#tail = result.catch(() => {});
98
+ return result;
99
+ }
100
+ async #checkDirectory(): Promise<void> {
101
+ const stat = await lstat(this.directory);
102
+ if (!privateDirectory(stat) || !same(stat, this.directoryIdentity))
103
+ throw new Error("Local image directory replaced");
104
+ }
105
+ #scope(scope: Scope): void {
106
+ if (scope.generation !== this.generation || !identity(scope.client))
107
+ throw new Error("Local image scope mismatch");
108
+ }
109
+ async #checkFile(entry: Entry): Promise<void> {
110
+ await this.#checkDirectory();
111
+ const stat = await lstat(entry.path);
112
+ if (
113
+ !stat.isFile() ||
114
+ !same(stat, entry.identity) ||
115
+ stat.size !== entry.bytes ||
116
+ stat.mode !== entry.identity.mode ||
117
+ stat.mtimeMs !== entry.identity.mtimeMs ||
118
+ stat.ctimeMs !== entry.identity.ctimeMs
119
+ )
120
+ throw new Error("Local image file replaced");
121
+ }
122
+ acquire(
123
+ input: Scope & { readonly digest: string; readonly bytes: Uint8Array },
124
+ ): Promise<LocalImageLease> {
125
+ const { generation, client, digest: requestedDigest } = input;
126
+ this.#scope({ generation, client });
127
+ if (
128
+ this.#closed ||
129
+ !/^[a-f0-9]{32}$/u.test(input.digest) ||
130
+ !bounded(input.bytes.byteLength, Math.min(IMAGE_LIMIT, this.#maxBytes)) ||
131
+ this.#pending >= this.#maxLeases ||
132
+ input.bytes.byteLength > this.#maxBytes - this.#pendingBytes
133
+ )
134
+ return Promise.reject(new Error("Local image acquisition unavailable"));
135
+ // Detach caller memory before the first await; queued acquisition memory is bounded too.
136
+ const data = Uint8Array.from(input.bytes);
137
+ this.#pending++;
138
+ this.#pendingBytes += data.byteLength;
139
+ return this.#serial(async () => {
140
+ if (this.#closed) throw new Error("Local image store closed");
141
+ await this.#expire();
142
+ await this.#checkDirectory();
143
+ if (this.#leases.size >= this.#maxLeases) throw new Error("Local image lease bound");
144
+ const fullDigest = Buffer.from(await webcrypto.subtle.digest("SHA-256", data)).toString(
145
+ "hex",
146
+ );
147
+ const digest = fullDigest.slice(0, 32);
148
+ if (digest !== requestedDigest) throw new Error("Local image digest mismatch");
149
+ let entry = this.#entries.get(digest);
150
+ if (entry) {
151
+ if (entry.path !== join(this.directory, fullDigest))
152
+ throw new Error("Local image digest collision");
153
+ await this.#checkFile(entry);
154
+ } else {
155
+ if (this.#entries.size >= this.#maxFiles || data.byteLength > this.#maxBytes - this.#bytes)
156
+ throw new Error("Local image storage bound");
157
+ entry = await this.#write(fullDigest, data);
158
+ this.#entries.set(digest, entry);
159
+ this.#bytes += entry.bytes;
160
+ }
161
+ const lease = Object.freeze({
162
+ id: randomUUID(),
163
+ generation: this.generation,
164
+ client,
165
+ digest,
166
+ path: entry.path,
167
+ bytes: entry.bytes,
168
+ expiresAt: this.#now() + this.#ttl,
169
+ });
170
+ this.#leases.set(lease.id, lease);
171
+ return lease;
172
+ }).finally(() => {
173
+ this.#pending--;
174
+ this.#pendingBytes -= data.byteLength;
175
+ });
176
+ }
177
+ async #write(digest: string, data: Uint8Array): Promise<Entry> {
178
+ const path = join(this.directory, digest);
179
+ const temporary = join(this.directory, `.pending-${randomUUID()}`);
180
+ const file = await open(
181
+ temporary,
182
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
183
+ 0o600,
184
+ );
185
+ let temporaryIdentity = await file.stat();
186
+ try {
187
+ await file.writeFile(data);
188
+ await file.chmod(0o400);
189
+ temporaryIdentity = await file.stat();
190
+ await this.#checkDirectory();
191
+ const current = await lstat(temporary);
192
+ if (!same(current, temporaryIdentity) || !current.isFile())
193
+ throw new Error("Local image staging replaced");
194
+ // link is atomic and fails if a digest path already exists; never overwrite it.
195
+ await link(temporary, path);
196
+ } finally {
197
+ await file.close();
198
+ await this.#unlinkOwned(temporary, temporaryIdentity);
199
+ }
200
+ const stat = await lstat(path);
201
+ if (!same(stat, temporaryIdentity) || !stat.isFile() || stat.size !== data.byteLength)
202
+ throw new Error("Local image publication replaced");
203
+ return { path, bytes: data.byteLength, identity: stat };
204
+ }
205
+ async #unlinkOwned(path: string, expected: Stats, immutable = false): Promise<void> {
206
+ await this.#checkDirectory();
207
+ let stat: Stats;
208
+ try {
209
+ stat = await lstat(path);
210
+ } catch (error) {
211
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
212
+ throw error;
213
+ }
214
+ if (
215
+ !stat.isFile() ||
216
+ !same(stat, expected) ||
217
+ (immutable &&
218
+ (stat.size !== expected.size ||
219
+ stat.mode !== expected.mode ||
220
+ stat.uid !== expected.uid ||
221
+ stat.mtimeMs !== expected.mtimeMs ||
222
+ stat.ctimeMs !== expected.ctimeMs))
223
+ )
224
+ throw new Error("Local image cleanup refused replacement");
225
+ await unlink(path);
226
+ }
227
+ async #collect(): Promise<void> {
228
+ const referenced = new Set([...this.#leases.values()].map((lease) => lease.digest));
229
+ for (const [digest, entry] of this.#entries) {
230
+ if (referenced.has(digest)) continue;
231
+ // Inodes can be reused after unlink. Published files must retain their
232
+ // immutable metadata; staging links intentionally change ctime instead.
233
+ await this.#unlinkOwned(entry.path, entry.identity, true);
234
+ this.#entries.delete(digest);
235
+ this.#bytes -= entry.bytes;
236
+ }
237
+ }
238
+ async #expire(): Promise<void> {
239
+ const now = this.#now();
240
+ for (const [id, lease] of this.#leases) if (lease.expiresAt <= now) this.#leases.delete(id);
241
+ await this.#collect();
242
+ }
243
+ release(scope: Scope & { readonly id: string }): Promise<boolean> {
244
+ return this.#serial(async () => {
245
+ this.#scope(scope);
246
+ const lease = this.#leases.get(scope.id);
247
+ if (!lease || lease.client !== scope.client) return false;
248
+ this.#leases.delete(scope.id);
249
+ await this.#collect();
250
+ return true;
251
+ });
252
+ }
253
+ releaseClient(scope: Scope): Promise<void> {
254
+ return this.#serial(async () => {
255
+ this.#scope(scope);
256
+ for (const [id, lease] of this.#leases)
257
+ if (lease.client === scope.client) this.#leases.delete(id);
258
+ await this.#collect();
259
+ });
260
+ }
261
+ sweep(): Promise<void> {
262
+ return this.#serial(() => this.#expire());
263
+ }
264
+ /** Bounded lifecycle manifest; no image bytes. Caller persists before publishing a lease. */
265
+ ownedFiles(): Promise<{ path: string; identity: Stats }[]> {
266
+ return this.#serial(async () => {
267
+ await this.#checkDirectory();
268
+ const files = [];
269
+ for (const entry of this.#entries.values()) {
270
+ await this.#checkFile(entry);
271
+ files.push({ path: entry.path, identity: entry.identity });
272
+ }
273
+ return files;
274
+ });
275
+ }
276
+ dispose(): Promise<void> {
277
+ this.#closed = true;
278
+ clearInterval(this.#timer);
279
+ return this.#serial(async () => {
280
+ this.#leases.clear();
281
+ await this.#collect();
282
+ await this.#checkDirectory();
283
+ await rmdir(this.directory); // Not recursive: unexpected entries must survive.
284
+ });
285
+ }
286
+ }
@@ -0,0 +1,88 @@
1
+ /** Bounded daemon-local demand for ordinary terminal image output. Unknown
2
+ * native-client inventory preserves fallback; only proven absence can suppress it. */
3
+ export interface SurfaceViewerDemand {
4
+ native(ready: boolean): void;
5
+ close(): void;
6
+ }
7
+ type Viewer = { session: string; panes: ReadonlySet<string>; native: boolean };
8
+ export class NativeSurfaceDemand {
9
+ #viewers = new Set<Viewer>();
10
+ #presence = new Map<string, boolean>();
11
+ #listeners = new Set<() => void>();
12
+ #overflow = 0;
13
+ #closed = false;
14
+ #queued = false;
15
+ presence(session: string, present: boolean | null): void {
16
+ if (this.#closed) return;
17
+ if (present === null) this.#presence.delete(session);
18
+ else {
19
+ if (!this.#presence.has(session) && this.#presence.size >= 256) {
20
+ const oldest = this.#presence.keys().next().value;
21
+ if (oldest !== undefined) this.#presence.delete(oldest);
22
+ }
23
+ this.#presence.set(session, present);
24
+ }
25
+ this.#notify();
26
+ }
27
+ viewer(session: string, panes: readonly string[]): SurfaceViewerDemand {
28
+ const viewer: Viewer = { session, panes: new Set(panes), native: false };
29
+ const retained = !this.#closed && this.#viewers.size < 256 && panes.length <= 256;
30
+ if (retained) this.#viewers.add(viewer);
31
+ else this.#overflow++;
32
+ this.#notify();
33
+ let closed = false;
34
+ return {
35
+ native: (ready) => {
36
+ if (closed || viewer.native === ready) return;
37
+ viewer.native = ready;
38
+ this.#notify();
39
+ },
40
+ close: () => {
41
+ if (closed) return;
42
+ closed = true;
43
+ if (retained) this.#viewers.delete(viewer);
44
+ else this.#overflow--;
45
+ this.#notify();
46
+ },
47
+ };
48
+ }
49
+ required(pane: string): boolean {
50
+ if (this.#closed || this.#overflow) return true;
51
+ let known = false;
52
+ for (const viewer of this.#viewers) {
53
+ if (!viewer.panes.has(pane)) continue;
54
+ known = true;
55
+ if (!viewer.native || this.#presence.get(viewer.session) !== false) return true;
56
+ }
57
+ return !known;
58
+ }
59
+ watch(listener: () => void): () => void {
60
+ if (this.#closed || this.#listeners.size >= 256) throw new Error("Surface demand unavailable");
61
+ this.#listeners.add(listener);
62
+ this.#notify();
63
+ return () => this.#listeners.delete(listener);
64
+ }
65
+ #notify(): void {
66
+ if (this.#queued) return;
67
+ this.#queued = true;
68
+ queueMicrotask(() => {
69
+ this.#queued = false;
70
+ for (const listener of [...this.#listeners]) {
71
+ if (!this.#listeners.has(listener)) continue;
72
+ try {
73
+ listener();
74
+ } catch {
75
+ this.#listeners.delete(listener);
76
+ }
77
+ }
78
+ if (this.#closed) this.#listeners.clear();
79
+ });
80
+ }
81
+ close(): void {
82
+ if (this.#closed) return;
83
+ this.#closed = true;
84
+ this.#viewers.clear();
85
+ this.#presence.clear();
86
+ this.#notify();
87
+ }
88
+ }
@@ -0,0 +1,161 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import {
4
+ TerminalAttachmentSemanticPaneIdSchemaZ,
5
+ TerminalSurfaceAuditTokenSchemaZ,
6
+ TerminalSurfaceOfferSchemaZ,
7
+ TERMINAL_SURFACE_MAX_CONTROL_BYTES,
8
+ } from "@tmux-ide/contracts";
9
+ import {
10
+ NativeSurfaceProviderRegistry,
11
+ type NativeSurfaceProvider,
12
+ } from "./native-surface-provider.ts";
13
+
14
+ const Register = z
15
+ .object({
16
+ type: z.literal("register"),
17
+ version: z.literal(1),
18
+ generation: z.uuid(),
19
+ auditToken: TerminalSurfaceAuditTokenSchemaZ,
20
+ })
21
+ .strict();
22
+ const Offer = z
23
+ .object({
24
+ type: z.literal("offer"),
25
+ connection: z.uuid(),
26
+ offer: TerminalSurfaceOfferSchemaZ,
27
+ })
28
+ .strict();
29
+ const WatchDemand = z
30
+ .object({
31
+ type: z.literal("watch-demand"),
32
+ panes: z.array(TerminalAttachmentSemanticPaneIdSchemaZ).min(1).max(32),
33
+ })
34
+ .strict();
35
+ type Context = Parameters<NativeSurfaceProvider["open"]>[0];
36
+
37
+ /** Only an authenticated owner connection may construct this channel. Kernel
38
+ * identity proof is still mandatory at the native receiver. No payload contains
39
+ * pixel storage, executable paths, or a reusable daemon credential. */
40
+ export class NativeSurfaceProviderChannel {
41
+ #unregister: (() => void) | null = null;
42
+ #connections = new Map<string, Context>();
43
+ #closed = false;
44
+ #stopDemand: (() => void) | null = null;
45
+ #demandPanes: string[] | null = null;
46
+ #lastDemand: string | null = null;
47
+ constructor(
48
+ readonly registry: NativeSurfaceProviderRegistry,
49
+ readonly send: (message: string) => boolean,
50
+ readonly disconnect: () => void,
51
+ ) {}
52
+ receive(text: string): void {
53
+ if (this.#closed) return;
54
+ if (Buffer.byteLength(text) > TERMINAL_SURFACE_MAX_CONTROL_BYTES) {
55
+ this.close();
56
+ return;
57
+ }
58
+ let value: unknown;
59
+ try {
60
+ value = JSON.parse(text);
61
+ } catch {
62
+ this.close();
63
+ return;
64
+ }
65
+ if (!this.#unregister) {
66
+ const registration = Register.safeParse(value);
67
+ if (!registration.success || registration.data.generation !== this.registry.generation) {
68
+ this.close();
69
+ return;
70
+ }
71
+ try {
72
+ this.#unregister = this.registry.register({
73
+ auditToken: registration.data.auditToken,
74
+ open: (context) => this.#open(context),
75
+ });
76
+ } catch {
77
+ this.close();
78
+ return;
79
+ }
80
+ this.#send({ type: "registered", version: 1, generation: this.registry.generation });
81
+ return;
82
+ }
83
+ const watch = WatchDemand.safeParse(value);
84
+ if (watch.success) {
85
+ const panes = [...new Set(watch.data.panes)].sort();
86
+ if (this.#demandPanes) {
87
+ if (JSON.stringify(panes) !== JSON.stringify(this.#demandPanes)) this.close();
88
+ return;
89
+ }
90
+ this.#demandPanes = panes;
91
+ try {
92
+ this.#stopDemand = this.registry.demand.watch(() => {
93
+ const required = panes.filter((pane) => this.registry.demand.required(pane));
94
+ const key = JSON.stringify(required);
95
+ if (key === this.#lastDemand) return;
96
+ this.#lastDemand = key;
97
+ this.#send({ type: "kitty-demand", panes, required });
98
+ });
99
+ } catch {
100
+ this.close();
101
+ }
102
+ return;
103
+ }
104
+ const parsed = Offer.safeParse(value);
105
+ if (!parsed.success) {
106
+ this.close();
107
+ return;
108
+ }
109
+ const { connection, offer } = parsed.data;
110
+ const context = this.#connections.get(connection);
111
+ if (!context) {
112
+ this.close();
113
+ return;
114
+ }
115
+ const accepted = context.offer(offer);
116
+ // The producer must wait for accepted=true before transferring the matching
117
+ // native right. A dropped channel or rejected offer grants no transfer.
118
+ this.#send({ type: "offered", connection, sequence: offer.sequence, accepted });
119
+ }
120
+ #open(context: Context) {
121
+ if (this.#closed || this.#connections.size >= 32)
122
+ throw new Error("Native provider unavailable");
123
+ const connection = randomUUID();
124
+ this.#connections.set(connection, context);
125
+ return {
126
+ start: () => {
127
+ this.#send({ type: "start", connection, receiver: context.receiver, panes: context.panes });
128
+ },
129
+ retire: (sequence: number) => {
130
+ this.#send({ type: "retired", connection, sequence });
131
+ },
132
+ fallback: () => {
133
+ this.#send({ type: "fallback", connection });
134
+ },
135
+ close: () => {
136
+ this.#connections.delete(connection);
137
+ this.#send({ type: "close", connection });
138
+ },
139
+ };
140
+ }
141
+ #send(value: unknown): void {
142
+ if (this.#closed) return;
143
+ const text = JSON.stringify(value);
144
+ try {
145
+ if (Buffer.byteLength(text) <= TERMINAL_SURFACE_MAX_CONTROL_BYTES && this.send(text)) return;
146
+ } catch {
147
+ /* A failed sink revokes all further native offers. */
148
+ }
149
+ this.close();
150
+ }
151
+ close(): void {
152
+ if (this.#closed) return;
153
+ this.#closed = true;
154
+ this.#stopDemand?.();
155
+ this.#stopDemand = null;
156
+ this.#unregister?.();
157
+ this.#unregister = null;
158
+ this.#connections.clear();
159
+ this.disconnect();
160
+ }
161
+ }