tmux-ide 2.9.10 → 2.9.12

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 (81) hide show
  1. package/bin/cli.js +4321 -2596
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +4 -0
  4. package/packages/contracts/src/pane-stream.ts +72 -0
  5. package/packages/contracts/src/terminal-delivery-binary-chunk.ts +55 -0
  6. package/packages/contracts/src/terminal-delivery.ts +17 -2
  7. package/packages/contracts/src/terminal-graphics.ts +68 -0
  8. package/packages/contracts/src/terminal-projection.ts +153 -0
  9. package/packages/core/src/fixtures/terminal-projection-vectors.json +585 -0
  10. package/packages/core/src/index.ts +2 -0
  11. package/packages/core/src/terminal-delivery-binary.ts +160 -0
  12. package/packages/core/src/terminal-delivery.ts +371 -16
  13. package/packages/core/src/terminal-projection-vectors.mjs +82 -0
  14. package/packages/core/src/terminal-projection.ts +476 -0
  15. package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
  16. package/packages/core/src/terminal-replica.ts +1 -1
  17. package/packages/daemon/dist/lib/daemon-embed.js +39 -3
  18. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
  19. package/packages/daemon/dist/lib/tmux-server-owner.js +1 -0
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  21. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  22. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  23. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  24. package/packages/daemon/dist/terminal/graphics/local-image-owner.js +330 -0
  25. package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
  26. package/packages/daemon/dist/terminal/mirror/mirror-service.js +12 -2
  27. package/packages/daemon/dist/terminal/mirror/session-channel.js +46 -23
  28. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +190 -19
  29. package/packages/daemon/dist/terminal/pane-stream/runtime.js +1 -0
  30. package/packages/daemon/dist/terminal/session-runtime/registry.js +19 -6
  31. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
  32. package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
  33. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
  34. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +5 -0
  35. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
  36. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
  37. package/packages/daemon/dist/tui/mirror/runtime/application-generation-starter.js +16 -1
  38. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agent-navigation.js +13 -0
  39. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +12 -4
  40. package/packages/daemon/dist/tui/mirror/runtime/application-retained-agent.js +18 -0
  41. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +90 -5
  42. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
  43. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
  44. package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
  45. package/packages/daemon/dist/tui/mirror/runtime/runtime-layout-presentation.js +6 -3
  46. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +7 -2
  47. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -0
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
  49. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
  50. package/packages/daemon/src/lib/daemon-embed.ts +38 -2
  51. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
  52. package/packages/daemon/src/lib/tmux-server-owner.ts +3 -0
  53. package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
  54. package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -0
  55. package/packages/daemon/src/terminal/mirror/mirror-service.ts +19 -2
  56. package/packages/daemon/src/terminal/mirror/session-channel.ts +57 -9
  57. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +248 -16
  58. package/packages/daemon/src/terminal/pane-stream/runtime.ts +3 -0
  59. package/packages/daemon/src/terminal/session-runtime/registry.ts +25 -3
  60. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
  61. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
  62. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
  63. package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
  64. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
  65. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +6 -0
  66. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +3 -0
  67. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +6 -0
  68. package/packages/daemon/src/tui/mirror/runtime/application-generation-starter.ts +17 -1
  69. package/packages/daemon/src/tui/mirror/runtime/application-machine-agent-navigation.ts +15 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +18 -4
  71. package/packages/daemon/src/tui/mirror/runtime/application-retained-agent.ts +29 -0
  72. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +89 -6
  73. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
  74. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
  75. package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
  76. package/packages/daemon/src/tui/mirror/runtime/runtime-layout-presentation.ts +7 -4
  77. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +9 -1
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +15 -0
  79. package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
  80. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
  81. package/packages/daemon-client/src/pane-stream-client.ts +92 -1
@@ -1,3 +1,4 @@
1
+ import type { LocalImageOwner } from "../terminal/graphics/local-image-owner.ts";
1
2
  import { shellEscape } from "./shell.ts";
