@gajae-code/coding-agent 0.15.6 → 0.16.1

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 (270) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/root-flags.d.ts +14 -0
  6. package/dist/types/cli/update-cli.d.ts +14 -0
  7. package/dist/types/cli.d.ts +7 -0
  8. package/dist/types/commands/launch.d.ts +10 -0
  9. package/dist/types/commands/sdk.d.ts +2 -0
  10. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  11. package/dist/types/config/billing-path.d.ts +25 -0
  12. package/dist/types/config/config-schema-version.d.ts +13 -1
  13. package/dist/types/config/file-lock.d.ts +5 -0
  14. package/dist/types/config/model-registry.d.ts +22 -0
  15. package/dist/types/config/settings-schema.d.ts +27 -8
  16. package/dist/types/config/settings.d.ts +2 -0
  17. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  18. package/dist/types/crash/record-loader.d.ts +15 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  20. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  21. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  22. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  23. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  24. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  26. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  27. package/dist/types/main.d.ts +1 -0
  28. package/dist/types/master-mode/context.d.ts +21 -0
  29. package/dist/types/master-mode/first-request.d.ts +34 -0
  30. package/dist/types/master-mode/snapshot.d.ts +32 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  32. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  33. package/dist/types/modes/components/model-selector.d.ts +4 -0
  34. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  35. package/dist/types/modes/components/session-selector.d.ts +1 -1
  36. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  37. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  38. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  39. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  40. package/dist/types/modes/interactive-mode.d.ts +16 -2
  41. package/dist/types/modes/print-mode.d.ts +17 -0
  42. package/dist/types/modes/theme/theme.d.ts +2 -1
  43. package/dist/types/modes/types.d.ts +2 -0
  44. package/dist/types/modes/ui-language.d.ts +7 -1
  45. package/dist/types/sdk/broker/broker.d.ts +79 -2
  46. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  47. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  48. package/dist/types/sdk/broker/index.d.ts +3 -0
  49. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  50. package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
  51. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  52. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  53. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  54. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  55. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  56. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  57. package/dist/types/sdk/broker/state-version.d.ts +21 -1
  58. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  59. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  60. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  61. package/dist/types/sdk/bus/index.d.ts +9 -1
  62. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  63. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  64. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  65. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  67. package/dist/types/sdk/cli/index.d.ts +1 -0
  68. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  69. package/dist/types/sdk/cli/rows.d.ts +12 -5
  70. package/dist/types/sdk/cli/session-cli.d.ts +20 -3
  71. package/dist/types/sdk/client/client.d.ts +3 -2
  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/events.d.ts +1 -0
  77. package/dist/types/sdk/host/host.d.ts +10 -0
  78. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  79. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  80. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +70 -17
  82. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  83. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  84. package/dist/types/sdk/lifecycle-session.d.ts +3 -1
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  86. package/dist/types/sdk/receipt-state.d.ts +2 -0
  87. package/dist/types/sdk/router/session-router.d.ts +6 -0
  88. package/dist/types/sdk/session.d.ts +16 -0
  89. package/dist/types/sdk/startup-capability.d.ts +2 -1
  90. package/dist/types/sdk/turn-result.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +52 -7
  92. package/dist/types/session/credential-store-errors.d.ts +1 -0
  93. package/dist/types/session/fold-coordinator.d.ts +29 -2
  94. package/dist/types/session/session-manager.d.ts +79 -2
  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/tools/ask.d.ts +1 -0
  98. package/dist/types/tools/bash.d.ts +16 -0
  99. package/dist/types/tools/browser/launch.d.ts +10 -0
  100. package/dist/types/tools/browser/registry.d.ts +8 -0
  101. package/dist/types/tools/gh.d.ts +8 -0
  102. package/dist/types/tools/index.d.ts +10 -10
  103. package/dist/types/tools/job.d.ts +3 -1
  104. package/dist/types/utils/external-editor.d.ts +7 -0
  105. package/examples/extensions/README.md +1 -1
  106. package/examples/extensions/pirate.ts +7 -4
  107. package/package.json +7 -7
  108. package/scripts/generate-sdk-operation-inventory.ts +13 -1
  109. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  110. package/src/async/job-manager.ts +107 -9
  111. package/src/browser-backend.ts +29 -0
  112. package/src/cli/accounts-cli.ts +9 -5
  113. package/src/cli/args.ts +17 -0
  114. package/src/cli/fast-help.ts +1 -0
  115. package/src/cli/plugin-cli.ts +29 -0
  116. package/src/cli/root-flags.ts +5 -0
  117. package/src/cli/update-cli.ts +140 -23
  118. package/src/commands/launch.ts +86 -18
  119. package/src/commands/sdk.ts +135 -28
  120. package/src/config/autorouting-tier-map.ts +12 -0
  121. package/src/config/billing-path.ts +42 -0
  122. package/src/config/config-schema-version.ts +18 -1
  123. package/src/config/file-lock.ts +158 -83
  124. package/src/config/model-preset-registry.ts +178 -23
  125. package/src/config/model-profiles.ts +5 -5
  126. package/src/config/model-registry.ts +291 -42
  127. package/src/config/model-resolver.ts +5 -0
  128. package/src/config/settings-schema.ts +27 -8
  129. package/src/config/settings.ts +51 -15
  130. package/src/coordinator-mcp/projection-scan.ts +27 -6
  131. package/src/coordinator-mcp/question-state.ts +9 -11
  132. package/src/coordinator-mcp/server.ts +115 -21
  133. package/src/crash/index-store.ts +2 -0
  134. package/src/crash/record-loader.ts +10 -0
  135. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  136. package/src/extensibility/extensions/runner.ts +27 -0
  137. package/src/extensibility/extensions/types.ts +8 -3
  138. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  139. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  140. package/src/extensibility/skills.ts +22 -12
  141. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  142. package/src/gjc-runtime/launch-worktree.ts +285 -4
  143. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  144. package/src/gjc-runtime/session-state-lock.ts +213 -14
  145. package/src/gjc-runtime/session-state-sidecar.ts +938 -39
  146. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  147. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
  148. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  149. package/src/hooks/skill-state.ts +6 -0
  150. package/src/internal-urls/docs-index.generated.ts +15 -15
  151. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  152. package/src/main.ts +61 -3
  153. package/src/master-mode/context.ts +50 -0
  154. package/src/master-mode/first-request.ts +65 -0
  155. package/src/master-mode/snapshot.ts +205 -0
  156. package/src/modes/acp/acp-agent.ts +610 -161
  157. package/src/modes/components/footer.ts +4 -2
  158. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  159. package/src/modes/components/model-selector.ts +76 -8
  160. package/src/modes/components/oauth-selector.ts +6 -1
  161. package/src/modes/components/session-selector.ts +23 -15
  162. package/src/modes/components/sessions-dashboard.ts +8 -5
  163. package/src/modes/components/settings-selector.ts +1 -0
  164. package/src/modes/components/status-line/model-name.ts +53 -0
  165. package/src/modes/components/status-line/priority-row.ts +107 -0
  166. package/src/modes/components/status-line/segments.ts +23 -9
  167. package/src/modes/components/tool-status-header.ts +151 -15
  168. package/src/modes/controllers/command-controller.ts +1 -1
  169. package/src/modes/controllers/event-controller.ts +30 -4
  170. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  171. package/src/modes/controllers/input-controller.ts +30 -2
  172. package/src/modes/controllers/selector-controller.ts +200 -98
  173. package/src/modes/interactive-mode.ts +36 -12
  174. package/src/modes/print-mode.ts +64 -0
  175. package/src/modes/prompt-action-autocomplete.ts +30 -4
  176. package/src/modes/runtime-init.ts +12 -9
  177. package/src/modes/theme/theme.ts +5 -0
  178. package/src/modes/types.ts +2 -0
  179. package/src/modes/ui-language.ts +49 -2
  180. package/src/modes/utils/ui-helpers.ts +22 -3
  181. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  182. package/src/prompts/system/master-mode.md +28 -0
  183. package/src/prompts/tools/ask.md +1 -1
  184. package/src/prompts/tools/bash.md +1 -0
  185. package/src/prompts/tools/job.md +1 -1
  186. package/src/sdk/acp/adapter.ts +5 -0
  187. package/src/sdk/broker/broker.ts +1646 -27
  188. package/src/sdk/broker/endpoint-authority.ts +86 -0
  189. package/src/sdk/broker/ensure.ts +81 -3
  190. package/src/sdk/broker/index.ts +3 -0
  191. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  192. package/src/sdk/broker/lifecycle.ts +440 -75
  193. package/src/sdk/broker/master-capability.ts +232 -0
  194. package/src/sdk/broker/session-index.ts +121 -17
  195. package/src/sdk/broker/session-scope.ts +272 -0
  196. package/src/sdk/broker/spawn-authority.ts +1031 -0
  197. package/src/sdk/broker/spawn-substrate.ts +591 -0
  198. package/src/sdk/broker/startup-budget.ts +144 -2
  199. package/src/sdk/broker/state-version.ts +43 -14
  200. package/src/sdk/broker/transport.ts +1 -0
  201. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  202. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  203. package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
  204. package/src/sdk/bus/discord-daemon.ts +50 -10
  205. package/src/sdk/bus/index.ts +84 -7
  206. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  207. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  208. package/src/sdk/bus/notification-service.ts +22 -5
  209. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  210. package/src/sdk/bus/reconciliation-store.ts +55 -31
  211. package/src/sdk/bus/slack-conversation.ts +1 -1
  212. package/src/sdk/bus/slack-daemon.ts +72 -38
  213. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  214. package/src/sdk/cli/index.ts +1 -0
  215. package/src/sdk/cli/master-cli.ts +259 -0
  216. package/src/sdk/cli/rows.ts +41 -15
  217. package/src/sdk/cli/session-cli.ts +297 -26
  218. package/src/sdk/client/client.ts +14 -8
  219. package/src/sdk/host/control/dispatch.ts +101 -40
  220. package/src/sdk/host/control/index.ts +2 -0
  221. package/src/sdk/host/control/operations.ts +2 -0
  222. package/src/sdk/host/control/runtime-gate.ts +203 -11
  223. package/src/sdk/host/events.ts +1 -0
  224. package/src/sdk/host/host.ts +29 -2
  225. package/src/sdk/host/query/cursor.ts +52 -7
  226. package/src/sdk/host/query/handlers.ts +33 -12
  227. package/src/sdk/host/query/revision-store.ts +12 -0
  228. package/src/sdk/host/session-runtime.test.ts +673 -14
  229. package/src/sdk/host/session-runtime.ts +674 -103
  230. package/src/sdk/host/surface-policy.ts +3 -3
  231. package/src/sdk/lifecycle/broker-client.ts +28 -0
  232. package/src/sdk/lifecycle/service.ts +279 -20
  233. package/src/sdk/lifecycle-session.ts +17 -4
  234. package/src/sdk/prompt-deadline-manager.ts +2 -1
  235. package/src/sdk/protocol/operation-inventory.generated.json +105 -3
  236. package/src/sdk/protocol/operation-registry.ts +14 -4
  237. package/src/sdk/receipt-state.ts +10 -0
  238. package/src/sdk/router/session-router.ts +219 -118
  239. package/src/sdk/session.ts +416 -195
  240. package/src/sdk/startup-capability.ts +1 -0
  241. package/src/sdk/turn-result.ts +22 -0
  242. package/src/session/agent-session.ts +1628 -318
  243. package/src/session/blob-store.ts +15 -5
  244. package/src/session/credential-store-errors.ts +2 -0
  245. package/src/session/fold-coordinator.ts +40 -3
  246. package/src/session/internal/managed-session-storage.ts +153 -78
  247. package/src/session/session-manager.ts +850 -97
  248. package/src/session/yield-queue.ts +24 -10
  249. package/src/setup/frictionless-onboarding.ts +2 -0
  250. package/src/slash-commands/builtin-registry.ts +41 -0
  251. package/src/task/executor.ts +9 -4
  252. package/src/telemetry/events.ts +1138 -12
  253. package/src/tools/ask-contract.test.ts +64 -0
  254. package/src/tools/ask-contract.ts +36 -1
  255. package/src/tools/ask.ts +10 -8
  256. package/src/tools/bash-interactive.ts +11 -2
  257. package/src/tools/bash.ts +412 -75
  258. package/src/tools/browser/launch.ts +37 -0
  259. package/src/tools/browser/registry.ts +99 -9
  260. package/src/tools/cron.ts +6 -4
  261. package/src/tools/descriptors.test.ts +15 -0
  262. package/src/tools/descriptors.ts +5 -1
  263. package/src/tools/gh.ts +104 -48
  264. package/src/tools/github-cache.ts +2 -2
  265. package/src/tools/index.ts +10 -6
  266. package/src/tools/job.ts +4 -1
  267. package/src/tools/monitor.ts +12 -9
  268. package/src/tools/resolve.ts +30 -10
  269. package/src/tools/tool-catalog.generated.ts +2 -2
  270. package/src/utils/external-editor.ts +72 -1
