@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
@@ -14,6 +14,7 @@ import {
14
14
  FileSessionStorage,
15
15
  type NativeDirectoryTreeSnapshot,
16
16
  type SessionStorageFileIdentity,
17
+ type VerifiedSessionDeleteResult,
17
18
  } from "../session-storage";
18
19
  import {
19
20
  acquireManagedLock,
@@ -24,10 +25,12 @@ import {
24
25
  ensureManagedDirectory,
25
26
  fsyncManagedArtifactTree,
26
27
  type ManagedDirectoryRoot,
28
+ type ManagedFileSnapshot,
29
+ ManagedPublishError,
27
30
  ManagedSessionDescendantStore,
28
31
  type ManagedSessionSecurityPolicy,
29
32
  type ManagedStorageLock,
30
- managedDirectoryRoot,
33
+ prepareManagedDirectoryRoot,
31
34
  publishManagedFileNoReplace,
32
35
  publishManagedTombstone,
33
36
  retainManagedDirectoryAuthority,
@@ -63,13 +66,12 @@ export interface ManagedCandidateWriteAuthority {
63
66
  readonly retainedDirectory?: string;
64
67
  }
65
68
 
66
- const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof managedDirectoryRoot>>();
69
+ const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof prepareManagedDirectoryRoot>>();
67
70
  const managedDirectoryIdentities = new WeakMap<ManagedScope, { dev: bigint; ino: bigint }>();
68
71
  const managedDirectoryAuthorities = new WeakMap<ManagedScope, native.RecoveryFsRoot | undefined>();
69
72
  const boundManagedWriteAuthorities = new WeakMap<ManagedScope, ManagedCandidateWriteAuthority>();
70
73
 
71
74
  function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandidateWriteAuthority): void {
72
- assertManagedDirectoryRoot(authority.rootAuthority);
73
75
  if (
74
76
  authority.retainedAuthority &&
75
77
  authority.retainedDirectory !== undefined &&
@@ -79,11 +81,27 @@ function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandid
79
81
  authority: authority.retainedAuthority,
80
82
  authorityBaseDir: scope.directoryPath,
81
83
  }).assertBound();
84
+ } else {
85
+ assertManagedDirectoryRoot(authority.rootAuthority);
82
86
  }
83
87
  managedRoots.set(scope, authority.rootAuthority);
84
88
  boundManagedWriteAuthorities.set(scope, authority);
85
89
  }
86
90
 