2
3
  import {
3
4
  createNativeSplitCapabilityProbe,
@@ -68,6 +69,7 @@ import {
68
69
  } from "./tmux-server-generation-runner.ts";
69
70
 
70
71
  export interface NativeTmuxServerOwnerOptions {
72
+ readonly localImages?: LocalImageOwner;
71
73
  readonly environmentId: string;
72
74
  readonly serverId: string;
73
75
  readonly generation: string;
@@ -485,6 +487,7 @@ export async function createNativeTmuxServerOwner(options: NativeTmuxServerOwner
485
487
  }),
486
488
  });
487
489
  const paneStreamRuntime = createPaneStreamRuntime({
490
+ localImages: options.localImages,
488
491
  daemonInstanceId: generation,
489
492
  webSocketUrl: options.webSocketUrl,
490
493
  sessionRuntimeRegistry,
@@ -0,0 +1,346 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants, type Stats } from "node:fs";
3
+ import {
4
+ link,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ rmdir,
12
+ unlink,
13
+ } from "node:fs/promises";
14
+ import { basename, join } from "node:path";
15
+ import { z } from "zod";
16
+ import { LocalImageStore, type LocalImageLease } from "./local-image-store.ts";
17
+
18
+ const OwnerSchema = z
19
+ .object({
20
+ generation: z.uuid(),
21
+ pid: z.number().int().positive().max(2147483647),
22
+ startedAt: z.iso.datetime(),
23
+ })
24
+ .strict();
25
+ export type LocalImageOwnerIdentity = z.infer<typeof OwnerSchema>;
26
+ const FileSchema = z
27
+ .object({
28
+ dev: z.number().nonnegative(),
29
+ ino: z.number().nonnegative(),
30
+ mode: z.number().int(),
31
+ size: z.number().nonnegative(),
32
+ mtimeMs: z.number(),
33
+ ctimeMs: z.number(),
34
+ })
35
+ .strict();
36
+ const ManifestSchema = z
37
+ .object({
38
+ version: z.literal(1),
39
+ owner: OwnerSchema,
40
+ directory: FileSchema,
41
+ store: z
42
+ .object({ name: z.string().regex(/^local-images-[A-Za-z0-9]+$/u), identity: FileSchema })
43
+ .strict(),
44
+ files: z
45
+ .array(z.object({ name: z.string().regex(/^[a-f0-9]{64}$/u), identity: FileSchema }).strict())
46
+ .max(512),
47
+ })
48
+ .strict();
49
+ type Manifest = z.infer<typeof ManifestSchema>;
50
+ const record = (s: Stats) => ({
51
+ dev: s.dev,
52
+ ino: s.ino,
53
+ mode: s.mode,
54
+ size: s.size,
55
+ mtimeMs: s.mtimeMs,
56
+ ctimeMs: s.ctimeMs,
57
+ });
58
+ const same = (a: Pick<Stats, "dev" | "ino">, b: Pick<Stats, "dev" | "ino">) =>
59
+ a.dev === b.dev && a.ino === b.ino;
60
+ const privateDir = (s: Stats) =>
61
+ s.isDirectory() && s.uid === process.getuid?.() && (s.mode & 0o077) === 0;
62
+ const privateFile = (s: Stats) =>
63
+ s.isFile() && s.uid === process.getuid?.() && (s.mode & 0o077) === 0;
64
+ async function directory(path: string, expected?: Pick<Stats, "dev" | "ino">) {
65
+ const stat = await lstat(path);
66
+ if (!privateDir(stat) || (expected && !same(stat, expected)))
67
+ throw new Error("Local image owner directory replaced");
68
+ return stat;
69
+ }
70
+ async function readManifest(path: string): Promise<{ manifest: Manifest; identity: Stats }> {
71
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
72
+ try {
73
+ const stat = await file.stat();
74
+ if (!privateFile(stat) || stat.size > 256 * 1024)
75
+ throw new Error("Invalid local image manifest file");
76
+ const bytes = Buffer.alloc(stat.size);
77
+ let offset = 0;
78
+ while (offset < bytes.length) {
79
+ const read = await file.read(bytes, offset, bytes.length - offset, offset);
80
+ if (!read.bytesRead) throw new Error("Truncated local image manifest");
81
+ offset += read.bytesRead;
82
+ }
83
+ const current = await lstat(path);
84
+ if (!same(stat, current) || current.size !== stat.size || current.mtimeMs !== stat.mtimeMs)
85
+ throw new Error("Local image manifest replaced");
86
+ const manifest = ManifestSchema.parse(JSON.parse(bytes.toString("utf8")));
87
+ if (new Set(manifest.files.map((f) => f.name)).size !== manifest.files.length)
88
+ throw new Error("Duplicate local image manifest file");
89
+ return { manifest, identity: stat };
90
+ } finally {
91
+ await file.close();
92
+ }
93
+ }
94
+ interface Options extends LocalImageOwnerIdentity {
95
+ /** Already proven private canonical root, while holding the startup claim. */
96
+ root: string;
97
+ store?: Pick<
98
+ Parameters<typeof LocalImageStore.create>[0],
99
+ "maxBytes" | "maxFiles" | "maxLeases" | "ttlMs"
100
+ >;
101
+ }
102
+ /** Per-generation lifecycle wrapper. The lease is never published before its
103
+ * owned file identity is persisted. A crash before journal publication leaves
104
+ * an unrecorded file, which cleanup deliberately preserves rather than guesses.
105
+ */
106
+ export class LocalImageOwner {
107
+ #tail: Promise<unknown> = Promise.resolve();
108
+ #disposal: Promise<void> | undefined;
109
+ #manifestIdentity: Stats | undefined;
110
+ private constructor(
111
+ readonly path: string,
112
+ readonly identity: LocalImageOwnerIdentity,
113
+ private readonly store: LocalImageStore,
114
+ readonly root: string,
115
+ readonly rootIdentity: Stats,
116
+ readonly pathIdentity: Stats,
117
+ readonly storeIdentity: Stats,
118
+ ) {}
119
+ static async create(options: Options): Promise<LocalImageOwner> {
120
+ const identity = OwnerSchema.parse({
121
+ generation: options.generation,
122
+ pid: options.pid,
123
+ startedAt: options.startedAt,
124
+ });
125
+ const rootIdentity = await directory(options.root);
126
+ const root = await realpath(options.root);
127
+ await directory(root, rootIdentity);
128
+ const path = join(root, `local-images-${identity.generation}`);
129
+ await mkdir(path, { mode: 0o700 }); // Exclusive: never adopt another instance directory.
130
+ const pathIdentity = await directory(path);
131
+ const store = await LocalImageStore.create({
132
+ parent: path,
133
+ generation: identity.generation,
134
+ ...options.store,
135
+ });
136
+ const owner = new LocalImageOwner(
137
+ path,
138
+ identity,
139
+ store,
140
+ root,
141
+ rootIdentity,
142
+ pathIdentity,
143
+ await directory(store.directory),
144
+ );
145
+ try {
146
+ await owner.#persist();
147
+ } catch (error) {
148
+ await store.dispose().catch(() => {});
149
+ throw error;
150
+ }
151
+ return owner;
152
+ }
153
+ #serial<T>(run: () => Promise<T>): Promise<T> {
154
+ const result = this.#tail.then(run);
155
+ this.#tail = result.catch(() => {});
156
+ return result;
157
+ }
158
+ async #check() {
159
+ await directory(this.root, this.rootIdentity);
160
+ await directory(this.path, this.pathIdentity);
161
+ }
162
+ async #persist() {
163
+ await this.#check();
164
+ const files = await this.store.ownedFiles();
165
+ const manifest: Manifest = {
166
+ version: 1,
167
+ owner: this.identity,
168
+ directory: record(this.pathIdentity),
169
+ store: { name: basename(this.store.directory), identity: record(this.storeIdentity) },
170
+ files: files.map((f) => ({ name: basename(f.path), identity: record(f.identity) })),
171
+ };
172
+ const encoded = JSON.stringify(ManifestSchema.parse(manifest));
173
+ const path = join(this.path, "owner.json");
174
+ if (this.#manifestIdentity) {
175
+ const current = await readManifest(path);
176
+ if (
177
+ JSON.stringify(record(current.identity)) !== JSON.stringify(record(this.#manifestIdentity))
178
+ )
179
+ throw new Error("Local image manifest replaced");
180
+ // Leases are in-memory; only the owned file set is crash-recovery data.
181
+ // Reuse its durable record after checking identity, including on shared leases.
182
+ if (JSON.stringify(current.manifest) === encoded) return;
183
+ }
184
+ const temporary = join(this.path, `.owner-${randomUUID()}`);
185
+ const file = await open(
186
+ temporary,
187
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
188
+ 0o600,
189
+ );
190
+ const stat = await file.stat();
191
+ try {
192
+ await file.writeFile(encoded);
193
+ await file.sync();
194
+ await this.#check();
195
+ if (!same(await lstat(temporary), stat))
196
+ throw new Error("Local image manifest staging replaced");
197
+ if (this.#manifestIdentity) await rename(temporary, path);
198
+ else {
199
+ await link(temporary, path);
200
+ await unlink(temporary);
201
+ }
202
+ this.#manifestIdentity = await lstat(path);
203
+ if (!same(this.#manifestIdentity, stat))
204
+ throw new Error("Local image manifest publication replaced");
205
+ } finally {
206
+ await file.close();
207
+ await this.#removeStaging(temporary, stat);
208
+ }
209
+ }
210
+ async #removeStaging(path: string, identity: Stats): Promise<void> {
211
+ await this.#check();
212
+ let remaining: Stats;
213
+ try {
214
+ remaining = await lstat(path);
215
+ } catch (error) {
216
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
217
+ throw error;
218
+ }
219
+ if (!remaining.isFile() || !same(remaining, identity))
220
+ throw new Error("Local image manifest cleanup refused replacement");
221
+ await unlink(path);
222
+ }
223
+ acquire(input: Parameters<LocalImageStore["acquire"]>[0]): Promise<LocalImageLease> {
224
+ // Store admission snapshots mutable bytes synchronously and bounds queued memory.
225
+ const lease = this.store.acquire(input);
226
+ void lease.catch(() => {});
227
+ return this.#serial(async () => {
228
+ const result = await lease;
229
+ try {
230
+ await this.#persist();
231
+ } catch (error) {
232
+ await this.store.release(result).catch(() => {});
233
+ throw error;
234
+ }
235
+ return result;
236
+ });
237
+ }
238
+ release(scope: Parameters<LocalImageStore["release"]>[0]): Promise<boolean> {
239
+ return this.#serial(async () => {
240
+ const result = await this.store.release(scope);
241
+ await this.#persist();
242
+ return result;
243
+ });
244
+ }
245
+ releaseClient(scope: Parameters<LocalImageStore["releaseClient"]>[0]): Promise<void> {
246
+ return this.#serial(async () => {
247
+ await this.store.releaseClient(scope);
248
+ await this.#persist();
249
+ });
250
+ }
251
+ dispose(): Promise<void> {
252
+ return (this.#disposal ??= this.#serial(async () => {
253
+ await this.#check();
254
+ await this.store.dispose();
255
+ const path = join(this.path, "owner.json");
256
+ const current = await readManifest(path);
257
+ if (
258
+ !this.#manifestIdentity ||
259
+ JSON.stringify(record(current.identity)) !== JSON.stringify(record(this.#manifestIdentity))
260
+ )
261
+ throw new Error("Local image manifest replaced");
262
+ await unlink(path);
263
+ await rmdir(this.path);
264
+ }));
265
+ }
266
+ }
267
+
268
+ /** No directory discovery. Caller supplies the exact predecessor read under the
269
+ * canonical claim, and its existing authoritative dead-owner check. PID reuse
270
+ * or uncertainty retains files even if generation/start identity differs.
271
+ */
272
+ export async function cleanupLocalImageOwner(options: {
273
+ root: string;
274
+ owner: LocalImageOwnerIdentity;
275
+ proveDead: (owner: LocalImageOwnerIdentity) => Promise<boolean>;
276
+ }): Promise<boolean> {
277
+ const owner = OwnerSchema.parse(options.owner);
278
+ const rootIdentity = await directory(options.root);
279
+ const path = join(options.root, `local-images-${owner.generation}`);
280
+ let pathIdentity: Stats;
281
+ try {
282
+ pathIdentity = await directory(path);
283
+ } catch (error) {
284
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
285
+ throw error;
286
+ }
287
+ const manifestPath = join(path, "owner.json");
288
+ const { manifest, identity: manifestIdentity } = await readManifest(manifestPath);
289
+ if (
290
+ JSON.stringify(manifest.owner) !== JSON.stringify(owner) ||
291
+ !same(manifest.directory, pathIdentity)
292
+ )
293
+ return false;
294
+ if (!(await options.proveDead(owner))) return false;
295
+ try {
296
+ process.kill(owner.pid, 0);
297
+ return false;
298
+ } catch (error) {
299
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") return false;
300
+ }
301
+ await directory(options.root, rootIdentity);
302
+ await directory(path, pathIdentity);
303
+ const storePath = join(path, manifest.store.name);
304
+ let storePresent = true;
305
+ try {
306
+ await directory(storePath, manifest.store.identity);
307
+ } catch (error) {
308
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
309
+ storePresent = false;
310
+ }
311
+ for (const entry of storePresent ? manifest.files : []) {
312
+ await directory(options.root, rootIdentity);
313
+ await directory(path, pathIdentity);
314
+ await directory(storePath, manifest.store.identity);
315
+ const file = join(storePath, entry.name);
316
+ let stat: Stats;
317
+ try {
318
+ stat = await lstat(file);
319
+ } catch (error) {
320
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
321
+ throw error;
322
+ }
323
+ if (!privateFile(stat) || JSON.stringify(record(stat)) !== JSON.stringify(entry.identity))
324
+ return false;
325
+ await unlink(file);
326
+ }
327
+ // Unknown/staging/replaced files make rmdir fail; none are removed speculatively.
328
+ try {
329
+ if (storePresent) await rmdir(storePath);
330
+ } catch (error) {
331
+ if ((error as NodeJS.ErrnoException).code === "ENOTEMPTY") return false;
332
+ throw error;
333
+ }
334
+ const current = await readManifest(manifestPath);
335
+ if (JSON.stringify(record(current.identity)) !== JSON.stringify(record(manifestIdentity)))
336
+ return false;
337
+ if ((await readdir(path)).some((name) => name !== "owner.json")) return false;
338
+ await unlink(manifestPath);
339
+ try {
340
+ await rmdir(path);
341
+ } catch (error) {
342
+ if ((error as NodeJS.ErrnoException).code === "ENOTEMPTY") return false;
343
+ throw error;
344
+ }
345
+ return true;
346
+ }
@@ -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
+ }
@@ -237,14 +237,31 @@ export class MirrorService {
237
237
  }
