tmux-ide 2.9.11 → 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 (56) hide show
  1. package/bin/cli.js +4158 -2545
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +4 -0
  4. package/packages/contracts/src/pane-stream.ts +60 -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.ts +1 -1
  16. package/packages/daemon/dist/lib/daemon-embed.js +39 -3
  17. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
  18. package/packages/daemon/dist/lib/tmux-server-owner.js +1 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  21. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  22. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  23. package/packages/daemon/dist/terminal/graphics/local-image-owner.js +330 -0
  24. package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
  25. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +176 -16
  26. package/packages/daemon/dist/terminal/pane-stream/runtime.js +1 -0
  27. package/packages/daemon/dist/terminal/session-runtime/registry.js +2 -0
  28. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
  29. package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
  30. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +3 -4
  31. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
  32. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +28 -3
  33. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
  34. package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
  35. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -0
  36. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
  37. package/packages/daemon/src/lib/daemon-embed.ts +38 -2
  38. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
  39. package/packages/daemon/src/lib/tmux-server-owner.ts +3 -0
  40. package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
  41. package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -0
  42. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +219 -15
  43. package/packages/daemon/src/terminal/pane-stream/runtime.ts +3 -0
  44. package/packages/daemon/src/terminal/session-runtime/registry.ts +2 -0
  45. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
  46. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
  47. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
  48. package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
  49. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +3 -4
  50. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +4 -0
  51. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +26 -4
  52. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
  53. package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
  54. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +15 -0
  55. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
  56. package/packages/daemon-client/src/pane-stream-client.ts +75 -1
