@gajae-code/coding-agent 0.11.7 → 0.11.8

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 (180) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/config/keybindings.d.ts +33 -3
  4. package/dist/types/config/model-profile-activation.d.ts +4 -3
  5. package/dist/types/config/model-profile-contract.d.ts +38 -0
  6. package/dist/types/config/settings-schema.d.ts +15 -2
  7. package/dist/types/edit/modes/patch.d.ts +13 -0
  8. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  9. package/dist/types/eval/py/executor.d.ts +1 -1
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  11. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  12. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  13. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  14. package/dist/types/main.d.ts +8 -0
  15. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  16. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  17. package/dist/types/modes/components/welcome.d.ts +2 -0
  18. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  19. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  20. package/dist/types/modes/interactive-mode.d.ts +5 -2
  21. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  22. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  24. package/dist/types/modes/types.d.ts +3 -0
  25. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  26. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  27. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  28. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  29. package/dist/types/sdk/broker/broker.d.ts +2 -0
  30. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  31. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  32. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  33. package/dist/types/sdk/bus/config.d.ts +2 -0
  34. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  35. package/dist/types/sdk/bus/index.d.ts +25 -0
  36. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  37. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  38. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  39. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  42. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  43. package/dist/types/sdk/host/host.d.ts +1 -1
  44. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  45. package/dist/types/sdk/index.d.ts +2 -0
  46. package/dist/types/sdk/prompt-status.d.ts +88 -0
  47. package/dist/types/sdk/session-directory.d.ts +1 -1
  48. package/dist/types/sdk/startup-capability.d.ts +3 -0
  49. package/dist/types/session/agent-session.d.ts +2 -0
  50. package/dist/types/session/internal/managed-session-scope.d.ts +11 -2
  51. package/dist/types/session/internal/managed-session-storage.d.ts +17 -2
  52. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  53. package/dist/types/session/session-storage.d.ts +18 -0
  54. package/dist/types/task/index.d.ts +3 -1
  55. package/dist/types/task/receipt.d.ts +2 -0
  56. package/dist/types/task/types.d.ts +94 -0
  57. package/dist/types/tools/ask.d.ts +454 -174
  58. package/dist/types/tools/image-gen.d.ts +47 -1
  59. package/dist/types/tools/index.d.ts +4 -0
  60. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  61. package/package.json +8 -8
  62. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  63. package/scripts/dogfood-repository-binding.ts +209 -0
  64. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  65. package/scripts/run-test-manifest.ts +4 -4
  66. package/src/cli/args.ts +2 -92
  67. package/src/cli/notify-cli.ts +5 -38
  68. package/src/cli.ts +18 -0
  69. package/src/commands/sdk.ts +2 -2
  70. package/src/config/keybindings.ts +157 -24
  71. package/src/config/model-profile-activation.ts +7 -18
  72. package/src/config/model-profile-contract.ts +161 -0
  73. package/src/config/model-registry.ts +11 -1
  74. package/src/config/settings-schema.ts +17 -2
  75. package/src/coordinator-mcp/model-preset.ts +21 -74
  76. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  77. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  78. package/src/edit/index.ts +3 -6
  79. package/src/edit/modes/patch.ts +38 -1
  80. package/src/edit/modes/replace.ts +42 -0
  81. package/src/edit/path-mutation-lock.ts +112 -0
  82. package/src/eval/py/executor.ts +30 -11
  83. package/src/eval/py/prelude.py +2 -2
  84. package/src/eval/py/tool-bridge.ts +34 -18
  85. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  86. package/src/gjc-runtime/launch-tmux.ts +1 -1
  87. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  88. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  89. package/src/gjc-runtime/repository-binding.ts +267 -0
  90. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  91. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  92. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  93. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  94. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  95. package/src/internal-urls/docs-index.generated.ts +6 -5
  96. package/src/internal-urls/local-protocol.ts +33 -16
  97. package/src/internal-urls/mcp-protocol.ts +73 -11
  98. package/src/main.ts +25 -10
  99. package/src/modes/DESIGN.md +5 -0
  100. package/src/modes/acp/acp-agent.ts +50 -16
  101. package/src/modes/components/model-selector.ts +42 -8
  102. package/src/modes/components/plan-preview-overlay.ts +1 -1
  103. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  104. package/src/modes/components/tool-status-header.ts +20 -4
  105. package/src/modes/components/welcome.ts +22 -18
  106. package/src/modes/controllers/command-controller.ts +97 -54
  107. package/src/modes/controllers/event-controller.ts +10 -0
  108. package/src/modes/controllers/input-controller.ts +7 -2
  109. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  110. package/src/modes/controllers/selector-controller.ts +46 -54
  111. package/src/modes/interactive-mode.ts +41 -27
  112. package/src/modes/prompt-action-autocomplete.ts +32 -13
  113. package/src/modes/prompt-suggestion-controller.ts +96 -0
  114. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  115. package/src/modes/types.ts +3 -0
  116. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  117. package/src/modes/utils/ui-helpers.ts +5 -3
  118. package/src/prompts/agents/architect.md +0 -1
  119. package/src/prompts/agents/critic.md +0 -1
  120. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  121. package/src/prompts/system/system-prompt.md +6 -4
  122. package/src/prompts/tools/cron.md +3 -1
  123. package/src/runtime-mcp/manager.ts +51 -53
  124. package/src/runtime-mcp/oauth-flow.ts +0 -72
  125. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  126. package/src/runtime-mcp/transports/http.ts +13 -6
  127. package/src/sdk/broker/broker.ts +34 -20
  128. package/src/sdk/broker/lifecycle.ts +185 -36
  129. package/src/sdk/bus/chat-daemon-control.ts +18 -5
  130. package/src/sdk/bus/config-commands.ts +2 -2
  131. package/src/sdk/bus/config.ts +9 -1
  132. package/src/sdk/bus/conversation-store.ts +40 -6
  133. package/src/sdk/bus/index.ts +240 -65
  134. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  135. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  136. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  137. package/src/sdk/bus/notification-orchestration.ts +12 -0
  138. package/src/sdk/bus/notification-service.ts +27 -2
  139. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  140. package/src/sdk/bus/recent-activity.ts +90 -26
  141. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  142. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  143. package/src/sdk/bus/telegram-daemon.ts +835 -110
  144. package/src/sdk/bus/telegram-reference.ts +20 -2
  145. package/src/sdk/cli/session-cli.ts +15 -2
  146. package/src/sdk/host/control/dispatch.ts +1 -1
  147. package/src/sdk/host/control/operations.ts +1 -1
  148. package/src/sdk/host/host.ts +2 -6
  149. package/src/sdk/host/query/handlers.ts +70 -5
  150. package/src/sdk/index.ts +12 -0
  151. package/src/sdk/prompt-status.ts +97 -0
  152. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  153. package/src/sdk/protocol/operation-registry.ts +18 -4
  154. package/src/sdk/session-directory.ts +5 -1
  155. package/src/sdk/session.ts +2 -0
  156. package/src/sdk/startup-capability.ts +9 -1
  157. package/src/session/agent-session.ts +52 -5
  158. package/src/session/blob-store.ts +7 -2
  159. package/src/session/internal/managed-session-scope.ts +854 -84
  160. package/src/session/internal/managed-session-storage.ts +251 -199
  161. package/src/session/internal/native-publish-outcome.ts +266 -0
  162. package/src/session/session-manager.ts +310 -327
  163. package/src/session/session-storage.ts +128 -11
  164. package/src/task/gjc-command.ts +2 -2
  165. package/src/task/index.ts +121 -15
  166. package/src/task/receipt.ts +3 -0
  167. package/src/task/types.ts +34 -3
  168. package/src/tools/ask.ts +198 -88
  169. package/src/tools/ast-edit.ts +2 -2
  170. package/src/tools/bash-pty-selection.ts +2 -2
  171. package/src/tools/browser/screenshot-format.ts +6 -1
  172. package/src/tools/cron.ts +1 -1
  173. package/src/tools/image-gen.ts +178 -6
  174. package/src/tools/index.ts +4 -0
  175. package/src/tools/render-utils.ts +7 -1
  176. package/src/utils/edit-mode.ts +2 -2
  177. package/src/utils/prompt-suggestion.ts +265 -0
  178. package/src/utils/sixel.ts +3 -3
  179. package/src/web/insane/url-guard.ts +3 -1
  180. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -18,6 +18,12 @@ import {
18
18
  verifyOwnerOnlyPathSecurity,
19
19
  verifyOwnerOnlyPathSecurityExpected,
20
20
  } from "@gajae-code/natives";
