@gajae-code/coding-agent 0.18.5 → 0.18.7

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 (133) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli-commands.d.ts +10 -0
  4. package/dist/types/cli-ordinary.d.ts +13 -0
  5. package/dist/types/cli.d.ts +12 -3
  6. package/dist/types/commands/contribution-prep.d.ts +0 -3
  7. package/dist/types/commands/sdk.d.ts +3 -0
  8. package/dist/types/commands/update.d.ts +1 -0
  9. package/dist/types/config/model-profile-activation.d.ts +19 -0
  10. package/dist/types/config/settings.d.ts +4 -0
  11. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  12. package/dist/types/discovery/helpers.d.ts +2 -2
  13. package/dist/types/edit/modes/replace.d.ts +3 -3
  14. package/dist/types/edit/streaming.d.ts +2 -2
  15. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  16. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  17. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  18. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  19. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  20. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  21. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  22. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  23. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  24. package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
  25. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  26. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  27. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  28. package/dist/types/sdk/host/host.d.ts +6 -0
  29. package/dist/types/sdk/index.d.ts +1 -0
  30. package/dist/types/sdk/router/session-router.d.ts +9 -0
  31. package/dist/types/sdk/session.d.ts +13 -0
  32. package/dist/types/session/agent-session.d.ts +5 -1
  33. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  34. package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
  35. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
  36. package/dist/types/session/session-retirement.d.ts +47 -0
  37. package/dist/types/session/session-storage.d.ts +0 -46
  38. package/dist/types/session/session-transcript-header.d.ts +2 -0
  39. package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
  40. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  41. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  42. package/dist/types/tools/ask-contract.d.ts +75 -254
  43. package/dist/types/tools/bash.d.ts +1 -1
  44. package/package.json +7 -7
  45. package/scripts/build-sdk-package-smoke.ts +5 -0
  46. package/scripts/resident-memory-bench.ts +174 -20
  47. package/src/capability/index.ts +1 -0
  48. package/src/capability/types.ts +4 -0
  49. package/src/cli/config-cli.ts +8 -18
  50. package/src/cli/public-command-entry.ts +5 -0
  51. package/src/cli/ssh-cli.ts +6 -3
  52. package/src/cli/web-search-cli.ts +9 -3
  53. package/src/cli-commands.ts +14 -0
  54. package/src/cli-main.ts +7 -8
  55. package/src/cli-ordinary.ts +197 -0
  56. package/src/cli.ts +25 -182
  57. package/src/commands/contribution-prep.ts +1 -3
  58. package/src/commands/sdk.ts +171 -21
  59. package/src/commands/update.ts +15 -1
  60. package/src/config/file-lock.ts +91 -2
  61. package/src/config/model-profile-activation.ts +70 -4
  62. package/src/config/model-profile-contract.ts +4 -1
  63. package/src/config/model-profiles.ts +23 -31
  64. package/src/config/model-resolver.ts +9 -1
  65. package/src/config/settings.ts +44 -4
  66. package/src/coordinator-mcp/event-webhook.ts +19 -14
  67. package/src/coordinator-mcp/policy.ts +14 -4
  68. package/src/coordinator-mcp/server.ts +142 -7
  69. package/src/coordinator-mcp/session-reaper.ts +9 -1
  70. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  71. package/src/discovery/helpers.ts +9 -3
  72. package/src/discovery/ssh.ts +8 -3
  73. package/src/edit/modes/replace.ts +6 -6
  74. package/src/edit/streaming.ts +15 -15
  75. package/src/exec/bash-shell-supervisor.ts +60 -3
  76. package/src/gjc-runtime/gc-runtime.ts +2 -1
  77. package/src/hindsight/config.ts +9 -3
  78. package/src/internal-urls/docs-index.generated.ts +11 -10
  79. package/src/memories/index.ts +8 -2
  80. package/src/modes/acp/acp-agent.ts +724 -370
  81. package/src/modes/components/tool-execution.ts +1 -1
  82. package/src/prompts/tools/ask.md +2 -0
  83. package/src/runtime-mcp/client.ts +13 -4
  84. package/src/runtime-mcp/elicitation.ts +142 -0
  85. package/src/sdk/broker/broker.ts +54 -1
  86. package/src/sdk/broker/daemon-entry.ts +67 -6
  87. package/src/sdk/broker/discovery.ts +8 -0
  88. package/src/sdk/broker/ensure.ts +156 -20
  89. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  90. package/src/sdk/broker/lifecycle.ts +334 -348
  91. package/src/sdk/broker/runtime.ts +5 -1
  92. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  93. package/src/sdk/broker/transport.ts +5 -1
  94. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  95. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  96. package/src/sdk/bus/index.ts +3 -3
  97. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  98. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  99. package/src/sdk/bus/telegram-daemon.ts +38 -3
  100. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  101. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  102. package/src/sdk/host/control/dispatch.ts +52 -4
  103. package/src/sdk/host/control/operations.ts +1 -1
  104. package/src/sdk/host/host.ts +6 -0
  105. package/src/sdk/host/session-runtime.test.ts +83 -4
  106. package/src/sdk/host/session-runtime.ts +15 -7
  107. package/src/sdk/index.ts +1 -0
  108. package/src/sdk/lifecycle-session.ts +1 -0
  109. package/src/sdk/mcp/server.ts +15 -3
  110. package/src/sdk/protocol/operation-registry.ts +1 -1
  111. package/src/sdk/router/session-router.ts +26 -3
  112. package/src/sdk/session.ts +51 -8
  113. package/src/session/agent-session.ts +169 -43
  114. package/src/session/blob-store.ts +78 -33
  115. package/src/session/internal/managed-session-storage.ts +203 -4
  116. package/src/session/internal/task-artifact-owner-access.ts +229 -0
  117. package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
  118. package/src/session/session-retirement.ts +155 -0
  119. package/src/session/session-storage.ts +1 -160
  120. package/src/session/session-transcript-header.ts +17 -0
  121. package/src/session/task-artifact-owner-codec.ts +578 -0
  122. package/src/session/task-artifact-owner-retirement.ts +475 -0
  123. package/src/session/task-artifact-owner.ts +289 -0
  124. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  125. package/src/setup/hermes-setup.ts +4 -1
  126. package/src/slash-commands/builtin-registry.ts +8 -5
  127. package/src/tools/ask-contract.test.ts +76 -1
  128. package/src/tools/ask-contract.ts +57 -6
  129. package/src/tools/ask.ts +16 -7
  130. package/src/tools/bash.ts +38 -29
  131. package/src/tools/descriptors.test.ts +29 -0
  132. package/src/tools/tool-catalog.generated.ts +7 -4
  133. package/src/web/search/index.ts +2 -2
