@gajae-code/coding-agent 0.15.5 → 0.16.0

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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -3,6 +3,12 @@ import * as fsSync from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import type { AssistantMessage } from "@gajae-code/ai/core";
6
+ import {
7
+ exactReplacePath,
8
+ exactUnlinkDirect,
9
+ type NativeExactFileIdentity,
10
+ renameNoReplacePath,
11
+ } from "@gajae-code/natives";
6
12
  import { normalizePathForComparison, postmortem } from "@gajae-code/utils";
7
13
  import { withFileLock } from "../config/file-lock";
8
14
  import {
@@ -165,6 +171,14 @@ export const __sessionStateSidecarPerfCounters = {
165
171
  },
166
172
  };
167
173
 
174
+ /** Test-only barriers for deterministic rescope transaction race coverage. */
175
+ export const __sessionStateSidecarTestHooks: {
176
+ afterRescopeLocksAcquired?: () => void | Promise<void>;
177
+ beforeRescopeJournalWrite?: (cwd: string) => void | Promise<void>;
178
+ beforePersistFromEvent?: (eventType: string, cwd: string) => void | Promise<void>;
179
+ beforeRescopePublish?: () => void | Promise<void>;
180
+ } = {};
181
+
168
182
  interface RuntimeStateEvent {
169
183
  type: string;
170
184
  messages?: unknown[];
@@ -636,6 +650,12 @@ export interface RuntimeStateContext {
636
650
  ownerTerminal?: OwnerTerminalContext | null;
637
651
  /** Internal fail-closed marker set only when managed owner metadata is malformed or missing. */
638
652
  ownerTerminalMetadataInvalid?: boolean;
653
+ /**
654
+ * The session file as it was BEFORE a committed rescope, used only by
655
+ * {@link relocateCoordinatorRuntimeStateForRescope} to authenticate the predecessor
656
+ * payload against its old identity. Absent outside a move.
657
+ */
658
+ previousSessionFile?: string | null;
639
659
  }
640
660
 
641
661
  interface RuntimeStateIdentity {
@@ -984,12 +1004,73 @@ export function eventAffectsCoordinatorRuntimeState(event: RuntimeStateEvent): b
984
1004
  }
985
1005
 
986
1006
  class PreviousRuntimeStateReadError extends Error {
987
- constructor() {
988
- super("Existing runtime state marker is invalid or unreadable; refusing to overwrite.");
1007
+ constructor(cause?: unknown) {
1008
+ const lockDetail = cause instanceof SessionStateLockUnavailableError ? ` ${cause.message}` : "";
1009
+ super(`Existing runtime state marker is invalid or unreadable; refusing to overwrite.${lockDetail}`);
989
1010
  this.name = "PreviousRuntimeStateReadError";
1011
+ if (cause !== undefined) this.cause = cause;
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * A readable, well-formed marker recorded against a different workspace path.
1017
+ *
1018
+ * Separate from {@link PreviousRuntimeStateReadError} because the two demand different
1019
+ * operator responses: an unreadable marker points at file damage, while a foreign marker
1020
+ * points at the same session directory being reachable from two checkouts.
1021
+ */
1022
+ class ForeignRuntimeStateError extends Error {
1023
+ readonly recordedCwd: string;
1024
+ readonly currentCwd: string;
1025
+ constructor(previous: Record<string, unknown>, input: RuntimeStateIdentity) {
1026
+ const recorded = typeof previous.cwd === "string" ? previous.cwd : "<unknown>";
1027
+ super(
1028
+ `Existing runtime state marker belongs to a different workspace; refusing to overwrite. ` +
1029
+ `recorded cwd ${recorded}, current cwd ${input.cwd}. ` +
1030
+ `A live or in-flight marker is never adopted; delete it only if no session is running there.`,
1031
+ );
1032
+ this.name = "ForeignRuntimeStateError";
1033
+ this.recordedCwd = recorded;
1034
+ this.currentCwd = input.cwd;
990
1035
  }
991
1036
  }
992
1037
 
1038
+ /**
1039
+ * A foreign marker is adoptable only when every one of these holds:
1040
+ *
1041
+ * - it already reached a terminal state and is explicitly not live, so no owner can still
1042
+ * be writing to it (`live` must be `false` rather than merely absent: an older or
1043
+ * truncated marker that omits the field says nothing about whether its owner runs);
1044
+ * - the marker file itself lives inside the current workspace, which is what distinguishes
1045
+ * a session directory that travelled here with its repository from one that still
1046
+ * belongs to a different workspace on this machine.
1047
+ *
1048
+ * The second condition is the load-bearing one. Terminal-and-not-live alone would let a
1049
+ * finished `C:\...` session be adopted by a `D:\...` workspace, and those are unrelated
1050
+ * working trees that must never inherit each other's runtime identity.
1051
+ */
1052
+ function isAdoptableForeignRuntimeState(
1053
+ previous: Record<string, unknown>,
1054
+ input: RuntimeStateIdentity,
1055
+ stateFile: string | null,
1056
+ ): boolean {
1057
+ if (previous.live !== false) return false;
1058
+ if (previous.state !== "completed" && previous.state !== "errored") return false;
1059
+ if (!stateFile) return false;
1060
+ return pathIsInside(stateFile, input.cwd, input.platform);
1061
+ }
1062
+
1063
+ /** True when `candidate` resolves to `root` itself or something beneath it. */
1064
+ function pathIsInside(candidate: string, root: string, platform: NodeJS.Platform = process.platform): boolean {
1065
+ if (sameResolvedPath(candidate, root, platform)) return true;
1066
+ const normalizedRoot = normalizePathForComparison(root, platform);
1067
+ const normalizedCandidate = normalizePathForComparison(candidate, platform);
1068
+ if (!normalizedRoot || !normalizedCandidate) return false;
1069
+ const separator = platform === "win32" ? "\\" : "/";
1070
+ const prefix = normalizedRoot.endsWith(separator) ? normalizedRoot : `${normalizedRoot}${separator}`;
1071
+ return normalizedCandidate.startsWith(prefix);
1072
+ }
1073
+
993
1074
  class RuntimeToolActivityRefusedError extends Error {
994
1075
  constructor(reason: string) {
995
1076
  super(`Refusing to overwrite the coordinator tool-activity snapshot: ${reason}.`);
@@ -1069,6 +1150,78 @@ function readPreviousPayload(stateFile: string): Record<string, unknown> {
1069
1150
  }
1070
1151
  }
1071
1152
 
1153
+ type RelocationPayloadObservation = {
1154
+ payload: Record<string, unknown>;
1155
+ identity: NativeExactFileIdentity | null;
1156
+ };
1157
+
1158
+ function sameRelocationFileIdentity(left: fsSync.BigIntStats, right: fsSync.BigIntStats): boolean {
1159
+ return (
1160
+ left.dev === right.dev &&
1161
+ left.ino === right.ino &&
1162
+ left.nlink === right.nlink &&
1163
+ left.size === right.size &&
1164
+ left.mtimeNs === right.mtimeNs &&
1165
+ left.ctimeNs === right.ctimeNs &&
1166
+ left.isFile() &&
1167
+ right.isFile() &&
1168
+ !left.isSymbolicLink() &&
1169
+ !right.isSymbolicLink()
1170
+ );
1171
+ }
1172
+
1173
+ /** Read relocation authority from one no-follow, identity-revalidated regular file. */
1174
+ function readRegularFileForRelocation(stateFile: string): { raw: string; identity: NativeExactFileIdentity } | null {
1175
+ let before: fsSync.BigIntStats;
1176
+ try {
1177
+ before = fsSync.lstatSync(stateFile, { bigint: true });
1178
+ } catch (error) {
1179
+ if (isAbsentStateFileError(error)) return null;
1180
+ throw new PreviousRuntimeStateReadError(error);
1181
+ }
1182
+ if (!before.isFile() || before.isSymbolicLink()) throw new PreviousRuntimeStateReadError();
1183
+ const flags = fsSync.constants.O_RDONLY | (process.platform === "win32" ? 0 : (fsSync.constants.O_NOFOLLOW ?? 0));
1184
+ let descriptor: number | undefined;
1185
+ try {
1186
+ descriptor = fsSync.openSync(stateFile, flags);
1187
+ const opened = fsSync.fstatSync(descriptor, { bigint: true });
1188
+ if (!sameRelocationFileIdentity(before, opened)) throw new PreviousRuntimeStateReadError();
1189
+ const raw = fsSync.readFileSync(descriptor, "utf8");
1190
+ const after = fsSync.lstatSync(stateFile, { bigint: true });
1191
+ const finalOpened = fsSync.fstatSync(descriptor, { bigint: true });
1192
+ const parent = fsSync.lstatSync(path.dirname(stateFile), { bigint: true });
1193
+ if (!sameRelocationFileIdentity(opened, finalOpened) || !sameRelocationFileIdentity(opened, after))
1194
+ throw new PreviousRuntimeStateReadError();
1195
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw new PreviousRuntimeStateReadError();
1196
+ return {
1197
+ raw,
1198
+ identity: {
1199
+ dev: opened.dev,
1200
+ ino: opened.ino,
1201
+ nlink: opened.nlink,
1202
+ parentDev: parent.dev,
1203
+ parentIno: parent.ino,
1204
+ size: opened.size,
1205
+ mtimeNs: opened.mtimeNs,
1206
+ sha256: createHash("sha256").update(raw).digest("hex"),
1207
+ quarantineName: `.gjc-delete-runtime-state-${randomUUID()}.json`,
1208
+ },
1209
+ };
1210
+ } catch (error) {
1211
+ if (error instanceof PreviousRuntimeStateReadError) throw error;
1212
+ throw new PreviousRuntimeStateReadError(error);
1213
+ } finally {
1214
+ if (descriptor !== undefined) fsSync.closeSync(descriptor);
1215
+ }
1216
+ }
1217
+
1218
+ function readPreviousPayloadForRelocation(stateFile: string): RelocationPayloadObservation {
1219
+ const observed = readRegularFileForRelocation(stateFile);
1220
+ return observed
1221
+ ? { payload: parsePreviousPayload(observed.raw), identity: observed.identity }
1222
+ : { payload: {}, identity: null };
1223
+ }
1224
+
1072
1225
  /**
1073
1226
  * Reads the authoritative bytes on disk, every time, inside the state-file lock.
1074
1227
  *
@@ -1130,7 +1283,11 @@ function shouldPreserveTerminalPayload(previous: RuntimeStateSidecarPayload, inp
1130
1283
  );
1131
1284
  }
1132
1285
 
1133
- function assertPreviousRuntimeStateIdentity(previous: Record<string, unknown>, input: RuntimeStateIdentity): void {
1286
+ function assertPreviousRuntimeStateIdentity(
1287
+ previous: Record<string, unknown>,
1288
+ input: RuntimeStateIdentity,
1289
+ stateFile: string | null = null,
1290
+ ): void {
1134
1291
  if (Object.keys(previous).length === 0) return;
1135
1292
  if (previous.session_id !== input.sessionId) throw new PreviousRuntimeStateReadError();
1136
1293
  // Signed coordinator mode never trusts an unsigned predecessor. The sole exception
@@ -1167,20 +1324,47 @@ function assertPreviousRuntimeStateIdentity(previous: Record<string, unknown>, i
1167
1324
  if (coordinatorSeed) return;
1168
1325
  // If the previous payload has runtime identity fields, verify them fully.
1169
1326
  if (typeof previous.cwd === "string" && typeof previous.workdir === "string") {
1170
- if (
1171
- !sameResolvedPath(previous.cwd, input.cwd, input.platform) ||
1172
- !sameResolvedPath(previous.workdir, input.cwd, input.platform) ||
1173
- (previous.session_file !== input.sessionFile &&
1174
- !(
1175
- typeof previous.session_file === "string" &&
1327
+ const pathsMatch =
1328
+ sameResolvedPath(previous.cwd, input.cwd, input.platform) &&
1329
+ sameResolvedPath(previous.workdir, input.cwd, input.platform) &&
1330
+ (previous.session_file === input.sessionFile ||
1331
+ (typeof previous.session_file === "string" &&
1176
1332
  typeof input.sessionFile === "string" &&
1177
- sameResolvedPath(previous.session_file, input.sessionFile, input.platform)
1178
- ))
1179
- )
1180
- throw new PreviousRuntimeStateReadError();
1333
+ sameResolvedPath(previous.session_file, input.sessionFile, input.platform)));
1334
+ if (!pathsMatch) {
1335
+ // A marker whose recorded workspace differs from the current one is readable and
1336
+ // well-formed; it simply belongs to another checkout. Reporting that as a read
1337
+ // failure sends operators hunting for file corruption that does not exist.
1338
+ //
1339
+ // A session directory committed to version control reaches a second machine this
1340
+ // way, so the mismatch is ordinary rather than exceptional. When the recorded
1341
+ // marker is already terminal and not live, nothing can still be writing to it and
1342
+ // adopting it is safe; anything else still refuses, because a live or in-flight
1343
+ // marker from another workspace may have a running owner behind it.
1344
+ if (!isAdoptableForeignRuntimeState(previous, input, stateFile))
1345
+ throw new ForeignRuntimeStateError(previous, input);
1346
+ }
1181
1347
  }
1182
1348
  }
1183
1349
 
1350
+ /** Relocation never accepts the coordinator bootstrap exception or partial identity rows. */
1351
+ function assertRelocationRuntimeStateIdentity(previous: Record<string, unknown>, input: RuntimeStateIdentity): void {
1352
+ assertPreviousRuntimeStateIdentity(previous, input);
1353
+ if (
1354
+ typeof previous.cwd !== "string" ||
1355
+ typeof previous.workdir !== "string" ||
1356
+ !sameResolvedPath(previous.cwd, input.cwd, input.platform) ||
1357
+ !sameResolvedPath(previous.workdir, input.cwd, input.platform) ||
1358
+ (previous.session_file !== input.sessionFile &&
1359
+ !(
1360
+ typeof previous.session_file === "string" &&
1361
+ typeof input.sessionFile === "string" &&
1362
+ sameResolvedPath(previous.session_file, input.sessionFile, input.platform)
1363
+ ))
1364
+ )
1365
+ throw new PreviousRuntimeStateReadError();
1366
+ }
1367
+
1184
1368
  function runtimeStateFileForContext(context: RuntimeStateContext): string | null {
1185
1369
  const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
1186
1370
  if (explicit) return explicit;
@@ -1400,6 +1584,44 @@ async function writeStateFileSync(
1400
1584
  await writeStateFile(stateFile, finalizeSidecarPayload(payload, keyId));
1401
1585
  }
1402
1586
 
1587
+ async function writeStateFileSyncConditional(
1588
+ stateFile: string,
1589
+ payload: Record<string, unknown>,
1590
+ keyId: string | null,
1591
+ expectedDestination: NativeExactFileIdentity | null,
1592
+ ): Promise<void> {
1593
+ const contents = `${JSON.stringify(finalizeSidecarPayload(payload, keyId))}\n`;
1594
+ await writeCoordinatorAtomic(stateFile, contents, {
1595
+ rename: async (source, destination) => {
1596
+ if (!expectedDestination) {
1597
+ const published = renameNoReplacePath(source, destination);
1598
+ if (!published.ok) throw new PreviousRuntimeStateReadError();
1599
+ return;
1600
+ }
1601
+ const stat = fsSync.lstatSync(source, { bigint: true });
1602
+ const parent = fsSync.lstatSync(path.dirname(source), { bigint: true });
1603
+ if (!stat.isFile() || stat.isSymbolicLink() || !parent.isDirectory() || parent.isSymbolicLink())
1604
+ throw new PreviousRuntimeStateReadError();
1605
+ const replaced = exactReplacePath(
1606
+ source,
1607
+ destination,
1608
+ {
1609
+ dev: stat.dev,
1610
+ ino: stat.ino,
1611
+ nlink: stat.nlink,
1612
+ parentDev: parent.dev,
1613
+ parentIno: parent.ino,
1614
+ size: stat.size,
1615
+ mtimeNs: stat.mtimeNs,
1616
+ sha256: createHash("sha256").update(contents).digest("hex"),
1617
+ },
1618
+ expectedDestination,
1619
+ );
1620
+ if (!replaced.ok) throw new PreviousRuntimeStateReadError();
1621
+ },
1622
+ });
1623
+ }
1624
+
1403
1625
  /**
1404
1626
  * The state-file critical section, shared byte-for-byte with the Coordinator MCP writer.
1405
1627
  *
@@ -1413,7 +1635,7 @@ async function withStateFileLock<T>(stateFile: string, operation: () => Promise<
1413
1635
  try {
1414
1636
  return await withSessionStateFileLock(stateFile, operation);
1415
1637
  } catch (error) {
1416
- if (error instanceof SessionStateLockUnavailableError) throw new PreviousRuntimeStateReadError();
1638
+ if (error instanceof SessionStateLockUnavailableError) throw new PreviousRuntimeStateReadError(error);
1417
1639
  throw error;
1418
1640
  }
1419
1641
  }
@@ -1441,6 +1663,101 @@ async function withCoordinatorTransactionLock<T>(stateFile: string, operation: (
1441
1663
  }
1442
1664
  }
1443
1665
 
1666
+ function orderedDistinctStateFiles(stateFiles: readonly string[], platform: NodeJS.Platform): string[] {
1667
+ const byIdentity = new Map<string, string>();
1668
+ for (const stateFile of stateFiles) {
1669
+ const resolved = path.resolve(stateFile);
1670
+ byIdentity.set(normalizePathForComparison(resolved, platform), resolved);
1671
+ }
1672
+ return [...byIdentity.entries()]
1673
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1674
+ .map(([, stateFile]) => stateFile);
1675
+ }
1676
+
1677
+ function canonicalDirectoryWithExistingAncestor(directory: string): string {
1678
+ let current = path.resolve(directory);
1679
+ const missing: string[] = [];
1680
+ for (;;) {
1681
+ try {
1682
+ return path.join(fsSync.realpathSync(current), ...missing.reverse());
1683
+ } catch (error) {
1684
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new PreviousRuntimeStateReadError(error);
1685
+ const parent = path.dirname(current);
1686
+ if (parent === current) throw new PreviousRuntimeStateReadError(error);
1687
+ missing.push(path.basename(current));
1688
+ current = parent;
1689
+ }
1690
+ }
1691
+ }
1692
+
1693
+ function canonicalStateFilePath(stateFile: string): string {
1694
+ return path.join(canonicalDirectoryWithExistingAncestor(path.dirname(stateFile)), path.basename(stateFile));
1695
+ }
1696
+
1697
+ function assertPinnedDirectoryIdentity(cwd: string, expected: { dev: bigint; ino: bigint }): void {
1698
+ const stat = fsSync.lstatSync(cwd, { bigint: true });
1699
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== expected.dev || stat.ino !== expected.ino)
1700
+ throw new PreviousRuntimeStateReadError();
1701
+ }
1702
+
1703
+ function assertNoSymlinkDirectoryComponents(root: string, directory: string): void {
1704
+ const relative = path.relative(root, directory);
1705
+ if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
1706
+ throw new PreviousRuntimeStateReadError();
1707
+ let current = root;
1708
+ for (const component of relative.split(path.sep)) {
1709
+ current = path.join(current, component);
1710
+ try {
1711
+ const stat = fsSync.lstatSync(current);
1712
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new PreviousRuntimeStateReadError();
1713
+ } catch (error) {
1714
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
1715
+ throw error;
1716
+ }
1717
+ }
1718
+ }
1719
+
1720
+ function confinedJournalPath(cwd: string, sessionId: string, platform: NodeJS.Platform): string {
1721
+ const canonicalRoot = fsSync.realpathSync(cwd);
1722
+ const rawJournalFile = runtimeStateRescopeJournalPath(canonicalRoot, sessionId);
1723
+ assertNoSymlinkDirectoryComponents(canonicalRoot, path.dirname(rawJournalFile));
1724
+ const journalFile = canonicalStateFilePath(rawJournalFile);
1725
+ const pathImpl = platform === "win32" ? path.win32 : path;
1726
+ const relative = pathImpl.relative(canonicalRoot, journalFile);
1727
+ if (
1728
+ relative === "" ||
1729
+ relative === ".." ||
1730
+ relative.startsWith(`..${pathImpl.sep}`) ||
1731
+ pathImpl.isAbsolute(relative)
1732
+ )
1733
+ throw new PreviousRuntimeStateReadError();
1734
+ return journalFile;
1735
+ }
1736
+
1737
+ async function withSerializedStateFiles<T>(stateFiles: readonly string[], operation: () => Promise<T>): Promise<T> {
1738
+ const [stateFile, ...remaining] = stateFiles;
1739
+ if (!stateFile) return await operation();
1740
+ return await serializeStateFileWrite(stateFile, async () => await withSerializedStateFiles(remaining, operation));
1741
+ }
1742
+
1743
+ async function withCoordinatorTransactionLocks<T>(
1744
+ stateFiles: readonly string[],
1745
+ operation: () => Promise<T>,
1746
+ ): Promise<T> {
1747
+ const [stateFile, ...remaining] = stateFiles;
1748
+ if (!stateFile) return await operation();
1749
+ return await withCoordinatorTransactionLock(
1750
+ stateFile,
1751
+ async () => await withCoordinatorTransactionLocks(remaining, operation),
1752
+ );
1753
+ }
1754
+
1755
+ async function withStateFileLocks<T>(stateFiles: readonly string[], operation: () => Promise<T>): Promise<T> {
1756
+ const [stateFile, ...remaining] = stateFiles;
1757
+ if (!stateFile) return await operation();
1758
+ return await withStateFileLock(stateFile, async () => await withStateFileLocks(remaining, operation));
1759
+ }
1760
+
1444
1761
  async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
1445
1762
  await writeCoordinatorAtomic(stateFile, `${JSON.stringify(payload)}\n`);
1446
1763
  }
@@ -1475,9 +1792,10 @@ async function persistCoordinatorRuntimeToolActivity(
1475
1792
  stateFile,
1476
1793
  async () =>
1477
1794
  await withStateFileLock(stateFile, async () => {
1795
+ assertNoRuntimeStateRescopeJournal(context, identity);
1478
1796
  const previous = await readPreviousPayloadForEvent(stateFile);
1479
1797
  if (Object.keys(previous).length === 0) return;
1480
- assertPreviousRuntimeStateIdentity(previous, identity);
1798
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1481
1799
  // A tool event that lands after the session settled must never
1482
1800
  // resurrect it into a live-looking state.
1483
1801
  if (previous.state === "completed" || previous.state === "errored") return;
@@ -1530,6 +1848,8 @@ export async function persistCoordinatorRuntimeStateFromEvent(
1530
1848
  const stateFile = runtimeStateFileForContext(context);
1531
1849
  const state = stateForEvent(event);
1532
1850
  if (!stateFile) return;
1851
+ const beforePersistFromEvent = __sessionStateSidecarTestHooks.beforePersistFromEvent;
1852
+ if (beforePersistFromEvent) await beforePersistFromEvent(event.type, context.cwd);
1533
1853
  if (!state) {
1534
1854
  const activityPhase = toolActivityPhaseForEvent(event);
1535
1855
  if (!activityPhase) return;
@@ -1552,10 +1872,11 @@ export async function persistCoordinatorRuntimeStateFromEvent(
1552
1872
  stateFile,
1553
1873
  async () =>
1554
1874
  await withStateFileLock(stateFile, async () => {
1875
+ assertNoRuntimeStateRescopeJournal(context, identity);
1555
1876
  const nowMs = Date.now();
1556
1877
  const now = new Date(nowMs).toISOString();
1557
1878
  const previous = await readPreviousPayloadForEvent(stateFile);
1558
- assertPreviousRuntimeStateIdentity(previous, identity);
1879
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1559
1880
  const finalResponse = finalResponseForEvent(event);
1560
1881
  const terminalReceipt =
1561
1882
  state === "completed" || state === "errored"
@@ -1608,6 +1929,658 @@ export async function persistCoordinatorRuntimeStateFromEvent(
1608
1929
  );
1609
1930
  }
1610
1931
 
1932
+ export async function persistCoordinatorWorkerIntegrationOutcome(
1933
+ context: RuntimeStateContext,
1934
+ outcome: {
1935
+ kind: "worker_integration" | "terminal_persistence";
1936
+ status: "completed" | "failed" | "timed_out";
1937
+ correlationId?: string;
1938
+ error?: string;
1939
+ },
1940
+ ): Promise<void> {
1941
+ const stateFile = runtimeStateFileForContext(context);
1942
+ if (!stateFile) return;
1943
+ const identity = normalizedIdentity(context);
1944
+ await serializeStateFileWrite(
1945
+ stateFile,
1946
+ async () =>
1947
+ await withCoordinatorTransactionLock(
1948
+ stateFile,
1949
+ async () =>
1950
+ await withStateFileLock(stateFile, async () => {
1951
+ assertNoRuntimeStateRescopeJournal(context, identity);
1952
+ const previous = await readPreviousPayloadForEvent(stateFile);
1953
+ if (Object.keys(previous).length === 0) return;
1954
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1955
+ const now = new Date().toISOString();
1956
+ const terminalPersistenceFailed =
1957
+ outcome.kind === "terminal_persistence" && outcome.status !== "completed";
1958
+ const reconciliation = {
1959
+ status: outcome.status,
1960
+ ...(outcome.correlationId ? { correlation_id: outcome.correlationId } : {}),
1961
+ ...(outcome.error ? { error: publicSafeErrorMessage(outcome.error) } : {}),
1962
+ observed_at: now,
1963
+ };
1964
+ const failureMessage = publicSafeErrorMessage(
1965
+ outcome.error ??
1966
+ (outcome.status === "timed_out"
1967
+ ? "Worker integration timed out after terminal publication."
1968
+ : "Worker integration failed after terminal publication."),
1969
+ );
1970
+ const payload = {
1971
+ ...previous,
1972
+ ...(terminalPersistenceFailed
1973
+ ? {
1974
+ state: "errored",
1975
+ ready_for_input: false,
1976
+ live: false,
1977
+ current_turn_id: null,
1978
+ execution_state: "failed",
1979
+ receipt_state: "absent",
1980
+ ended_at: now,
1981
+ reason: "terminal_persistence_failed",
1982
+ source: "agent_session_reconciliation",
1983
+ event: "terminal_persistence",
1984
+ }
1985
+ : {}),
1986
+ updated_at: now,
1987
+ [outcome.kind]: reconciliation,
1988
+ ...(outcome.status !== "completed"
1989
+ ? {
1990
+ error: {
1991
+ code: `${outcome.kind}_failed`,
1992
+ message: failureMessage,
1993
+ recoverable: true,
1994
+ },
1995
+ }
1996
+ : {}),
1997
+ };
1998
+ await writeStateFileSync(stateFile, payload, identity.sidecarKeyId);
1999
+ }),
2000
+ ),
2001
+ );
2002
+ }
2003
+
2004
+ /**
2005
+ * The old identity a rescued predecessor payload must authenticate against: the launch-root
2006
+ * cwd and the session file as they were BEFORE the move. Signed mode still requires the
2007
+ * predecessor's key id and signature; unsigned mode requires an exact cwd/workdir/session
2008
+ * file match. Reusing {@link normalizedIdentity} keeps the platform, key-id, and path
2009
+ * resolution rules byte-identical with the persist path's fence.
2010
+ */
2011
+ function previousRescopeIdentity(context: RuntimeStateContext, previousCwd: string): RuntimeStateIdentity {
2012
+ return normalizedIdentity({
2013
+ sessionId: context.sessionId,
2014
+ cwd: previousCwd,
2015
+ sessionFile: context.previousSessionFile ?? context.sessionFile ?? null,
2016
+ platform: context.platform,
2017
+ });
2018
+ }
2019
+
2020
+ interface CoordinatorRuntimeStateRescopeJournal {
2021
+ schema_version: 1;
2022
+ move_id: string;
2023
+ session_id: string;
2024
+ previous_cwd: string;
2025
+ new_cwd: string;
2026
+ previous_session_file: string | null;
2027
+ new_session_file: string | null;
2028
+ old_state_file: string;
2029
+ new_state_file: string;
2030
+ state_file_mode: "pinned" | "derived";
2031
+ source_sha256: string | null;
2032
+ phase: "prepared" | "publishing";
2033
+ key_id: string | null;
2034
+ signature: string | null;
2035
+ created_at: string;
2036
+ }
2037
+
2038
+ const RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN =
2039
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
2040
+
2041
+ function runtimeStateRescopeJournalPath(cwd: string, sessionId: string): string {
2042
+ return path.join(sessionRuntimeDir(cwd, sessionId), "runtime-state-rescope.json");
2043
+ }
2044
+
2045
+ function sameOptionalResolvedPath(
2046
+ left: string | null,
2047
+ right: string | null,
2048
+ platform: NodeJS.Platform = process.platform,
2049
+ ): boolean {
2050
+ return (
2051
+ left === right ||
2052
+ (typeof left === "string" && typeof right === "string" && sameResolvedPath(left, right, platform))
2053
+ );
2054
+ }
2055
+
2056
+ function parseRuntimeStateRescopeJournal(raw: string): CoordinatorRuntimeStateRescopeJournal {
2057
+ let value: unknown;
2058
+ try {
2059
+ value = JSON.parse(raw);
2060
+ } catch {
2061
+ throw new PreviousRuntimeStateReadError();
2062
+ }
2063
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new PreviousRuntimeStateReadError();
2064
+ const journal = value as Record<string, unknown>;
2065
+ if (
2066
+ !hasExactOwnKeys(journal, [
2067
+ "schema_version",
2068
+ "move_id",
2069
+ "session_id",
2070
+ "previous_cwd",
2071
+ "new_cwd",
2072
+ "previous_session_file",
2073
+ "new_session_file",
2074
+ "old_state_file",
2075
+ "new_state_file",
2076
+ "state_file_mode",
2077
+ "source_sha256",
2078
+ "phase",
2079
+ "key_id",
2080
+ "signature",
2081
+ "created_at",
2082
+ ]) ||
2083
+ journal.schema_version !== 1 ||
2084
+ typeof journal.move_id !== "string" ||
2085
+ !RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN.test(journal.move_id) ||
2086
+ typeof journal.session_id !== "string" ||
2087
+ typeof journal.previous_cwd !== "string" ||
2088
+ typeof journal.new_cwd !== "string" ||
2089
+ (journal.previous_session_file !== null && typeof journal.previous_session_file !== "string") ||
2090
+ (journal.new_session_file !== null && typeof journal.new_session_file !== "string") ||
2091
+ typeof journal.old_state_file !== "string" ||
2092
+ typeof journal.new_state_file !== "string" ||
2093
+ (journal.state_file_mode !== "pinned" && journal.state_file_mode !== "derived") ||
2094
+ (journal.source_sha256 !== null &&
2095
+ (typeof journal.source_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(journal.source_sha256))) ||
2096
+ (journal.phase !== "prepared" && journal.phase !== "publishing") ||
2097
+ (journal.key_id !== null && typeof journal.key_id !== "string") ||
2098
+ (journal.signature !== null && typeof journal.signature !== "string") ||
2099
+ !isCanonicalIsoTimestamp(journal.created_at)
2100
+ )
2101
+ throw new PreviousRuntimeStateReadError();
2102
+ return journal as unknown as CoordinatorRuntimeStateRescopeJournal;
2103
+ }
2104
+
2105
+ function rescopeStateFiles(
2106
+ sessionId: string,
2107
+ previousCwd: string,
2108
+ newCwd: string,
2109
+ ): { oldStateFile: string; newStateFile: string; mode: "pinned" | "derived" } {
2110
+ const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
2111
+ if (explicit) {
2112
+ const stateFile = canonicalStateFilePath(explicit);
2113
+ return { oldStateFile: stateFile, newStateFile: stateFile, mode: "pinned" };
2114
+ }
2115
+ return {
2116
+ oldStateFile: canonicalStateFilePath(
2117
+ path.join(sessionRuntimeDir(fsSync.realpathSync(previousCwd), sessionId), "runtime-state.json"),
2118
+ ),
2119
+ newStateFile: canonicalStateFilePath(
2120
+ path.join(sessionRuntimeDir(fsSync.realpathSync(newCwd), sessionId), "runtime-state.json"),
2121
+ ),
2122
+ mode: "derived",
2123
+ };
2124
+ }
2125
+
2126
+ function journalSignaturePayload(journal: CoordinatorRuntimeStateRescopeJournal): Record<string, unknown> {
2127
+ const { signature: _signature, ...unsigned } = journal;
2128
+ return unsigned;
2129
+ }
2130
+
2131
+ function verifyRuntimeStateRescopeJournal(journal: CoordinatorRuntimeStateRescopeJournal): void {
2132
+ if (journal.key_id === null) {
2133
+ if (journal.signature !== null) throw new PreviousRuntimeStateReadError();
2134
+ return;
2135
+ }
2136
+ if (
2137
+ journal.signature === null ||
2138
+ !coordinatorSidecarSigningKey ||
2139
+ !verify(
2140
+ null,
2141
+ Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
2142
+ createPublicKey(coordinatorSidecarSigningKey),
2143
+ Buffer.from(journal.signature, "base64"),
2144
+ )
2145
+ )
2146
+ throw new PreviousRuntimeStateReadError();
2147
+ }
2148
+
2149
+ function assertNoRuntimeStateRescopeJournal(context: RuntimeStateContext, identity: RuntimeStateIdentity): void {
2150
+ let canonicalCwd: string;
2151
+ try {
2152
+ canonicalCwd = fsSync.realpathSync(context.cwd);
2153
+ } catch (error) {
2154
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
2155
+ throw error;
2156
+ }
2157
+ const journalFile = runtimeStateRescopeJournalPath(canonicalCwd, context.sessionId);
2158
+ const observed = readRegularFileForRelocation(journalFile);
2159
+ if (!observed) return;
2160
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2161
+ verifyRuntimeStateRescopeJournal(journal);
2162
+ if (journal.session_id !== context.sessionId || journal.key_id !== identity.sidecarKeyId)
2163
+ throw new PreviousRuntimeStateReadError();
2164
+ if (
2165
+ !sameResolvedPath(journal.previous_cwd, identity.cwd, identity.platform) &&
2166
+ !sameResolvedPath(journal.new_cwd, identity.cwd, identity.platform)
2167
+ )
2168
+ throw new PreviousRuntimeStateReadError();
2169
+ throw new PreviousRuntimeStateReadError();
2170
+ }
2171
+
2172
+ export async function prepareCoordinatorRuntimeStateRescope(input: {
2173
+ moveId?: string;
2174
+ sessionId: string;
2175
+ previousCwd: string;
2176
+ newCwd: string;
2177
+ previousCwdIdentity?: { dev: bigint; ino: bigint };
2178
+ newCwdIdentity?: { dev: bigint; ino: bigint };
2179
+ previousSessionFile?: string | null;
2180
+ newSessionFile?: string | null;
2181
+ }): Promise<string> {
2182
+ const previousStat = fsSync.lstatSync(input.previousCwd, { bigint: true });
2183
+ const newStat = fsSync.lstatSync(input.newCwd, { bigint: true });
2184
+ const previousCwdIdentity = input.previousCwdIdentity ?? { dev: previousStat.dev, ino: previousStat.ino };
2185
+ const newCwdIdentity = input.newCwdIdentity ?? { dev: newStat.dev, ino: newStat.ino };
2186
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2187
+ assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
2188
+ const previousCwd = fsSync.realpathSync(input.previousCwd);
2189
+ const newCwd = fsSync.realpathSync(input.newCwd);
2190
+ const stateFiles = rescopeStateFiles(input.sessionId, previousCwd, newCwd);
2191
+ const moveId = input.moveId ?? randomUUID();
2192
+ if (!RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN.test(moveId)) throw new PreviousRuntimeStateReadError();
2193
+ const identity = normalizedIdentity({
2194
+ sessionId: input.sessionId,
2195
+ cwd: newCwd,
2196
+ sessionFile: input.newSessionFile ?? null,
2197
+ });
2198
+ const previousJournalFile = confinedJournalPath(previousCwd, input.sessionId, identity.platform);
2199
+ const newJournalFile = confinedJournalPath(newCwd, input.sessionId, identity.platform);
2200
+ const oldIdentity = previousRescopeIdentity(
2201
+ {
2202
+ sessionId: input.sessionId,
2203
+ cwd: newCwd,
2204
+ sessionFile: input.newSessionFile ?? null,
2205
+ previousSessionFile: input.previousSessionFile ?? null,
2206
+ },
2207
+ previousCwd,
2208
+ );
2209
+ return await withSerializedStateFiles([stateFiles.oldStateFile], async () =>
2210
+ withCoordinatorTransactionLocks([stateFiles.oldStateFile], async () =>
2211
+ withStateFileLocks([stateFiles.oldStateFile], async () => {
2212
+ const sourceObservation = readPreviousPayloadForRelocation(stateFiles.oldStateFile);
2213
+ if (Object.keys(sourceObservation.payload).length > 0)
2214
+ assertRelocationRuntimeStateIdentity(sourceObservation.payload, oldIdentity);
2215
+ const journal: CoordinatorRuntimeStateRescopeJournal = {
2216
+ schema_version: 1,
2217
+ move_id: moveId,
2218
+ session_id: input.sessionId,
2219
+ previous_cwd: previousCwd,
2220
+ new_cwd: newCwd,
2221
+ previous_session_file: input.previousSessionFile ? path.resolve(input.previousSessionFile) : null,
2222
+ new_session_file: input.newSessionFile ? path.resolve(input.newSessionFile) : null,
2223
+ old_state_file: stateFiles.oldStateFile,
2224
+ new_state_file: stateFiles.newStateFile,
2225
+ state_file_mode: stateFiles.mode,
2226
+ source_sha256: sourceObservation.identity?.sha256 ?? null,
2227
+ phase: "prepared",
2228
+ key_id: identity.sidecarKeyId,
2229
+ signature: null,
2230
+ created_at: new Date().toISOString(),
2231
+ };
2232
+ if (journal.key_id !== null) {
2233
+ if (!coordinatorSidecarSigningKey) throw new PreviousRuntimeStateReadError();
2234
+ journal.signature = sign(
2235
+ null,
2236
+ Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
2237
+ coordinatorSidecarSigningKey,
2238
+ ).toString("base64");
2239
+ }
2240
+ const writeJournal = async (journalFile: string): Promise<void> => {
2241
+ await writeCoordinatorAtomic(journalFile, `${JSON.stringify(journal)}\n`, {
2242
+ rename: async (source, destination) => {
2243
+ const published = renameNoReplacePath(source, destination);
2244
+ if (!published.ok) throw new PreviousRuntimeStateReadError();
2245
+ },
2246
+ });
2247
+ };
2248
+ await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(previousCwd);
2249
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2250
+ await writeJournal(previousJournalFile);
2251
+ if (confinedJournalPath(previousCwd, input.sessionId, identity.platform) !== previousJournalFile)
2252
+ throw new PreviousRuntimeStateReadError();
2253
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2254
+ await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(newCwd);
2255
+ assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
2256
+ await writeJournal(newJournalFile);
2257
+ if (confinedJournalPath(newCwd, input.sessionId, identity.platform) !== newJournalFile)
2258
+ throw new PreviousRuntimeStateReadError();
2259
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2260
+ assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
2261
+ return journal.move_id;
2262
+ }),
2263
+ ),
2264
+ );
2265
+ }
2266
+
2267
+ export async function markCoordinatorRuntimeStateRescopePublishing(
2268
+ context: RuntimeStateContext,
2269
+ previousCwd: string,
2270
+ moveId: string,
2271
+ ): Promise<void> {
2272
+ const identity = normalizedIdentity(context);
2273
+ const journalFiles = orderedDistinctStateFiles(
2274
+ [previousCwd, context.cwd].map(cwd =>
2275
+ runtimeStateRescopeJournalPath(fsSync.realpathSync(cwd), context.sessionId),
2276
+ ),
2277
+ identity.platform,
2278
+ );
2279
+ for (const journalFile of journalFiles) {
2280
+ const observed = readRegularFileForRelocation(journalFile);
2281
+ if (!observed) throw new PreviousRuntimeStateReadError();
2282
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2283
+ verifyRuntimeStateRescopeJournal(journal);
2284
+ if (
2285
+ journal.move_id !== moveId ||
2286
+ journal.session_id !== context.sessionId ||
2287
+ journal.key_id !== identity.sidecarKeyId ||
2288
+ journal.phase !== "prepared"
2289
+ )
2290
+ throw new PreviousRuntimeStateReadError();
2291
+ journal.phase = "publishing";
2292
+ journal.signature = null;
2293
+ if (journal.key_id !== null) {
2294
+ if (!coordinatorSidecarSigningKey) throw new PreviousRuntimeStateReadError();
2295
+ journal.signature = sign(
2296
+ null,
2297
+ Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
2298
+ coordinatorSidecarSigningKey,
2299
+ ).toString("base64");
2300
+ }
2301
+ const contents = `${JSON.stringify(journal)}\n`;
2302
+ await writeCoordinatorAtomic(journalFile, contents, {
2303
+ rename: async (source, destination) => {
2304
+ const sourceStat = fsSync.lstatSync(source, { bigint: true });
2305
+ const sourceParent = fsSync.lstatSync(path.dirname(source), { bigint: true });
2306
+ const replaced = exactReplacePath(
2307
+ source,
2308
+ destination,
2309
+ {
2310
+ dev: sourceStat.dev,
2311
+ ino: sourceStat.ino,
2312
+ nlink: sourceStat.nlink,
2313
+ parentDev: sourceParent.dev,
2314
+ parentIno: sourceParent.ino,
2315
+ size: sourceStat.size,
2316
+ mtimeNs: sourceStat.mtimeNs,
2317
+ sha256: createHash("sha256").update(contents).digest("hex"),
2318
+ },
2319
+ observed.identity,
2320
+ );
2321
+ if (!replaced.ok) throw new PreviousRuntimeStateReadError();
2322
+ },
2323
+ });
2324
+ }
2325
+ }
2326
+
2327
+ export async function clearCoordinatorRuntimeStateRescope(
2328
+ context: RuntimeStateContext,
2329
+ expectedMoveId?: string,
2330
+ previousCwd?: string,
2331
+ ): Promise<void> {
2332
+ const identity = normalizedIdentity(context);
2333
+ const candidateCwds = [context.cwd, previousCwd].filter((cwd): cwd is string => typeof cwd === "string");
2334
+ const journalFiles = orderedDistinctStateFiles(
2335
+ candidateCwds.map(cwd => runtimeStateRescopeJournalPath(fsSync.realpathSync(cwd), context.sessionId)),
2336
+ identity.platform,
2337
+ );
2338
+ for (const journalFile of journalFiles) {
2339
+ const observed = readRegularFileForRelocation(journalFile);
2340
+ if (!observed) continue;
2341
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2342
+ verifyRuntimeStateRescopeJournal(journal);
2343
+ if (expectedMoveId !== undefined && journal.move_id !== expectedMoveId) throw new PreviousRuntimeStateReadError();
2344
+ if (journal.session_id !== context.sessionId || journal.key_id !== identity.sidecarKeyId)
2345
+ throw new PreviousRuntimeStateReadError();
2346
+ const removed = exactUnlinkDirect(journalFile, observed.identity);
2347
+ if (!removed.ok && removed.code !== "not_found") throw new PreviousRuntimeStateReadError();
2348
+ await syncCoordinatorDirectory(path.dirname(journalFile));
2349
+ }
2350
+ }
2351
+
2352
+ /**
2353
+ * Cheap synchronous probe for a pending rescope-recovery journal at this cwd. Sessions
2354
+ * that never moved (the overwhelming majority) have none, so callers use this to avoid
2355
+ * arming a startup-turn barrier for a no-op recovery \u2014 an armed barrier is not free even
2356
+ * when it resolves immediately: it flips the session's startup gate from absent to present
2357
+ * and reorders barrier-gated post-turn work.
2358
+ */
2359
+ export function hasCoordinatorRuntimeStateRescopeJournal(context: RuntimeStateContext): boolean {
2360
+ try {
2361
+ return readRegularFileForRelocation(runtimeStateRescopeJournalPath(context.cwd, context.sessionId)) !== null;
2362
+ } catch {
2363
+ return false;
2364
+ }
2365
+ }
2366
+
2367
+ export async function recoverCoordinatorRuntimeStateRescope(context: RuntimeStateContext): Promise<void> {
2368
+ const journalFile = runtimeStateRescopeJournalPath(context.cwd, context.sessionId);
2369
+ const observed = readRegularFileForRelocation(journalFile);
2370
+ if (!observed) return;
2371
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2372
+ verifyRuntimeStateRescopeJournal(journal);
2373
+ const current = normalizedIdentity(context);
2374
+ const configuredStateFiles = rescopeStateFiles(journal.session_id, journal.previous_cwd, journal.new_cwd);
2375
+ if (
2376
+ journal.session_id !== context.sessionId ||
2377
+ journal.key_id !== current.sidecarKeyId ||
2378
+ journal.state_file_mode !== configuredStateFiles.mode ||
2379
+ !sameResolvedPath(journal.old_state_file, configuredStateFiles.oldStateFile, current.platform) ||
2380
+ !sameResolvedPath(journal.new_state_file, configuredStateFiles.newStateFile, current.platform)
2381
+ )
2382
+ throw new PreviousRuntimeStateReadError();
2383
+ if (
2384
+ sameResolvedPath(journal.previous_cwd, current.cwd, current.platform) &&
2385
+ sameOptionalResolvedPath(journal.previous_session_file, current.sessionFile, current.platform)
2386
+ ) {
2387
+ if (journal.phase !== "prepared") throw new PreviousRuntimeStateReadError();
2388
+ const source = readPreviousPayloadForRelocation(journal.old_state_file);
2389
+ if (Object.keys(source.payload).length === 0) {
2390
+ if (journal.source_sha256 !== null) throw new PreviousRuntimeStateReadError();
2391
+ } else {
2392
+ assertRelocationRuntimeStateIdentity(
2393
+ source.payload,
2394
+ previousRescopeIdentity(
2395
+ { ...context, previousSessionFile: journal.previous_session_file },
2396
+ journal.previous_cwd,
2397
+ ),
2398
+ );
2399
+ if (source.identity?.sha256 !== journal.source_sha256) throw new PreviousRuntimeStateReadError();
2400
+ }
2401
+ await clearCoordinatorRuntimeStateRescope(context, journal.move_id, journal.new_cwd);
2402
+ return;
2403
+ }
2404
+ if (
2405
+ !sameResolvedPath(journal.new_cwd, current.cwd, current.platform) ||
2406
+ !sameOptionalResolvedPath(journal.new_session_file, current.sessionFile, current.platform) ||
2407
+ journal.phase !== "publishing"
2408
+ )
2409
+ throw new PreviousRuntimeStateReadError();
2410
+ const completed = await relocateCoordinatorRuntimeStateForRescope(
2411
+ { ...context, previousSessionFile: journal.previous_session_file },
2412
+ journal.previous_cwd,
2413
+ );
2414
+ if (!completed) throw new PreviousRuntimeStateReadError();
2415
+ await clearCoordinatorRuntimeStateRescope(context, journal.move_id, journal.previous_cwd);
2416
+ }
2417
+
2418
+ function sameExactFileIdentity(left: NativeExactFileIdentity | null, right: NativeExactFileIdentity | null): boolean {
2419
+ return (
2420
+ left === right ||
2421
+ (left !== null &&
2422
+ right !== null &&
2423
+ left.dev === right.dev &&
2424
+ left.ino === right.ino &&
2425
+ left.nlink === right.nlink &&
2426
+ left.size === right.size &&
2427
+ left.mtimeNs === right.mtimeNs &&
2428
+ left.sha256 === right.sha256)
2429
+ );
2430
+ }
2431
+
2432
+ /**
2433
+ * Repairs the coordinator-shared runtime state after a committed cwd rescope, for every
2434
+ * move surface (`move_session`, TUI `/move`, SDK/ACP `session.cwd.move`) through the one
2435
+ * shared {@link SessionManager.moveTo} seam.
2436
+ *
2437
+ * The identity fence in {@link assertPreviousRuntimeStateIdentity} rejects any write whose
2438
+ * predecessor payload records a different cwd/workdir than the live session. A rescope
2439
+ * changes the session cwd but leaves the predecessor payload pointing at the launch root,
2440
+ * so every later persist for the session would be refused as a foreign marker.
2441
+ *
2442
+ * The predecessor is fully authenticated against its OLD identity before it is trusted:
2443
+ * signed mode verifies the predecessor's key id and Ed25519 signature; unsigned mode
2444
+ * requires an exact old cwd/workdir/session-file match (the same checks the fence applies).
2445
+ * Only a predecessor that passes is rewritten to the new identity and re-signed. When the
2446
+ * state file is cwd-derived rather than pinned by
2447
+ * {@link GJC_COORDINATOR_SESSION_STATE_FILE_ENV}, the payload is migrated to the new path
2448
+ * and the orphaned launch-root file is removed. A payload already self-healed at the new
2449
+ * cwd is left untouched.
2450
+ *
2451
+ * Both the old and new cwd-derived paths are locked (new first, then old) so a concurrent
2452
+ * writer at either path serializes behind this repair. Best-effort by contract: the move
2453
+ * is already durable, so a failure here is logged by the caller and never resurfaced as a
2454
+ * rescope rejection.
2455
+ */
2456
+ export async function relocateCoordinatorRuntimeStateForRescope(
2457
+ context: RuntimeStateContext,
2458
+ previousCwd: string,
2459
+ ): Promise<boolean> {
2460
+ const canonicalNewCwd = fsSync.realpathSync(context.cwd);
2461
+ const canonicalPreviousCwd = fsSync.realpathSync(previousCwd);
2462
+ context = { ...context, cwd: canonicalNewCwd };
2463
+ const rawNewStateFile = runtimeStateFileForContext(context);
2464
+ const newStateFile = rawNewStateFile ? canonicalStateFilePath(rawNewStateFile) : null;
2465
+ if (!newStateFile) return true;
2466
+ const identity = normalizedIdentity(context);
2467
+ const oldIdentity = previousRescopeIdentity(context, previousCwd);
2468
+ const explicitStateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
2469
+ // A pinned state file never moves with the cwd; a cwd-derived one lives under the
2470
+ // per-session runtime directory rooted at the (now former) cwd.
2471
+ const oldStateFile = explicitStateFile
2472
+ ? newStateFile
2473
+ : canonicalStateFilePath(
2474
+ path.join(sessionRuntimeDir(canonicalPreviousCwd, identity.sessionId), "runtime-state.json"),
2475
+ );
2476
+ const samePath = sameResolvedPath(oldStateFile, newStateFile, identity.platform);
2477
+ let expectedSourceSha256: string | null | undefined;
2478
+ const journalObservation = readRegularFileForRelocation(
2479
+ runtimeStateRescopeJournalPath(canonicalNewCwd, context.sessionId),
2480
+ );
2481
+ if (journalObservation) {
2482
+ const journal = parseRuntimeStateRescopeJournal(journalObservation.raw);
2483
+ verifyRuntimeStateRescopeJournal(journal);
2484
+ if (
2485
+ journal.session_id !== context.sessionId ||
2486
+ journal.key_id !== identity.sidecarKeyId ||
2487
+ !sameResolvedPath(journal.previous_cwd, canonicalPreviousCwd, identity.platform) ||
2488
+ !sameResolvedPath(journal.new_cwd, canonicalNewCwd, identity.platform) ||
2489
+ !sameResolvedPath(journal.old_state_file, oldStateFile, identity.platform) ||
2490
+ !sameResolvedPath(journal.new_state_file, newStateFile, identity.platform)
2491
+ )
2492
+ throw new PreviousRuntimeStateReadError();
2493
+ expectedSourceSha256 = journal.source_sha256;
2494
+ }
2495
+ const stateFiles = orderedDistinctStateFiles([oldStateFile, newStateFile], identity.platform);
2496
+ return await withSerializedStateFiles(
2497
+ stateFiles,
2498
+ async () =>
2499
+ await withCoordinatorTransactionLocks(
2500
+ stateFiles,
2501
+ async () =>
2502
+ await withStateFileLocks(stateFiles, async () => {
2503
+ await __sessionStateSidecarTestHooks.afterRescopeLocksAcquired?.();
2504
+ const destinationObservation = readPreviousPayloadForRelocation(newStateFile);
2505
+ const destination = destinationObservation.payload;
2506
+ if (samePath && Object.keys(destination).length > 0) {
2507
+ try {
2508
+ assertRelocationRuntimeStateIdentity(destination, identity);
2509
+ return true;
2510
+ } catch {
2511
+ // A pinned predecessor still carries the old identity and is migrated below.
2512
+ }
2513
+ }
2514
+ let destinationIsCurrent = false;
2515
+ if (!samePath && Object.keys(destination).length > 0) {
2516
+ try {
2517
+ assertRelocationRuntimeStateIdentity(destination, identity);
2518
+ } catch {
2519
+ return false;
2520
+ }
2521
+ destinationIsCurrent = true;
2522
+ }
2523
+ const sourceObservation = samePath
2524
+ ? destinationObservation
2525
+ : readPreviousPayloadForRelocation(oldStateFile);
2526
+ const source = sourceObservation.payload;
2527
+ if (Object.keys(source).length === 0) {
2528
+ return expectedSourceSha256 === undefined || expectedSourceSha256 === null || destinationIsCurrent;
2529
+ }
2530
+ if (
2531
+ expectedSourceSha256 !== undefined &&
2532
+ (expectedSourceSha256 === null || sourceObservation.identity?.sha256 !== expectedSourceSha256)
2533
+ )
2534
+ return false;
2535
+ // Trust the predecessor only if it authenticates against its OLD identity. A payload
2536
+ // whose signature, key, or old cwd/session-file does not match is left untouched; the
2537
+ // fence, not this repair, remains the authority for a foreign marker.
2538
+ try {
2539
+ assertRelocationRuntimeStateIdentity(source, oldIdentity);
2540
+ } catch {
2541
+ return false;
2542
+ }
2543
+ const publishSource = !destinationIsCurrent;
2544
+ if (publishSource) {
2545
+ const migrated = {
2546
+ ...source,
2547
+ cwd: identity.cwd,
2548
+ workdir: identity.workdir,
2549
+ session_file: identity.sessionFile,
2550
+ branch: branchForContext(context),
2551
+ source: "session_rescope",
2552
+ event: "move_session",
2553
+ updated_at: new Date().toISOString(),
2554
+ };
2555
+ await __sessionStateSidecarTestHooks.beforeRescopePublish?.();
2556
+ await writeStateFileSyncConditional(
2557
+ newStateFile,
2558
+ migrated,
2559
+ identity.sidecarKeyId,
2560
+ destinationObservation.identity,
2561
+ );
2562
+ }
2563
+ if (samePath) return true;
2564
+ // Remove the orphan while both path transactions remain held. Re-read and authenticate
2565
+ // so an unexpected non-cooperating replacement is never removed as this session's state.
2566
+ const orphanObservation = readPreviousPayloadForRelocation(oldStateFile);
2567
+ const orphan = orphanObservation.payload;
2568
+ if (Object.keys(orphan).length === 0) return true;
2569
+ try {
2570
+ assertRelocationRuntimeStateIdentity(orphan, oldIdentity);
2571
+ } catch {
2572
+ return false;
2573
+ }
2574
+ if (!sameExactFileIdentity(sourceObservation.identity, orphanObservation.identity)) return false;
2575
+ if (!orphanObservation.identity) throw new PreviousRuntimeStateReadError();
2576
+ const removed = exactUnlinkDirect(oldStateFile, orphanObservation.identity);
2577
+ if (!removed.ok && removed.code !== "not_found") throw new PreviousRuntimeStateReadError();
2578
+ return true;
2579
+ }),
2580
+ ),
2581
+ );
2582
+ }
2583
+
1611
2584
  function ownerTerminalSignal(reason: postmortem.Reason): TerminalSignal {
1612
2585
  if (reason === postmortem.Reason.SIGTERM) return "SIGTERM";
1613
2586
  if (reason === postmortem.Reason.SIGINT) return "SIGINT";
@@ -1829,8 +2802,9 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
1829
2802
  stateFile,
1830
2803
  async () =>
1831
2804
  await withStateFileLock(stateFile, async () => {
2805
+ assertNoRuntimeStateRescopeJournal(context, identity);
1832
2806
  const previous = readPreviousPayload(stateFile);
1833
- assertPreviousRuntimeStateIdentity(previous, identity);
2807
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1834
2808
  if (shouldPreserveTerminalPayload(previous as RuntimeStateSidecarPayload, identity)) return;
1835
2809
  // The immutable owner verdict remains in its lifecycle artifact; never replace a
1836
2810
  // complete agent terminal payload merely to mirror that verdict here.
@@ -1900,16 +2874,21 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
1900
2874
  });
1901
2875
  }
1902
2876
 
1903
- export function registerCoordinatorRuntimeStateFinalizer(context: RuntimeStateContext): () => void {
2877
+ export function registerCoordinatorRuntimeStateFinalizer(
2878
+ context: RuntimeStateContext,
2879
+ resolveContext?: () => RuntimeStateContext,
2880
+ ): () => void {
1904
2881
  if (!runtimeStateFileForContext(context)) return () => {};
1905
- const ownerTerminal = ownerTerminalContextFromEnvironment();
1906
- const finalizerContext: RuntimeStateContext =
1907
- ownerTerminal === "invalid"
1908
- ? { ...context, ownerTerminalMetadataInvalid: true }
1909
- : ownerTerminal
1910
- ? { ...context, ownerTerminal }
1911
- : context;
1912
2882
  return postmortem.register("coordinator-runtime-state", async reason => {
2883
+ const liveContext = resolveContext?.() ?? context;
2884
+ const ownerTerminal = ownerTerminalContextFromEnvironment();
2885
+ const finalizerContext: RuntimeStateContext =
2886
+ ownerTerminal === "invalid"
2887
+ ? { ...liveContext, ownerTerminalMetadataInvalid: true }
2888
+ : ownerTerminal
2889
+ ? { ...liveContext, ownerTerminal }
2890
+ : liveContext;
2891
+ await recoverCoordinatorRuntimeStateRescope(finalizerContext);
1913
2892
  await persistCoordinatorRuntimeStateFromPostmortem(reason, finalizerContext);
1914
2893
  });
1915
2894
  }