21
+ import {
22
+ classifyNativePublishOutcome,
23
+ formatNativePublishDiagnostic,
24
+ mayCleanCurrentStaging,
25
+ type NativePublishOutcome,
26
+ } from "./native-publish-outcome";
21
27
 
22
28
  export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
23
29
  export const MANAGED_ARTIFACT_MAX_FILES = 10_000;
@@ -28,6 +34,82 @@ const LOCK_HEARTBEAT_MS = 10_000;
28
34
  const LOCK_WAIT_MS = 5_000;
29
35
 
30
36
  const LOCK_STALE_RECHECK_MS = 100;
37
+ export class ManagedPublishError extends Error {
38
+ readonly classification:
39
+ | "destination_conflict"
40
+ | "atomic_unavailable"
41
+ | "invalid_request"
42
+ | "durability_not_provable"
43
+ | "identity_mismatch"
44
+ | "io_error"
45
+ | "durability_failed";
46
+ readonly diagnostic: string;
47
+
48
+ constructor(classification: ManagedPublishError["classification"], outcome: NativePublishOutcome) {
49
+ super(classification);
50
+ this.name = "ManagedPublishError";
51
+ this.classification = classification;
52
+ this.diagnostic = formatNativePublishDiagnostic(outcome);
53
+ }
54
+ }
55
+
56
+ function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
57
+ const classification =
58
+ outcome.reason === "destination_exists"
59
+ ? "destination_conflict"
60
+ : outcome.reason === "atomic_unavailable"
61
+ ? "atomic_unavailable"
62
+ : outcome.reason === "invalid_request"
63
+ ? "invalid_request"
64
+ : outcome.reason === "durability_not_provable"
65
+ ? "durability_not_provable"
66
+ : outcome.reason === "identity_violation"
67
+ ? "identity_mismatch"
68
+ : outcome.reason === "io_failure"
69
+ ? "io_error"
70
+ : "durability_failed";
71
+ return new ManagedPublishError(classification, outcome);
72
+ }
73
+
74
+ /** A same-filesystem rename updates the moved root's ctime but no other tree identity. */
75
+ function sameDirectoryTreeSnapshotAfterRename(
76
+ left: NativeDirectoryTreeSnapshot,
77
+ right: NativeDirectoryTreeSnapshot,
78
+ ): boolean {
79
+ return (
80
+ left.rootDev === right.rootDev &&
81
+ left.rootIno === right.rootIno &&
82
+ left.entries.length === right.entries.length &&
83
+ left.entries.every((entry, index) => {
84
+ const other = right.entries[index];
85
+ return (
86
+ other !== undefined &&
87
+ entry.relativePath === other.relativePath &&
88
+ entry.kind === other.kind &&
89
+ entry.dev === other.dev &&
90
+ entry.ino === other.ino &&
91
+ entry.size === other.size &&
92
+ entry.mtimeNs === other.mtimeNs &&
93
+ (entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
94
+ entry.sha256 === other.sha256
95
+ );
96
+ })
97
+ );
98
+ }
99
+
100
+ class ManagedTreeMoveOutcomeError extends Error {
101
+ constructor(
102
+ message: string,
103
+ readonly stagingCleanupSafe: boolean,
104
+ ) {
105
+ super(message);
106
+ }
107
+ }
108
+
109
+ /** Cleanup is safe only when the native move reports that it did not commit. */
110
+ export function mayCleanManagedTreeStaging(error: unknown): boolean {
111
+ return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
112
+ }
31
113
 
32
114
  export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
33
115
 
@@ -39,6 +121,8 @@ export type ManagedStorageFailure =
39
121
  | "source_changed"
40
122
  | "unsafe_artifacts"
41
123
  | "durability_failed"
124
+ | "atomic_unavailable"
125
+ | "durability_not_provable"
42
126
  | "migration_retired"
43
127
  | "managed_storage_unsupported";
44
128
 
@@ -97,6 +181,9 @@ export function validateNativeSecurityResult(
97
181
  ) {
98
182
  throw new Error("Malformed ACL security failure evidence");
99
183
  }
184
+ if (operation === "verify" && result.operation !== "query") {
185
+ throw new Error("Verify failure unexpectedly reports ACL mutation");
186
+ }
100
187
  } else if (result.operation !== undefined || result.attribute !== undefined) {
101
188
  throw new Error("Unexpected ACL fields on owner-only security failure");
102
189
  }