@@ -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 {
@@ -992,6 +1012,65 @@ class PreviousRuntimeStateReadError extends Error {
992
1012
  }
993
1013
  }
994
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;
1035
+ }
1036
+ }
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
+
995
1074
  class RuntimeToolActivityRefusedError extends Error {
996
1075
  constructor(reason: string) {
997
1076
  super(`Refusing to overwrite the coordinator tool-activity snapshot: ${reason}.`);
@@ -1071,6 +1150,78 @@ function readPreviousPayload(stateFile: string): Record<string, unknown> {
1071
1150
  }
1072
1151
  }
1073
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
+
1074
1225
  /**
1075
1226
  * Reads the authoritative bytes on disk, every time, inside the state-file lock.
1076
1227
  *
@@ -1132,7 +1283,11 @@ function shouldPreserveTerminalPayload(previous: RuntimeStateSidecarPayload, inp
1132
1283
  );
1133
1284
  }
1134
1285
 
1135
- 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 {
1136
1291
  if (Object.keys(previous).length === 0) return;
1137
1292
  if (previous.session_id !== input.sessionId) throw new PreviousRuntimeStateReadError();
1138
1293
  // Signed coordinator mode never trusts an unsigned predecessor. The sole exception
@@ -1169,20 +1324,47 @@ function assertPreviousRuntimeStateIdentity(previous: Record<string, unknown>, i
1169
1324
  if (coordinatorSeed) return;
1170
1325
  // If the previous payload has runtime identity fields, verify them fully.
1171
1326
  if (typeof previous.cwd === "string" && typeof previous.workdir === "string") {
1172
- if (
1173
- !sameResolvedPath(previous.cwd, input.cwd, input.platform) ||
1174
- !sameResolvedPath(previous.workdir, input.cwd, input.platform) ||
1175
- (previous.session_file !== input.sessionFile &&
1176
- !(
1177
- 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" &&
1178
1332
  typeof input.sessionFile === "string" &&
1179
- sameResolvedPath(previous.session_file, input.sessionFile, input.platform)
1180
- ))
1181
- )
1182
- 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
+ }
1183
1347
  }
1184
1348
  }
1185
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
+
1186
1368
  function runtimeStateFileForContext(context: RuntimeStateContext): string | null {
1187
1369
  const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
1188
1370
  if (explicit) return explicit;
@@ -1402,6 +1584,44 @@ async function writeStateFileSync(
1402
1584
  await writeStateFile(stateFile, finalizeSidecarPayload(payload, keyId));
1403
1585
  }
1404
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
+
1405
1625
  /**
1406
1626
  * The state-file critical section, shared byte-for-byte with the Coordinator MCP writer.
1407
1627
  *
@@ -1412,12 +1632,9 @@ async function writeStateFileSync(
1412
1632
  * than faulting forever.
1413
1633
  */
1414
1634
  async function withStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
1415
- try {
1416
- return await withSessionStateFileLock(stateFile, operation);
1417
- } catch (error) {
1418
- if (error instanceof SessionStateLockUnavailableError) throw new PreviousRuntimeStateReadError(error);
1419
- throw error;
1420
- }
1635
+ // Preserve the lock's typed path/reason diagnostics. A transition timeout means the
1636
+ // state file was never entered, not that its existing JSON marker was unreadable.
1637
+ return await withSessionStateFileLock(stateFile, operation);
1421
1638
  }