238
238
  }
239
239
 
240
- async describeSessionAuthority(session: string): Promise<{
240
+ async describeSessionAuthority(
241
+ session: string,
242
+ expectedSemanticPaneIds?: readonly string[],
243
+ ): Promise<{
241
244
  readonly description: MirrorSessionDescription;
242
245
  readonly runtimeSessionId: string;
243
246
  }> {
244
247
  const entry = await this.acquire(session);
245
248
  try {
246
249
  const identity = await entry.channel.attachedSessionIdentity();
247
- return { description: entry.channel.describe(), runtimeSessionId: identity.runtimeSessionId };
250
+ let description = entry.channel.describe();
251
+ if (
252
+ expectedSemanticPaneIds?.some(
253
+ (id) => !description.panes.some((pane) => pane.semanticPaneId === id),
254
+ )
255
+ ) {
256
+ // A mutation receipt and fresh catalog can precede debounced mirror
257
+ // discovery. Join the verified native inventory before declaring a
258
+ // requested pane absent; never substitute receipt IDs for tmux truth.
259
+ await entry.channel.describeTrustedInventory(identity.runtimeSessionId, true);
260
+ description = entry.channel.describe();
261
+ }
262
+ if (this.disposed || entry.retired || this.channels.get(session) !== entry)
263
+ throw new Error("Mirror authority retired during description");
264
+ return { description, runtimeSessionId: identity.runtimeSessionId };
248
265
  } finally {
249
266
  this.release(session, entry);
250
267
  }