@@ -183,6 +270,40 @@ export interface ManagedDirectoryRoot {
183
270
  readonly ino: bigint;
184
271
  }
185
272
 
273
+ /**
274
+ * Establish the first managed directory beneath a potentially shared ancestor.
275
+ * The ancestor is used only to create the managed chain; every component from
276
+ * `configuredRoot` downward is independently type, symlink, owner, mode, and
277
+ * ACL checked. This deliberately does not impose owner-only policy on `/tmp`.
278
+ */
279
+ export function prepareManagedDirectoryRoot(
280
+ configuredRoot: string,
281
+ policy: ManagedSessionSecurityPolicy = "default",
282
+ ): ManagedDirectoryRoot {
283
+ const target = path.resolve(configuredRoot);
284
+ const missing: string[] = [];
285
+ let existing = target;
286
+ for (;;) {
287
+ try {
288
+ assertSafeDirectory(existing);
289
+ break;
290
+ } catch (error) {
291
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
292
+ const parent = path.dirname(existing);
293
+ if (parent === existing) throw new Error(`Managed root is unavailable: ${configuredRoot}`);
294
+ missing.unshift(path.basename(existing));
295
+ existing = parent;
296
+ }
297
+ }
298
+ let current = fs.realpathSync.native(existing);
299
+ for (const component of missing) {
300
+ current = path.join(current, component);
301
+ const created = ensureDirectoryComponent(current);
302
+ secureManagedDirectory(current, created, policy);
303
+ }
304
+ if (missing.length === 0) secureManagedDirectory(current, false, policy);
305
+ return managedDirectoryRoot(current);
306
+ }
186
307
  export function managedDirectoryRoot(configuredRoot: string): ManagedDirectoryRoot {
187
308
  const canonicalPath = fs.realpathSync.native(configuredRoot);
188
309
  const stat = fs.lstatSync(canonicalPath, { bigint: true });
@@ -339,8 +460,8 @@ export class ManagedSessionDescendantStore {
339
460
  retained?: { authority: RecoveryFsRoot; authorityBaseDir: string },
340
461
  policy?: ManagedSessionSecurityPolicy,
341
462
  ) {
342
- assertManagedDirectoryRoot(root);
343
463
  managedRelativePath(root, baseDir);
464
+
344
465
  this.#root = root;
345
466
  this.#baseDir = path.resolve(baseDir);
346
467
  this.#policy = policy ?? "default";
@@ -350,20 +471,17 @@ export class ManagedSessionDescendantStore {
350
471
  const captured = retained.authority.snapshotManagedTree(relative);
351
472
  if (!captured.ok || !captured.snapshot)
352
473
  throw new Error(captured.code ?? "Managed subtree identity unavailable");
353
- const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
354
- if (
355
- captured.snapshot.rootDev !== subtreeStat.dev.toString() ||
356
- captured.snapshot.rootIno !== subtreeStat.ino.toString()
357
- )
358
- throw new Error("Managed descendant root identity changed");
359
474
  this.#subtreeRoot = Object.freeze({
360
475
  canonicalPath: this.#baseDir,
361
- dev: subtreeStat.dev,
362
- ino: subtreeStat.ino,
476
+ dev: BigInt(captured.snapshot.rootDev),
477
+ ino: BigInt(captured.snapshot.rootIno),
363
478
  });
364
479
  this.#authority = retained.authority;
480
+ this.#assertBound();
481
+
365
482
  return;
366
483
  }