@@ -95,6 +95,10 @@ export class SemanticPaneReplica {
95
95
  : [...this.#dirtyRows].sort((a, b) => a - b);
96
96
  }
97
97
  accept(message) {
98
+ if (message.type === "terminal.projection") {
99
+ this.#fail("protocol-violation", message.transactionId);
100
+ return;
101
+ }
98
102
  if (message.type === "terminal.delivery.fault") {
99
103
  this.#assembler = null;
100
104
  this.#snapshot = null;
@@ -8,7 +8,8 @@ import { createGuardedNativeSplitResize } from "./guarded-native-split-resize.ts
8
8
  import { WindowLinkTargetSchemaZ } from "@tmux-ide/contracts";
9
9
  import { WindowSplitLayoutUnavailable } from "../terminal/mirror/window-split-authority.ts";
10
10
  import { requireSupportedTmuxVersion } from "./tmux-version.ts";
11
- import { join } from "node:path";
11
+ import { dirname, join } from "node:path";
12
+ import { LocalImageOwner, cleanupLocalImageOwner } from "../terminal/graphics/local-image-owner.ts";
12
13
  import { createClaudeTeamMembershipReader } from "../terminal/attachments/claude-team-names.ts";
13
14
  import { PaneSourceDiscovery } from "./pane-source-discovery.ts";
14
15
  import { createBackgroundNativeCapture } from "./background-native-capture.ts";
@@ -159,6 +160,7 @@ import { captureDaemonProvenance } from "./daemon-provenance.ts";
159
160
  import { registerLogSecret, setLogIdentity } from "./log.ts";
160
161
  import {
161
162
  canonicalDaemonUrl,
163
+ getCanonicalDaemonInfoPath,
162
164
  clearCanonicalDaemonInfoIfOwned,
163
165
  clearCanonicalDaemonInfoIfUnchanged,
164
166
  inspectCanonicalDaemonInfo,
@@ -1399,6 +1401,7 @@ async function startEmbeddedDaemonGeneration(
1399
1401
  };
1400
1402
  let terminalAttachmentRuntime: NativeTerminalAttachmentRuntime | null = null;
1401
1403
  let paneStreamRuntime: PaneStreamRuntime | null = null;
1404
+ let localImages: LocalImageOwner | undefined;
1402
1405
  let serverOwners: Awaited<ReturnType<typeof createEmbeddedTmuxServerOwners>> | null = null;
1403
1406
  let defaultEditorContextRetired = false;
1404
1407
  const defaultDiagnosticsLifetime = new AbortController();
@@ -1732,7 +1735,34 @@ async function startEmbeddedDaemonGeneration(
1732
1735
  };
1733
1736
  const peekTerminalAttachmentRuntime = (): NativeTerminalAttachmentRuntime | null =>
1734
1737
  terminalAttachmentRuntime;
1738
+ // The held canonical claim has already proven and hardened this private root.
1739
+ // Local images are optional: unavailable storage retains inline graphics.
1740
+ if (process.platform !== "win32") {
1741
+ const root = dirname(getCanonicalDaemonInfoPath());
1742
+ try {
1743
+ if (existingCanonical.status === "valid") {
1744
+ await cleanupLocalImageOwner({
1745
+ root,
1746
+ owner: {
1747
+ generation: existingCanonical.info.instanceId,
1748
+ pid: existingCanonical.info.pid,
1749
+ startedAt: existingCanonical.info.startedAt,
1750
+ },
1751
+ proveDead: () => isCanonicalDaemonRecordOwnerProvenDead(existingCanonical),
1752
+ });
1753
+ }
1754
+ localImages = await LocalImageOwner.create({
1755
+ root,
1756
+ generation: instanceId,
1757
+ pid: process.pid,
1758
+ startedAt,
1759
+ });
1760
+ } catch {
1761
+ localImages = undefined;
1762
+ }
1763
+ }
1735
1764
  paneStreamRuntime = createPaneStreamRuntime({
1765
+ localImages,
1736
1766
  daemonInstanceId: instanceId,
1737
1767
  webSocketUrl: paneStreamWebSocketUrl(bindHostname, port),
1738
1768
  sessionRuntimeRegistry,
@@ -1758,6 +1788,7 @@ async function startEmbeddedDaemonGeneration(
1758
1788
  });
1759
1789
  await externalInteractionObserver.start();
1760
1790
  serverOwners = await createEmbeddedTmuxServerOwners({
1791
+ localImages,
1761
1792
  environmentId,
1762
1793
  onDefaultScope: async (scope) => {
1763
1794
  interactionEvidence = new InteractionEvidenceAuthority(environmentId, scope);
@@ -2075,7 +2106,10 @@ async function startEmbeddedDaemonGeneration(
2075
2106
  // The pane-stream coordinator may still hold runtime consumers while it
2076
2107
  // drains. Preserve the normal shutdown order on startup rollback too:
2077
2108
  // transports first, then the one session/control authority.
2078
- await Promise.allSettled([sessionRuntimeRegistry?.dispose() ?? Promise.resolve()]);
2109
+ await Promise.allSettled([
2110
+ sessionRuntimeRegistry?.dispose() ?? Promise.resolve(),
2111
+ localImages?.dispose() ?? Promise.resolve(),
2112
+ ]);
2079
2113
  throw error;
2080
2114
  }
2081
2115
  const {
@@ -2108,6 +2142,7 @@ async function startEmbeddedDaemonGeneration(
2108
2142
  )),
2109
2143
  ...(await retirePaneStreamTransport()),
2110
2144
  ];
2145
+ await localImages?.dispose();
2111
2146
  terminalInventoryRuntime.dispose();
2112
2147
  const paneDisposal = Promise.resolve().then(() => workspacePaneCreation.dispose());
2113
2148
  const workspaceOpenDisposal = Promise.resolve().then(() => workspaceOpen.dispose());
@@ -2375,6 +2410,7 @@ async function startEmbeddedDaemonGeneration(
2375
2410
  )),
2376
2411
  );
2377
2412
  failures.push(...(await retirePaneStreamTransport()));
2413
+ await capture(() => localImages?.dispose());
2378
2414
  await capture(() => terminalInventoryRuntime.dispose());
2379
2415
  await capture(() => workspacePaneCreation.dispose());
2380
2416
  await capture(() => workspaceOpen.dispose());
@@ -1,3 +1,4 @@
1
+ import type { LocalImageOwner } from "../terminal/graphics/local-image-owner.ts";
1
2
  import type { Server, IncomingMessage } from "node:http";
2
3
  import type { Socket } from "node:net";
3
4
  import { randomUUID } from "node:crypto";
@@ -22,6 +23,7 @@ import {
22
23
 
23
24
  /** Adapts the established default owner without duplicating its control clients. */
24
25
  export async function createEmbeddedTmuxServerOwners(options: {
26
+ readonly localImages?: LocalImageOwner;
25
27
  readonly environmentId: string;
26
28
  readonly onDefaultScope?: (scope: TmuxServerScope) => Promise<void>;
27
29
  readonly defaultAuthority: WorkspacePaneTmuxAuthority;
@@ -89,6 +91,7 @@ export async function createEmbeddedTmuxServerOwners(options: {
89
91
  if (registration.serverId === defaultRegistration.serverId) await retireDefault();
90
92
  const route = tmuxServerPaneStreamPath(scope);
91
93
  const owner = await createNativeTmuxServerOwner({
94
+ localImages: options.localImages,
92
95
  environmentId: options.environmentId,
93
96
  ...scope,
94
97
  tmuxAuthority: observation.authority,
@@ -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
+ }