1422
1639
 
1423
1640
  function coordinatorTransactionLockFile(stateFile: string): string {
@@ -1430,19 +1647,110 @@ function coordinatorTransactionLockFile(stateFile: string): string {
1430
1647
  * protocol.
1431
1648
  */
1432
1649
  async function withCoordinatorTransactionLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
1433
- try {
1434
- return await withFileLock(coordinatorTransactionLockFile(stateFile), operation, {
1435
- staleMs: 30_000,
1436
- retries: 12_000,
1437
- retryDelayMs: 5,
1438
- });
1439
- } catch (error) {
1440
- if (error instanceof Error && error.message.startsWith("Failed to acquire lock"))
1441
- throw new PreviousRuntimeStateReadError();
1442
- throw error;
1650
+ // Namespace-lock failures are contention or filesystem failures, not evidence that
1651
+ // runtime-state.json is malformed. Let the original diagnostic reach the caller.
1652
+ return await withFileLock(coordinatorTransactionLockFile(stateFile), operation, {
1653
+ staleMs: 30_000,
1654
+ retries: 12_000,
1655
+ retryDelayMs: 5,
1656
+ });
1657
+ }
1658
+
1659
+ function orderedDistinctStateFiles(stateFiles: readonly string[], platform: NodeJS.Platform): string[] {
1660
+ const byIdentity = new Map<string, string>();
1661
+ for (const stateFile of stateFiles) {
1662
+ const resolved = path.resolve(stateFile);
1663
+ byIdentity.set(normalizePathForComparison(resolved, platform), resolved);
1664
+ }
1665
+ return [...byIdentity.entries()]
1666
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1667
+ .map(([, stateFile]) => stateFile);
1668
+ }
1669
+
1670
+ function canonicalDirectoryWithExistingAncestor(directory: string): string {
1671
+ let current = path.resolve(directory);
1672
+ const missing: string[] = [];
1673
+ for (;;) {
1674
+ try {
1675
+ return path.join(fsSync.realpathSync(current), ...missing.reverse());
1676
+ } catch (error) {
1677
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new PreviousRuntimeStateReadError(error);
1678
+ const parent = path.dirname(current);
1679
+ if (parent === current) throw new PreviousRuntimeStateReadError(error);
1680
+ missing.push(path.basename(current));
1681
+ current = parent;
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ function canonicalStateFilePath(stateFile: string): string {
1687
+ return path.join(canonicalDirectoryWithExistingAncestor(path.dirname(stateFile)), path.basename(stateFile));
1688
+ }
1689
+
1690
+ function assertPinnedDirectoryIdentity(cwd: string, expected: { dev: bigint; ino: bigint }): void {
1691
+ const stat = fsSync.lstatSync(cwd, { bigint: true });
1692
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== expected.dev || stat.ino !== expected.ino)
1693
+ throw new PreviousRuntimeStateReadError();
1694
+ }
1695
+
1696
+ function assertNoSymlinkDirectoryComponents(root: string, directory: string): void {
1697
+ const relative = path.relative(root, directory);
1698
+ if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
1699
+ throw new PreviousRuntimeStateReadError();
1700
+ let current = root;
1701
+ for (const component of relative.split(path.sep)) {
1702
+ current = path.join(current, component);
1703
+ try {
1704
+ const stat = fsSync.lstatSync(current);
1705
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new PreviousRuntimeStateReadError();
1706
+ } catch (error) {
1707
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
1708
+ throw error;
1709
+ }
1443
1710
  }
1444
1711
  }
1445
1712
 
1713
+ function confinedJournalPath(cwd: string, sessionId: string, platform: NodeJS.Platform): string {
1714
+ const canonicalRoot = fsSync.realpathSync(cwd);
1715
+ const rawJournalFile = runtimeStateRescopeJournalPath(canonicalRoot, sessionId);
1716
+ assertNoSymlinkDirectoryComponents(canonicalRoot, path.dirname(rawJournalFile));
1717
+ const journalFile = canonicalStateFilePath(rawJournalFile);
1718
+ const pathImpl = platform === "win32" ? path.win32 : path;
1719
+ const relative = pathImpl.relative(canonicalRoot, journalFile);
1720
+ if (
1721
+ relative === "" ||
1722
+ relative === ".." ||
1723
+ relative.startsWith(`..${pathImpl.sep}`) ||
1724
+ pathImpl.isAbsolute(relative)
1725
+ )
1726
+ throw new PreviousRuntimeStateReadError();
1727
+ return journalFile;
1728
+ }
1729
+
1730
+ async function withSerializedStateFiles<T>(stateFiles: readonly string[], operation: () => Promise<T>): Promise<T> {
1731
+ const [stateFile, ...remaining] = stateFiles;
1732
+ if (!stateFile) return await operation();
1733
+ return await serializeStateFileWrite(stateFile, async () => await withSerializedStateFiles(remaining, operation));
1734
+ }
1735
+
1736
+ async function withCoordinatorTransactionLocks<T>(
1737
+ stateFiles: readonly string[],
1738
+ operation: () => Promise<T>,
1739
+ ): Promise<T> {
1740
+ const [stateFile, ...remaining] = stateFiles;
1741
+ if (!stateFile) return await operation();
1742
+ return await withCoordinatorTransactionLock(
1743
+ stateFile,
1744
+ async () => await withCoordinatorTransactionLocks(remaining, operation),
1745
+ );
1746
+ }
1747
+
1748
+ async function withStateFileLocks<T>(stateFiles: readonly string[], operation: () => Promise<T>): Promise<T> {
1749
+ const [stateFile, ...remaining] = stateFiles;
1750
+ if (!stateFile) return await operation();
1751
+ return await withStateFileLock(stateFile, async () => await withStateFileLocks(remaining, operation));
1752
+ }
1753
+
1446
1754
  async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
1447
1755
  await writeCoordinatorAtomic(stateFile, `${JSON.stringify(payload)}\n`);
1448
1756
  }
@@ -1477,9 +1785,10 @@ async function persistCoordinatorRuntimeToolActivity(
1477
1785
  stateFile,
1478
1786
  async () =>
1479
1787
  await withStateFileLock(stateFile, async () => {
1788
+ assertNoRuntimeStateRescopeJournal(context, identity);
1480
1789
  const previous = await readPreviousPayloadForEvent(stateFile);
1481
1790
  if (Object.keys(previous).length === 0) return;
1482
- assertPreviousRuntimeStateIdentity(previous, identity);
1791
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1483
1792
  // A tool event that lands after the session settled must never
1484
1793
  // resurrect it into a live-looking state.
1485
1794
  if (previous.state === "completed" || previous.state === "errored") return;
@@ -1532,6 +1841,8 @@ export async function persistCoordinatorRuntimeStateFromEvent(
1532
1841
  const stateFile = runtimeStateFileForContext(context);
1533
1842
  const state = stateForEvent(event);
1534
1843
  if (!stateFile) return;
1844
+ const beforePersistFromEvent = __sessionStateSidecarTestHooks.beforePersistFromEvent;
1845
+ if (beforePersistFromEvent) await beforePersistFromEvent(event.type, context.cwd);
1535
1846
  if (!state) {
1536
1847
  const activityPhase = toolActivityPhaseForEvent(event);
1537
1848
  if (!activityPhase) return;
@@ -1554,10 +1865,11 @@ export async function persistCoordinatorRuntimeStateFromEvent(
1554
1865
  stateFile,
1555
1866
  async () =>
1556
1867
  await withStateFileLock(stateFile, async () => {
1868
+ assertNoRuntimeStateRescopeJournal(context, identity);
1557
1869
  const nowMs = Date.now();
1558
1870
  const now = new Date(nowMs).toISOString();
1559
1871
  const previous = await readPreviousPayloadForEvent(stateFile);
1560
- assertPreviousRuntimeStateIdentity(previous, identity);
1872
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1561
1873
  const finalResponse = finalResponseForEvent(event);
1562
1874
  const terminalReceipt =
1563
1875
  state === "completed" || state === "errored"
@@ -1629,9 +1941,10 @@ export async function persistCoordinatorWorkerIntegrationOutcome(
1629
1941
  stateFile,
1630
1942
  async () =>
1631
1943
  await withStateFileLock(stateFile, async () => {
1944
+ assertNoRuntimeStateRescopeJournal(context, identity);
1632
1945
  const previous = await readPreviousPayloadForEvent(stateFile);
1633
1946
  if (Object.keys(previous).length === 0) return;
1634
- assertPreviousRuntimeStateIdentity(previous, identity);
1947
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1635
1948
  const now = new Date().toISOString();
1636
1949
  const terminalPersistenceFailed =
1637
1950
  outcome.kind === "terminal_persistence" && outcome.status !== "completed";
@@ -1681,6 +1994,586 @@ export async function persistCoordinatorWorkerIntegrationOutcome(
1681
1994
  );
1682
1995
  }
1683
1996
 
1997
+ /**
1998
+ * The old identity a rescued predecessor payload must authenticate against: the launch-root
1999
+ * cwd and the session file as they were BEFORE the move. Signed mode still requires the
2000
+ * predecessor's key id and signature; unsigned mode requires an exact cwd/workdir/session
2001
+ * file match. Reusing {@link normalizedIdentity} keeps the platform, key-id, and path
2002
+ * resolution rules byte-identical with the persist path's fence.
2003
+ */
2004
+ function previousRescopeIdentity(context: RuntimeStateContext, previousCwd: string): RuntimeStateIdentity {
2005
+ return normalizedIdentity({
2006
+ sessionId: context.sessionId,
2007
+ cwd: previousCwd,
2008
+ sessionFile: context.previousSessionFile ?? context.sessionFile ?? null,
2009
+ platform: context.platform,
2010
+ });
2011
+ }
2012
+
2013
+ interface CoordinatorRuntimeStateRescopeJournal {
2014
+ schema_version: 1;
2015
+ move_id: string;
2016
+ session_id: string;
2017
+ previous_cwd: string;
2018
+ new_cwd: string;
2019
+ previous_session_file: string | null;
2020
+ new_session_file: string | null;
2021
+ old_state_file: string;
2022
+ new_state_file: string;
2023
+ state_file_mode: "pinned" | "derived";
2024
+ source_sha256: string | null;
2025
+ phase: "prepared" | "publishing";
2026
+ key_id: string | null;
2027
+ signature: string | null;
2028
+ created_at: string;
2029
+ }
2030
+
2031
+ const RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN =
2032
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
2033
+
2034
+ function runtimeStateRescopeJournalPath(cwd: string, sessionId: string): string {
2035
+ return path.join(sessionRuntimeDir(cwd, sessionId), "runtime-state-rescope.json");
2036
+ }
2037
+
2038
+ function sameOptionalResolvedPath(
2039
+ left: string | null,
2040
+ right: string | null,
2041
+ platform: NodeJS.Platform = process.platform,
2042
+ ): boolean {
2043
+ return (
2044
+ left === right ||
2045
+ (typeof left === "string" && typeof right === "string" && sameResolvedPath(left, right, platform))
2046
+ );
2047
+ }
2048
+
2049
+ function parseRuntimeStateRescopeJournal(raw: string): CoordinatorRuntimeStateRescopeJournal {
2050
+ let value: unknown;
2051
+ try {
2052
+ value = JSON.parse(raw);
2053
+ } catch {
2054
+ throw new PreviousRuntimeStateReadError();
2055
+ }
2056
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new PreviousRuntimeStateReadError();
2057
+ const journal = value as Record<string, unknown>;
2058
+ if (
2059
+ !hasExactOwnKeys(journal, [
2060
+ "schema_version",
2061
+ "move_id",
2062
+ "session_id",
2063
+ "previous_cwd",
2064
+ "new_cwd",
2065
+ "previous_session_file",
2066
+ "new_session_file",
2067
+ "old_state_file",
2068
+ "new_state_file",
2069
+ "state_file_mode",
2070
+ "source_sha256",
2071
+ "phase",
2072
+ "key_id",
2073
+ "signature",
2074
+ "created_at",
2075
+ ]) ||
2076
+ journal.schema_version !== 1 ||
2077
+ typeof journal.move_id !== "string" ||
2078
+ !RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN.test(journal.move_id) ||
2079
+ typeof journal.session_id !== "string" ||
2080
+ typeof journal.previous_cwd !== "string" ||
2081
+ typeof journal.new_cwd !== "string" ||
2082
+ (journal.previous_session_file !== null && typeof journal.previous_session_file !== "string") ||
2083
+ (journal.new_session_file !== null && typeof journal.new_session_file !== "string") ||
2084
+ typeof journal.old_state_file !== "string" ||
2085
+ typeof journal.new_state_file !== "string" ||
2086
+ (journal.state_file_mode !== "pinned" && journal.state_file_mode !== "derived") ||
2087
+ (journal.source_sha256 !== null &&
2088
+ (typeof journal.source_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(journal.source_sha256))) ||
2089
+ (journal.phase !== "prepared" && journal.phase !== "publishing") ||
2090
+ (journal.key_id !== null && typeof journal.key_id !== "string") ||
2091
+ (journal.signature !== null && typeof journal.signature !== "string") ||
2092
+ !isCanonicalIsoTimestamp(journal.created_at)
2093
+ )
2094
+ throw new PreviousRuntimeStateReadError();
2095
+ return journal as unknown as CoordinatorRuntimeStateRescopeJournal;
2096
+ }
2097
+
2098
+ function rescopeStateFiles(
2099
+ sessionId: string,
2100
+ previousCwd: string,
2101
+ newCwd: string,
2102
+ ): { oldStateFile: string; newStateFile: string; mode: "pinned" | "derived" } {
2103
+ const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
2104
+ if (explicit) {
2105
+ const stateFile = canonicalStateFilePath(explicit);
2106
+ return { oldStateFile: stateFile, newStateFile: stateFile, mode: "pinned" };
2107
+ }
2108
+ return {
2109
+ oldStateFile: canonicalStateFilePath(
2110
+ path.join(sessionRuntimeDir(fsSync.realpathSync(previousCwd), sessionId), "runtime-state.json"),
2111
+ ),
2112
+ newStateFile: canonicalStateFilePath(
2113
+ path.join(sessionRuntimeDir(fsSync.realpathSync(newCwd), sessionId), "runtime-state.json"),
2114
+ ),
2115
+ mode: "derived",
2116
+ };
2117
+ }
2118
+
2119
+ function journalSignaturePayload(journal: CoordinatorRuntimeStateRescopeJournal): Record<string, unknown> {
2120
+ const { signature: _signature, ...unsigned } = journal;
2121
+ return unsigned;
2122
+ }
2123
+
2124
+ function verifyRuntimeStateRescopeJournal(journal: CoordinatorRuntimeStateRescopeJournal): void {
2125
+ if (journal.key_id === null) {
2126
+ if (journal.signature !== null) throw new PreviousRuntimeStateReadError();
2127
+ return;
2128
+ }
2129
+ if (
2130
+ journal.signature === null ||
2131
+ !coordinatorSidecarSigningKey ||
2132
+ !verify(
2133
+ null,
2134
+ Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
2135
+ createPublicKey(coordinatorSidecarSigningKey),
2136
+ Buffer.from(journal.signature, "base64"),
2137
+ )
2138
+ )
2139
+ throw new PreviousRuntimeStateReadError();
2140
+ }
2141
+
2142
+ function assertNoRuntimeStateRescopeJournal(context: RuntimeStateContext, identity: RuntimeStateIdentity): void {
2143
+ let canonicalCwd: string;
2144
+ try {
2145
+ canonicalCwd = fsSync.realpathSync(context.cwd);
2146
+ } catch (error) {
2147
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
2148
+ throw error;
2149
+ }
2150
+ const journalFile = runtimeStateRescopeJournalPath(canonicalCwd, context.sessionId);
2151
+ const observed = readRegularFileForRelocation(journalFile);
2152
+ if (!observed) return;
2153
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2154
+ verifyRuntimeStateRescopeJournal(journal);
2155
+ if (journal.session_id !== context.sessionId || journal.key_id !== identity.sidecarKeyId)
2156
+ throw new PreviousRuntimeStateReadError();
2157
+ if (
2158
+ !sameResolvedPath(journal.previous_cwd, identity.cwd, identity.platform) &&
2159
+ !sameResolvedPath(journal.new_cwd, identity.cwd, identity.platform)
2160
+ )
2161
+ throw new PreviousRuntimeStateReadError();
2162
+ throw new PreviousRuntimeStateReadError();
2163
+ }
2164
+
2165
+ export async function prepareCoordinatorRuntimeStateRescope(input: {
2166
+ moveId?: string;
2167
+ sessionId: string;
2168
+ previousCwd: string;
2169
+ newCwd: string;
2170
+ previousCwdIdentity?: { dev: bigint; ino: bigint };
2171
+ newCwdIdentity?: { dev: bigint; ino: bigint };
2172
+ previousSessionFile?: string | null;
2173
+ newSessionFile?: string | null;
2174
+ }): Promise<string> {
2175
+ const previousStat = fsSync.lstatSync(input.previousCwd, { bigint: true });
2176
+ const newStat = fsSync.lstatSync(input.newCwd, { bigint: true });
2177
+ const previousCwdIdentity = input.previousCwdIdentity ?? { dev: previousStat.dev, ino: previousStat.ino };
2178
+ const newCwdIdentity = input.newCwdIdentity ?? { dev: newStat.dev, ino: newStat.ino };
2179
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2180
+ assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
2181
+ const previousCwd = fsSync.realpathSync(input.previousCwd);
2182
+ const newCwd = fsSync.realpathSync(input.newCwd);
2183
+ const stateFiles = rescopeStateFiles(input.sessionId, previousCwd, newCwd);
2184
+ const moveId = input.moveId ?? randomUUID();
2185
+ if (!RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN.test(moveId)) throw new PreviousRuntimeStateReadError();
2186
+ const identity = normalizedIdentity({
2187
+ sessionId: input.sessionId,
2188
+ cwd: newCwd,
2189
+ sessionFile: input.newSessionFile ?? null,
2190
+ });
2191
+ const previousJournalFile = confinedJournalPath(previousCwd, input.sessionId, identity.platform);
2192
+ const newJournalFile = confinedJournalPath(newCwd, input.sessionId, identity.platform);
2193
+ const oldIdentity = previousRescopeIdentity(
2194
+ {
2195
+ sessionId: input.sessionId,
2196
+ cwd: newCwd,
2197
+ sessionFile: input.newSessionFile ?? null,
2198
+ previousSessionFile: input.previousSessionFile ?? null,
2199
+ },
2200
+ previousCwd,
2201
+ );
2202
+ return await withSerializedStateFiles([stateFiles.oldStateFile], async () =>
2203
+ withCoordinatorTransactionLocks([stateFiles.oldStateFile], async () =>
2204
+ withStateFileLocks([stateFiles.oldStateFile], async () => {
2205
+ const sourceObservation = readPreviousPayloadForRelocation(stateFiles.oldStateFile);
2206
+ if (Object.keys(sourceObservation.payload).length > 0)
2207
+ assertRelocationRuntimeStateIdentity(sourceObservation.payload, oldIdentity);
2208
+ const journal: CoordinatorRuntimeStateRescopeJournal = {
2209
+ schema_version: 1,
2210
+ move_id: moveId,
2211
+ session_id: input.sessionId,
2212
+ previous_cwd: previousCwd,
2213
+ new_cwd: newCwd,
2214
+ previous_session_file: input.previousSessionFile ? path.resolve(input.previousSessionFile) : null,
2215
+ new_session_file: input.newSessionFile ? path.resolve(input.newSessionFile) : null,
2216
+ old_state_file: stateFiles.oldStateFile,
2217
+ new_state_file: stateFiles.newStateFile,
2218
+ state_file_mode: stateFiles.mode,
2219
+ source_sha256: sourceObservation.identity?.sha256 ?? null,
2220
+ phase: "prepared",
2221
+ key_id: identity.sidecarKeyId,
2222
+ signature: null,
2223
+ created_at: new Date().toISOString(),
2224
+ };
2225
+ if (journal.key_id !== null) {
2226
+ if (!coordinatorSidecarSigningKey) throw new PreviousRuntimeStateReadError();
2227
+ journal.signature = sign(
2228
+ null,
2229
+ Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
2230
+ coordinatorSidecarSigningKey,
2231
+ ).toString("base64");
2232
+ }
2233
+ const writeJournal = async (journalFile: string): Promise<void> => {
2234
+ await writeCoordinatorAtomic(journalFile, `${JSON.stringify(journal)}\n`, {
2235
+ rename: async (source, destination) => {
2236
+ const published = renameNoReplacePath(source, destination);
2237
+ if (!published.ok) throw new PreviousRuntimeStateReadError();
2238
+ },
2239
+ });
2240
+ };
2241
+ await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(previousCwd);
2242
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2243
+ await writeJournal(previousJournalFile);
2244
+ if (confinedJournalPath(previousCwd, input.sessionId, identity.platform) !== previousJournalFile)
2245
+ throw new PreviousRuntimeStateReadError();
2246
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2247
+ await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(newCwd);
2248
+ assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
2249
+ await writeJournal(newJournalFile);
2250
+ if (confinedJournalPath(newCwd, input.sessionId, identity.platform) !== newJournalFile)
2251
+ throw new PreviousRuntimeStateReadError();
2252
+ assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
2253
+ assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
2254
+ return journal.move_id;
2255
+ }),
2256
+ ),
2257
+ );
2258
+ }
2259
+
2260
+ export async function markCoordinatorRuntimeStateRescopePublishing(
2261
+ context: RuntimeStateContext,
2262
+ previousCwd: string,
2263
+ moveId: string,
2264
+ ): Promise<void> {
2265
+ const identity = normalizedIdentity(context);
2266
+ const journalFiles = orderedDistinctStateFiles(
2267
+ [previousCwd, context.cwd].map(cwd =>
2268
+ runtimeStateRescopeJournalPath(fsSync.realpathSync(cwd), context.sessionId),
2269
+ ),
2270
+ identity.platform,
2271
+ );
2272
+ for (const journalFile of journalFiles) {
2273
+ const observed = readRegularFileForRelocation(journalFile);
2274
+ if (!observed) throw new PreviousRuntimeStateReadError();
2275
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2276
+ verifyRuntimeStateRescopeJournal(journal);
2277
+ if (
2278
+ journal.move_id !== moveId ||
2279
+ journal.session_id !== context.sessionId ||
2280
+ journal.key_id !== identity.sidecarKeyId ||
2281
+ journal.phase !== "prepared"
2282
+ )
2283
+ throw new PreviousRuntimeStateReadError();
2284
+ journal.phase = "publishing";
2285
+ journal.signature = null;
2286
+ if (journal.key_id !== null) {
2287
+ if (!coordinatorSidecarSigningKey) throw new PreviousRuntimeStateReadError();
2288
+ journal.signature = sign(
2289
+ null,
2290
+ Buffer.from(canonicalCoordinatorSidecarPayload(journalSignaturePayload(journal))),
2291
+ coordinatorSidecarSigningKey,
2292
+ ).toString("base64");
2293
+ }
2294
+ const contents = `${JSON.stringify(journal)}\n`;
2295
+ await writeCoordinatorAtomic(journalFile, contents, {
2296
+ rename: async (source, destination) => {
2297
+ const sourceStat = fsSync.lstatSync(source, { bigint: true });
2298
+ const sourceParent = fsSync.lstatSync(path.dirname(source), { bigint: true });
2299
+ const replaced = exactReplacePath(
2300
+ source,
2301
+ destination,
2302
+ {
2303
+ dev: sourceStat.dev,
2304
+ ino: sourceStat.ino,
2305
+ nlink: sourceStat.nlink,
2306
+ parentDev: sourceParent.dev,
2307
+ parentIno: sourceParent.ino,
2308
+ size: sourceStat.size,
2309
+ mtimeNs: sourceStat.mtimeNs,
2310
+ sha256: createHash("sha256").update(contents).digest("hex"),
2311
+ },
2312
+ observed.identity,
2313
+ );
2314
+ if (!replaced.ok) throw new PreviousRuntimeStateReadError();
2315
+ },
2316
+ });
2317
+ }
2318
+ }
2319
+
2320
+ export async function clearCoordinatorRuntimeStateRescope(
2321
+ context: RuntimeStateContext,
2322
+ expectedMoveId?: string,
2323
+ previousCwd?: string,
2324
+ ): Promise<void> {
2325
+ const identity = normalizedIdentity(context);
2326
+ const candidateCwds = [context.cwd, previousCwd].filter((cwd): cwd is string => typeof cwd === "string");
2327
+ const journalFiles = orderedDistinctStateFiles(
2328
+ candidateCwds.map(cwd => runtimeStateRescopeJournalPath(fsSync.realpathSync(cwd), context.sessionId)),
2329
+ identity.platform,
2330
+ );
2331
+ for (const journalFile of journalFiles) {
2332
+ const observed = readRegularFileForRelocation(journalFile);
2333
+ if (!observed) continue;
2334
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2335
+ verifyRuntimeStateRescopeJournal(journal);
2336
+ if (expectedMoveId !== undefined && journal.move_id !== expectedMoveId) throw new PreviousRuntimeStateReadError();
2337
+ if (journal.session_id !== context.sessionId || journal.key_id !== identity.sidecarKeyId)
2338
+ throw new PreviousRuntimeStateReadError();
2339
+ const removed = exactUnlinkDirect(journalFile, observed.identity);
2340
+ if (!removed.ok && removed.code !== "not_found") throw new PreviousRuntimeStateReadError();
2341
+ await syncCoordinatorDirectory(path.dirname(journalFile));
2342
+ }
2343
+ }
2344
+
2345
+ /**
2346
+ * Cheap synchronous probe for a pending rescope-recovery journal at this cwd. Sessions
2347
+ * that never moved (the overwhelming majority) have none, so callers use this to avoid
2348
+ * arming a startup-turn barrier for a no-op recovery \u2014 an armed barrier is not free even
2349
+ * when it resolves immediately: it flips the session's startup gate from absent to present
2350
+ * and reorders barrier-gated post-turn work.
2351
+ */
2352
+ export function hasCoordinatorRuntimeStateRescopeJournal(context: RuntimeStateContext): boolean {
2353
+ try {
2354
+ return readRegularFileForRelocation(runtimeStateRescopeJournalPath(context.cwd, context.sessionId)) !== null;
2355
+ } catch {
2356
+ return false;
2357
+ }
2358
+ }
2359
+
2360
+ export async function recoverCoordinatorRuntimeStateRescope(context: RuntimeStateContext): Promise<void> {
2361
+ const journalFile = runtimeStateRescopeJournalPath(context.cwd, context.sessionId);
2362
+ const observed = readRegularFileForRelocation(journalFile);
2363
+ if (!observed) return;
2364
+ const journal = parseRuntimeStateRescopeJournal(observed.raw);
2365
+ verifyRuntimeStateRescopeJournal(journal);
2366
+ const current = normalizedIdentity(context);
2367
+ const configuredStateFiles = rescopeStateFiles(journal.session_id, journal.previous_cwd, journal.new_cwd);
2368
+ if (
2369
+ journal.session_id !== context.sessionId ||
2370
+ journal.key_id !== current.sidecarKeyId ||
2371
+ journal.state_file_mode !== configuredStateFiles.mode ||
2372
+ !sameResolvedPath(journal.old_state_file, configuredStateFiles.oldStateFile, current.platform) ||
2373
+ !sameResolvedPath(journal.new_state_file, configuredStateFiles.newStateFile, current.platform)
2374
+ )
2375
+ throw new PreviousRuntimeStateReadError();
2376
+ if (
2377
+ sameResolvedPath(journal.previous_cwd, current.cwd, current.platform) &&
2378
+ sameOptionalResolvedPath(journal.previous_session_file, current.sessionFile, current.platform)
2379
+ ) {
2380
+ if (journal.phase !== "prepared") throw new PreviousRuntimeStateReadError();
2381
+ const source = readPreviousPayloadForRelocation(journal.old_state_file);
2382
+ if (Object.keys(source.payload).length === 0) {
2383
+ if (journal.source_sha256 !== null) throw new PreviousRuntimeStateReadError();
2384
+ } else {
2385
+ assertRelocationRuntimeStateIdentity(
2386
+ source.payload,
2387
+ previousRescopeIdentity(
2388
+ { ...context, previousSessionFile: journal.previous_session_file },
2389
+ journal.previous_cwd,
2390
+ ),
2391
+ );
2392
+ if (source.identity?.sha256 !== journal.source_sha256) throw new PreviousRuntimeStateReadError();
2393
+ }
2394
+ await clearCoordinatorRuntimeStateRescope(context, journal.move_id, journal.new_cwd);
2395
+ return;
2396
+ }
2397
+ if (
2398
+ !sameResolvedPath(journal.new_cwd, current.cwd, current.platform) ||
2399
+ !sameOptionalResolvedPath(journal.new_session_file, current.sessionFile, current.platform) ||
2400
+ journal.phase !== "publishing"
2401
+ )
2402
+ throw new PreviousRuntimeStateReadError();
2403
+ const completed = await relocateCoordinatorRuntimeStateForRescope(
2404
+ { ...context, previousSessionFile: journal.previous_session_file },
2405
+ journal.previous_cwd,
2406
+ );
2407
+ if (!completed) throw new PreviousRuntimeStateReadError();
2408
+ await clearCoordinatorRuntimeStateRescope(context, journal.move_id, journal.previous_cwd);
2409
+ }
2410
+
2411
+ function sameExactFileIdentity(left: NativeExactFileIdentity | null, right: NativeExactFileIdentity | null): boolean {
2412
+ return (
2413
+ left === right ||
2414
+ (left !== null &&
2415
+ right !== null &&
2416
+ left.dev === right.dev &&
2417
+ left.ino === right.ino &&
2418
+ left.nlink === right.nlink &&
2419
+ left.size === right.size &&
2420
+ left.mtimeNs === right.mtimeNs &&
2421
+ left.sha256 === right.sha256)
2422
+ );
2423
+ }
2424
+
2425
+ /**
2426
+ * Repairs the coordinator-shared runtime state after a committed cwd rescope, for every
2427
+ * move surface (`move_session`, TUI `/move`, SDK/ACP `session.cwd.move`) through the one
2428
+ * shared {@link SessionManager.moveTo} seam.
2429
+ *
2430
+ * The identity fence in {@link assertPreviousRuntimeStateIdentity} rejects any write whose
2431
+ * predecessor payload records a different cwd/workdir than the live session. A rescope
2432
+ * changes the session cwd but leaves the predecessor payload pointing at the launch root,
2433
+ * so every later persist for the session would be refused as a foreign marker.
2434
+ *
2435
+ * The predecessor is fully authenticated against its OLD identity before it is trusted:
2436
+ * signed mode verifies the predecessor's key id and Ed25519 signature; unsigned mode
2437
+ * requires an exact old cwd/workdir/session-file match (the same checks the fence applies).
2438
+ * Only a predecessor that passes is rewritten to the new identity and re-signed. When the
2439
+ * state file is cwd-derived rather than pinned by
2440
+ * {@link GJC_COORDINATOR_SESSION_STATE_FILE_ENV}, the payload is migrated to the new path
2441
+ * and the orphaned launch-root file is removed. A payload already self-healed at the new
2442
+ * cwd is left untouched.
2443
+ *
2444
+ * Both the old and new cwd-derived paths are locked (new first, then old) so a concurrent
2445
+ * writer at either path serializes behind this repair. Best-effort by contract: the move
2446
+ * is already durable, so a failure here is logged by the caller and never resurfaced as a
2447
+ * rescope rejection.
2448
+ */
2449
+ export async function relocateCoordinatorRuntimeStateForRescope(
2450
+ context: RuntimeStateContext,
2451
+ previousCwd: string,
2452
+ ): Promise<boolean> {
2453
+ const canonicalNewCwd = fsSync.realpathSync(context.cwd);
2454
+ const canonicalPreviousCwd = fsSync.realpathSync(previousCwd);
2455
+ context = { ...context, cwd: canonicalNewCwd };
2456
+ const rawNewStateFile = runtimeStateFileForContext(context);
2457
+ const newStateFile = rawNewStateFile ? canonicalStateFilePath(rawNewStateFile) : null;
2458
+ if (!newStateFile) return true;
2459
+ const identity = normalizedIdentity(context);
2460
+ const oldIdentity = previousRescopeIdentity(context, previousCwd);
2461
+ const explicitStateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
2462
+ // A pinned state file never moves with the cwd; a cwd-derived one lives under the
2463
+ // per-session runtime directory rooted at the (now former) cwd.
2464
+ const oldStateFile = explicitStateFile
2465
+ ? newStateFile
2466
+ : canonicalStateFilePath(
2467
+ path.join(sessionRuntimeDir(canonicalPreviousCwd, identity.sessionId), "runtime-state.json"),
2468
+ );
2469
+ const samePath = sameResolvedPath(oldStateFile, newStateFile, identity.platform);
2470
+ let expectedSourceSha256: string | null | undefined;
2471
+ const journalObservation = readRegularFileForRelocation(
2472
+ runtimeStateRescopeJournalPath(canonicalNewCwd, context.sessionId),
2473
+ );
2474
+ if (journalObservation) {
2475
+ const journal = parseRuntimeStateRescopeJournal(journalObservation.raw);
2476
+ verifyRuntimeStateRescopeJournal(journal);
2477
+ if (
2478
+ journal.session_id !== context.sessionId ||
2479
+ journal.key_id !== identity.sidecarKeyId ||
2480
+ !sameResolvedPath(journal.previous_cwd, canonicalPreviousCwd, identity.platform) ||
2481
+ !sameResolvedPath(journal.new_cwd, canonicalNewCwd, identity.platform) ||
2482
+ !sameResolvedPath(journal.old_state_file, oldStateFile, identity.platform) ||
2483
+ !sameResolvedPath(journal.new_state_file, newStateFile, identity.platform)
2484
+ )
2485
+ throw new PreviousRuntimeStateReadError();
2486
+ expectedSourceSha256 = journal.source_sha256;
2487
+ }
2488
+ const stateFiles = orderedDistinctStateFiles([oldStateFile, newStateFile], identity.platform);
2489
+ return await withSerializedStateFiles(
2490
+ stateFiles,
2491
+ async () =>
2492
+ await withCoordinatorTransactionLocks(
2493
+ stateFiles,
2494
+ async () =>
2495
+ await withStateFileLocks(stateFiles, async () => {
2496
+ await __sessionStateSidecarTestHooks.afterRescopeLocksAcquired?.();
2497
+ const destinationObservation = readPreviousPayloadForRelocation(newStateFile);
2498
+ const destination = destinationObservation.payload;
2499
+ if (samePath && Object.keys(destination).length > 0) {
2500
+ try {
2501
+ assertRelocationRuntimeStateIdentity(destination, identity);
2502
+ return true;
2503
+ } catch {
2504
+ // A pinned predecessor still carries the old identity and is migrated below.
2505
+ }
2506
+ }
2507
+ let destinationIsCurrent = false;
2508
+ if (!samePath && Object.keys(destination).length > 0) {
2509
+ try {
2510
+ assertRelocationRuntimeStateIdentity(destination, identity);
2511
+ } catch {
2512
+ return false;
2513
+ }
2514
+ destinationIsCurrent = true;
2515
+ }
2516
+ const sourceObservation = samePath
2517
+ ? destinationObservation
2518
+ : readPreviousPayloadForRelocation(oldStateFile);
2519
+ const source = sourceObservation.payload;
2520
+ if (Object.keys(source).length === 0) {
2521
+ return expectedSourceSha256 === undefined || expectedSourceSha256 === null || destinationIsCurrent;
2522
+ }
2523
+ if (
2524
+ expectedSourceSha256 !== undefined &&
2525
+ (expectedSourceSha256 === null || sourceObservation.identity?.sha256 !== expectedSourceSha256)
2526
+ )
2527
+ return false;
2528
+ // Trust the predecessor only if it authenticates against its OLD identity. A payload
2529
+ // whose signature, key, or old cwd/session-file does not match is left untouched; the
2530
+ // fence, not this repair, remains the authority for a foreign marker.
2531
+ try {
2532
+ assertRelocationRuntimeStateIdentity(source, oldIdentity);
2533
+ } catch {
2534
+ return false;
2535
+ }
2536
+ const publishSource = !destinationIsCurrent;
2537
+ if (publishSource) {
2538
+ const migrated = {
2539
+ ...source,
2540
+ cwd: identity.cwd,
2541
+ workdir: identity.workdir,
2542
+ session_file: identity.sessionFile,
2543
+ branch: branchForContext(context),
2544
+ source: "session_rescope",
2545
+ event: "move_session",
2546
+ updated_at: new Date().toISOString(),
2547
+ };
2548
+ await __sessionStateSidecarTestHooks.beforeRescopePublish?.();
2549
+ await writeStateFileSyncConditional(
2550
+ newStateFile,
2551
+ migrated,
2552
+ identity.sidecarKeyId,
2553
+ destinationObservation.identity,
2554
+ );
2555
+ }
2556
+ if (samePath) return true;
2557
+ // Remove the orphan while both path transactions remain held. Re-read and authenticate
2558
+ // so an unexpected non-cooperating replacement is never removed as this session's state.
2559
+ const orphanObservation = readPreviousPayloadForRelocation(oldStateFile);
2560
+ const orphan = orphanObservation.payload;
2561
+ if (Object.keys(orphan).length === 0) return true;
2562
+ try {
2563
+ assertRelocationRuntimeStateIdentity(orphan, oldIdentity);
2564
+ } catch {
2565
+ return false;
2566
+ }
2567
+ if (!sameExactFileIdentity(sourceObservation.identity, orphanObservation.identity)) return false;
2568
+ if (!orphanObservation.identity) throw new PreviousRuntimeStateReadError();
2569
+ const removed = exactUnlinkDirect(oldStateFile, orphanObservation.identity);
2570
+ if (!removed.ok && removed.code !== "not_found") throw new PreviousRuntimeStateReadError();
2571
+ return true;
2572
+ }),
2573
+ ),
2574
+ );
2575
+ }
2576
+
1684
2577
  function ownerTerminalSignal(reason: postmortem.Reason): TerminalSignal {
1685
2578
  if (reason === postmortem.Reason.SIGTERM) return "SIGTERM";
1686
2579
  if (reason === postmortem.Reason.SIGINT) return "SIGINT";
@@ -1902,8 +2795,9 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
1902
2795
  stateFile,
1903
2796
  async () =>
1904
2797
  await withStateFileLock(stateFile, async () => {
2798
+ assertNoRuntimeStateRescopeJournal(context, identity);
1905
2799
  const previous = readPreviousPayload(stateFile);
1906
- assertPreviousRuntimeStateIdentity(previous, identity);
2800
+ assertPreviousRuntimeStateIdentity(previous, identity, stateFile);
1907
2801
  if (shouldPreserveTerminalPayload(previous as RuntimeStateSidecarPayload, identity)) return;
1908
2802
  // The immutable owner verdict remains in its lifecycle artifact; never replace a
1909
2803
  // complete agent terminal payload merely to mirror that verdict here.
@@ -1973,16 +2867,21 @@ export async function persistCoordinatorRuntimeStateFromPostmortem(
1973
2867
  });
1974
2868
  }
1975
2869
 
1976
- export function registerCoordinatorRuntimeStateFinalizer(context: RuntimeStateContext): () => void {
2870
+ export function registerCoordinatorRuntimeStateFinalizer(
2871
+ context: RuntimeStateContext,
2872
+ resolveContext?: () => RuntimeStateContext,
2873
+ ): () => void {
1977
2874
  if (!runtimeStateFileForContext(context)) return () => {};
1978
- const ownerTerminal = ownerTerminalContextFromEnvironment();
1979
- const finalizerContext: RuntimeStateContext =
1980
- ownerTerminal === "invalid"
1981
- ? { ...context, ownerTerminalMetadataInvalid: true }
1982
- : ownerTerminal
1983
- ? { ...context, ownerTerminal }
1984
- : context;
1985
2875
  return postmortem.register("coordinator-runtime-state", async reason => {
2876
+ const liveContext = resolveContext?.() ?? context;
2877
+ const ownerTerminal = ownerTerminalContextFromEnvironment();
2878
+ const finalizerContext: RuntimeStateContext =
2879
+ ownerTerminal === "invalid"
2880
+ ? { ...liveContext, ownerTerminalMetadataInvalid: true }
2881
+ : ownerTerminal
2882
+ ? { ...liveContext, ownerTerminal }
2883
+ : liveContext;
2884
+ await recoverCoordinatorRuntimeStateRescope(finalizerContext);
1986
2885
  await persistCoordinatorRuntimeStateFromPostmortem(reason, finalizerContext);
1987
2886
  });
1988
2887
  }