91
+ /** A prepared scope is a retained authority boundary, not a path that may be re-adopted. */
92
+ function assertRetainedManagedDirectoryIdentity(scope: ManagedScope): void {
93
+ const expected = managedDirectoryIdentities.get(scope);
94
+ if (!expected) return;
95
+ const current = fs.lstatSync(scope.directoryPath, { bigint: true });
96
+ if (
97
+ !current.isDirectory() ||
98
+ current.isSymbolicLink() ||
99
+ current.dev !== expected.dev ||
100
+ current.ino !== expected.ino
101
+ )
102
+ throw new Error("Managed session directory changed");
103
+ }
104
+
87
105
  export function managedDirectoryAuthorityForScope(scope: ManagedScope): native.RecoveryFsRoot | undefined {
88
106
  if (!managedDirectoryAuthorities.has(scope)) throw new Error("Managed session directory authority was not prepared");
89
107
  return managedDirectoryAuthorities.get(scope);
@@ -97,21 +115,24 @@ export function managedDirectoryIdentityForScope(scope: ManagedScope): { dev: bi
97
115
 
98
116
  function configuredRootPath(scope: ManagedScope): string {
99
117
  let candidate = pathIsWithin(scope.agentDir, scope.sessionsRoot) ? scope.agentDir : path.dirname(scope.sessionsRoot);
118
+ const suffix: string[] = [];
100
119
  for (;;) {
101
120
  try {
102
121
  const stat = fs.lstatSync(candidate);
103
122
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe configured root: ${candidate}`);
104
- return fs.realpathSync.native(candidate);
123
+ const canonical = fs.realpathSync.native(candidate);
124
+ return suffix.length === 0 ? canonical : path.join(canonical, ...suffix);
105
125
  } catch (error) {
106
126
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
107
127
  const parent = path.dirname(candidate);
108
128
  if (parent === candidate) throw new Error("Configured managed root is unavailable.");
129
+ suffix.unshift(path.basename(candidate));
109
130
  candidate = parent;
110
131
  }
111
132
  }
112
133
  }
113
134
 
114
- function scopeRoot(scope: ManagedScope) {
135
+ function scopeRoot(scope: ManagedScope, policy: ManagedSessionSecurityPolicy = "default") {
115
136
  const bound = boundManagedWriteAuthorities.get(scope);
116
137
  if (bound) {
117
138
  bindManagedWriteAuthority(scope, bound);
@@ -119,7 +140,7 @@ function scopeRoot(scope: ManagedScope) {
119
140
  }
120
141
  const retained = managedRoots.get(scope);
121
142
  if (retained) return retained;
122
- const root = managedDirectoryRoot(configuredRootPath(scope));
143
+ const root = prepareManagedDirectoryRoot(configuredRootPath(scope), policy);
123
144
  managedRoots.set(scope, root);
124
145
  return root;
125
146
  }
@@ -137,11 +158,20 @@ export type ManagedScopeErrorCode =
137
158
  | "network_unsupported"
138
159
  | "sessions_root_unavailable"
139
160
  | "binding_conflict"
140
- | "binding_invalid";
161
+ | "binding_invalid"
162
+ | "atomic_unavailable"
163
+ | "invalid_request"
164
+ | "durability_failed"
165
+ | "durability_not_provable";
141
166
 
142
167
  export type ManagedScopeResolution =
143
168
  | { kind: "resolved"; scope: ManagedScope }
144
- | { kind: "error"; code: ManagedScopeErrorCode; message: string };
169
+ | {
170
+ kind: "error";
171
+ code: ManagedScopeErrorCode;
172
+ message: string;
173
+ cause?: { readonly classification: string; readonly diagnostic?: string };
174
+ };
145
175
 
146
176
  export interface ManagedCandidate {
147
177
  sessionId: string;
@@ -170,6 +200,9 @@ export type ManagedOpenFailure =
170
200
  | "source_changed"
171
201
  | "unsafe_artifacts"
172
202
  | "durability_failed"
203
+ | "atomic_unavailable"
204
+ | "invalid_request"
205
+ | "durability_not_provable"
173
206
  | "migration_retired"
174
207
  | "legacy_migration_disabled"
175
208
  | "managed_storage_unsupported";
@@ -181,6 +214,7 @@ export type ManagedOpenCandidateResult =
181
214
  export type ManagedDeleteCandidateResult =
182
215
  | { kind: "deleted"; tombstonePath: string }
183
216
  | { kind: "already_deleted"; tombstonePath: string }
217
+ | { kind: "cleanup_pending"; tombstonePath: string; phase: "artifacts" | "transcript"; message: string }
184
218
  | { kind: "error"; code: ManagedOpenFailure; message: string };
185
219
 
186
220
  type NativeIdentity =
@@ -535,6 +569,57 @@ function fsyncManagedParent(pathname: string): void {
535
569
  }
536
570
  }
537
571
 
572
+ function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
573
+ let captured: ManagedFileSnapshot;
574
+ try {
575
+ captured = captureManagedFileNoFollow(bindingPath);
576
+ } catch {
577
+ throw new Error("binding_invalid");
578
+ }
579
+ if (captured.bytes.toString("utf8") !== expected) throw new Error("binding_invalid");
580
+ let descriptor: number | undefined;
581
+ try {
582
+ descriptor = fs.openSync(bindingPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
583
+ const before = fs.fstatSync(descriptor, { bigint: true });
584
+ if (
585
+ before.dev !== captured.identity.dev ||
586
+ before.ino !== captured.identity.ino ||
587
+ before.size !== BigInt(captured.identity.size) ||
588
+ before.mtimeNs !== captured.identity.mtimeNs
589
+ )
590
+ throw new Error("binding_invalid");
591
+ fs.fsyncSync(descriptor);
592
+ const after = fs.fstatSync(descriptor, { bigint: true });
593
+ if (
594
+ after.dev !== captured.identity.dev ||
595
+ after.ino !== captured.identity.ino ||
596
+ after.size !== BigInt(captured.identity.size) ||
597
+ after.mtimeNs !== captured.identity.mtimeNs
598
+ )
599
+ throw new Error("binding_invalid");
600
+ } catch (error) {
601
+ if (error instanceof Error && error.message === "binding_invalid") throw error;
602
+ throw new Error("durability_failed", { cause: error });
603
+ } finally {
604
+ if (descriptor !== undefined) fs.closeSync(descriptor);
605
+ }
606
+ fsyncManagedParent(bindingPath);
607
+ let recaptured: ManagedFileSnapshot;
608
+ try {
609
+ recaptured = captureManagedFileNoFollow(bindingPath);
610
+ } catch {
611
+ throw new Error("binding_invalid");
612
+ }
613
+ if (
614
+ recaptured.bytes.toString("utf8") !== expected ||
615
+ recaptured.identity.dev !== captured.identity.dev ||
616
+ recaptured.identity.ino !== captured.identity.ino ||
617
+ recaptured.identity.size !== captured.identity.size ||
618
+ recaptured.identity.mtimeNs !== captured.identity.mtimeNs
619
+ )
620
+ throw new Error("binding_invalid");
621
+ }
622
+
538
623
  type CandidatePreflight =
539
624
  | { kind: "capture"; identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint } }
540
625
  | {
@@ -673,22 +758,48 @@ export async function ensureManagedScope(
673
758
  policy: ManagedSessionSecurityPolicy = "default",
674
759
  ): Promise<ManagedScopeResolution> {
675
760
  try {
676
- const root = scopeRoot(scope);
761
+ assertRetainedManagedDirectoryIdentity(scope);
762
+ const root = scopeRoot(scope, policy);
677
763
  ensureManagedDirectory(scope.sessionsRoot, root, policy);
678
764
  ensureManagedDirectory(scope.directoryPath, root, policy);
679
765
  const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
680
766
  const binding = `${JSON.stringify(bindingFor(scope))}\n`;
767
+ let bindingCollision = false;
681
768
  try {
682
769
  await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root, policy);
683
770
  } catch (error) {
684
771
  if ((error as Error).message !== "destination_conflict") throw error;
772
+ bindingCollision = true;
685
773
  }
686
- return validateExistingBinding(scope) ?? { kind: "resolved", scope };
774
+ const validated = validateExistingBinding(scope);
775
+ if (validated) return validated;
776
+ if (bindingCollision) fsyncCanonicalBinding(bindingPath, binding);
777
+
778
+ const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
779
+ if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
780
+ throw new Error("Managed session directory changed");
781
+ managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
782
+ return { kind: "resolved", scope };
687
783
  } catch (error) {
784
+ const publication = error instanceof ManagedPublishError ? error : undefined;
785
+ const message =
786
+ publication?.classification ??
787
+ (error instanceof Error ? error.message : "The managed scope could not be initialized.");
788
+ const code =
789
+ message === "atomic_unavailable" ||
790
+ message === "invalid_request" ||
791
+ message === "durability_failed" ||
792
+ message === "durability_not_provable"
793
+ ? message
794
+ : "binding_invalid";
795
+
688
796
  return {
689
797
  kind: "error",
690
- code: "binding_invalid",
691
- message: error instanceof Error ? error.message : "The managed scope could not be initialized.",
798
+ code,
799
+ message,
800
+ ...(publication
801
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
802
+ : { cause: { classification: code } }),
692
803
  };
693
804
  }
694
805
  }
@@ -759,7 +870,8 @@ export function prepareManagedSessionScopeForWriteSync(
759
870
  authority?: ManagedCandidateWriteAuthority,
760
871
  ): ManagedScopeResolution {
761
872
  try {
762
- const root = authority?.rootAuthority ?? scopeRoot(scope);
873
+ assertRetainedManagedDirectoryIdentity(scope);
874
+ const root = authority?.rootAuthority ?? scopeRoot(scope, policy);
763
875
  if (authority) bindManagedWriteAuthority(scope, authority);
764
876
  ensureManagedDirectory(scope.sessionsRoot, root, policy);
765
877
  ensureManagedDirectory(scope.directoryPath, root, policy);
@@ -821,10 +933,26 @@ export function prepareManagedSessionScopeForWriteSync(
821
933
  ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
822
934
  return { kind: "resolved", scope };
823
935
  } catch (error) {
936
+ const publication = error instanceof ManagedPublishError ? error : undefined;
937
+ const message =
938
+ publication?.classification ??
939
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
940
+ const code =
941
+ message === "atomic_unavailable" ||
942
+ message === "invalid_request" ||
943
+ message === "durability_failed" ||
944
+ message === "durability_not_provable"
945
+ ? message
946
+ : "binding_invalid";
824
947
  return {
825
948
  kind: "error",
826
- code: "binding_invalid",
827
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
949
+ code,
950
+ message,
951
+ ...(publication
952
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
953
+ : code === "binding_invalid"
954
+ ? {}
955
+ : { cause: { classification: code } }),
828
956
  };
829
957
  }
830
958
  }
@@ -948,6 +1076,9 @@ function expectedFailure(error: unknown): ManagedOpenFailure {
948
1076
  message === "source_changed" ||
949
1077
  message === "unsafe_artifacts" ||
950
1078
  message === "durability_failed" ||
1079
+ message === "atomic_unavailable" ||
1080
+ message === "invalid_request" ||
1081
+ message === "durability_not_provable" ||
951
1082
  message === "migration_retired"
952
1083
  ? message
953
1084
  : "managed_storage_unsupported";
@@ -990,7 +1121,7 @@ function revalidatePickerConsent(
990
1121
  function receiptPathFor(
991
1122
  scope: ManagedScope,
992
1123
  source: ManagedCandidate,
993
- state: "prepared" | "published" | "committed" = "committed",
1124
+ state: "prepared" | "detached" | "published" | "committed" = "committed",
994
1125
  ): string {
995
1126
  const suffix = state === "committed" ? "" : `.${state}`;
996
1127
  return path.join(
@@ -1029,6 +1160,14 @@ function receiptMatches(
1029
1160
  identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
1030
1161
  };
1031
1162
  artifactManifest?: unknown;
1163
+ sourceArtifactQuarantine?: { role?: unknown };
1164
+ sourceArtifactCleanup?: {
1165
+ state?: unknown;
1166
+ role?: unknown;
1167
+ retainedPath?: unknown;
1168
+ identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
1169
+ tree?: unknown;
1170
+ };
1032
1171
  };
1033
1172
  const exact = (
1034
1173
  recorded: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown } | undefined,
@@ -1069,12 +1208,43 @@ function receiptMatches(
1069
1208
  item.size >= 0
1070
1209
  );
1071
1210
  });
1211
+ const quarantine = record.sourceArtifactQuarantine;
1212
+ const cleanup = record.sourceArtifactCleanup;
1213
+ const cleanupIdentity = cleanup?.identity;
1214
+ const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
1215
+ const validCleanup =
1216
+ cleanup === undefined ||
1217
+ (quarantine?.role === "detached_artifact_root" &&
1218
+ cleanup.state === "cleanup_pending" &&
1219
+ cleanup.role === "exchange_placeholder" &&
1220
+ typeof cleanup.retainedPath === "string" &&
1221
+ cleanupIdentity?.dev !== undefined &&
1222
+ cleanupIdentity.ino !== undefined &&
1223
+ cleanupIdentity.size !== undefined &&
1224
+ cleanupIdentity.mtimeNs !== undefined &&
1225
+ !!cleanupTree &&
1226
+ cleanupAuthorityMatches(
1227
+ {
1228
+ state: "cleanup_pending",
1229
+ role: "exchange_placeholder",
1230
+ retainedPath: cleanup.retainedPath,
1231
+ identity: {
1232
+ dev: BigInt(String(cleanupIdentity.dev)),
1233
+ ino: BigInt(String(cleanupIdentity.ino)),
1234
+ size: BigInt(String(cleanupIdentity.size)),
1235
+ mtimeNs: BigInt(String(cleanupIdentity.mtimeNs)),
1236
+ },
1237
+ tree: cleanupTree,
1238
+ },
1239
+ path.dirname(source.path),
1240
+ ));
1072
1241
  return (
1073
1242
  record.schemaVersion === 2 &&
1074
1243
  record.state === "committed" &&
1075
1244
  record.policy === "copy-retain" &&
1076
1245
  record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
1077
1246
  validManifest &&
1247
+ validCleanup &&
1078
1248
  manifestContains(destination.path, manifest as readonly ArtifactManifestEntry[]) &&
1079
1249
  record.source?.path === source.path &&
1080
1250
  record.source?.sessionId === source.sessionId &&
@@ -1217,6 +1387,65 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
1217
1387
  * a returned partial result appends `pending(N + 1)` with the observed detached
1218
1388
  * identity/path. Restart accepts only a contiguous, target-bound sequence.
1219
1389
  */
1390
+ type RetainedArtifactsRootReceipt = {
1391
+ path: string;
1392
+ identity: SessionStorageFileIdentity;
1393
+ tree: NativeDirectoryTreeSnapshot;
1394
+ };
1395
+
1396
+ function retainedArtifactsRootReceipt(receipt: CleanupReceipt): RetainedArtifactsRootReceipt | undefined {
1397
+ const pathname = deterministicRemovalRoot(receipt.plannedArtifactsPath);
1398
+ if (!fs.existsSync(pathname)) return undefined;
1399
+ if (!receipt.expectedArtifactsIdentity) throw new Error("durability_failed");
1400
+ const identity = artifactIdentityAt(pathname);
1401
+ if (!identity || !sameArtifactRootIdentity(identity, receipt.expectedArtifactsIdentity))
1402
+ throw new Error("durability_failed");
1403
+ const tree = snapshotArtifactTree(pathname);
1404
+ return { path: pathname, identity, tree };
1405
+ }
1406
+
1407
+ function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean {
1408
+ if (record.retainedArtifactsRoot === undefined) return true;
1409
+ if (!record.retainedArtifactsRoot || typeof record.retainedArtifactsRoot !== "object")
1410
+ throw new Error("durability_failed");
1411
+ const retained = record.retainedArtifactsRoot as Record<string, unknown>;
1412
+ const identity = retained.identity;
1413
+ const tree = artifactTreeSnapshot(retained.tree);
1414
+ if (
1415
+ typeof record.plannedArtifactsPath !== "string" ||
1416
+ retained.path !== deterministicRemovalRoot(record.plannedArtifactsPath) ||
1417
+ !identity ||
1418
+ typeof identity !== "object" ||
1419
+ Array.isArray(identity) ||
1420
+ !tree
1421
+ )
1422
+ throw new Error("durability_failed");
1423
+ const artifactIdentity = identity as Record<string, unknown>;
1424
+ if (
1425
+ typeof artifactIdentity.dev !== "string" ||
1426
+ typeof artifactIdentity.ino !== "string" ||
1427
+ typeof artifactIdentity.size !== "number" ||
1428
+ typeof artifactIdentity.mtimeNs !== "string" ||
1429
+ typeof artifactIdentity.sha256 !== "string"
1430
+ )
1431
+ throw new Error("durability_failed");
1432
+ if (!fs.existsSync(retained.path)) return true;
1433
+ const observed = artifactIdentityAt(retained.path);
1434
+ if (
1435
+ !observed ||
1436
+ !sameArtifactRootIdentity(observed, {
1437
+ dev: BigInt(artifactIdentity.dev),
1438
+ ino: BigInt(artifactIdentity.ino),
1439
+ size: artifactIdentity.size,
1440
+ mtimeNs: BigInt(artifactIdentity.mtimeNs),
1441
+ sha256: artifactIdentity.sha256,
1442
+ }) ||
1443
+ JSON.stringify(snapshotArtifactTree(retained.path)) !== JSON.stringify(tree)
1444
+ )
1445
+ throw new Error("durability_failed");
1446
+ return true;
1447
+ }
1448
+
1220
1449
  type CleanupReceipt = {
1221
1450
  attempt: number;
1222
1451
  target: RetiredTarget;
@@ -1224,6 +1453,13 @@ type CleanupReceipt = {
1224
1453
  expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
1225
1454
  detachedArtifactsPath?: string;
1226
1455
  detachedTranscriptPath?: string;
1456
+ retainedArtifactsSuccessorPath?: string;
1457
+ retainedArtifactsPlaceholderPath?: string;
1458
+ retainedArtifactsUnknownPath?: string;
1459
+ retainedTranscriptSuccessorPath?: string;
1460
+ retainedTranscriptPlaceholderPath?: string;
1461
+ retainedTranscriptUnknownPath?: string;
1462
+
1227
1463
  plannedArtifactsPath: string;
1228
1464
  plannedTranscriptPath: string;
1229
1465
  };
@@ -1257,6 +1493,24 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup
1257
1493
  ...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}),
1258
1494
  ...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}),
1259
1495
  ...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}),
1496
+ ...(receipt.retainedArtifactsSuccessorPath
1497
+ ? { retainedArtifactsSuccessorPath: receipt.retainedArtifactsSuccessorPath }
1498
+ : {}),
1499
+ ...(receipt.retainedArtifactsPlaceholderPath
1500
+ ? { retainedArtifactsPlaceholderPath: receipt.retainedArtifactsPlaceholderPath }
1501
+ : {}),
1502
+ ...(receipt.retainedArtifactsUnknownPath
1503
+ ? { retainedArtifactsUnknownPath: receipt.retainedArtifactsUnknownPath }
1504
+ : {}),
1505
+ ...(receipt.retainedTranscriptSuccessorPath
1506
+ ? { retainedTranscriptSuccessorPath: receipt.retainedTranscriptSuccessorPath }
1507
+ : {}),
1508
+ ...(receipt.retainedTranscriptPlaceholderPath
1509
+ ? { retainedTranscriptPlaceholderPath: receipt.retainedTranscriptPlaceholderPath }
1510
+ : {}),
1511
+ ...(receipt.retainedTranscriptUnknownPath
1512
+ ? { retainedTranscriptUnknownPath: receipt.retainedTranscriptUnknownPath }
1513
+ : {}),
1260
1514
  plannedArtifactsPath: receipt.plannedArtifactsPath,
1261
1515
  plannedTranscriptPath: receipt.plannedTranscriptPath,
1262
1516
  };
@@ -1277,6 +1531,7 @@ function cleanupArtifactsRemoved(
1277
1531
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1278
1532
  const record = value as Record<string, unknown>;
1279
1533
  const recorded = record.target as Record<string, unknown> | undefined;
1534
+ const identity = recorded?.identity as Record<string, unknown> | undefined;
1280
1535
  return (
1281
1536
  record.schemaVersion === 2 &&
1282
1537
  record.state === "artifacts_removed" &&
@@ -1285,9 +1540,17 @@ function cleanupArtifactsRemoved(
1285
1540
  record.attempt === attempt &&
1286
1541
  recorded?.path === target.path &&
1287
1542
  recorded.sessionId === target.sessionId &&
1288
- recorded.cwd === target.cwd
1543
+ recorded.cwd === target.cwd &&
1544
+ identity?.canonicalPath === target.identity.canonicalPath &&
1545
+ identity.dev === String(target.identity.dev) &&
1546
+ identity.ino === String(target.identity.ino) &&
1547
+ identity.size === target.identity.size &&
1548
+ identity.mtimeNs === String(target.identity.mtimeNs) &&
1549
+ identity.sha256 === target.identity.sha256 &&
1550
+ retainedArtifactsRootMatches(record)
1289
1551
  );
1290
- } catch {
1552
+ } catch (error) {
1553
+ if ((error as Error).message === "durability_failed") throw error;
1291
1554
  return false;
1292
1555
  }
1293
1556
  }
@@ -1298,9 +1561,14 @@ async function publishCleanupArtifactsRemoved(
1298
1561
  receipt: CleanupReceipt,
1299
1562
  lock: ManagedStorageLock,
1300
1563
  ): Promise<void> {
1564
+ const retainedArtifactsRoot = retainedArtifactsRootReceipt(receipt);
1301
1565
  await publishManagedTombstone(
1302
1566
  cleanupReceiptPath(tombstone, receipt.target, "artifacts_removed", receipt.attempt),
1303
- { ...cleanupReceipt(scope, tombstone, receipt), state: "artifacts_removed" },
1567
+ {
1568
+ ...cleanupReceipt(scope, tombstone, receipt),
1569
+ state: "artifacts_removed",
1570
+ ...(retainedArtifactsRoot ? { retainedArtifactsRoot } : {}),
1571
+ },
1304
1572
  lock.assertOwned,
1305
1573
  ).catch(error => {
1306
1574
  if ((error as Error).message !== "destination_conflict") throw error;
@@ -1317,6 +1585,14 @@ function isQuarantinePath(target: RetiredTarget, pathname: unknown): pathname is
1317
1585
  );
1318
1586
  }
1319
1587
 
1588
+ function isRetainedNativePath(target: RetiredTarget, pathname: unknown): pathname is string {
1589
+ return (
1590
+ typeof pathname === "string" &&
1591
+ path.dirname(pathname) === path.dirname(target.path) &&
1592
+ path.basename(pathname).startsWith(".gjc-")
1593
+ );
1594
+ }
1595
+
1320
1596
  function deterministicRemovalRoot(plannedRoot: string): string {
1321
1597
  return `${plannedRoot}.removing`;
1322
1598
  }
@@ -1328,17 +1604,44 @@ function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pa
1328
1604
  );
1329
1605
  }
1330
1606
 
1331
- function artifactRootsAbsent(
1332
- _scope: ManagedScope,
1333
- tombstone: string,
1334
- target: RetiredTarget,
1335
- pending: CleanupReceipt,
1336
- ): boolean {
1607
+ function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): boolean {
1608
+ try {
1609
+ const observed = captureManagedFileNoFollow(pathname);
1610
+ const digest = createHash("sha256").update(observed.bytes).digest("hex");
1611
+ return (
1612
+ observed.identity.dev === target.identity.dev &&
1613
+ observed.identity.ino === target.identity.ino &&
1614
+ observed.identity.size === target.identity.size &&
1615
+ observed.identity.mtimeNs === target.identity.mtimeNs &&
1616
+ digest === target.identity.sha256
1617
+ );
1618
+ } catch (error) {
1619
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
1620
+ throw new Error("durability_failed");
1621
+ }
1622
+ }
1623
+
1624
+ function cleanupRootsAbsent(tombstone: string, target: RetiredTarget, pending: CleanupReceipt): boolean {
1337
1625
  const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
1626
+ const activeTranscriptRoots = new Set(
1627
+ [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string =>
1628
+ isQuarantinePath(target, pathname),
1629
+ ),
1630
+ );
1338
1631
  const roots = new Set<string>([
1632
+ target.path,
1633
+ target.path.slice(0, -6),
1339
1634
  pending.plannedArtifactsPath,
1340
1635
  deterministicRemovalRoot(pending.plannedArtifactsPath),
1341
1636
  ...(pending.detachedArtifactsPath ? [pending.detachedArtifactsPath] : []),
1637
+ ...[
1638
+ pending.retainedArtifactsSuccessorPath,
1639
+ pending.retainedArtifactsPlaceholderPath,
1640
+ pending.retainedArtifactsUnknownPath,
1641
+ pending.retainedTranscriptSuccessorPath,
1642
+ pending.retainedTranscriptPlaceholderPath,
1643
+ pending.retainedTranscriptUnknownPath,
1644
+ ].filter((pathname): pathname is string => isRetainedNativePath(target, pathname)),
1342
1645
  ]);
1343
1646
  for (const name of fs.readdirSync(path.dirname(tombstone))) {
1344
1647
  if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
@@ -1347,26 +1650,91 @@ function artifactRootsAbsent(
1347
1650
  ) as {
1348
1651
  plannedArtifactsPath?: unknown;
1349
1652
  detachedArtifactsPath?: unknown;
1653
+ plannedTranscriptPath?: unknown;
1654
+ detachedTranscriptPath?: unknown;
1655
+ retainedArtifactsSuccessorPath?: unknown;
1656
+ retainedArtifactsPlaceholderPath?: unknown;
1657
+ retainedArtifactsUnknownPath?: unknown;
1658
+ retainedTranscriptSuccessorPath?: unknown;
1659
+ retainedTranscriptPlaceholderPath?: unknown;
1660
+ retainedTranscriptUnknownPath?: unknown;
1350
1661
  };
1351
1662
  if (isQuarantinePath(target, record.plannedArtifactsPath)) {
1352
1663
  roots.add(record.plannedArtifactsPath);
1353
1664
  roots.add(deterministicRemovalRoot(record.plannedArtifactsPath));
1354
1665
  }
1355
1666
  if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath);
1667
+ for (const pathname of [record.plannedTranscriptPath, record.detachedTranscriptPath]) {
1668
+ if (isQuarantinePath(target, pathname) && !activeTranscriptRoots.has(pathname)) roots.add(pathname);
1669
+ }
1670
+ for (const pathname of [
1671
+ record.retainedArtifactsSuccessorPath,
1672
+ record.retainedArtifactsPlaceholderPath,
1673
+ record.retainedArtifactsUnknownPath,
1674
+ record.retainedTranscriptSuccessorPath,
1675
+ record.retainedTranscriptPlaceholderPath,
1676
+ record.retainedTranscriptUnknownPath,
1677
+ ]) {
1678
+ if (isRetainedNativePath(target, pathname)) roots.add(pathname);
1679
+ }
1680
+ }
1681
+ for (const pathname of roots) {
1682
+ try {
1683
+ fs.lstatSync(pathname);
1684
+ throw new Error("durability_failed");
1685
+ } catch (error) {
1686
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1687
+ throw error;
1688
+ }
1356
1689
  }
1357
- return [...roots].every(pathname => !fs.existsSync(pathname));
1690
+ for (const pathname of activeTranscriptRoots) {
1691
+ try {
1692
+ fs.lstatSync(pathname);
1693
+ } catch (error) {
1694
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1695
+ throw error;
1696
+ }
1697
+ if (!transcriptRootMatchesTarget(pathname, target)) throw new Error("durability_failed");
1698
+ return false;
1699
+ }
1700
+ return true;
1358
1701
  }
1359
1702
 
1360
1703
  function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean {
1361
1704
  return left.dev === right.dev && left.ino === right.ino;
1362
1705
  }
1363
1706
 
1364
- /**
1365
- * A pending receipt is written before the native no-replace detach. If a crash
1366
- * happens after that detach, the next receipt may only inherit the exact
1367
- * planned quarantine pathname and identity from its predecessor.
1368
- */
1707
+ function sameArtifactTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
1708
+ if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno || left.entries.length !== right.entries.length)
1709
+ return false;
1710
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
1711
+ JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino, entry.size, entry.mtimeNs, entry.sha256]);
1712
+ const leftEntries = left.entries.map(entryKey).sort();
1713
+ const rightEntries = right.entries.map(entryKey).sort();
1714
+ return leftEntries.every((entry, index) => entry === rightEntries[index]);
1715
+ }
1716
+
1717
+ function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void {
1718
+ if (!pending.detachedArtifactsPath) return;
1719
+ if (!pending.expectedArtifactsIdentity || !pending.expectedArtifactsTree) throw new Error("durability_failed");
1720
+ try {
1721
+ fs.lstatSync(pending.detachedArtifactsPath);
1722
+ } catch (error) {
1723
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1724
+ throw new Error("durability_failed");
1725
+ }
1726
+ const observed = artifactIdentityAt(pending.detachedArtifactsPath);
1727
+ if (
1728
+ !observed ||
1729
+ !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
1730
+ !sameArtifactTreeSnapshot(snapshotArtifactTree(pending.detachedArtifactsPath), pending.expectedArtifactsTree)
1731
+ )
1732
+ throw new Error("binding_invalid");
1733
+ }
1734
+
1369
1735
  function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceipt): CleanupReceipt {
1736
+ assertRetainedArtifactsAuthority(pending);
1737
+
1370
1738
  let detachedArtifactsPath = pending.detachedArtifactsPath;
1371
1739
  let detachedTranscriptPath = pending.detachedTranscriptPath;
1372
1740
  for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) {
@@ -1430,6 +1798,16 @@ function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | und
1430
1798
  })
1431
1799
  )
1432
1800
  return undefined;
1801
+ const roots = snapshot.entries.filter(entry => {
1802
+ const item = entry as Record<string, unknown>;
1803
+ return (
1804
+ item.relativePath === "" &&
1805
+ item.kind === "directory" &&
1806
+ item.dev === snapshot.rootDev &&
1807
+ item.ino === snapshot.rootIno
1808
+ );
1809
+ });
1810
+ if (roots.length !== 1) return undefined;
1433
1811
  return snapshot as unknown as NativeDirectoryTreeSnapshot;
1434
1812
  }
1435
1813
 
@@ -1481,7 +1859,19 @@ function pendingCleanupReceipt(
1481
1859
  plannedPaths.has(record.plannedArtifactsPath as string) ||
1482
1860
  plannedPaths.has(record.plannedTranscriptPath as string) ||
1483
1861
  (record.detachedArtifactsPath !== undefined && !isQuarantinePath(target, record.detachedArtifactsPath)) ||
1484
- (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath))
1862
+ (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath)) ||
1863
+ (record.retainedArtifactsSuccessorPath !== undefined &&
1864
+ !isRetainedNativePath(target, record.retainedArtifactsSuccessorPath)) ||
1865
+ (record.retainedArtifactsPlaceholderPath !== undefined &&
1866
+ !isRetainedNativePath(target, record.retainedArtifactsPlaceholderPath)) ||
1867
+ (record.retainedArtifactsUnknownPath !== undefined &&
1868
+ !isRetainedNativePath(target, record.retainedArtifactsUnknownPath)) ||
1869
+ (record.retainedTranscriptSuccessorPath !== undefined &&
1870
+ !isRetainedNativePath(target, record.retainedTranscriptSuccessorPath)) ||
1871
+ (record.retainedTranscriptPlaceholderPath !== undefined &&
1872
+ !isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) ||
1873
+ (record.retainedTranscriptUnknownPath !== undefined &&
1874
+ !isRetainedNativePath(target, record.retainedTranscriptUnknownPath))
1485
1875
  )
1486
1876
  throw new Error("durability_failed");
1487
1877
  const artifact = record.expectedArtifactsIdentity as Record<string, unknown> | undefined;
@@ -1504,7 +1894,7 @@ function pendingCleanupReceipt(
1504
1894
  const expectedArtifactsTree =
1505
1895
  record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree);
1506
1896
  if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1507
- if (record.detachedArtifactsPath !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1897
+
1508
1898
  if (
1509
1899
  latest &&
1510
1900
  ((record.detachedArtifactsPath !== undefined &&
@@ -1523,6 +1913,12 @@ function pendingCleanupReceipt(
1523
1913
  expectedArtifactsTree,
1524
1914
  detachedArtifactsPath: record.detachedArtifactsPath as string | undefined,
1525
1915
  detachedTranscriptPath: record.detachedTranscriptPath as string | undefined,
1916
+ retainedArtifactsSuccessorPath: record.retainedArtifactsSuccessorPath as string | undefined,
1917
+ retainedArtifactsPlaceholderPath: record.retainedArtifactsPlaceholderPath as string | undefined,
1918
+ retainedArtifactsUnknownPath: record.retainedArtifactsUnknownPath as string | undefined,
1919
+ retainedTranscriptSuccessorPath: record.retainedTranscriptSuccessorPath as string | undefined,
1920
+ retainedTranscriptPlaceholderPath: record.retainedTranscriptPlaceholderPath as string | undefined,
1921
+ retainedTranscriptUnknownPath: record.retainedTranscriptUnknownPath as string | undefined,
1526
1922
  plannedArtifactsPath: record.plannedArtifactsPath as string,
1527
1923
  plannedTranscriptPath: record.plannedTranscriptPath as string,
1528
1924
  };
@@ -1572,7 +1968,13 @@ function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | und
1572
1968
  expectedArtifactsIdentity,
1573
1969
  expectedArtifactsTree,
1574
1970
  detachedArtifactsPath: pending?.detachedArtifactsPath,
1971
+ retainedArtifactsSuccessorPath: pending?.retainedArtifactsSuccessorPath,
1972
+ retainedArtifactsPlaceholderPath: pending?.retainedArtifactsPlaceholderPath,
1973
+ retainedArtifactsUnknownPath: pending?.retainedArtifactsUnknownPath,
1575
1974
  detachedTranscriptPath: pending?.detachedTranscriptPath,
1975
+ retainedTranscriptSuccessorPath: pending?.retainedTranscriptSuccessorPath,
1976
+ retainedTranscriptPlaceholderPath: pending?.retainedTranscriptPlaceholderPath,
1977
+ retainedTranscriptUnknownPath: pending?.retainedTranscriptUnknownPath,
1576
1978
  plannedArtifactsPath: path.join(directory, `.gjc-delete-${operation}-artifacts-${attempt}`),
1577
1979
  plannedTranscriptPath: path.join(directory, `.gjc-delete-${operation}-transcript-${attempt}`),
1578
1980
  };
@@ -1585,6 +1987,36 @@ function requiresFreshCleanupPlan(pending: CleanupReceipt): boolean {
1585
1987
  );
1586
1988
  }
1587
1989
 
1990
+ /** Evidence-carrier for a verified `cleanup_pending` deletion, bound to the active plan. */
1991
+ function cleanupPendingEvidence(
1992
+ retry: CleanupReceipt,
1993
+ active: CleanupReceipt,
1994
+ deletion: Extract<VerifiedSessionDeleteResult, { kind: "cleanup_pending" }>,
1995
+ ): CleanupReceipt {
1996
+ return {
1997
+ ...retry,
1998
+ expectedArtifactsIdentity:
1999
+ deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2000
+ expectedArtifactsTree: deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2001
+ detachedArtifactsPath:
2002
+ deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2003
+ detachedTranscriptPath:
2004
+ deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2005
+ retainedArtifactsSuccessorPath:
2006
+ deletion.phase === "artifacts" ? deletion.retainedSuccessorPath : active.retainedArtifactsSuccessorPath,
2007
+ retainedArtifactsPlaceholderPath:
2008
+ deletion.phase === "artifacts" ? deletion.retainedPlaceholderPath : active.retainedArtifactsPlaceholderPath,
2009
+ retainedArtifactsUnknownPath:
2010
+ deletion.phase === "artifacts" ? deletion.retainedUnknownPath : active.retainedArtifactsUnknownPath,
2011
+ retainedTranscriptSuccessorPath:
2012
+ deletion.phase === "transcript" ? deletion.retainedSuccessorPath : active.retainedTranscriptSuccessorPath,
2013
+ retainedTranscriptPlaceholderPath:
2014
+ deletion.phase === "transcript" ? deletion.retainedPlaceholderPath : active.retainedTranscriptPlaceholderPath,
2015
+ retainedTranscriptUnknownPath:
2016
+ deletion.phase === "transcript" ? deletion.retainedUnknownPath : active.retainedTranscriptUnknownPath,
2017
+ };
2018
+ }
2019
+
1588
2020
  async function publishCleanupPending(
1589
2021
  scope: ManagedScope,
1590
2022
  tombstone: string,
@@ -1872,13 +2304,17 @@ function sameDirectoryObject(leftPath: string, rightPath: string): boolean {
1872
2304
  function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
1873
2305
  try {
1874
2306
  const stat = fs.lstatSync(pathname, { bigint: true });
2307
+ const snapshot = native.snapshotDirectoryTree(pathname);
2308
+ const root = snapshot.snapshot?.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
1875
2309
  return (
1876
2310
  stat.isDirectory() &&
1877
2311
  !stat.isSymbolicLink() &&
1878
2312
  stat.dev === plan.identity.dev &&
1879
2313
  stat.ino === plan.identity.ino &&
1880
- stat.size === plan.identity.size &&
1881
2314
  stat.mtimeNs === plan.identity.mtimeNs &&
2315
+ (process.platform === "win32"
2316
+ ? snapshot.ok && !!root && BigInt(root.size) === plan.identity.size
2317
+ : stat.size === plan.identity.size) &&
1882
2318
  sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
1883
2319
  );
1884
2320
  } catch {
@@ -1886,29 +2322,130 @@ function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoo
1886
2322
  }
1887
2323
  }
1888
2324
 
1889
- function detachArtifactRootForMigration(plan: DetachedArtifactRoot): DetachedArtifactRoot {
2325
+ function sameArtifactTree(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
2326
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
2327
+ JSON.stringify([
2328
+ entry.relativePath,
2329
+ entry.kind,
2330
+ entry.dev,
2331
+ entry.ino,
2332
+ entry.size,
2333
+ entry.mtimeNs,
2334
+ entry.ctimeNs,
2335
+ entry.sha256,
2336
+ ]);
2337
+ return (
2338
+ left.rootDev === right.rootDev &&
2339
+ left.rootIno === right.rootIno &&
2340
+ left.entries.length === right.entries.length &&
2341
+ left.entries
2342
+ .map(entryKey)
2343
+ .sort()
2344
+ .every((entry, index) => entry === right.entries.map(entryKey).sort()[index])
2345
+ );
2346
+ }
2347
+
2348
+ type SourceArtifactCleanup = {
2349
+ state: "cleanup_pending";
2350
+ role: "exchange_placeholder";
2351
+ retainedPath: string;
2352
+ identity: DetachedArtifactRoot["identity"];
2353
+ tree: NativeDirectoryTreeSnapshot;
2354
+ };
2355
+
2356
+ function cleanupAuthorityMatches(cleanup: SourceArtifactCleanup, parent: string): boolean {
2357
+ try {
2358
+ const stat = fs.lstatSync(cleanup.retainedPath, { bigint: true });
2359
+ if (
2360
+ path.dirname(cleanup.retainedPath) !== parent ||
2361
+ !stat.isDirectory() ||
2362
+ stat.isSymbolicLink() ||
2363
+ stat.dev !== cleanup.identity.dev ||
2364
+ stat.ino !== cleanup.identity.ino ||
2365
+ stat.size !== cleanup.identity.size ||
2366
+ stat.mtimeNs !== cleanup.identity.mtimeNs
2367
+ )
2368
+ return false;
2369
+ const snapshot = native.snapshotDirectoryTree(cleanup.retainedPath);
2370
+ return (
2371
+ !!snapshot.ok &&
2372
+ !!snapshot.snapshot &&
2373
+ sameArtifactTree(snapshot.snapshot, cleanup.tree) &&
2374
+ cleanup.tree.entries.length === 1 &&
2375
+ cleanup.tree.entries[0]?.relativePath === "" &&
2376
+ cleanup.tree.entries[0]?.kind === "directory"
2377
+ );
2378
+ } catch {
2379
+ return false;
2380
+ }
2381
+ }
2382
+
2383
+ function detachArtifactRootForMigration(plan: DetachedArtifactRoot): {
2384
+ detached: DetachedArtifactRoot;
2385
+ cleanup?: SourceArtifactCleanup;
2386
+ } {
1890
2387
  const result = native.exactUnlink(plan.originalPath, {
1891
2388
  ...plan.identity,
1892
2389
  directory: true,
1893
2390
  detachOnly: true,
1894
2391
  quarantineName: path.basename(plan.detachedPath),
1895
2392
  });
1896
- if (!result.ok || !result.detachedPath || !matchesDetachedArtifactRoot(result.detachedPath, plan))
2393
+ const cleanSuccess =
2394
+ result.ok && !result.retainedSuccessorPath && !result.retainedPlaceholderPath && !result.retainedUnknownPath;
2395
+ const cleanupPending =
2396
+ !result.ok &&
2397
+ result.code === "cleanup_pending" &&
2398
+ !!result.detachedPath &&
2399
+ !!result.retainedPlaceholderPath &&
2400
+ !result.retainedSuccessorPath &&
2401
+ !result.retainedUnknownPath;
2402
+ if (
2403
+ (!cleanSuccess && !cleanupPending) ||
2404
+ !result.detachedPath ||
2405
+ !matchesDetachedArtifactRoot(result.detachedPath, plan)
2406
+ )
1897
2407
  throw new Error("durability_failed");
2408
+
1898
2409
  const detachedPath =
1899
2410
  process.platform === "win32" ? fs.realpathSync.native(result.detachedPath) : result.detachedPath;
1900
2411
  if (!matchesDetachedArtifactRoot(detachedPath, plan)) throw new Error("durability_failed");
1901
- return { ...plan, detachedPath };
2412
+ const detached = { ...plan, detachedPath };
2413
+ if (!cleanupPending) return { detached };
2414
+ const placeholder = result.retainedPlaceholderPath!;
2415
+ const stat = fs.lstatSync(placeholder, { bigint: true });
2416
+ if (!stat.isDirectory() || stat.isSymbolicLink() || path.dirname(placeholder) !== path.dirname(plan.originalPath))
2417
+ throw new Error("durability_failed");
2418
+ const snapshot = native.snapshotDirectoryTree(placeholder);
2419
+ const cleanup: SourceArtifactCleanup = {
2420
+ state: "cleanup_pending",
2421
+ role: "exchange_placeholder",
2422
+ retainedPath: placeholder,
2423
+ identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs },
2424
+ tree: snapshot.snapshot!,
2425
+ };
2426
+ if (!snapshot.ok || !snapshot.snapshot || !cleanupAuthorityMatches(cleanup, path.dirname(plan.originalPath)))
2427
+ throw new Error("durability_failed");
2428
+ return { detached, cleanup };
1902
2429
  }
1903
2430
 
1904
2431
  function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandidate): void {
1905
- const receipt = receiptPathFor(scope, source, "prepared");
2432
+ const preparedReceipt = receiptPathFor(scope, source, "prepared");
2433
+ const detachedReceipt = receiptPathFor(scope, source, "detached");
2434
+ const receipt = fs.existsSync(detachedReceipt) ? detachedReceipt : preparedReceipt;
1906
2435
  let record: {
1907
2436
  sourceArtifactQuarantine?: {
1908
2437
  path?: unknown;
1909
2438
  detachedPath?: unknown;
1910
2439
  identity?: Record<string, unknown>;
1911
2440
  tree?: unknown;
2441
+ role?: unknown;
2442
+ };
2443
+ sourceArtifactCleanup?: {
2444
+ state?: unknown;
2445
+ role?: unknown;
2446
+ retainedPath?: unknown;
2447
+ identity?: Record<string, unknown>;
2448
+ tree?: unknown;
1912
2449
  };
1913
2450
  };
1914
2451
  try {
@@ -1933,25 +2470,62 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
1933
2470
  typeof identity.mtimeNs !== "string"
1934
2471
  )
1935
2472
  throw new Error("durability_failed");
2473
+ if (receipt === detachedReceipt && quarantine.role !== "detached_artifact_root")
2474
+ throw new Error("durability_failed");
2475
+ if (receipt === detachedReceipt) {
2476
+ const cleanup = record.sourceArtifactCleanup;
2477
+ const cleanupIdentity = cleanup?.identity;
2478
+ const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
2479
+ if (
2480
+ cleanup?.state !== "cleanup_pending" ||
2481
+ cleanup.role !== "exchange_placeholder" ||
2482
+ typeof cleanup.retainedPath !== "string" ||
2483
+ !cleanupIdentity ||
2484
+ typeof cleanupIdentity.dev !== "string" ||
2485
+ typeof cleanupIdentity.ino !== "string" ||
2486
+ typeof cleanupIdentity.size !== "string" ||
2487
+ typeof cleanupIdentity.mtimeNs !== "string" ||
2488
+ !cleanupTree ||
2489
+ !cleanupAuthorityMatches(
2490
+ {
2491
+ state: "cleanup_pending",
2492
+ role: "exchange_placeholder",
2493
+ retainedPath: cleanup.retainedPath,
2494
+ identity: {
2495
+ dev: BigInt(cleanupIdentity.dev),
2496
+ ino: BigInt(cleanupIdentity.ino),
2497
+ size: BigInt(cleanupIdentity.size),
2498
+ mtimeNs: BigInt(cleanupIdentity.mtimeNs),
2499
+ },
2500
+ tree: cleanupTree,
2501
+ },
2502
+ path.dirname(source.path),
2503
+ )
2504
+ )
2505
+ throw new Error("durability_failed");
2506
+ }
2507
+
1936
2508
  const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
1937
2509
  const assertPreparedTree = (pathname: string): void => {
1938
2510
  validateManagedArtifactTree(pathname);
1939
2511
  const observed = native.snapshotDirectoryTree(pathname);
1940
- if (!observed.ok || !observed.snapshot || JSON.stringify(observed.snapshot) !== JSON.stringify(expectedTree))
2512
+ if (!observed.ok || !observed.snapshot || !sameArtifactTree(observed.snapshot, expectedTree))
1941
2513
  throw new Error("durability_failed");
1942
2514
  };
1943
2515
  if (fs.existsSync(quarantine.path)) {
1944
2516
  const existing = fs.lstatSync(quarantine.path, { bigint: true });
1945
2517
  if (
1946
- existing.isSymbolicLink() ||
1947
- !existing.isDirectory() ||
1948
- existing.dev !== BigInt(identity.dev) ||
1949
- existing.ino !== BigInt(identity.ino) ||
1950
- existing.size !== BigInt(identity.size) ||
1951
- existing.mtimeNs !== BigInt(identity.mtimeNs)
1952
- )
1953
- throw new Error("durability_failed");
1954
- assertPreparedTree(quarantine.path);
2518
+ !existing.isSymbolicLink() &&
2519
+ existing.isDirectory() &&
2520
+ existing.dev === BigInt(identity.dev) &&
2521
+ existing.ino === BigInt(identity.ino) &&
2522
+ existing.size === BigInt(identity.size) &&
2523
+ existing.mtimeNs === BigInt(identity.mtimeNs)
2524
+ ) {
2525
+ assertPreparedTree(quarantine.path);
2526
+ }
2527
+ // A changed source pathname is independent retained authority. Do not replace
2528
+ // it with the detached original; retain both roots for recovery.
1955
2529
  return;
1956
2530
  }
1957
2531
  assertPreparedTree(quarantine.detachedPath);
@@ -1965,7 +2539,9 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
1965
2539
  if (!result.ok) throw new Error("durability_failed");
1966
2540
  }
1967
2541
 
1968
- function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot): void {
2542
+ function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: SourceArtifactCleanup): void {
2543
+ if (cleanup && !cleanupAuthorityMatches(cleanup, path.dirname(detached.originalPath)))
2544
+ throw new Error("durability_failed");
1969
2545
  const result = native.exactRestore(detached.detachedPath, detached.originalPath, {
1970
2546
  ...detached.identity,
1971
2547
  directory: true,
@@ -2030,7 +2606,7 @@ async function copyArtifacts(
2030
2606
  function migrationReceipt(
2031
2607
  scope: ManagedScope,
2032
2608
  lock: ManagedStorageLock,
2033
- state: "prepared" | "published" | "committed",
2609
+ state: "prepared" | "artifact_detached" | "published" | "committed",
2034
2610
  source: ManagedCandidate,
2035
2611
  destination: ManagedCandidate | { path: string; sessionId: string; cwd: string },
2036
2612
  manifest: readonly ArtifactManifestEntry[],
@@ -2039,7 +2615,9 @@ function migrationReceipt(
2039
2615
  detachedPath: string;
2040
2616
  identity: DetachedArtifactRoot["identity"];
2041
2617
  tree: NativeDirectoryTreeSnapshot;
2618
+ role?: "detached_artifact_root";
2042
2619
  },
2620
+ sourceArtifactCleanup?: SourceArtifactCleanup,
2043
2621
  ): Uint8Array {
2044
2622
  lock.assertOwned();
2045
2623
  const destinationRecord =
@@ -2057,12 +2635,20 @@ function migrationReceipt(
2057
2635
  header: { id: destination.sessionId, cwd: destination.cwd },
2058
2636
  };
2059
2637
  return new TextEncoder().encode(
2060
- `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2638
+ `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}), ...(sourceArtifactCleanup ? { sourceArtifactCleanup } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2061
2639
  );
2062
2640
  }
2063
2641
 
2642
+ function detachedReceiptMatches(receipt: string, expected: Uint8Array): boolean {
2643
+ try {
2644
+ return captureManagedFileNoFollow(receipt).bytes.equals(expected);
2645
+ } catch {
2646
+ return false;
2647
+ }
2648
+ }
2649
+
2064
2650
  async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise<void> {
2065
- for (const state of ["prepared", "published"] as const) {
2651
+ for (const state of ["prepared", "detached", "published"] as const) {
2066
2652
  const pathname = receiptPathFor(scope, candidate, state);
2067
2653
  try {
2068
2654
  const stat = fs.lstatSync(pathname);
@@ -2145,8 +2731,7 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2145
2731
  if (!observedPending) continue;
2146
2732
  if (
2147
2733
  cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2148
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
2149
- !fs.existsSync(observedPending.plannedTranscriptPath)
2734
+ cleanupRootsAbsent(tombstone, target, observedPending)
2150
2735
  ) {
2151
2736
  fsyncManagedParent(target.path);
2152
2737
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2185,6 +2770,12 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2185
2770
  ? pending.plannedTranscriptPath
2186
2771
  : undefined) ??
2187
2772
  (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2773
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2774
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2775
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2776
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2777
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2778
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2188
2779
  plannedArtifactsPath: active.plannedArtifactsPath,
2189
2780
  plannedTranscriptPath: active.plannedTranscriptPath,
2190
2781
  ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
@@ -2201,6 +2792,14 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2201
2792
  transcriptIdentity: target.identity,
2202
2793
  plannedArtifactsPath: active.plannedArtifactsPath,
2203
2794
  plannedTranscriptPath: active.plannedTranscriptPath,
2795
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2796
+
2797
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2798
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2799
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2800
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2801
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2802
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2204
2803
  artifactsRemoved: true,
2205
2804
  });
2206
2805
  }
@@ -2221,10 +2820,34 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2221
2820
  deletion.phase === "transcript"
2222
2821
  ? deletion.detachedTranscriptPath
2223
2822
  : active.detachedTranscriptPath,
2823
+ retainedArtifactsSuccessorPath:
2824
+ deletion.phase === "artifacts"
2825
+ ? deletion.retainedSuccessorPath
2826
+ : active.retainedArtifactsSuccessorPath,
2827
+ retainedArtifactsPlaceholderPath:
2828
+ deletion.phase === "artifacts"
2829
+ ? deletion.retainedPlaceholderPath
2830
+ : active.retainedArtifactsPlaceholderPath,
2831
+ retainedArtifactsUnknownPath:
2832
+ deletion.phase === "artifacts"
2833
+ ? deletion.retainedUnknownPath
2834
+ : active.retainedArtifactsUnknownPath,
2835
+ retainedTranscriptSuccessorPath:
2836
+ deletion.phase === "transcript"
2837
+ ? deletion.retainedSuccessorPath
2838
+ : active.retainedTranscriptSuccessorPath,
2839
+ retainedTranscriptPlaceholderPath:
2840
+ deletion.phase === "transcript"
2841
+ ? deletion.retainedPlaceholderPath
2842
+ : active.retainedTranscriptPlaceholderPath,
2843
+ retainedTranscriptUnknownPath:
2844
+ deletion.phase === "transcript"
2845
+ ? deletion.retainedUnknownPath
2846
+ : active.retainedTranscriptUnknownPath,
2224
2847
  },
2225
2848
  lock,
2226
2849
  );
2227
- throw new Error("durability_failed");
2850
+ continue;
2228
2851
  }
2229
2852
  fsyncManagedParent(target.path);
2230
2853
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2257,11 +2880,24 @@ export async function prepareManagedSessionScopeForWrite(
2257
2880
  await reconcileManagedTombstones(scope);
2258
2881
  return { kind: "resolved", scope };
2259
2882
  } catch (error) {
2260
- if (error instanceof Error && error.message === "durability_failed") return { kind: "resolved", scope };
2883
+ const publication = error instanceof ManagedPublishError ? error : undefined;
2884
+ const message =
2885
+ publication?.classification ??
2886
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
2887
+ const code =
2888
+ message === "atomic_unavailable" ||
2889
+ message === "invalid_request" ||
2890
+ message === "durability_failed" ||
2891
+ message === "durability_not_provable"
2892
+ ? message
2893
+ : "binding_invalid";
2261
2894
  return {
2262
2895
  kind: "error",
2263
- code: "binding_invalid",
2264
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
2896
+ code,
2897
+ message,
2898
+ ...(publication
2899
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
2900
+ : { cause: { classification: code } }),
2265
2901
  };
2266
2902
  }
2267
2903
  }
@@ -2300,7 +2936,8 @@ export async function openManagedCandidateForWrite(
2300
2936
  if (prepared.kind === "error")
2301
2937
  return {
2302
2938
  kind: "error",
2303
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
2939
+ code: expectedFailure(new Error(prepared.code)),
2940
+
2304
2941
  message: prepared.message,
2305
2942
  };
2306
2943
  current = revalidatePickerConsent(scope, candidate, expectedIdentity);
@@ -2319,11 +2956,15 @@ export async function openManagedCandidateForWrite(
2319
2956
  const internal = managedInternalDirectory(scope);
2320
2957
  let lock: ManagedStorageLock | undefined;
2321
2958
  let detachedArtifacts: DetachedArtifactRoot | undefined;
2959
+ let sourceArtifactCleanup: SourceArtifactCleanup | undefined;
2960
+
2322
2961
  try {
2323
2962
  revalidatePickerConsent(scope, current, expectedIdentity);
2324
2963
  lock = await acquireManagedLock(path.join(internal, MANAGED_LOCKS_DIRECTORY), operation, scopeRoot(scope));
2325
2964
  const heldLock = lock;
2965
+
2326
2966
  const afterLock = revalidatePickerConsent(scope, current, expectedIdentity);
2967
+
2327
2968
  const listing = listManagedCandidates(scope);
2328
2969
  if (listing.kind === "error") return { kind: "error", code: "binding_invalid", message: listing.message };
2329
2970
  const destination = path.join(scope.directoryPath, path.basename(afterLock.path));
@@ -2335,11 +2976,22 @@ export async function openManagedCandidateForWrite(
2335
2976
  code: "destination_conflict",
2336
2977
  message: "A distinct v2 transcript already owns this session id.",
2337
2978
  };
2979
+
2338
2980
  scopeRoot(scope);
2339
2981
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2340
2982
  restorePreparedArtifactRoot(scope, afterLock);
2341
2983
  scopeRoot(scope);
2342
- const sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
2984
+
2985
+ let sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
2986
+ if (
2987
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
2988
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
2989
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
2990
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
2991
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
2992
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
2993
+ )
2994
+ throw new Error("source_changed");
2343
2995
  let manifest: readonly ArtifactManifestEntry[] = [];
2344
2996
  const artifactPlan = planArtifactRootForMigration(afterLock.path, operation);
2345
2997
  const intendedDestination = { path: destination, sessionId: afterLock.sessionId, cwd: afterLock.cwd };
@@ -2361,6 +3013,7 @@ export async function openManagedCandidateForWrite(
2361
3013
  message: "The migration destination already exists without validated ownership.",
2362
3014
  };
2363
3015
  }
3016
+
2364
3017
  const preparedReceipt = receiptPathFor(scope, afterLock, "prepared");
2365
3018
  try {
2366
3019
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
@@ -2389,14 +3042,52 @@ export async function openManagedCandidateForWrite(
2389
3042
  } catch (error) {
2390
3043
  if ((error as Error).message !== "destination_conflict") throw error;
2391
3044
  }
3045
+
3046
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2392
3047
  lock.assertOwned();
2393
3048
  if (!preparedReceiptMatches(preparedReceipt, scope, afterLock, intendedDestination, artifactPlan))
2394
3049
  throw new Error("durability_failed");
2395
3050
  if (artifactPlan && fs.existsSync(artifactPlan.detachedPath)) throw new Error("destination_conflict");
3051
+
2396
3052
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
3053
+
2397
3054
  scopeRoot(scope);
2398
- detachedArtifacts = artifactPlan ? detachArtifactRootForMigration(artifactPlan) : undefined;
3055
+
3056
+ if (artifactPlan) {
3057
+ const detached = detachArtifactRootForMigration(artifactPlan);
3058
+ detachedArtifacts = detached.detached;
3059
+ sourceArtifactCleanup = detached.cleanup;
3060
+ const detachedReceipt = receiptPathFor(scope, afterLock, "detached");
3061
+ const detachedRecord = migrationReceipt(
3062
+ scope,
3063
+ lock,
3064
+ "artifact_detached",
3065
+ afterLock,
3066
+ intendedDestination,
3067
+ [],
3068
+ {
3069
+ path: detachedArtifacts.originalPath,
3070
+ detachedPath: detachedArtifacts.detachedPath,
3071
+ identity: detachedArtifacts.identity,
3072
+ tree: detachedArtifacts.tree,
3073
+ role: "detached_artifact_root",
3074
+ },
3075
+ sourceArtifactCleanup,
3076
+ );
3077
+ try {
3078
+ await publishManagedFileNoReplace(
3079
+ detachedReceipt,
3080
+ detachedRecord,
3081
+ assertPublicationConsent,
3082
+ scopeRoot(scope),
3083
+ );
3084
+ } catch (error) {
3085
+ if ((error as Error).message !== "destination_conflict") throw error;
3086
+ }
3087
+ if (!detachedReceiptMatches(detachedReceipt, detachedRecord)) throw new Error("durability_failed");
3088
+ }
2399
3089
  manifest = detachedArtifacts ? artifactManifestFromSnapshot(detachedArtifacts.tree) : [];
3090
+
2400
3091
  await copyArtifacts(
2401
3092
  scope,
2402
3093
  afterLock.path,
@@ -2407,6 +3098,22 @@ export async function openManagedCandidateForWrite(
2407
3098
  expectedIdentity,
2408
3099
  detachedArtifacts?.detachedPath,
2409
3100
  );
3101
+
3102
+ if (!existing) {
3103
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3104
+ lock.assertOwned();
3105
+ sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3106
+ if (
3107
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
3108
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
3109
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
3110
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
3111
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
3112
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
3113
+ )
3114
+ throw new Error("source_changed");
3115
+ }
3116
+
2410
3117
  if (!existing) {
2411
3118
  try {
2412
3119
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
@@ -2416,6 +3123,7 @@ export async function openManagedCandidateForWrite(
2416
3123
  if ((error as Error).message !== "destination_conflict") throw error;
2417
3124
  }
2418
3125
  }
3126
+
2419
3127
  // Artifact files, directories, and the transcript must be durable before a receipt can grant shadow authority.
2420
3128
  scopeRoot(scope);
2421
3129
  if (manifest.length > 0) fsyncManagedArtifactTree(destination.slice(0, -6));
@@ -2429,12 +3137,14 @@ export async function openManagedCandidateForWrite(
2429
3137
  !manifestMatches(destination, manifest)
2430
3138
  )
2431
3139
  throw new Error("durability_failed");
3140
+
2432
3141
  if (detachedArtifacts) {
2433
3142
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2434
3143
  scopeRoot(scope);
2435
- restoreDetachedArtifactRoot(detachedArtifacts);
3144
+ restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2436
3145
  detachedArtifacts = undefined;
2437
3146
  }
3147
+
2438
3148
  const latest = validateCandidateForScope(scope, afterLock);
2439
3149
  if (!latest || !sameCandidate(latest, afterLock) || !manifestMatches(afterLock.path, manifest))
2440
3150
  return {
@@ -2442,6 +3152,7 @@ export async function openManagedCandidateForWrite(
2442
3152
  code: "source_changed",
2443
3153
  message: "The legacy candidate or its artifacts changed before migration commit.",
2444
3154
  };
3155
+
2445
3156
  for (const state of ["published", "committed"] as const) {
2446
3157
  const receipt = receiptPathFor(scope, afterLock, state);
2447
3158
  try {
@@ -2449,7 +3160,24 @@ export async function openManagedCandidateForWrite(
2449
3160
  lock.assertOwned();
2450
3161
  await publishManagedFileNoReplace(
2451
3162
  receipt,
2452
- migrationReceipt(scope, lock, state, afterLock, migrated, manifest),
3163
+ migrationReceipt(
3164
+ scope,
3165
+ lock,
3166
+ state,
3167
+ afterLock,
3168
+ migrated,
3169
+ manifest,
3170
+ artifactPlan
3171
+ ? {
3172
+ path: artifactPlan.originalPath,
3173
+ detachedPath: artifactPlan.detachedPath,
3174
+ identity: artifactPlan.identity,
3175
+ tree: artifactPlan.tree,
3176
+ role: "detached_artifact_root",
3177
+ }
3178
+ : undefined,
3179
+ sourceArtifactCleanup,
3180
+ ),
2453
3181
  assertPublicationConsent,
2454
3182
  scopeRoot(scope),
2455
3183
  );
@@ -2457,6 +3185,7 @@ export async function openManagedCandidateForWrite(
2457
3185
  if ((error as Error).message !== "destination_conflict") throw error;
2458
3186
  }
2459
3187
  }
3188
+
2460
3189
  const receipt = receiptPathFor(scope, afterLock);
2461
3190
  lock.assertOwned();
2462
3191
  if (!receiptMatches(receipt, afterLock, migrated, scope))
@@ -2465,6 +3194,7 @@ export async function openManagedCandidateForWrite(
2465
3194
  code: "durability_failed",
2466
3195
  message: "The migration receipt does not bind the copied v2 transcript and artifacts.",
2467
3196
  };
3197
+
2468
3198
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2469
3199
  scopeRoot(scope);
2470
3200
  await removeStagedReceipts(scope, afterLock);
@@ -2476,7 +3206,7 @@ export async function openManagedCandidateForWrite(
2476
3206
  };
2477
3207
  } catch (error) {
2478
3208
  try {
2479
- if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts);
3209
+ if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2480
3210
  } catch {
2481
3211
  return {
2482
3212
  kind: "error",
@@ -2500,7 +3230,7 @@ export async function deleteManagedSessionCandidate(
2500
3230
  if (prepared.kind === "error")
2501
3231
  return {
2502
3232
  kind: "error",
2503
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
3233
+ code: expectedFailure(new Error(prepared.code)),
2504
3234
  message: prepared.message,
2505
3235
  };
2506
3236
  const current = validateCandidateForScope(scope, candidate);
@@ -2560,8 +3290,7 @@ export async function deleteManagedSessionCandidate(
2560
3290
  if (!observedPending) continue;
2561
3291
  if (
2562
3292
  cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2563
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
2564
- !fs.existsSync(observedPending.plannedTranscriptPath)
3293
+ cleanupRootsAbsent(tombstone, target, observedPending)
2565
3294
  ) {
2566
3295
  fsyncManagedParent(target.path);
2567
3296
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2596,6 +3325,12 @@ export async function deleteManagedSessionCandidate(
2596
3325
  active.detachedTranscriptPath ??
2597
3326
  observedPending?.detachedTranscriptPath ??
2598
3327
  (pending && fs.existsSync(pending.plannedTranscriptPath) ? pending.plannedTranscriptPath : undefined),
3328
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3329
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3330
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3331
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3332
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3333
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2599
3334
  plannedArtifactsPath: active.plannedArtifactsPath,
2600
3335
  plannedTranscriptPath: active.plannedTranscriptPath,
2601
3336
  ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
@@ -2612,6 +3347,13 @@ export async function deleteManagedSessionCandidate(
2612
3347
  transcriptIdentity: target.identity,
2613
3348
  plannedArtifactsPath: active.plannedArtifactsPath,
2614
3349
  plannedTranscriptPath: active.plannedTranscriptPath,
3350
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3351
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3352
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3353
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3354
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3355
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3356
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2615
3357
  artifactsRemoved: true,
2616
3358
  });
2617
3359
  }
@@ -2627,23 +3369,51 @@ export async function deleteManagedSessionCandidate(
2627
3369
  )
2628
3370
  throw new Error("durability_failed");
2629
3371
  const retry = nextCleanupReceipt(target, active);
2630
- await publishCleanupPending(
2631
- scope,
2632
- tombstone,
2633
- {
2634
- ...retry,
2635
- expectedArtifactsIdentity:
2636
- deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2637
- expectedArtifactsTree:
2638
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2639
- detachedArtifactsPath:
2640
- deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2641
- detachedTranscriptPath:
2642
- deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2643
- },
2644
- lock,
2645
- );
2646
- throw new Error("durability_failed");
3372
+ await publishCleanupPending(scope, tombstone, cleanupPendingEvidence(retry, active, deletion), lock);
3373
+ if (deletion.phase === "artifacts") {
3374
+ // A typed retained artifact root proves durable canonical absence; advance
3375
+ // to the transcript phase instead of stalling on the retained evidence.
3376
+ await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
3377
+ deletion = await new FileSessionStorage().deleteSessionVerified({
3378
+ sessionsRoot: scope.sessionsRoot,
3379
+ transcriptPath: target.path,
3380
+ sessionId: target.sessionId,
3381
+ cwd: target.cwd,
3382
+ transcriptIdentity: target.identity,
3383
+ plannedArtifactsPath: active.plannedArtifactsPath,
3384
+ plannedTranscriptPath: active.plannedTranscriptPath,
3385
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3386
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3387
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3388
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3389
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3390
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3391
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3392
+ artifactsRemoved: true,
3393
+ });
3394
+ if (deletion.kind === "cleanup_pending") {
3395
+ const followup = nextCleanupReceipt(target, active);
3396
+ await publishCleanupPending(
3397
+ scope,
3398
+ tombstone,
3399
+ cleanupPendingEvidence(followup, active, deletion),
3400
+ lock,
3401
+ );
3402
+ if (
3403
+ deletion.phase !== "transcript" ||
3404
+ deletion.detachedTranscriptPath !== active.plannedTranscriptPath
3405
+ )
3406
+ return {
3407
+ kind: "cleanup_pending",
3408
+ tombstonePath: tombstone,
3409
+ phase: deletion.phase,
3410
+ message:
3411
+ "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.",
3412
+ };
3413
+ }
3414
+ }
3415
+ // A typed retained transcript quarantine is durable canonical-absence
3416
+ // evidence — never a terminal byte-deletion claim; complete the receipt.
2647
3417
  }
2648
3418
  fsyncManagedParent(target.path);
2649
3419
  await publishCleanupCompleted(scope, tombstone, target, lock);