@@ -4,6 +4,7 @@ import * as fsp from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { exactUnlink } from "@gajae-code/natives";
6
6
  import { isEnoent, logger, postmortem } from "@gajae-code/utils";
7
+ import { readLinuxProcStartTimeSync } from "../gjc-runtime/linux-proc";
7
8
 
8
9
  const BLOB_PREFIX = "blob:sha256:";
9
10
  const CANONICAL_BLOB_NAME = /^[0-9a-f]{64}$/;
@@ -46,10 +47,21 @@ const RESIDENT_CACHE_SWEEP_EXPECTED_IO_CODES = new Set([
46
47
  ]);
47
48
  const ownedResidentCacheInstanceDirs = new Set<string>();
48
49
  const activeResidentCacheRootSweeps = new Set<string>();
49
- let ownResidentCacheProcessStartTimeMs: number | null | undefined;
50
+ let ownResidentCacheProcessStart: ResidentCacheProcessStart | undefined;
50
51
 
51
- /** Marks `startTimeMs` as an absolute instant parsed from a UTC-pinned `ps` render. */
52
- const RESIDENT_CACHE_START_TIME_BASIS = "utc";
52
+ /** Marks `startTimeMs` as an absolute instant parsed from a UTC-pinned `ps` render (non-Linux). */
53
+ const RESIDENT_CACHE_UTC_START_TIME_BASIS = "utc";
54
+ /**
55
+ * Prefix for a Linux start identity: `startTimeMs` holds `/proc/<pid>/stat` field 22
56
+ * (clock ticks since boot) and the suffix is the kernel `boot_id`. Ticks are immune
57
+ * to wall-clock adjustment; the boot id scopes them to one kernel boot.
58
+ */
59
+ const RESIDENT_CACHE_LINUX_START_TIME_BASIS_PREFIX = "linux-proc-ticks:";
60
+
61
+ interface ResidentCacheProcessStart {
62
+ readonly startTimeMs: number | null;
63
+ readonly basis: string;
64
+ }
53
65
 
54
66
  interface ResidentCacheOwnerToken {
55
67
  readonly pid: number;
@@ -240,19 +252,47 @@ function assertResidentCacheDirectoryPathMatchesDescriptor(pathname: string, des
240
252
  assertResidentCacheDirectoryDescriptor(pathname, current, descriptor, uid);
241
253
  }
242
254
 
243
- function residentCacheProcessStartTimeMs(pid: number): number | null {
244
- if (pid === process.pid && ownResidentCacheProcessStartTimeMs !== undefined) {
245
- return ownResidentCacheProcessStartTimeMs;
246
- }
255
+ let residentCacheLinuxBootId: string | null | undefined;
247
256
 
248
- // Mirror file-lock's `ps`-based process incarnation probe, but pin the locale
249
- // AND the zone. `lstart` is a zoneless local wall clock rendered by `ps` from
250
- // /etc/localtime, while `Date.parse` of a zoneless string binds it to the JS
251
- // runtime's own zone. Those are two independent resolutions, so a reader whose
252
- // runtime resolves UTC while `ps` renders UTC+9 derives an epoch nine hours off
253
- // for the very same live process — and this value's only job is to detect PID
254
- // reuse, so a disagreeing reader reports every live owner as reused and the GC
255
- // reaps a running session's cache. Pinning TZ on the child fixes the rendering
257
+ function readResidentCacheLinuxBootId(): string | null {
258
+ if (residentCacheLinuxBootId !== undefined) return residentCacheLinuxBootId;
259
+ let bootId: string | null = null;
260
+ try {
261
+ const text = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
262
+ if (/^[0-9a-f-]{36}$/.test(text)) bootId = text;
263
+ } catch {
264
+ // Without a boot id, tick counts cannot be scoped to one boot.
265
+ }
266
+ residentCacheLinuxBootId = bootId;
267
+ return bootId;
268
+ }
269
+
270
+ function residentCacheProcessStart(pid: number): ResidentCacheProcessStart {
271
+ if (pid === process.pid && ownResidentCacheProcessStart !== undefined) return ownResidentCacheProcessStart;
272
+ const start = process.platform === "linux" ? residentCacheLinuxProcessStart(pid) : residentCacheUtcProcessStart(pid);
273
+ if (pid === process.pid) ownResidentCacheProcessStart = start;
274
+ return start;
275
+ }
276
+
277
+ // `ps -o lstart` is NOT a stable identity on Linux: procps renders it as
278
+ // `btime + starttime_ticks`, and `btime` is re-derived from the current wall clock
279
+ // minus uptime. On hosts whose clock is continuously slewed (WSL2 is the common
280
+ // case) `btime` drifts, so the very same live process reports a start time that
281
+ // moves by seconds to minutes over a session. Any drift reads as PID reuse and the
282
+ // GC reaps a running session's cache. The raw tick count never moves.
283
+ function residentCacheLinuxProcessStart(pid: number): ResidentCacheProcessStart {
284
+ const bootId = readResidentCacheLinuxBootId();
285
+ if (bootId === null) return { startTimeMs: null, basis: RESIDENT_CACHE_LINUX_START_TIME_BASIS_PREFIX };
286
+ const basis = `${RESIDENT_CACHE_LINUX_START_TIME_BASIS_PREFIX}${bootId}`;
287
+ const ticks = readLinuxProcStartTimeSync(pid);
288
+ const startTimeMs = ticks === null ? Number.NaN : Number(ticks);
289
+ return { startTimeMs: Number.isSafeInteger(startTimeMs) ? startTimeMs : null, basis };
290
+ }
291
+
292
+ function residentCacheUtcProcessStart(pid: number): ResidentCacheProcessStart {
293
+ // Pin the locale AND the zone. `lstart` is a zoneless local wall clock rendered
294
+ // by `ps` from /etc/localtime, while `Date.parse` of a zoneless string binds it
295
+ // to the JS runtime's own zone. Pinning TZ on the child fixes the rendering
256
296
  // side; the explicit GMT suffix fixes the parsing side.
257
297
  let startTimeMs: number | null = null;
258
298
  try {
@@ -269,8 +309,7 @@ function residentCacheProcessStartTimeMs(pid: number): number | null {
269
309
  } catch {
270
310
  // An unavailable process-start source cannot prove PID reuse.
271
311
  }
272
- if (pid === process.pid) ownResidentCacheProcessStartTimeMs = startTimeMs;
273
- return startTimeMs;
312
+ return { startTimeMs, basis: RESIDENT_CACHE_UTC_START_TIME_BASIS };
274
313
  }
275
314
 
276
315
  type ResidentCacheOwnerLiveness = "alive" | "dead" | "unknown";
@@ -376,10 +415,11 @@ function readResidentCacheOwnerSnapshot(instanceDir: string, uid: number): Resid
376
415
  function writeResidentCacheOwnerToken(instanceDir: string, uid: number): void {
377
416
  const nonce = path.basename(instanceDir).slice("i-".length);
378
417
  if (!nonce) throw new ResidentCacheTrustError("instance_nonce_missing", instanceDir);
418
+ const ownStart = residentCacheProcessStart(process.pid);
379
419
  const owner: ResidentCacheOwnerToken = {
380
420
  pid: process.pid,
381
- startTimeMs: residentCacheProcessStartTimeMs(process.pid),
382
- startTimeBasis: RESIDENT_CACHE_START_TIME_BASIS,
421
+ startTimeMs: ownStart.startTimeMs,
422
+ startTimeBasis: ownStart.basis,
383
423
  nonce,
384
424
  createdAt: Date.now(),
385
425
  };
@@ -579,26 +619,31 @@ function residentCacheSweepLimit(value: number | undefined, ceiling: number): nu
579
619
  return Math.max(0, Math.min(Math.floor(value), ceiling));
580
620
  }
581
621
 
582
- function cachedResidentCacheProcessStartTimeMs(pid: number, cache: Map<number, number | null>): number | null {
583
- if (cache.has(pid)) return cache.get(pid) ?? null;
584
- const startTimeMs = residentCacheProcessStartTimeMs(pid);
585
- cache.set(pid, startTimeMs);
586
- return startTimeMs;
622
+ function cachedResidentCacheProcessStart(
623
+ pid: number,
624
+ cache: Map<number, ResidentCacheProcessStart>,
625
+ ): ResidentCacheProcessStart {
626
+ const cached = cache.get(pid);
627
+ if (cached !== undefined) return cached;
628
+ const start = residentCacheProcessStart(pid);
629
+ cache.set(pid, start);
630
+ return start;
587
631
  }
588
632
 
589
633
  function residentCacheOwnerIsStale(
590
634
  owner: ResidentCacheOwnerToken,
591
- startTimeCache: Map<number, number | null>,
635
+ startTimeCache: Map<number, ResidentCacheProcessStart>,
592
636
  ): boolean {
593
637
  const liveness = residentCacheOwnerLiveness(owner.pid);
594
638
  if (liveness === "dead") return true;
595
639
  if (liveness !== "alive" || owner.startTimeMs === null) return false;
596
- // A token from before the UTC pin carries a zone-dependent wall clock. Comparing
597
- // it against an absolute instant would read an ordinary timezone offset as PID
598
- // reuse and reap a live owner, so an unlabelled basis leaves reuse unproven.
599
- if (owner.startTimeBasis !== RESIDENT_CACHE_START_TIME_BASIS) return false;
600
- const currentStartTimeMs = cachedResidentCacheProcessStartTimeMs(owner.pid, startTimeCache);
601
- return currentStartTimeMs !== null && currentStartTimeMs !== owner.startTimeMs;
640
+ const current = cachedResidentCacheProcessStart(owner.pid, startTimeCache);
641
+ // Start times are comparable only on the same basis (and, on Linux, the same
642
+ // boot). A token from an older writer (unlabelled zone-dependent wall clock, or
643
+ // the drifting `ps` render) or another basis leaves reuse unproven, so a live
644
+ // owner is never reaped on a basis mismatch.
645
+ if (owner.startTimeBasis !== current.basis) return false;
646
+ return current.startTimeMs !== null && current.startTimeMs !== owner.startTimeMs;
602
647
  }
603
648
 
604
649
  function reapResidentCacheInstanceDir(
@@ -681,7 +726,7 @@ export async function sweepResidentCacheRoot(root: string, options: ResidentCach
681
726
  // cache state behind.
682
727
  rootDescriptor = openVerifiedResidentCacheDirectory(root, uid);
683
728
  assertResidentCacheDirectoryPathMatchesDescriptor(root, rootDescriptor, uid);
684
- const startTimeCache = new Map<number, number | null>();
729
+ const startTimeCache = new Map<number, ResidentCacheProcessStart>();
685
730
  const entries = fs.opendirSync(root);
686
731
  try {
687
732
  let examined = 0;
@@ -746,7 +791,7 @@ function openVerifiedCacheInstanceDir(root: string, instanceName?: string): stri
746
791
  const staleInstance = readResidentCacheOwnerSnapshot(candidate, uid);
747
792
  if (
748
793
  staleInstance === null ||
749
- !residentCacheOwnerIsStale(staleInstance.owner, new Map<number, number | null>()) ||
794
+ !residentCacheOwnerIsStale(staleInstance.owner, new Map<number, ResidentCacheProcessStart>()) ||
750
795
  !reapResidentCacheInstanceDir(root, rootDescriptor, candidate, staleInstance, uid)
751
796
  ) {
752
797
  throw error;
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as fsp from "node:fs/promises";
4
+ import * as os from "node:os";
4
5
  import * as path from "node:path";
5
6
  import type {
6
7
  NativeDirectoryTreeSnapshot,
@@ -11,6 +12,7 @@ import type {
11
12
  RecoveryFsRoot,
12
13
  } from "@gajae-code/natives";
13
14
  import { isEnoent, logger } from "@gajae-code/utils";
15
+ import { isProcessIncarnation, observeProcessIncarnation } from "../../sdk/broker/process-incarnation";
14
16
  import type { SessionStorageRangeSnapshot, SessionStorageStat } from "../session-storage";
15
17
  import {
16
18
  classifyNativePublishOutcome,
@@ -370,8 +372,69 @@ type ReplacementCleanupReceipt = {
370
372
  destination: string;
371
373
  successor: SerializedReplacementIdentity;
372
374
  predecessor: SerializedReplacementIdentity;
375
+ publisher?: ReplacementReceiptPublisher;
373
376
  };
374
377
 
378
+ type ReplacementReceiptPublisher = {
379
+ attemptId: string;
380
+ ownerId: string;
381
+ pid: number;
382
+ incarnation: string;
383
+ host: string;
384
+ };
385
+
386
+ // A process can contain multiple JS isolates. Only this exact owner may infer
387
+ // that an absent local attempt has settled; another isolate's empty set is not
388
+ // evidence that its sibling has finished publishing.
389
+ const replacementPublisherOwnerId = randomUUID();
390
+ const activeReplacementAttempts = new Set<string>();
391
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
392
+
393
+ function replacementReceiptPublisher(bytes: Uint8Array): ReplacementReceiptPublisher | undefined {
394
+ let record: Record<string, unknown> | undefined;
395
+ try {
396
+ record = asRecord(JSON.parse(Buffer.from(bytes).toString("utf8")));
397
+ } catch {
398
+ return undefined;
399
+ }
400
+ if (!record || !Object.hasOwn(record, "publisher")) return undefined;
401
+ const publisher = asRecord(record.publisher);
402
+ if (
403
+ !publisher ||
404
+ typeof publisher.attemptId !== "string" ||
405
+ !UUID_PATTERN.test(publisher.attemptId) ||
406
+ typeof publisher.ownerId !== "string" ||
407
+ !UUID_PATTERN.test(publisher.ownerId) ||
408
+ !Number.isSafeInteger(publisher.pid) ||
409
+ Number(publisher.pid) <= 0 ||
410
+ Number(publisher.pid) > 0x7fff_ffff ||
411
+ !isProcessIncarnation(publisher.incarnation) ||
412
+ typeof publisher.host !== "string" ||
413
+ !publisher.host ||
414
+ typeof record.destination !== "string" ||
415
+ typeof record.staging !== "string" ||
416
+ path.resolve(record.staging) !==
417
+ path.join(
418
+ path.dirname(path.resolve(record.destination)),
419
+ `.${path.basename(record.destination)}.${publisher.attemptId}.replacement`,
420
+ )
421
+ )
422
+ throw new Error("managed_replace_cleanup_receipt_invalid");
423
+ return publisher as ReplacementReceiptPublisher;
424
+ }
425
+
426
+ /** Unknown/remote/stopped owners retain their receipt; elapsed time is not death proof. */
427
+ function replacementReceiptIsOwned(bytes: Uint8Array): boolean {
428
+ const publisher = replacementReceiptPublisher(bytes);
429
+ if (!publisher) return false;
430
+ if (publisher.host !== os.hostname()) return true;
431
+ if (publisher.pid === process.pid && publisher.ownerId === replacementPublisherOwnerId)
432
+ return activeReplacementAttempts.has(publisher.attemptId);
433
+ const observed = observeProcessIncarnation(publisher.pid);
434
+ if (observed.status === "absent") return false;
435
+ return observed.status !== "present" || observed.incarnation === publisher.incarnation;
436
+ }
437
+
375
438
  type LegacyReplacementCleanupIdentity = {
376
439
  dev: bigint;
377
440
  ino: bigint;
@@ -1411,6 +1474,7 @@ export class ManagedSessionDescendantStore {
1411
1474
  retained?: { authority: RecoveryFsRoot; authorityBaseDir: string },
1412
1475
  policy?: ManagedSessionSecurityPolicy,
1413
1476
  profileAgentDir?: string,
1477
+ expectedSubtreeRoot?: ManagedDirectoryRoot,
1414
1478
  ) {
1415
1479
  managedRelativePath(root, baseDir);
1416
1480
 
@@ -1449,13 +1513,46 @@ export class ManagedSessionDescendantStore {
1449
1513
  });
1450
1514
  }
1451
1515
  this.#authority = retained.authority;
1516
+ if (
1517
+ expectedSubtreeRoot &&
1518
+ (expectedSubtreeRoot.canonicalPath !== this.#baseDir ||
1519
+ expectedSubtreeRoot.dev !== this.#subtreeRoot.dev ||
1520
+ expectedSubtreeRoot.ino !== this.#subtreeRoot.ino)
1521
+ )
1522
+ throw new Error("Managed subtree authority changed during establishment");
1452
1523
  this.#assertBound();
1453
1524
 
1454
1525
  return;
1455
1526
  }
1456
1527
  assertManagedDirectoryRoot(root);
1457
- ensureManagedDirectory(this.#baseDir, root, this.#policy);
1528
+ if (expectedSubtreeRoot) {
1529
+ if (expectedSubtreeRoot.canonicalPath !== this.#baseDir)
1530
+ throw new Error("Managed subtree authority path mismatch");
1531
+ assertManagedDirectoryRoot(expectedSubtreeRoot);
1532
+ const verified = validateNativeSecurityResult(
1533
+ process.platform === "win32"
1534
+ ? nativeSessionStorage().verifyOwnerOnlyPathSecurityExpected(
1535
+ this.#baseDir,
1536
+ "directory",
1537
+ expectedSubtreeRoot.dev,
1538
+ expectedSubtreeRoot.ino,
1539
+ )
1540
+ : nativeSessionStorage().verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"),
1541
+ "verify",
1542
+ "directory",
1543
+ );
1544
+ if (!verified.ok) throw securityError(this.#baseDir, verified);
1545
+ assertManagedDirectoryRoot(expectedSubtreeRoot);
1546
+ } else {
1547
+ ensureManagedDirectory(this.#baseDir, root, this.#policy);
1548
+ }
1458
1549
  const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
1550
+ if (
1551
+ expectedSubtreeRoot &&
1552
+ (canonicalFileId(subtreeStat.dev) !== expectedSubtreeRoot.dev ||
1553
+ canonicalFileId(subtreeStat.ino) !== expectedSubtreeRoot.ino)
1554
+ )
1555
+ throw new Error("Managed subtree authority changed during establishment");
1459
1556
  this.#subtreeRoot = Object.freeze({
1460
1557
  canonicalPath: this.#baseDir,
1461
1558
  dev: canonicalFileId(subtreeStat.dev),
@@ -1571,6 +1668,50 @@ export class ManagedSessionDescendantStore {
1571
1668
  }
1572
1669
  this.#assertBound();
1573
1670
  }
1671
+
1672
+ /** Capture a directory identity through this store's retained managed root without repairing it. */
1673
+ captureDirectoryIdentity(relativePath: string): { dev: string; ino: string } {
1674
+ this.#assertBound();
1675
+ const resolved = this.#resolve(relativePath);
1676
+ if (!this.#authority) this.#assertPathBackedDirectoryChain(resolved);
1677
+ const named = fs.lstatSync(resolved, { bigint: true });
1678
+ if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("managed_directory_identity_unavailable");
1679
+ const dev = canonicalFileId(named.dev);
1680
+ const ino = canonicalFileId(named.ino);
1681
+ if (dev !== this.#subtreeRoot.dev) throw new Error("managed_directory_identity_unavailable");
1682
+ if (this.#authority) {
1683
+ const retainedRelative = this.#relative(resolved);
1684
+ if (retainedRelative === "") {
1685
+ if (dev !== this.#subtreeRoot.dev || ino !== this.#subtreeRoot.ino)
1686
+ throw new Error("Managed descendant root binding changed");
1687
+ } else {
1688
+ const retained = this.#authority.retainManagedDirectory(retainedRelative, dev.toString(), ino.toString());
1689
+ try {
1690
+ const identity = retained.identity();
1691
+ if (
1692
+ !identity.ok ||
1693
+ !identity.identity ||
1694
+ identity.identity.dev !== dev.toString() ||
1695
+ identity.identity.ino !== ino.toString()
1696
+ )
1697
+ throw new Error(identity.code ?? "managed_directory_identity_unavailable");
1698
+ } finally {
1699
+ retained.close();
1700
+ }
1701
+ }
1702
+ }
1703
+ const after = fs.lstatSync(resolved, { bigint: true });
1704
+ if (
1705
+ !after.isDirectory() ||
1706
+ after.isSymbolicLink() ||
1707
+ canonicalFileId(after.dev) !== dev ||
1708
+ canonicalFileId(after.ino) !== ino
1709
+ )
1710
+ throw new Error("managed_directory_identity_changed");
1711
+ this.#assertBound();
1712
+ return { dev: dev.toString(), ino: ino.toString() };
1713
+ }
1714
+
1574
1715
  #assertBound(): void {
1575
1716
  if (!this.#authority) {
1576
1717
  const named = fs.statSync(this.#baseDir, { bigint: true });
@@ -1668,6 +1809,7 @@ export class ManagedSessionDescendantStore {
1668
1809
  if (this.#recoverReplacementCleanupPlaceholder(receiptPath, binding, receipt)) return;
1669
1810
  throw new Error("managed_replace_cleanup_receipt_invalid");
1670
1811
  }
1812
+ if (replacementReceiptIsOwned(receipt.bytes)) return;
1671
1813
  const predecessor = binding.predecessor;
1672
1814
  const quarantineName = replacementReceiptRetirementName(receipt.identity, predecessor);
1673
1815
  const detached = nativeSessionStorage().exactUnlink(receiptPath, {
@@ -1770,7 +1912,14 @@ export class ManagedSessionDescendantStore {
1770
1912
  }
1771
1913
 
1772
1914
  #reconcilePendingReplacementReceipt(receiptPath: string): void {
1773
- const pending = captureManagedFilePrefixNoFollow(receiptPath, REPLACEMENT_CLEANUP_RECEIPT_MAX_BYTES);
1915
+ let pending: ManagedFileSnapshot;
1916
+ try {
1917
+ pending = captureManagedFilePrefixNoFollow(receiptPath, REPLACEMENT_CLEANUP_RECEIPT_MAX_BYTES);
1918
+ } catch (error) {
1919
+ // Directory enumeration is not a claim on a peer's receipt name.
1920
+ if (isEnoent(error)) return;
1921
+ throw error;
1922
+ }
1774
1923
  const parsed = parseReplacementCleanupReceipt(pending.bytes);
1775
1924
  const predecessor = parsed ? parseReplacementIdentity(parsed.predecessor) : undefined;
1776
1925
  if (
@@ -1780,6 +1929,7 @@ export class ManagedSessionDescendantStore {
1780
1929
  path.dirname(path.resolve(parsed.destination)) !== this.#baseDir
1781
1930
  )
1782
1931
  throw new Error("managed_replace_cleanup_receipt_invalid");
1932
+ if (replacementReceiptIsOwned(pending.bytes)) return;
1783
1933
  const destination = replacementReceiptPath(this.#baseDir, predecessor, pending.identity);
1784
1934
  const outcome = classifyNativePublishOutcome(
1785
1935
  nativeSessionStorage().renameNoReplacePath(receiptPath, destination),
@@ -1788,7 +1938,11 @@ export class ManagedSessionDescendantStore {
1788
1938
  fsyncDirectory(this.#baseDir);
1789
1939
  return;
1790
1940
  }
1791
- if (outcome.reason === "destination_exists" || outcome.reason === "invalid_request") {
1941
+ if (
1942
+ outcome.reason === "destination_exists" ||
1943
+ outcome.reason === "invalid_request" ||
1944
+ (outcome.reason === "identity_violation" && outcome.mutationState === "not_committed")
1945
+ ) {
1792
1946
  const captureIfPresent = (pathname: string): ManagedFileSnapshot | undefined => {
1793
1947
  try {
1794
1948
  return captureManagedFilePrefixNoFollow(pathname, REPLACEMENT_CLEANUP_RECEIPT_MAX_BYTES);
@@ -2943,6 +3097,34 @@ export class ManagedSessionDescendantStore {
2943
3097
  if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
2944
3098
  this.#assertBound();
2945
3099
  }
3100
+
3101
+ /** Remove one exact managed tree through the parent-identity-bound native protocol. */
3102
+ removeTreeExpectedWithParentIdentity(
3103
+ relativePath: string,
3104
+ expected: NativeDirectoryTreeSnapshot,
3105
+ parentIdentity: { dev: bigint; ino: bigint },
3106
+ ): NativeExactUnlinkResult {
3107
+ this.#beforeMutation();
3108
+ this.#assertBound();
3109
+ const resolved = this.#resolve(relativePath);
3110
+ const parentPath = path.dirname(resolved);
3111
+ const parentRelative = path.relative(this.#baseDir, parentPath);
3112
+ if (path.isAbsolute(parentRelative) || parentRelative === ".." || parentRelative.startsWith(`..${path.sep}`))
3113
+ throw new Error("managed_remove_parent_outside_store");
3114
+ const currentParent = this.captureDirectoryIdentity(parentRelative);
3115
+ if (
3116
+ currentParent.dev !== canonicalFileId(parentIdentity.dev).toString() ||
3117
+ currentParent.ino !== canonicalFileId(parentIdentity.ino).toString()
3118
+ )
3119
+ throw new Error("managed_remove_parent_identity_mismatch");
3120
+ const removed = nativeSessionStorage().exactRemoveDirectoryTree(resolved, expected, {
3121
+ dev: BigInt(currentParent.dev),
3122
+ ino: BigInt(currentParent.ino),
3123
+ });
3124
+ this.#assertBound();
3125
+ return removed;
3126
+ }
3127
+
2946
3128
  fsyncTree(): NativeDirectoryTreeSnapshot {
2947
3129
  this.#beforeMutation();
2948
3130
  this.#assertBound();
@@ -3524,7 +3706,21 @@ function replaceManagedFileGeneratedSync(
3524
3706
  ): ManagedFileSnapshot["identity"] {
3525
3707
  const parent = path.dirname(destination);
3526
3708
  ensureManagedDirectory(parent, root, policy);
3527
- const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
3709
+ const attemptId = randomUUID();
3710
+ const staging = path.join(parent, `.${path.basename(destination)}.${attemptId}.replacement`);
3711
+ const publisherObservation = expectedDestination ? observeProcessIncarnation(process.pid) : undefined;
3712
+ if (expectedDestination && publisherObservation?.status !== "present")
3713
+ throw new Error("managed_replace_publisher_identity_unavailable");
3714
+ const publisher: ReplacementReceiptPublisher | undefined =
3715
+ publisherObservation?.status === "present"
3716
+ ? {
3717
+ attemptId,
3718
+ ownerId: replacementPublisherOwnerId,
3719
+ pid: process.pid,
3720
+ incarnation: publisherObservation.incarnation,
3721
+ host: os.hostname(),
3722
+ }
3723
+ : undefined;
3528
3724
  let fd: number | undefined;
3529
3725
  let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
3530
3726
  let preserveStaging = false;
@@ -3542,6 +3738,7 @@ function replaceManagedFileGeneratedSync(
3542
3738
  let publicationDurable = false;
3543
3739
  let publicationCommitted = false;
3544
3740
  let expectedSuccessor: ManagedFileSnapshot["identity"] | undefined;
3741
+ activeReplacementAttempts.add(attemptId);
3545
3742
  try {
3546
3743
  fd = fs.openSync(
3547
3744
  staging,
@@ -3578,6 +3775,7 @@ function replaceManagedFileGeneratedSync(
3578
3775
  destination,
3579
3776
  successor: serializeReplacementIdentity(successor),
3580
3777
  predecessor: serializeReplacementIdentity(expectedDestination),
3778
+ publisher,
3581
3779
  } satisfies ReplacementCleanupReceipt),
3582
3780
  ),
3583
3781
  root,
@@ -3733,6 +3931,7 @@ function replaceManagedFileGeneratedSync(
3733
3931
  }
3734
3932
  }
3735
3933
  }
3934
+ activeReplacementAttempts.delete(attemptId);
3736
3935
  }
3737
3936
  if (failure !== undefined && !(acceptCommittedCleanupFailure && publicationDurable && publishedIdentity))
3738
3937
  throw failure;