@gajae-code/coding-agent 0.16.7 → 0.17.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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -19,7 +19,7 @@ import {
19
19
  } from "../coordinator-mcp/durability";
20
20
  import { reduceTerminalReceiptState } from "../sdk/receipt-state";
21
21
  import { TOOL_CATALOG } from "../tools/tool-catalog.generated";
22
- import { sessionRoot, sessionRuntimeDir } from "./session-layout";
22
+ import { sessionRoot, sessionRuntimeDir, sessionRuntimeStatePath } from "./session-layout";
23
23
  import { SessionStateLockUnavailableError, withSessionStateFileLock } from "./session-state-lock";
24
24
  import {
25
25
  isValidOwnerIntent,
@@ -643,6 +643,17 @@ export interface RuntimeStateContext {
643
643
  sessionId: string;
644
644
  cwd: string;
645
645
  sessionFile?: string | null;
646
+ /**
647
+ * The marker file THIS context owns.
648
+ *
649
+ * `undefined` keeps the process-wide coordinator pin authoritative (falling back to the
650
+ * session-derived path when this process carries no pin), which is what a top-level
651
+ * session wants. Naming a path is how a session that is NOT the pinned one — an
652
+ * in-process role-agent/subagent session sharing the parent's environment — takes
653
+ * ownership of its own marker instead of reading and writing the parent's (#5473).
654
+ * `null` means this context has no marker at all and every write is skipped.
655
+ */
656
+ stateFile?: string | null;
646
657
  /** Optional platform seam for deterministic cross-platform path identity checks. */
647
658
  platform?: NodeJS.Platform;
648
659
  branch?: string | null;
@@ -835,11 +846,34 @@ function sameResolvedPath(left: string, right: string, platform: NodeJS.Platform
835
846
  return normalizePathForComparison(left, platform) === normalizePathForComparison(right, platform);
836
847
  }
837
848
 
849
+ /**
850
+ * The marker file the launching coordinator pinned for this process, if any.
851
+ *
852
+ * The pin is process-wide environment, but it describes exactly ONE session: the one the
853
+ * pin was minted for. Every other session that runs inside the same process — an
854
+ * in-process role-agent/subagent fan-out — must never read or write that file, or its
855
+ * writes are refused as a foreign identity and the parent's lifecycle gets clobbered.
856
+ */
857
+ function coordinatorPinnedStateFile(): string | null {
858
+ return process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim() || null;
859
+ }
860
+
861
+ /** True when `context` owns the process-wide coordinator pin rather than naming its own marker. */
862
+ function usesCoordinatorPin(context: Pick<RuntimeStateContext, "stateFile" | "platform">): boolean {
863
+ const pinned = coordinatorPinnedStateFile();
864
+ if (!pinned) return false;
865
+ if (context.stateFile === undefined) return true;
866
+ if (context.stateFile === null) return false;
867
+ if (context.stateFile === pinned) return true;
868
+ return sameResolvedPath(context.stateFile, pinned, context.platform ?? process.platform);
869
+ }
870
+
838
871
  function normalizedIdentity(
839
- context: Pick<RuntimeStateContext, "sessionId" | "cwd" | "sessionFile" | "platform">,
872
+ context: Pick<RuntimeStateContext, "sessionId" | "cwd" | "sessionFile" | "platform" | "stateFile">,
840
873
  ): RuntimeStateIdentity {
841
- const explicitStateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
842
- const sessionId = explicitStateFile
874
+ // A session that owns its own marker is identified by its OWN session id. Only the
875
+ // pinned session adopts the coordinator-correlation id the pin was launched under.
876
+ const sessionId = usesCoordinatorPin(context)
843
877
  ? process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || context.sessionId.trim()
844
878
  : context.sessionId.trim();
845
879
  const cwd = context.cwd.trim();
@@ -855,7 +889,14 @@ function normalizedIdentity(
855
889
  (sidecarSignatureRequired &&
856
890
  (!sidecarKeyId || !/^[a-f0-9]{64}$/.test(sidecarKeyId) || !coordinatorSidecarSigningKey))
857
891
  )
858
- throw new PreviousRuntimeStateReadError();
892
+ throw new PreviousRuntimeStateReadError(
893
+ undefined,
894
+ !sessionId
895
+ ? "the runtime session id is empty"
896
+ : !cwd
897
+ ? "the runtime cwd is empty"
898
+ : "this launch requires Coordinator sidecar signing but its signing key material is missing",
899
+ );
859
900
  return {
860
901
  sessionId,
861
902
  cwd: pathApi.resolve(cwd),
@@ -1028,9 +1069,14 @@ class ForeignRuntimeStateError extends Error {
1028
1069
  readonly currentCwd: string;
1029
1070
  constructor(previous: Record<string, unknown>, input: RuntimeStateIdentity) {
1030
1071
  const recorded = typeof previous.cwd === "string" ? previous.cwd : "<unknown>";
1072
+ // The message keeps naming both workspaces — that is what distinguishes a foreign marker
1073
+ // from file corruption — but it echoes the recorded path through the diagnostic sanitizer
1074
+ // so a marker cannot inject terminal control sequences into an operator's log. The exact
1075
+ // value stays available on the typed fields below.
1031
1076
  super(
1032
1077
  `Existing runtime state marker belongs to a different workspace; refusing to overwrite. ` +
1033
- `recorded cwd ${recorded}, current cwd ${input.cwd}. ` +
1078
+ `recorded cwd ${sanitizeDiagnosticText(recorded)}, current cwd ${sanitizeDiagnosticText(input.cwd)}; ` +
1079
+ `${previousPayloadDigestOf(previous)}. ` +
1034
1080
  `A live or in-flight marker is never adopted; delete it only if no session is running there.`,
1035
1081
  );
1036
1082
  this.name = "ForeignRuntimeStateError";
@@ -1087,82 +1133,217 @@ function isAbsentStateFileError(error: unknown): boolean {
1087
1133
  }
1088
1134
 
1089
1135
  function parsePreviousPayload(raw: string): Record<string, unknown> {
1090
- const payload: unknown = JSON.parse(raw);
1091
- if (!validPreviousRuntimeStateShape(payload)) throw new PreviousRuntimeStateReadError();
1092
- // Structural validation above bounds every interpolated value to a known state
1136
+ let payload: unknown;
1137
+ try {
1138
+ payload = JSON.parse(raw);
1139
+ } catch {
1140
+ throw new PreviousRuntimeStateReadError(
1141
+ undefined,
1142
+ `the marker is not valid JSON (${Buffer.byteLength(raw, "utf8")} bytes read); ${previousPayloadDigest(raw)}`,
1143
+ );
1144
+ }
1145
+ const violation = previousPayloadShapeViolation(payload);
1146
+ if (violation) throw new PreviousRuntimeStateReadError(undefined, `${violation}; ${previousPayloadDigest(raw)}`);
1147
+ const marker = payload as Record<string, unknown>;
1148
+ // Structural validation above bounds every interpolated lifecycle value to a known state
1093
1149
  // or boolean; never include arbitrary marker contents in a terminal diagnostic.
1094
- if (payload.ready_for_input !== undefined) {
1095
- const expectedReady = payload.state === "ready_for_input";
1096
- if (payload.ready_for_input !== expectedReady)
1150
+ // Before #4351, completed markers could retain the readiness bit. Normalize only
1151
+ // that legacy shape; all other lifecycle contradictions remain errors.
1152
+ // This mutation runs before the sidecar fence in {@link assertPreviousRuntimeStateIdentity},
1153
+ // which is safe only because signing postdates the pre-#4351 writer contract (#4351 landed
1154
+ // 2026-08-12, sidecar signing 2026-08-22): no signed payload can carry this shape, so the
1155
+ // fence never sees a normalized predecessor it should have rejected. A future signed
1156
+ // predecessor written under the old contract must be handled here explicitly rather than
1157
+ // escaping as a misleading signature mismatch.
1158
+ if (marker.state === "completed" && marker.ready_for_input === true) marker.ready_for_input = false;
1159
+ if (marker.ready_for_input !== undefined) {
1160
+ const expectedReady = marker.state === "ready_for_input";
1161
+ if (marker.ready_for_input !== expectedReady)
1097
1162
  throw new PreviousRuntimeStateReadError(
1098
1163
  undefined,
1099
- `ready_for_input must be ${expectedReady} when state is ${payload.state} (received ${payload.ready_for_input})`,
1164
+ `ready_for_input must be ${expectedReady} when state is ${marker.state} (received ${marker.ready_for_input}); ${previousPayloadDigest(raw)}`,
1100
1165
  );
1101
1166
  }
1102
- if (payload.live !== undefined && payload.live !== null) {
1103
- const expectedLive = payload.state === "running";
1104
- if (payload.live !== expectedLive)
1167
+ if (marker.live !== undefined && marker.live !== null) {
1168
+ const expectedLive = marker.state === "running";
1169
+ if (marker.live !== expectedLive)
1105
1170
  throw new PreviousRuntimeStateReadError(
1106
1171
  undefined,
1107
- `live must be ${expectedLive} when state is ${payload.state} (received ${payload.live})`,
1172
+ `live must be ${expectedLive} when state is ${marker.state} (received ${marker.live}); ${previousPayloadDigest(raw)}`,
1108
1173
  );
1109
1174
  }
1110
- return payload;
1175
+ return marker;
1111
1176
  }
1112
1177
 
1113
- function validPreviousRuntimeStateShape(value: unknown): value is Record<string, unknown> {
1114
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1178
+ /**
1179
+ * The lifecycle values the shared coordinator/session marker may carry.
1180
+ *
1181
+ * The file is written by BOTH the runtime sidecar and the coordinator projection writer, so
1182
+ * the reader must be total over the union of their vocabularies. `prepared` (a session that
1183
+ * is live and endpoint-addressable but withholds readiness) and `stale`/`unknown` are
1184
+ * coordinator-side lifecycles the sidecar never writes itself but must still read back (#5473).
1185
+ */
1186
+ const PREVIOUS_RUNTIME_STATE_VALUES = [
1187
+ "booting",
1188
+ "prepared",
1189
+ "ready_for_input",
1190
+ "running",
1191
+ "needs_user_input",
1192
+ "completed",
1193
+ "errored",
1194
+ "stale",
1195
+ "unknown",
1196
+ ] as const;
1197
+
1198
+ const PREVIOUS_RUNTIME_STATES = new Set<string>(PREVIOUS_RUNTIME_STATE_VALUES);
1199
+
1200
+ /** Only short, control-character-free identifiers may reach an operator-visible diagnostic. */
1201
+ const SAFE_DIAGNOSTIC_IDENTIFIER = /^[A-Za-z0-9_.:-]{1,64}$/;
1202
+
1203
+ /**
1204
+ * Describe a received marker field without echoing untrusted marker text.
1205
+ *
1206
+ * A corrupt or hostile marker must never be able to write terminal control sequences into
1207
+ * an operator's diagnostics, so only short identifier-shaped scalars are echoed verbatim;
1208
+ * every other value is reduced to its category (and, for strings, its length). The payload
1209
+ * digest reported alongside binds the description to the exact bytes that were read.
1210
+ */
1211
+ function describePreviousPayloadValue(value: unknown): string {
1212
+ if (value === undefined) return "absent";
1213
+ if (value === null) return "null";
1214
+ if (typeof value === "boolean") return value ? "true" : "false";
1215
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "a non-finite number";
1216
+ if (typeof value === "string")
1217
+ return SAFE_DIAGNOSTIC_IDENTIFIER.test(value) ? JSON.stringify(value) : `a string (${value.length} chars)`;
1218
+ return Array.isArray(value) ? `an array (${value.length} entries)` : "a non-array object";
1219
+ }
1220
+
1221
+ /** The digest that binds a refusal to the exact marker bytes that produced it. */
1222
+ function previousPayloadDigest(raw: string): string {
1223
+ return `payload sha256 ${createHash("sha256").update(raw).digest("hex")}`;
1224
+ }
1225
+
1226
+ /**
1227
+ * The digest a refusal can always compute, for paths that hold no raw bytes.
1228
+ *
1229
+ * Canonicalizing the parsed payload makes the digest a deterministic function of the marker's
1230
+ * VALUE as this process read it — key order is normalized, so a re-parse reproduces it. One
1231
+ * legacy shape is normalized before it can reach here (#4351 rewrites a completed marker's
1232
+ * stale `ready_for_input: true` to false), so the digest binds the payload that was actually
1233
+ * refused rather than the file's original bytes; where the raw bytes are still in hand the
1234
+ * caller uses {@link previousPayloadDigest} instead and gets the byte-exact form.
1235
+ */
1236
+ function previousPayloadDigestOf(previous: Record<string, unknown>): string {
1237
+ return `payload-content sha256 ${createHash("sha256")
1238
+ .update(canonicalCoordinatorSidecarPayload(previous))
1239
+ .digest("hex")}`;
1240
+ }
1241
+
1242
+ /**
1243
+ * Bound and de-fang operator-visible text taken from a marker.
1244
+ *
1245
+ * A corrupt or hostile marker must never be able to write terminal control sequences or
1246
+ * reorder an operator's line into a diagnostic, so C0/C1 controls and every Unicode `Cf`
1247
+ * (format) character — bidi overrides and isolates, zero-width and BOM marks, invisible
1248
+ * operators — are replaced before the value is bounded.
1249
+ */
1250
+ function sanitizeDiagnosticText(value: string): string {
1251
+ const sanitized = value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\p{Cf}]/gu, "?");
1252
+ return sanitized.length > 240 ? `${sanitized.slice(0, 240)}...` : sanitized;
1253
+ }
1254
+
1255
+ /** Quote and de-fang a path or identity for a diagnostic. */
1256
+ function quotedDiagnosticText(value: string): string {
1257
+ return JSON.stringify(sanitizeDiagnosticText(value));
1258
+ }
1259
+
1260
+ /** Identify the marker THIS writer owns, so a cross-session refusal explains itself. */
1261
+ function thisMarkerHint(input: RuntimeStateIdentity, stateFile: string | null): string {
1262
+ return (
1263
+ `this write targets session ${quotedDiagnosticText(input.sessionId)} at cwd ${quotedDiagnosticText(input.cwd)}` +
1264
+ (input.sessionFile === null ? "" : ` with session file ${quotedDiagnosticText(input.sessionFile)}`) +
1265
+ (stateFile === null ? "" : ` using marker ${quotedDiagnosticText(stateFile)}`)
1266
+ );
1267
+ }
1268
+
1269
+ /**
1270
+ * Why a previous-marker payload is unusable, or `null` when it is readable.
1271
+ *
1272
+ * Every rejection names the field that failed so the two-writer cases stay distinguishable:
1273
+ * a coordinator-only lifecycle value, a marker written by a different session, damaged JSON,
1274
+ * and a contradictory lifecycle field all used to collapse into one opaque message (#5473).
1275
+ */
1276
+ function previousPayloadShapeViolation(value: unknown): string | null {
1277
+ if (!value || typeof value !== "object" || Array.isArray(value))
1278
+ return `the marker payload must be a JSON object (received ${describePreviousPayloadValue(value)})`;
1115
1279
  const payload = value as Record<string, unknown>;
1116
- if (
1117
- payload.schema_version !== 1 ||
1118
- typeof payload.session_id !== "string" ||
1119
- payload.session_id.trim().length === 0 ||
1120
- (payload.state !== "booting" &&
1121
- payload.state !== "ready_for_input" &&
1122
- payload.state !== "running" &&
1123
- payload.state !== "needs_user_input" &&
1124
- payload.state !== "completed" &&
1125
- payload.state !== "errored" &&
1126
- payload.state !== "stale" &&
1127
- payload.state !== "unknown")
1128
- )
1129
- return false;
1280
+ if (payload.schema_version !== 1)
1281
+ return `schema_version must be 1 (received ${describePreviousPayloadValue(payload.schema_version)})`;
1282
+ if (typeof payload.session_id !== "string" || payload.session_id.trim().length === 0)
1283
+ return `session_id must be a non-empty string (received ${describePreviousPayloadValue(payload.session_id)})`;
1284
+ if (typeof payload.state !== "string" || !PREVIOUS_RUNTIME_STATES.has(payload.state))
1285
+ return (
1286
+ `state must be one of ${PREVIOUS_RUNTIME_STATE_VALUES.join(", ")} ` +
1287
+ `(received ${describePreviousPayloadValue(payload.state)})`
1288
+ );
1130
1289
  // Coordinator-seeded payloads (#2549) carry session_id and state but not the
1131
1290
  // runtime identity fields cwd/workdir/session_file. Accept their absence; when
1132
1291
  // present, validate them as before.
1133
- if (typeof payload.cwd !== "undefined") {
1134
- if (typeof payload.cwd !== "string" || payload.cwd.trim().length === 0) return false;
1135
- }
1136
- if (typeof payload.workdir !== "undefined") {
1137
- if (typeof payload.workdir !== "string" || payload.workdir.trim().length === 0) return false;
1138
- }
1139
- if (Object.hasOwn(payload, "session_file")) {
1140
- if (payload.session_file !== null && typeof payload.session_file !== "string") return false;
1141
- }
1142
- if (payload.ready_for_input !== undefined && typeof payload.ready_for_input !== "boolean") return false;
1143
- if (payload.live !== undefined && payload.live !== null && typeof payload.live !== "boolean") return false;
1144
- if (payload.reason !== undefined && payload.reason !== null && typeof payload.reason !== "string") return false;
1292
+ if (payload.cwd !== undefined && (typeof payload.cwd !== "string" || payload.cwd.trim().length === 0))
1293
+ return `cwd must be a non-empty string when present (received ${describePreviousPayloadValue(payload.cwd)})`;
1294
+ if (payload.workdir !== undefined && (typeof payload.workdir !== "string" || payload.workdir.trim().length === 0))
1295
+ return `workdir must be a non-empty string when present (received ${describePreviousPayloadValue(payload.workdir)})`;
1296
+ if (
1297
+ Object.hasOwn(payload, "session_file") &&
1298
+ payload.session_file !== null &&
1299
+ typeof payload.session_file !== "string"
1300
+ )
1301
+ return `session_file must be null or a string (received ${describePreviousPayloadValue(payload.session_file)})`;
1302
+ if (payload.ready_for_input !== undefined && typeof payload.ready_for_input !== "boolean")
1303
+ return `ready_for_input must be a boolean when present (received ${describePreviousPayloadValue(payload.ready_for_input)})`;
1304
+ if (payload.live !== undefined && payload.live !== null && typeof payload.live !== "boolean")
1305
+ return `live must be null or a boolean when present (received ${describePreviousPayloadValue(payload.live)})`;
1306
+ if (payload.reason !== undefined && payload.reason !== null && typeof payload.reason !== "string")
1307
+ return `reason must be null or a string when present (received ${describePreviousPayloadValue(payload.reason)})`;
1145
1308
  if (
1146
1309
  payload.updated_at !== undefined &&
1147
1310
  (typeof payload.updated_at !== "string" || !Number.isFinite(Date.parse(payload.updated_at)))
1148
1311
  )
1149
- return false;
1150
- return true;
1312
+ return `updated_at must be a parseable timestamp when present (received ${describePreviousPayloadValue(payload.updated_at)})`;
1313
+ return null;
1314
+ }
1315
+
1316
+ /**
1317
+ * Categorize a marker read failure without echoing the marker's own bytes.
1318
+ *
1319
+ * Only the OS error code — a closed, identifier-shaped token — reaches the diagnostic; an
1320
+ * arbitrary error message could carry marker-derived text or terminal control sequences.
1321
+ */
1322
+ function describeMarkerReadFailure(error: unknown): string {
1323
+ const code = (error as { code?: unknown }).code;
1324
+ return typeof code === "string" && SAFE_DIAGNOSTIC_IDENTIFIER.test(code)
1325
+ ? `the marker could not be read (${code})`
1326
+ : "the marker could not be read";
1151
1327
  }
1152
1328
 
1153
1329
  function readPreviousPayload(stateFile: string): Record<string, unknown> {
1154
1330
  let raw: string;
1155
1331
  try {
1332
+ // Mirror the event path's shape check so a directory marker reports the same
1333
+ // diagnosis whether a lifecycle event or the postmortem finalizer reaches it.
1334
+ if (!fsSync.statSync(stateFile).isFile())
1335
+ throw new PreviousRuntimeStateReadError(undefined, "the marker path is not a regular file");
1156
1336
  raw = fsSync.readFileSync(stateFile, "utf8");
1157
1337
  } catch (error) {
1338
+ if (error instanceof PreviousRuntimeStateReadError) throw error;
1158
1339
  if (isAbsentStateFileError(error)) return {};
1159
- throw new PreviousRuntimeStateReadError();
1340
+ throw new PreviousRuntimeStateReadError(error, describeMarkerReadFailure(error));
1160
1341
  }
1161
1342
  try {
1162
1343
  return parsePreviousPayload(raw);
1163
1344
  } catch (error) {
1164
1345
  if (error instanceof PreviousRuntimeStateReadError) throw error;
1165
- throw new PreviousRuntimeStateReadError();
1346
+ throw new PreviousRuntimeStateReadError(error, describeMarkerReadFailure(error));
1166
1347
  }
1167
1348
  }
1168
1349
 
@@ -1253,14 +1434,14 @@ async function readPreviousPayloadForEvent(stateFile: string): Promise<Record<st
1253
1434
  stat = await fs.stat(stateFile);
1254
1435
  } catch (error) {
1255
1436
  if (isAbsentStateFileError(error)) return {};
1256
- throw new PreviousRuntimeStateReadError();
1437
+ throw new PreviousRuntimeStateReadError(error, describeMarkerReadFailure(error));
1257
1438
  }
1258
- if (!stat.isFile()) throw new PreviousRuntimeStateReadError();
1439
+ if (!stat.isFile()) throw new PreviousRuntimeStateReadError(undefined, "the marker path is not a regular file");
1259
1440
  try {
1260
1441
  return parsePreviousPayload(await fs.readFile(stateFile, "utf8"));
1261
1442
  } catch (error) {
1262
1443
  if (error instanceof PreviousRuntimeStateReadError) throw error;
1263
- throw new PreviousRuntimeStateReadError();
1444
+ throw new PreviousRuntimeStateReadError(error, describeMarkerReadFailure(error));
1264
1445
  }
1265
1446
  }
1266
1447
 
@@ -1305,7 +1486,12 @@ function assertPreviousRuntimeStateIdentity(
1305
1486
  stateFile: string | null = null,
1306
1487
  ): void {
1307
1488
  if (Object.keys(previous).length === 0) return;
1308
- if (previous.session_id !== input.sessionId) throw new PreviousRuntimeStateReadError();
1489
+ if (previous.session_id !== input.sessionId)
1490
+ throw new PreviousRuntimeStateReadError(
1491
+ undefined,
1492
+ `session_id must be ${quotedDiagnosticText(input.sessionId)} (received ${describePreviousPayloadValue(previous.session_id)}); ` +
1493
+ `${thisMarkerHint(input, stateFile)}; ${previousPayloadDigestOf(previous)}`,
1494
+ );
1309
1495
  // Signed coordinator mode never trusts an unsigned predecessor. The sole exception
1310
1496
  // is the narrow, coordinator-authenticated bootstrap seed: it has no runtime identity
1311
1497
  // fields, is explicitly sourced by the coordinator, and carries the turn fence.
@@ -1325,7 +1511,14 @@ function assertPreviousRuntimeStateIdentity(
1325
1511
  typeof previous.sidecar_signature !== "string" ||
1326
1512
  !coordinatorSidecarSigningKey
1327
1513
  )
1328
- throw new PreviousRuntimeStateReadError();
1514
+ throw new PreviousRuntimeStateReadError(
1515
+ undefined,
1516
+ previous.sidecar_key_id === input.sidecarKeyId
1517
+ ? `the marker's sidecar signature is ${describePreviousPayloadValue(previous.sidecar_signature)}; ` +
1518
+ `${thisMarkerHint(input, stateFile)}; ${previousPayloadDigestOf(previous)}`
1519
+ : `the marker is not signed by this session's sidecar key (recorded ${describePreviousPayloadValue(previous.sidecar_key_id)}, required ${input.sidecarKeyId}); ` +
1520
+ `${thisMarkerHint(input, stateFile)}; ${previousPayloadDigestOf(previous)}`,
1521
+ );
1329
1522
  const { sidecar_signature: signature, ...unsigned } = previous;
1330
1523
  if (
1331
1524
  !verify(
@@ -1335,7 +1528,10 @@ function assertPreviousRuntimeStateIdentity(
1335
1528
  Buffer.from(signature, "base64"),
1336
1529
  )
1337
1530
  )
1338
- throw new PreviousRuntimeStateReadError();
1531
+ throw new PreviousRuntimeStateReadError(
1532
+ undefined,
1533
+ `the marker's sidecar signature does not verify; ${thisMarkerHint(input, stateFile)}; ${previousPayloadDigestOf(previous)}`,
1534
+ );
1339
1535
  }
1340
1536
  if (coordinatorSeed) return;
1341
1537
  // If the previous payload has runtime identity fields, verify them fully.
@@ -1382,10 +1578,13 @@ function assertRelocationRuntimeStateIdentity(previous: Record<string, unknown>,
1382
1578
  }
1383
1579
 
1384
1580
  function runtimeStateFileForContext(context: RuntimeStateContext): string | null {
1385
- const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
1581
+ // An explicitly owned marker — including "this session has none" — always wins. Only a
1582
+ // context that names nothing is answered by the process-wide coordinator pin.
1583
+ if (context.stateFile !== undefined) return context.stateFile;
1584
+ const explicit = coordinatorPinnedStateFile();
1386
1585
  if (explicit) return explicit;
1387
1586
  if (!context.sessionId.trim()) return null;
1388
- return path.join(sessionRuntimeDir(context.cwd, context.sessionId), "runtime-state.json");
1587
+ return sessionRuntimeStatePath(context.cwd, context.sessionId);
1389
1588
  }
1390
1589
  function branchForContext(context: RuntimeStateContext): string | null {
1391
1590
  return context.branch ?? (process.env[GJC_COORDINATOR_SESSION_BRANCH_ENV]?.trim() || null);
@@ -2023,6 +2222,7 @@ function previousRescopeIdentity(context: RuntimeStateContext, previousCwd: stri
2023
2222
  cwd: previousCwd,
2024
2223
  sessionFile: context.previousSessionFile ?? context.sessionFile ?? null,
2025
2224
  platform: context.platform,
2225
+ stateFile: context.stateFile,
2026
2226
  });
2027
2227
  }
2028
2228
 
@@ -2115,21 +2315,41 @@ function rescopeStateFiles(
2115
2315
  sessionId: string,
2116
2316
  previousCwd: string,
2117
2317
  newCwd: string,
2318
+ stateFile?: string | null,
2118
2319
  ): { oldStateFile: string; newStateFile: string; mode: "pinned" | "derived" } {
2119
- const explicit = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
2120
- if (explicit) {
2121
- const stateFile = canonicalStateFilePath(explicit);
2122
- return { oldStateFile: stateFile, newStateFile: stateFile, mode: "pinned" };
2320
+ // Ownership, never the ambient pin, decides whether this session's marker travels with
2321
+ // the cwd: a session that owns its own marker keeps it under the per-session runtime
2322
+ // directory even while the process carries the coordinator pin for a different session
2323
+ // (#5473).
2324
+ const pinned = coordinatorPinnedStateFile();
2325
+ if (pinned && usesCoordinatorPin({ stateFile })) {
2326
+ const canonical = canonicalStateFilePath(pinned);
2327
+ return { oldStateFile: canonical, newStateFile: canonical, mode: "pinned" };
2123
2328
  }
2124
- return {
2125
- oldStateFile: canonicalStateFilePath(
2126
- path.join(sessionRuntimeDir(fsSync.realpathSync(previousCwd), sessionId), "runtime-state.json"),
2127
- ),
2128
- newStateFile: canonicalStateFilePath(
2129
- path.join(sessionRuntimeDir(fsSync.realpathSync(newCwd), sessionId), "runtime-state.json"),
2130
- ),
2131
- mode: "derived",
2329
+ const derived = {
2330
+ oldStateFile: canonicalStateFilePath(sessionRuntimeStatePath(fsSync.realpathSync(previousCwd), sessionId)),
2331
+ newStateFile: canonicalStateFilePath(sessionRuntimeStatePath(fsSync.realpathSync(newCwd), sessionId)),
2132
2332
  };
2333
+ if (stateFile === undefined) return { ...derived, mode: "derived" };
2334
+ if (stateFile === null)
2335
+ throw new PreviousRuntimeStateReadError(
2336
+ undefined,
2337
+ "this session owns no runtime-state marker; refusing to rescope one",
2338
+ );
2339
+ // The classification is total: a caller-named marker must be the pin (handled above) or
2340
+ // this session's own derived marker. Both sides are canonicalized first, because a caller
2341
+ // may name its marker through a non-canonical spelling of the cwd — a symlinked checkout or
2342
+ // the macOS `/var`, `/tmp` prefixes — and a marker file that does not exist yet has no
2343
+ // realpath to normalize through.
2344
+ if (
2345
+ !sameResolvedPath(canonicalStateFilePath(stateFile), derived.oldStateFile) &&
2346
+ !sameResolvedPath(canonicalStateFilePath(stateFile), derived.newStateFile)
2347
+ )
2348
+ throw new PreviousRuntimeStateReadError(
2349
+ undefined,
2350
+ `the rescope marker ${quotedDiagnosticText(stateFile)} is neither the process pin nor this session's derived marker; refusing to guess its ownership`,
2351
+ );
2352
+ return { ...derived, mode: "derived" };
2133
2353
  }
2134
2354
 
2135
2355
  function journalSignaturePayload(journal: CoordinatorRuntimeStateRescopeJournal): Record<string, unknown> {
@@ -2187,6 +2407,8 @@ export async function prepareCoordinatorRuntimeStateRescope(input: {
2187
2407
  newCwdIdentity?: { dev: bigint; ino: bigint };
2188
2408
  previousSessionFile?: string | null;
2189
2409
  newSessionFile?: string | null;
2410
+ /** The marker this session owns; `undefined` lets the process pin answer for it. */
2411
+ stateFile?: string | null;
2190
2412
  }): Promise<string> {
2191
2413
  const previousStat = fsSync.lstatSync(input.previousCwd, { bigint: true });
2192
2414
  const newStat = fsSync.lstatSync(input.newCwd, { bigint: true });
@@ -2196,13 +2418,14 @@ export async function prepareCoordinatorRuntimeStateRescope(input: {
2196
2418
  assertPinnedDirectoryIdentity(input.newCwd, newCwdIdentity);
2197
2419
  const previousCwd = fsSync.realpathSync(input.previousCwd);
2198
2420
  const newCwd = fsSync.realpathSync(input.newCwd);
2199
- const stateFiles = rescopeStateFiles(input.sessionId, previousCwd, newCwd);
2421
+ const stateFiles = rescopeStateFiles(input.sessionId, previousCwd, newCwd, input.stateFile);
2200
2422
  const moveId = input.moveId ?? randomUUID();
2201
2423
  if (!RUNTIME_STATE_RESCOPE_MOVE_ID_PATTERN.test(moveId)) throw new PreviousRuntimeStateReadError();
2202
2424
  const identity = normalizedIdentity({
2203
2425
  sessionId: input.sessionId,
2204
2426
  cwd: newCwd,
2205
2427
  sessionFile: input.newSessionFile ?? null,
2428
+ stateFile: input.stateFile,
2206
2429
  });
2207
2430
  const previousJournalFile = confinedJournalPath(previousCwd, input.sessionId, identity.platform);
2208
2431
  const newJournalFile = confinedJournalPath(newCwd, input.sessionId, identity.platform);
@@ -2212,6 +2435,7 @@ export async function prepareCoordinatorRuntimeStateRescope(input: {
2212
2435
  cwd: newCwd,
2213
2436
  sessionFile: input.newSessionFile ?? null,
2214
2437
  previousSessionFile: input.previousSessionFile ?? null,
2438
+ stateFile: input.stateFile,
2215
2439
  },
2216
2440
  previousCwd,
2217
2441
  );
@@ -2380,7 +2604,12 @@ export async function recoverCoordinatorRuntimeStateRescope(context: RuntimeStat
2380
2604
  const journal = parseRuntimeStateRescopeJournal(observed.raw);
2381
2605
  verifyRuntimeStateRescopeJournal(journal);
2382
2606
  const current = normalizedIdentity(context);
2383
- const configuredStateFiles = rescopeStateFiles(journal.session_id, journal.previous_cwd, journal.new_cwd);
2607
+ const configuredStateFiles = rescopeStateFiles(
2608
+ journal.session_id,
2609
+ journal.previous_cwd,
2610
+ journal.new_cwd,
2611
+ context.stateFile,
2612
+ );
2384
2613
  if (
2385
2614
  journal.session_id !== context.sessionId ||
2386
2615
  journal.key_id !== current.sidecarKeyId ||
@@ -2388,7 +2617,14 @@ export async function recoverCoordinatorRuntimeStateRescope(context: RuntimeStat
2388
2617
  !sameResolvedPath(journal.old_state_file, configuredStateFiles.oldStateFile, current.platform) ||
2389
2618
  !sameResolvedPath(journal.new_state_file, configuredStateFiles.newStateFile, current.platform)
2390
2619
  )
2391
- throw new PreviousRuntimeStateReadError();
2620
+ throw new PreviousRuntimeStateReadError(
2621
+ undefined,
2622
+ `the rescope journal does not match this session's marker configuration: ` +
2623
+ `journal session ${describePreviousPayloadValue(journal.session_id)} mode ${journal.state_file_mode} ` +
2624
+ `(${sanitizeDiagnosticText(journal.old_state_file)} -> ${sanitizeDiagnosticText(journal.new_state_file)}), ` +
2625
+ `this session ${JSON.stringify(context.sessionId)} mode ${configuredStateFiles.mode} ` +
2626
+ `(${sanitizeDiagnosticText(configuredStateFiles.oldStateFile)} -> ${sanitizeDiagnosticText(configuredStateFiles.newStateFile)})`,
2627
+ );
2392
2628
  if (
2393
2629
  sameResolvedPath(journal.previous_cwd, current.cwd, current.platform) &&
2394
2630
  sameOptionalResolvedPath(journal.previous_session_file, current.sessionFile, current.platform)
@@ -2474,14 +2710,26 @@ export async function relocateCoordinatorRuntimeStateForRescope(
2474
2710
  if (!newStateFile) return true;
2475
2711
  const identity = normalizedIdentity(context);
2476
2712
  const oldIdentity = previousRescopeIdentity(context, previousCwd);
2477
- const explicitStateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
2478
2713
  // A pinned state file never moves with the cwd; a cwd-derived one lives under the
2479
- // per-session runtime directory rooted at the (now former) cwd.
2480
- const oldStateFile = explicitStateFile
2481
- ? newStateFile
2482
- : canonicalStateFilePath(
2483
- path.join(sessionRuntimeDir(canonicalPreviousCwd, identity.sessionId), "runtime-state.json"),
2484
- );
2714
+ // per-session runtime directory rooted at the (now former) cwd. Ownership — not the
2715
+ // ambient pin — decides which of the two this session has, and the same total
2716
+ // classification `prepare`/`recover` apply is enforced here (#5473).
2717
+ let oldStateFile: string;
2718
+ if (usesCoordinatorPin(context)) {
2719
+ oldStateFile = newStateFile;
2720
+ } else {
2721
+ oldStateFile = canonicalStateFilePath(sessionRuntimeStatePath(canonicalPreviousCwd, identity.sessionId));
2722
+ if (context.stateFile !== undefined && context.stateFile !== null) {
2723
+ if (
2724
+ !sameResolvedPath(context.stateFile, oldStateFile, identity.platform) &&
2725
+ !sameResolvedPath(context.stateFile, newStateFile, identity.platform)
2726
+ )
2727
+ throw new PreviousRuntimeStateReadError(
2728
+ undefined,
2729
+ `the rescope marker ${quotedDiagnosticText(context.stateFile)} is neither the process pin nor this session's derived marker; refusing to guess its ownership`,
2730
+ );
2731
+ }
2732
+ }
2485
2733
  const samePath = sameResolvedPath(oldStateFile, newStateFile, identity.platform);
2486
2734
  let expectedSourceSha256: string | null | undefined;
2487
2735
  const journalObservation = readRegularFileForRelocation(
@@ -4852,6 +4852,15 @@ function commandName(args: readonly string[]): string {
4852
4852
  return "status";
4853
4853
  }
4854
4854
 
4855
+ /**
4856
+ * A help request is `--help`/`-h` anywhere in the argv, or a leading `help`
4857
+ * verb. Exported so the CLI command class routes help back through the
4858
+ * verb-aware renderer instead of a generic command-level fallback.
4859
+ */
4860
+ export function isUltragoalHelpInvocation(args: readonly string[]): boolean {
4861
+ return args.some(isHelpArg) || args[0] === "help";
4862
+ }
4863
+
4855
4864
  function renderUltragoalHelp(args: readonly string[]): string | null {
4856
4865
  if (!args.some(isHelpArg) && args[0] !== "help") return null;
4857
4866
  const subject =
@@ -5745,7 +5754,7 @@ export async function runNativeUltragoalCommand(
5745
5754
  ): Promise<UltragoalCommandResult> {
5746
5755
  const command = commandName(args);
5747
5756
  const result = await dispatchUltragoalCommand(args, cwd, options.agentDir);
5748
- const isHelp = args.some(isHelpArg) || args[0] === "help";
5757
+ const isHelp = isUltragoalHelpInvocation(args);
5749
5758
  if (!isHelp && result.status === 0 && RECONCILE_COMMANDS.has(command)) {
5750
5759
  await reconcileUltragoalState(cwd, options.agentDir);
5751
5760
  }
@@ -9,7 +9,7 @@
9
9
  import { createHash, randomBytes } from "node:crypto";
10
10
  import * as fs from "node:fs/promises";
11
11
  import * as path from "node:path";
12
- import { FileLockAcquireError, withFileLock } from "../config/file-lock";
12
+ import { isFileLockAcquireTimeout, withFileLock } from "../config/file-lock";
13
13
 
14
14
  import { controlSocketPath, sessionPaths } from "./storage";
15
15
 
@@ -76,7 +76,7 @@ async function withLeaseMutationLock<T>(root: string, sessionId: string, fn: ()
76
76
  retryDelayMs: 20,
77
77
  });
78
78
  } catch (error) {
79
- if (error instanceof FileLockAcquireError && error.code === "acquire_timeout") {
79
+ if (isFileLockAcquireTimeout(error)) {
80
80
  throw new LeaseError(`lease_lock_timeout:${sessionId}`, "lease_lock_timeout");
81
81
  }
82
82
  throw error;