484
+ assertManagedDirectoryRoot(root);
367
485
  ensureManagedDirectory(this.#baseDir, root, this.#policy);
368
486
  const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
369
487
  this.#subtreeRoot = Object.freeze({ canonicalPath: this.#baseDir, dev: subtreeStat.dev, ino: subtreeStat.ino });
@@ -557,7 +675,7 @@ export class ManagedSessionDescendantStore {
557
675
  appendSync(relativePath: string, bytes: Uint8Array): void {
558
676
  this.#assertBound();
559
677
  const resolved = this.#resolve(relativePath);
560
- const existing = this.readExpected(relativePath);
678
+ let existing = this.readExpected(relativePath);
561
679
  if (!existing) throw new Error("managed_append_missing");
562
680
  if (this.#authority) {
563
681
  const appended = this.#authority.appendManaged(
@@ -574,20 +692,49 @@ export class ManagedSessionDescendantStore {
574
692
  this.#assertBound();
575
693
  return;
576
694
  }
695
+ // On Darwin, the first write-append open can change only ctime (e.g. com.apple.provenance)
696
+ // while leaving dev/ino/size/mtime/content intact. Allow one bounded refresh+retry for that
697
+ // transition only; any other identity change or a second ctime transition stays fail-closed.
698
+ // See https://github.com/Yeachan-Heo/gajae-code/issues/2944
577
699
  let fd: number | undefined;
700
+ let darwinCtimeRefreshConsumed = false;
578
701
  try {
579
- fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
580
- const before = identity(fs.fstatSync(fd, { bigint: true }));
581
- if (!sameIdentity(before, existing.identity)) throw new Error("identity_mismatch");
582
- secureFileDescriptor(resolved, fd, "verify");
583
- let offset = 0;
584
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
585
- fs.fsyncSync(fd);
586
- secureFileDescriptor(resolved, fd, "verify");
587
- const after = identity(fs.fstatSync(fd, { bigint: true }));
588
- const named = identity(fs.lstatSync(resolved, { bigint: true }));
589
- if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
590
- throw new Error("identity_mismatch");
702
+ for (;;) {
703
+ fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
704
+ const before = identity(fs.fstatSync(fd, { bigint: true }));
705
+ if (!sameIdentity(before, existing.identity)) {
706
+ const allowDarwinCtimeRefresh =
707
+ process.platform === "darwin" &&
708
+ !darwinCtimeRefreshConsumed &&
709
+ isCtimeOnlyIdentityMismatch(before, existing.identity);
710
+ if (!allowDarwinCtimeRefresh) throw new Error("identity_mismatch");
711
+ fs.closeSync(fd);
712
+ fd = undefined;
713
+ darwinCtimeRefreshConsumed = true;
714
+ const original = existing;
715
+ const refreshed = this.readExpected(relativePath);
716
+ if (
717
+ !refreshed ||
718
+ !sameStableIdentityIgnoringCtime(refreshed.identity, original.identity) ||
719
+ refreshed.identity.sha256 !== original.identity.sha256 ||
720
+ !refreshed.bytes.equals(original.bytes)
721
+ ) {
722
+ throw new Error("identity_mismatch");
723
+ }
724
+ existing = refreshed;
725
+ continue;
726
+ }
727
+ secureFileDescriptor(resolved, fd, "verify");
728
+ let offset = 0;
729
+ while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
730
+ fs.fsyncSync(fd);
731
+ secureFileDescriptor(resolved, fd, "verify");
732
+ const after = identity(fs.fstatSync(fd, { bigint: true }));
733
+ const named = identity(fs.lstatSync(resolved, { bigint: true }));
734
+ if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
735
+ throw new Error("identity_mismatch");
736
+ break;
737
+ }
591
738
  } finally {
592
739
  if (fd !== undefined) fs.closeSync(fd);
593
740
  }
@@ -608,6 +755,7 @@ export class ManagedSessionDescendantStore {
608
755
  if (!created.ok) throw new Error(created.code ?? "managed_publish_failed");
609
756
  if (!created.identity) throw new Error("managed_publish_identity_unavailable");
610
757
  let captured: ReturnType<RecoveryFsRoot["readManaged"]> | undefined;
758
+ let published: unknown;
611
759
  try {
612
760
  captured = this.#authority.readManaged(temporary);
613
761
  if (!captured.ok || !captured.identity || !captured.data)
@@ -633,7 +781,7 @@ export class ManagedSessionDescendantStore {
633
781
  digest,
634
782
  );
635
783
  if (!synced.ok) throw new Error(synced.code ?? "managed_publish_fsync_failed");
636
- const published = this.#authority.renameManagedFileNoReplace(
784
+ published = this.#authority.renameManagedFileNoReplace(
637
785
  temporary,
638
786
  relative,
639
787
  captured.identity.dev,
@@ -643,35 +791,14 @@ export class ManagedSessionDescendantStore {
643
791
  captured.identity.ctimeNs,
644
792
  digest,
645
793
  );
646
- if (!published.ok)
647
- throw new Error(
648
- published.code === "already_exists"
649
- ? "destination_conflict"
650
- : (published.code ?? "managed_publish_failed"),
651
- );
794
+ const outcome = classifyNativePublishOutcome(published, "retained_file");
795
+ if (!outcome.ok) throw publishFailure(outcome);
652
796
  } catch (error) {
653
- if (captured?.ok && captured.identity && captured.data) {
654
- const digest = captured.identity.sha256 ?? createHash("sha256").update(captured.data).digest("hex");
655
- const installed = this.#authority.readManaged(relative);
656
- if (
657
- installed.ok &&
658
- installed.identity &&
659
- installed.data &&
660
- installed.identity.dev === captured.identity.dev &&
661
- installed.identity.ino === captured.identity.ino &&
662
- (installed.identity.sha256 ?? createHash("sha256").update(installed.data).digest("hex")) === digest
663
- ) {
664
- const removed = this.#authority.removeManaged(
665
- relative,
666
- installed.identity.dev,
667
- installed.identity.ino,
668
- installed.identity.size,
669
- installed.identity.mtimeNs,
670
- installed.identity.ctimeNs,
671
- digest,
672
- );
673
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
674
- }
797
+ // A committed or unknown native outcome is evidence, not authorization to
798
+ // probe or remove the destination. Only a validated pre-mutation result
799
+ // may clean this operation's still-named staging object.
800
+ const outcome = captured ? classifyNativePublishOutcome(published, "retained_file") : undefined;
801
+ if (captured?.ok && captured.identity && captured.data && outcome && mayCleanCurrentStaging(outcome)) {
675
802
  const staged = this.#authority.readManaged(temporary);
676
803
  if (
677
804
  staged.ok &&
@@ -689,7 +816,8 @@ export class ManagedSessionDescendantStore {
689
816
  staged.identity.ctimeNs,
690
817
  staged.identity.sha256 ?? createHash("sha256").update(staged.data).digest("hex"),
691
818
  );
692
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
819
+ if (!removed.ok && removed.code !== "cleanup_pending")
820
+ throw new Error(removed.code ?? "managed_publish_reconcile_failed");
693
821
  }
694
822
  }
695
823
  throw error;
@@ -762,7 +890,17 @@ export class ManagedSessionDescendantStore {
762
890
  sha256: expected.identity.sha256,
763
891
  quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
764
892
  });
765
- if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
893
+ if (
894
+ !removed.ok &&
895
+ !(
896
+ removed.code === "cleanup_pending" &&
897
+ (removed.detachedPath ??
898
+ removed.retainedSuccessorPath ??
899
+ removed.retainedPlaceholderPath ??
900
+ removed.retainedUnknownPath) !== undefined
901
+ )
902
+ )
903
+ throw new Error(removed.code ?? "managed_remove_failed");
766
904
  this.#assertBound();
767
905
  return;
768
906
  }
@@ -775,7 +913,8 @@ export class ManagedSessionDescendantStore {
775
913
  expected.identity.ctimeNs.toString(),
776
914
  expected.identity.sha256,
777
915
  );
778
- if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
916
+ if (!removed.ok && !(removed.code === "cleanup_pending" && removed.recoveryPath !== undefined))
917
+ throw new Error(removed.code ?? "managed_remove_failed");
779
918
  this.#assertBound();
780
919
  }
781
920
  /** Read and remove one managed descendant through retained authority. */
@@ -799,7 +938,8 @@ export class ManagedSessionDescendantStore {
799
938
  existing.identity.ctimeNs,
800
939
  existing.identity.sha256 ?? createHash("sha256").update(existing.data).digest("hex"),
801
940
  );
802
- if (!removed.ok) throw new Error(removed.code ?? "managed_consume_failed");
941
+ if (!removed.ok && !(removed.code === "cleanup_pending" && removed.recoveryPath !== undefined))
942
+ throw new Error(removed.code ?? "managed_consume_failed");
803
943
  this.#assertBound();
804
944
  return existing.data;
805
945
  }
@@ -885,92 +1025,31 @@ export class ManagedSessionDescendantStore {
885
1025
  sourceRelativePath: string,
886
1026
  destinationRelativePath: string,
887
1027
  expected: NativeDirectoryTreeSnapshot,
888
- ): void {
1028
+ ): NativeDirectoryTreeSnapshot {
889
1029
  this.#assertBound();
890
- if (!this.#authority) {
891
- const source = this.#resolve(sourceRelativePath);
892
- const destination = this.#resolve(destinationRelativePath);
893
- if (fs.existsSync(destination)) throw new Error("already_exists");
894
- let destinationSnapshot: NativeDirectoryTreeSnapshot | undefined;
895
- try {
896
- this.ensureDirectory(destinationRelativePath);
897
- destinationSnapshot = this.captureTree(destinationRelativePath);
898
- for (const entry of expected.entries) {
899
- if (entry.relativePath === "") continue;
900
- const target = path.posix.join(destinationRelativePath.replaceAll(path.sep, "/"), entry.relativePath);
901
- if (entry.kind === "directory") {
902
- this.ensureDirectory(target);
903
- destinationSnapshot = this.captureTree(destinationRelativePath);
904
- continue;
905
- }
906
- const sourceEntry = this.readExpected(
907
- path.posix.join(sourceRelativePath.replaceAll(path.sep, "/"), entry.relativePath),
908
- );
909
- if (
910
- !sourceEntry ||
911
- sourceEntry.identity.dev.toString() !== entry.dev ||
912
- sourceEntry.identity.ino.toString() !== entry.ino ||
913
- sourceEntry.identity.size.toString() !== entry.size ||
914
- sourceEntry.identity.mtimeNs.toString() !== entry.mtimeNs ||
915
- sourceEntry.identity.sha256 !== entry.sha256
916
- )
917
- throw new Error("artifact_source_changed");
918
- this.publishNoReplaceSync(target, sourceEntry.bytes);
919
- destinationSnapshot = this.captureTree(destinationRelativePath);
920
- }
921
- destinationSnapshot = this.captureTree(destinationRelativePath);
922
- const comparable = (tree: NativeDirectoryTreeSnapshot) =>
923
- tree.entries.map(entry => ({
924
- relativePath: entry.relativePath,
925
- kind: entry.kind,
926
- size: entry.size,
927
- sha256: entry.sha256,
928
- }));
929
- if (JSON.stringify(comparable(destinationSnapshot)) !== JSON.stringify(comparable(expected)))
930
- throw new Error("artifact_destination_mismatch");
931
- const terminalSource = snapshotDirectoryTree(source);
932
- if (
933
- !terminalSource.ok ||
934
- !terminalSource.snapshot ||
935
- JSON.stringify(terminalSource.snapshot) !== JSON.stringify(expected)
1030
+ const moved = this.#authority
1031
+ ? this.#authority.renameManagedTreeNoReplace(
1032
+ this.#relative(this.#resolve(sourceRelativePath)),
1033
+ this.#relative(this.#resolve(destinationRelativePath)),
1034
+ expected,
936
1035
  )
937
- throw new Error("artifact_source_changed");
938
- const retired = exactRemoveDirectoryTree(source, expected);
939
- if (!retired.ok) throw new Error(retired.code ?? "managed_remove_failed");
940
- } catch (error) {
941
- if (destinationSnapshot) {
942
- const removed = exactRemoveDirectoryTree(destination, destinationSnapshot);
943
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
944
- }
945
- throw error;
946
- }
947
- this.#assertBound();
948
- return;
949
- }
950
- const moved = this.#authority.renameManagedTreeNoReplace(
951
- this.#relative(this.#resolve(sourceRelativePath)),
952
- this.#relative(this.#resolve(destinationRelativePath)),
953
- expected,
954
- );
955
- if (!moved.ok) {
956
- try {
957
- const installed = this.captureTree(destinationRelativePath);
958
- const comparable = (tree: NativeDirectoryTreeSnapshot) =>
959
- tree.entries.map(entry => ({
960
- relativePath: entry.relativePath,
961
- kind: entry.kind,
962
- size: entry.size,
963
- sha256: entry.sha256,
964
- }));
965
- if (JSON.stringify(comparable(installed)) !== JSON.stringify(comparable(expected)))
966
- throw new Error("managed_publish_reconcile_failed");
967
- this.removeTreeExpected(destinationRelativePath, installed);
968
- } catch (error) {
969
- if (!(error instanceof Error) || error.message !== "not_found") throw error;
970
- }
971
- throw new Error(moved.code ?? "managed_move_failed");
972
- }
1036
+ : renameNoReplacePath(this.#resolve(sourceRelativePath), this.#resolve(destinationRelativePath));
1037
+ const outcome = classifyNativePublishOutcome(moved, this.#authority ? "retained_tree" : "direct_rename");
1038
+ if (!outcome.ok)
1039
+ throw new ManagedTreeMoveOutcomeError(publishFailure(outcome).message, mayCleanCurrentStaging(outcome));
1040
+ const movedSnapshot = this.#authority
1041
+ ? this.#authority.snapshotManagedTree(this.#relative(this.#resolve(destinationRelativePath)))
1042
+ : snapshotDirectoryTree(this.#resolve(destinationRelativePath));
1043
+ if (
1044
+ !movedSnapshot.ok ||
1045
+ !movedSnapshot.snapshot ||
1046
+ !sameDirectoryTreeSnapshotAfterRename(movedSnapshot.snapshot, expected)
1047
+ )
1048
+ throw new ManagedTreeMoveOutcomeError("artifact_destination_mismatch", false);
1049
+ if (!this.#authority) fsyncDirectory(this.#baseDir);
1050
+
973
1051
  this.#assertBound();
1052
+ return movedSnapshot.snapshot;
974
1053
  }
975
1054
 
976
1055
  removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
@@ -1098,6 +1177,24 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1098
1177
  );
1099
1178
  }
1100
1179
 
1180
+ /** Content/path identity that remains meaningful across a Darwin write-open ctime-only transition. */
1181
+ function sameStableIdentityIgnoringCtime(
1182
+ left: ManagedFileSnapshot["identity"],
1183
+ right: ManagedFileSnapshot["identity"],
1184
+ ): boolean {
1185
+ return (
1186
+ left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs
1187
+ );
1188
+ }
1189
+
1190
+ /** True when observed identity matches expected on every field except ctimeNs. */
1191
+ function isCtimeOnlyIdentityMismatch(
1192
+ observed: ManagedFileSnapshot["identity"],
1193
+ expected: ManagedFileSnapshot["identity"],
1194
+ ): boolean {
1195
+ return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1196
+ }
1197
+
1101
1198
  function parseLock(pathname: string): LockRecord | undefined {
1102
1199
  try {
1103
1200
  const stat = fs.lstatSync(pathname);
@@ -1228,9 +1325,10 @@ export async function publishManagedFileNoReplace(
1228
1325
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1229
1326
  let fd: number | undefined;
1230
1327
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1231
- let stagingSnapshot: ManagedFileSnapshot | undefined;
1232
- let committed = false;
1233
1328
  let failure: unknown;
1329
+ let outcome: NativePublishOutcome | undefined;
1330
+ let renameAttempted = false;
1331
+
1234
1332
  try {
1235
1333
  assertOwned?.();
1236
1334
  fd = fs.openSync(
@@ -1245,17 +1343,13 @@ export async function publishManagedFileNoReplace(
1245
1343
  secureFileDescriptor(staging, fd, "verify");
1246
1344
  const staged = fs.fstatSync(fd, { bigint: true });
1247
1345
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1248
- stagingSnapshot = {
1249
- bytes: Buffer.from(bytes),
1250
- identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
1251
- };
1252
1346
  assertOwned?.();
1253
1347
 
1254
- const published = renameNoReplacePath(staging, destination);
1255
- if (!published.ok) {
1256
- if (published.code === "quarantine_collision") throw new Error("destination_conflict");
1257
- throw new Error("durability_failed");
1258
- }
1348
+ renameAttempted = true;
1349
+ outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1350
+
1351
+ if (!outcome.ok) throw publishFailure(outcome);
1352
+
1259
1353
  const named = fs.lstatSync(destination, { bigint: true });
1260
1354
  if (
1261
1355
  !named.isFile() ||
@@ -1270,25 +1364,12 @@ export async function publishManagedFileNoReplace(
1270
1364
  fd = undefined;
1271
1365
 
1272
1366
  fsyncDirectory(parent);
1273
- committed = true;
1274
1367
  } catch (error) {
1275
1368
  failure = error;
1276
1369
  } finally {
1277
1370
  if (fd !== undefined) fs.closeSync(fd);
1278
- if (!committed && stagingSnapshot) {
1279
- try {
1280
- const installed = captureManagedFileNoFollow(destination);
1281
- if (
1282
- installed.identity.dev === stagingSnapshot.identity.dev &&
1283
- installed.identity.ino === stagingSnapshot.identity.ino &&
1284
- installed.identity.sha256 === stagingSnapshot.identity.sha256
1285
- )
1286
- await unlinkManagedFileVerified(destination, installed.identity);
1287
- } catch (error) {
1288
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1289
- }
1290
- }
1291
- if (stagingIdentity) {
1371
+
1372
+ if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1292
1373
  await fsp
1293
1374
  .lstat(staging, { bigint: true })
1294
1375
  .then(stat => {
@@ -1314,9 +1395,9 @@ export function publishManagedFileNoReplaceSync(
1314
1395
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1315
1396
  let fd: number | undefined;
1316
1397
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1317
- let stagingSnapshot: ManagedFileSnapshot | undefined;
1318
- let committed = false;
1319
1398
  let failure: unknown;
1399
+ let outcome: NativePublishOutcome | undefined;
1400
+
1320
1401
  try {
1321
1402
  fd = fs.openSync(
1322
1403
  staging,
@@ -1330,16 +1411,10 @@ export function publishManagedFileNoReplaceSync(
1330
1411
  secureFileDescriptor(staging, fd, "verify");
1331
1412
  const staged = fs.fstatSync(fd, { bigint: true });
1332
1413
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1333
- stagingSnapshot = {
1334
- bytes: Buffer.from(bytes),
1335
- identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
1336
- };
1337
1414
 
1338
- const published = renameNoReplacePath(staging, destination);
1339
- if (!published.ok) {
1340
- if (published.code === "quarantine_collision") throw new Error("destination_conflict");
1341
- throw new Error("durability_failed");
1342
- }
1415
+ outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1416
+ if (!outcome.ok) throw publishFailure(outcome);
1417
+
1343
1418
  const named = fs.lstatSync(destination, { bigint: true });
1344
1419
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1345
1420
  throw new Error("destination_identity_changed");
@@ -1348,35 +1423,12 @@ export function publishManagedFileNoReplaceSync(
1348
1423
  fs.closeSync(fd);
1349
1424
  fd = undefined;
1350
1425
  fsyncDirectory(parent);
1351
- committed = true;
1352
1426
  } catch (error) {
1353
1427
  failure = error;
1354
1428
  } finally {
1355
1429
  if (fd !== undefined) fs.closeSync(fd);
1356
1430
  }
1357
- if (stagingIdentity) {
1358
- if (!committed && stagingSnapshot) {
1359
- try {
1360
- const installed = captureManagedFileNoFollow(destination);
1361
- if (
1362
- installed.identity.dev === stagingSnapshot.identity.dev &&
1363
- installed.identity.ino === stagingSnapshot.identity.ino &&
1364
- installed.identity.sha256 === stagingSnapshot.identity.sha256
1365
- ) {
1366
- const removed = exactUnlink(destination, {
1367
- dev: installed.identity.dev,
1368
- ino: installed.identity.ino,
1369
- size: BigInt(installed.identity.size),
1370
- mtimeNs: installed.identity.mtimeNs,
1371
- sha256: installed.identity.sha256,
1372
- quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
1373
- });
1374
- if (!removed.ok) failure ??= new Error(removed.code ?? "managed_publish_reconcile_failed");
1375
- }
1376
- } catch (error) {
1377
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1378
- }
1379
- }
1431
+ if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
1380
1432
  try {
1381
1433
  const named = fs.lstatSync(staging, { bigint: true });
1382
1434
  if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {