@gajae-code/coding-agent 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (599) hide show
  1. package/CHANGELOG.md +184 -2
  2. package/README.md +2 -0
  3. package/bin/gajaessi.js +4 -0
  4. package/dist/types/async/job-manager.d.ts +55 -2
  5. package/dist/types/async/support.d.ts +14 -0
  6. package/dist/types/capability/mcp.d.ts +6 -0
  7. package/dist/types/capability/types.d.ts +6 -0
  8. package/dist/types/cli/accounts-cli.d.ts +19 -0
  9. package/dist/types/cli/args.d.ts +3 -0
  10. package/dist/types/cli/config-cli.d.ts +6 -0
  11. package/dist/types/cli/crash-cli.d.ts +10 -0
  12. package/dist/types/cli/customize-doctor.d.ts +145 -0
  13. package/dist/types/cli/daemon-cli.d.ts +3 -1
  14. package/dist/types/cli/mcp-cli.d.ts +11 -0
  15. package/dist/types/cli/notify-cli.d.ts +1 -1
  16. package/dist/types/cli/root-flags.d.ts +12 -0
  17. package/dist/types/cli/setup-cli.d.ts +3 -1
  18. package/dist/types/cli/skills-cli.d.ts +2 -1
  19. package/dist/types/cli/update-cli.d.ts +19 -1
  20. package/dist/types/cli.d.ts +6 -0
  21. package/dist/types/commands/accounts.d.ts +40 -0
  22. package/dist/types/commands/crash.d.ts +24 -0
  23. package/dist/types/commands/customize.d.ts +22 -0
  24. package/dist/types/commands/daemon.d.ts +1 -1
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/commands/setup.d.ts +6 -0
  27. package/dist/types/commands/skills.d.ts +6 -1
  28. package/dist/types/commands/update.d.ts +7 -0
  29. package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
  30. package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
  31. package/dist/types/config/config-schema-version.d.ts +10 -0
  32. package/dist/types/config/file-lock.d.ts +18 -0
  33. package/dist/types/config/model-profile-activation.d.ts +3 -1
  34. package/dist/types/config/model-registry.d.ts +9 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +70 -6
  37. package/dist/types/config/settings.d.ts +63 -0
  38. package/dist/types/config/skill-settings-defaults.d.ts +26 -0
  39. package/dist/types/config/task-delegation.d.ts +39 -0
  40. package/dist/types/config/update-channel.d.ts +10 -0
  41. package/dist/types/coordinator-mcp/server.d.ts +5 -1
  42. package/dist/types/crash/index-store.d.ts +87 -0
  43. package/dist/types/crash/nudge.d.ts +50 -0
  44. package/dist/types/crash/record-loader.d.ts +17 -0
  45. package/dist/types/crash/report.d.ts +134 -0
  46. package/dist/types/crash/sanitize.d.ts +25 -0
  47. package/dist/types/cursor.d.ts +13 -2
  48. package/dist/types/customization/import.d.ts +37 -0
  49. package/dist/types/customization/index.d.ts +4 -0
  50. package/dist/types/customization/inventory.d.ts +25 -0
  51. package/dist/types/customization/mutations.d.ts +32 -0
  52. package/dist/types/customization/types.d.ts +115 -0
  53. package/dist/types/daemon/control-types.d.ts +8 -1
  54. package/dist/types/daemon/operator-contract.d.ts +4 -4
  55. package/dist/types/discovery/claude.d.ts +39 -1
  56. package/dist/types/discovery/codex.d.ts +39 -1
  57. package/dist/types/discovery/index.d.ts +2 -0
  58. package/dist/types/discovery/mcp-compat.d.ts +30 -0
  59. package/dist/types/edit/renderer.d.ts +23 -0
  60. package/dist/types/extensibility/extensions/runner.d.ts +3 -0
  61. package/dist/types/extensibility/extensions/types.d.ts +21 -4
  62. package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
  63. package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
  64. package/dist/types/extensibility/hooks/loader.d.ts +19 -5
  65. package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
  66. package/dist/types/extensibility/skill-management.d.ts +107 -0
  67. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
  68. package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
  69. package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
  70. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
  71. package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
  72. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
  73. package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
  74. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
  75. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
  76. package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
  77. package/dist/types/hooks/events.d.ts +60 -0
  78. package/dist/types/hooks/normalize.d.ts +83 -0
  79. package/dist/types/hooks/skill-state.d.ts +9 -2
  80. package/dist/types/main.d.ts +1 -1
  81. package/dist/types/modes/acp/abort-scope.d.ts +13 -0
  82. package/dist/types/modes/acp/acp-agent.d.ts +7 -4
  83. package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
  84. package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
  85. package/dist/types/modes/components/customization/index.d.ts +1 -0
  86. package/dist/types/modes/components/gajae-pet-widget.d.ts +8 -30
  87. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  88. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  89. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  90. package/dist/types/modes/components/pet-capability.d.ts +11 -25
  91. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  92. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  93. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  94. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  95. package/dist/types/modes/interactive-mode.d.ts +1 -0
  96. package/dist/types/modes/types.d.ts +1 -0
  97. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  98. package/dist/types/runtime-mcp/config.d.ts +9 -0
  99. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  100. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  101. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  102. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  103. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  104. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  105. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  106. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  107. package/dist/types/runtime-mcp/types.d.ts +102 -10
  108. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  109. package/dist/types/sdk/broker/broker.d.ts +3 -55
  110. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  111. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  112. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  113. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  114. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  115. package/dist/types/sdk/bus/config.d.ts +11 -0
  116. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  117. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  118. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  119. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  120. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  121. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  122. package/dist/types/sdk/bus/index.d.ts +85 -99
  123. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  124. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  125. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  126. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  127. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  128. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  129. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  130. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  131. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  132. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  133. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  135. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  136. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  137. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  138. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  139. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  140. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  141. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  142. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  143. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  144. package/dist/types/sdk/cli/rows.d.ts +2 -11
  145. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  146. package/dist/types/sdk/client/client.d.ts +97 -1
  147. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  148. package/dist/types/sdk/host/control/index.d.ts +1 -1
  149. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  150. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  151. package/dist/types/sdk/host/host.d.ts +15 -1
  152. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  153. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  154. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  155. package/dist/types/sdk/host/types.d.ts +1 -1
  156. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  157. package/dist/types/sdk/index.d.ts +2 -1
  158. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  159. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  160. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  161. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  162. package/dist/types/sdk/mcp/server.d.ts +9 -6
  163. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  164. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  165. package/dist/types/sdk/prompt-status.d.ts +10 -3
  166. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  167. package/dist/types/sdk/receipt-state.d.ts +17 -0
  168. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  169. package/dist/types/sdk/router/index.d.ts +1 -0
  170. package/dist/types/sdk/router/session-router.d.ts +152 -0
  171. package/dist/types/sdk/session-list.d.ts +22 -0
  172. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  173. package/dist/types/sdk/session.d.ts +34 -1
  174. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  175. package/dist/types/session/account-inventory.d.ts +86 -0
  176. package/dist/types/session/agent-session.d.ts +145 -6
  177. package/dist/types/session/agent-storage.d.ts +10 -0
  178. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  179. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  180. package/dist/types/session/messages.d.ts +1 -1
  181. package/dist/types/session/secure-token-file.d.ts +3 -0
  182. package/dist/types/session/session-manager.d.ts +40 -1
  183. package/dist/types/session/startup-auth-config.d.ts +44 -0
  184. package/dist/types/session/terminal-abort.d.ts +378 -0
  185. package/dist/types/session/yield-queue.d.ts +7 -0
  186. package/dist/types/session-import/claude.d.ts +26 -0
  187. package/dist/types/session-import/codex.d.ts +88 -0
  188. package/dist/types/session-import/command.d.ts +35 -0
  189. package/dist/types/session-import/detect.d.ts +22 -0
  190. package/dist/types/session-import/index.d.ts +7 -0
  191. package/dist/types/session-import/provider-codex.d.ts +22 -0
  192. package/dist/types/session-import/provider-service.d.ts +38 -0
  193. package/dist/types/session-import/redact.d.ts +23 -0
  194. package/dist/types/session-import/service.d.ts +47 -0
  195. package/dist/types/session-import/types.d.ts +120 -0
  196. package/dist/types/setup/paseo/check.d.ts +16 -0
  197. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  198. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  199. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  200. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  201. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  202. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  203. package/dist/types/setup/paseo/remove.d.ts +14 -0
  204. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  205. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  206. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  207. package/dist/types/skill-state/active-state.d.ts +5 -1
  208. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  209. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  210. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  211. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  212. package/dist/types/slash-commands/types.d.ts +7 -1
  213. package/dist/types/task/executor.d.ts +10 -0
  214. package/dist/types/task/index.d.ts +3 -1
  215. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  216. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  217. package/dist/types/tools/bash.d.ts +2 -6
  218. package/dist/types/tools/browser/attach.d.ts +11 -0
  219. package/dist/types/tools/browser/launch.d.ts +20 -0
  220. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  221. package/dist/types/tools/browser/render.d.ts +1 -0
  222. package/dist/types/tools/browser.d.ts +3 -1
  223. package/dist/types/tools/image-gen.d.ts +9 -2
  224. package/dist/types/tools/index.d.ts +8 -0
  225. package/dist/types/tools/monitor.d.ts +1 -1
  226. package/dist/types/tools/skill-discovery.d.ts +8 -1
  227. package/dist/types/tools/subagent.d.ts +2 -0
  228. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  229. package/dist/types/utils/herdr-pane.d.ts +76 -0
  230. package/package.json +19 -9
  231. package/scripts/build-binary.ts +2 -1
  232. package/scripts/build-sdk-package-smoke.ts +2 -13
  233. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  234. package/scripts/compile-args.ts +2 -0
  235. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  236. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  237. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  238. package/scripts/generate-tool-catalog.ts +6 -4
  239. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  240. package/src/async/job-manager.ts +352 -17
  241. package/src/async/support.ts +25 -0
  242. package/src/autoresearch/dashboard.ts +28 -13
  243. package/src/capability/index.ts +3 -1
  244. package/src/capability/mcp.ts +6 -0
  245. package/src/capability/types.ts +6 -0
  246. package/src/cli/accounts-cli.ts +395 -0
  247. package/src/cli/args.ts +23 -0
  248. package/src/cli/auth-broker-cli.ts +136 -66
  249. package/src/cli/auth-gateway-cli.ts +94 -70
  250. package/src/cli/config-cli.ts +27 -2
  251. package/src/cli/crash-cli.ts +78 -0
  252. package/src/cli/customize-doctor.ts +1605 -0
  253. package/src/cli/daemon-cli.ts +12 -3
  254. package/src/cli/fast-help.ts +1 -0
  255. package/src/cli/mcp-cli.ts +128 -60
  256. package/src/cli/notify-cli.ts +8 -14
  257. package/src/cli/root-flags.ts +8 -0
  258. package/src/cli/setup-cli.ts +145 -0
  259. package/src/cli/skills-cli.ts +38 -2
  260. package/src/cli/update-cli.ts +167 -12
  261. package/src/cli.ts +4 -0
  262. package/src/commands/accounts.ts +71 -0
  263. package/src/commands/crash.ts +35 -0
  264. package/src/commands/customize.ts +84 -0
  265. package/src/commands/daemon.ts +2 -2
  266. package/src/commands/deep-interview.ts +2 -0
  267. package/src/commands/launch.ts +1 -0
  268. package/src/commands/mcp.ts +12 -7
  269. package/src/commands/ralplan.ts +2 -0
  270. package/src/commands/sdk.ts +65 -32
  271. package/src/commands/setup.ts +5 -0
  272. package/src/commands/skills.ts +14 -4
  273. package/src/commands/ultragoal.ts +10 -1
  274. package/src/commands/update.ts +26 -15
  275. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  276. package/src/config/atomic-yaml-patch.ts +769 -29
  277. package/src/config/config-schema-version.ts +10 -0
  278. package/src/config/file-lock.ts +70 -1
  279. package/src/config/mcp-schema.json +52 -14
  280. package/src/config/model-profile-activation.ts +20 -1
  281. package/src/config/model-profiles.ts +29 -19
  282. package/src/config/model-registry.ts +132 -21
  283. package/src/config/models-config-schema.ts +1 -1
  284. package/src/config/settings-schema.ts +76 -6
  285. package/src/config/settings.ts +4038 -199
  286. package/src/config/skill-settings-defaults.ts +41 -3
  287. package/src/config/task-delegation.ts +87 -0
  288. package/src/config/update-channel.ts +23 -0
  289. package/src/coordinator-mcp/server.ts +377 -308
  290. package/src/crash/index-store.ts +723 -0
  291. package/src/crash/nudge.ts +89 -0
  292. package/src/crash/record-loader.ts +149 -0
  293. package/src/crash/report.ts +551 -0
  294. package/src/crash/sanitize.ts +129 -0
  295. package/src/cursor.ts +36 -10
  296. package/src/customization/import.ts +946 -0
  297. package/src/customization/index.ts +4 -0
  298. package/src/customization/inventory.ts +335 -0
  299. package/src/customization/mutations.ts +141 -0
  300. package/src/customization/types.ts +223 -0
  301. package/src/daemon/control-types.ts +8 -1
  302. package/src/daemon/operator-contract.ts +6 -6
  303. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  304. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  305. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  306. package/src/discovery/claude-hooks.ts +38 -0
  307. package/src/discovery/claude.ts +142 -209
  308. package/src/discovery/codex-hooks.ts +38 -0
  309. package/src/discovery/codex.ts +132 -198
  310. package/src/discovery/helpers.ts +8 -1
  311. package/src/discovery/index.ts +2 -0
  312. package/src/discovery/mcp-compat.ts +234 -0
  313. package/src/discovery/mcp-json.ts +6 -0
  314. package/src/edit/renderer.ts +40 -0
  315. package/src/edit/streaming.ts +4 -2
  316. package/src/exec/bash-executor.ts +24 -20
  317. package/src/export/html/index.ts +51 -2
  318. package/src/extensibility/extensions/loader.ts +9 -1
  319. package/src/extensibility/extensions/runner.ts +17 -4
  320. package/src/extensibility/extensions/types.ts +31 -6
  321. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  322. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  323. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  324. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  325. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  326. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  327. package/src/extensibility/hooks/loader.ts +154 -10
  328. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  329. package/src/extensibility/skill-management.ts +368 -0
  330. package/src/extensibility/skills.ts +52 -16
  331. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  332. package/src/gjc-runtime/launch-worktree.ts +56 -2
  333. package/src/gjc-runtime/linux-proc.ts +7 -1
  334. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  335. package/src/gjc-runtime/session-state-lock.ts +992 -0
  336. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  337. package/src/gjc-runtime/state-writer.ts +39 -14
  338. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  339. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  340. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  341. package/src/gjc-runtime/workflow-settings.ts +596 -0
  342. package/src/goals/tools/goal-tool.ts +6 -1
  343. package/src/hooks/events.ts +291 -0
  344. package/src/hooks/native-skill-hook.ts +4 -0
  345. package/src/hooks/normalize.ts +369 -0
  346. package/src/hooks/skill-state.ts +165 -46
  347. package/src/internal-urls/docs-index.generated.ts +40 -32
  348. package/src/main.ts +32 -5
  349. package/src/migrate/mcp-mapper.ts +25 -2
  350. package/src/modes/acp/abort-scope.ts +29 -0
  351. package/src/modes/acp/acp-agent.ts +423 -98
  352. package/src/modes/components/assistant-message.ts +6 -1
  353. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  354. package/src/modes/components/customization/import-wizard.ts +372 -0
  355. package/src/modes/components/customization/index.ts +1 -0
  356. package/src/modes/components/gajae-pet-widget.ts +539 -213
  357. package/src/modes/components/irc-sidebar.ts +11 -0
  358. package/src/modes/components/iterm-pet-transport.ts +585 -0
  359. package/src/modes/components/oauth-selector.ts +277 -86
  360. package/src/modes/components/pet-capability.ts +39 -40
  361. package/src/modes/components/settings-selector.ts +9 -0
  362. package/src/modes/components/status-line/gh.ts +45 -1
  363. package/src/modes/components/status-line/segments.ts +16 -1
  364. package/src/modes/components/tool-status-header.ts +13 -3
  365. package/src/modes/controllers/command-controller.ts +20 -8
  366. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  367. package/src/modes/controllers/input-controller.ts +10 -0
  368. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  369. package/src/modes/controllers/selector-controller.ts +177 -72
  370. package/src/modes/controllers/stt-controller.ts +3 -3
  371. package/src/modes/interactive-mode.ts +88 -16
  372. package/src/modes/print-mode.ts +35 -6
  373. package/src/modes/runtime-init.ts +21 -68
  374. package/src/modes/types.ts +1 -0
  375. package/src/modes/utils/injected-user-submission.ts +25 -0
  376. package/src/modes/utils/terminal-bell.ts +15 -1
  377. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  378. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  379. package/src/prompts/agents/critic.md +1 -1
  380. package/src/prompts/system/rlm-research.md +1 -1
  381. package/src/prompts/system/system-prompt.md +0 -2
  382. package/src/prompts/tools/bash.md +7 -1
  383. package/src/prompts/tools/browser.md +1 -1
  384. package/src/prompts/tools/cron.md +6 -6
  385. package/src/prompts/tools/read.md +0 -2
  386. package/src/prompts/tools/skill-discovery.md +4 -2
  387. package/src/runtime-mcp/client.ts +427 -23
  388. package/src/runtime-mcp/config.ts +37 -5
  389. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  390. package/src/runtime-mcp/manager.ts +52 -2
  391. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  392. package/src/runtime-mcp/oauth-flow.ts +46 -1
  393. package/src/runtime-mcp/pool-key.ts +5 -1
  394. package/src/runtime-mcp/pool.test.ts +9 -0
  395. package/src/runtime-mcp/protocol.test.ts +353 -0
  396. package/src/runtime-mcp/protocol.ts +624 -0
  397. package/src/runtime-mcp/scope-config.ts +78 -0
  398. package/src/runtime-mcp/tool-bridge.ts +44 -10
  399. package/src/runtime-mcp/transports/http.ts +84 -13
  400. package/src/runtime-mcp/types.ts +123 -12
  401. package/src/sdk/acp/adapter.ts +328 -97
  402. package/src/sdk/broker/broker.ts +160 -741
  403. package/src/sdk/broker/ensure.ts +9 -1
  404. package/src/sdk/broker/lifecycle.ts +510 -116
  405. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  406. package/src/sdk/broker/process-incarnation.ts +19 -0
  407. package/src/sdk/broker/session-index.ts +516 -117
  408. package/src/sdk/broker/startup-failure.ts +28 -5
  409. package/src/sdk/broker/transport.ts +1 -13
  410. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  411. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  412. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  413. package/src/sdk/bus/config.ts +13 -0
  414. package/src/sdk/bus/conversation-store.ts +59 -171
  415. package/src/sdk/bus/daemon-paths.ts +20 -0
  416. package/src/sdk/bus/discord-conversation.ts +2 -0
  417. package/src/sdk/bus/discord-daemon.ts +524 -126
  418. package/src/sdk/bus/discord-live-provider.ts +206 -79
  419. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  420. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  421. package/src/sdk/bus/index.ts +2480 -456
  422. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  423. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  424. package/src/sdk/bus/managed-daemon.ts +2 -145
  425. package/src/sdk/bus/notification-service.ts +196 -0
  426. package/src/sdk/bus/operator-runtime.ts +1 -1
  427. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  428. package/src/sdk/bus/reconciliation-store.ts +553 -24
  429. package/src/sdk/bus/session-control.ts +49 -3
  430. package/src/sdk/bus/slack-conversation.ts +4 -0
  431. package/src/sdk/bus/slack-daemon.ts +733 -134
  432. package/src/sdk/bus/slack-live-provider.ts +65 -15
  433. package/src/sdk/bus/slack-provider.ts +4 -0
  434. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  435. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  436. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  437. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  438. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  439. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  440. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  441. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  442. package/src/sdk/bus/telegram-reference.ts +1 -226
  443. package/src/sdk/bus/telegram-setup.ts +2 -2
  444. package/src/sdk/bus/threaded-render.ts +1 -0
  445. package/src/sdk/bus/topic-registry.ts +279 -146
  446. package/src/sdk/cli/rows.ts +2 -35
  447. package/src/sdk/cli/session-cli.ts +776 -700
  448. package/src/sdk/client/client.ts +892 -1
  449. package/src/sdk/host/control/dispatch.ts +93 -58
  450. package/src/sdk/host/control/index.ts +1 -1
  451. package/src/sdk/host/control/operations.ts +18 -10
  452. package/src/sdk/host/control/runtime-gate.ts +40 -0
  453. package/src/sdk/host/host.ts +63 -12
  454. package/src/sdk/host/query/handlers.ts +66 -7
  455. package/src/sdk/host/reverse-leases.ts +2 -2
  456. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  457. package/src/sdk/host/session-runtime.test.ts +2787 -34
  458. package/src/sdk/host/session-runtime.ts +1489 -101
  459. package/src/sdk/host/surface-policy.ts +12 -8
  460. package/src/sdk/host/types.ts +1 -1
  461. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  462. package/src/sdk/host/websocket-transport.ts +85 -50
  463. package/src/sdk/index.ts +2 -1
  464. package/src/sdk/lifecycle/broker-client.ts +53 -0
  465. package/src/sdk/lifecycle/client.ts +234 -0
  466. package/src/sdk/lifecycle/index.ts +3 -0
  467. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  468. package/src/sdk/lifecycle/service.ts +720 -0
  469. package/src/sdk/mcp/server.ts +226 -188
  470. package/src/sdk/prompt-deadline-lease.ts +55 -0
  471. package/src/sdk/prompt-deadline-manager.ts +148 -0
  472. package/src/sdk/prompt-status.ts +11 -3
  473. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  474. package/src/sdk/protocol/operation-registry.ts +71 -8
  475. package/src/sdk/receipt-state.ts +43 -0
  476. package/src/sdk/reconciliation-extensions.ts +14 -0
  477. package/src/sdk/router/index.ts +1 -0
  478. package/src/sdk/router/session-router.ts +1658 -0
  479. package/src/sdk/session-list.ts +93 -0
  480. package/src/sdk/session-reconnect.ts +45 -1
  481. package/src/sdk/session.ts +716 -120
  482. package/src/sdk/transport/serve-cli.ts +18 -34
  483. package/src/session/account-inventory.ts +603 -0
  484. package/src/session/agent-session.ts +2857 -582
  485. package/src/session/agent-storage.ts +35 -0
  486. package/src/session/blob-store.ts +32 -1
  487. package/src/session/coordinator-tool-label.ts +40 -0
  488. package/src/session/internal/managed-session-scope.ts +4 -0
  489. package/src/session/internal/managed-session-storage.ts +173 -12
  490. package/src/session/messages.ts +1 -1
  491. package/src/session/secure-token-file.ts +80 -0
  492. package/src/session/session-manager.ts +730 -58
  493. package/src/session/session-storage.ts +25 -16
  494. package/src/session/startup-auth-config.ts +297 -0
  495. package/src/session/terminal-abort.ts +1253 -0
  496. package/src/session/yield-queue.ts +57 -15
  497. package/src/session-import/claude.ts +334 -0
  498. package/src/session-import/codex.ts +1033 -0
  499. package/src/session-import/command.ts +77 -0
  500. package/src/session-import/detect.ts +133 -0
  501. package/src/session-import/index.ts +7 -0
  502. package/src/session-import/provider-codex.ts +327 -0
  503. package/src/session-import/provider-service.ts +457 -0
  504. package/src/session-import/redact.ts +128 -0
  505. package/src/session-import/service.ts +1264 -0
  506. package/src/session-import/types.ts +148 -0
  507. package/src/setup/paseo/check.ts +213 -0
  508. package/src/setup/paseo/install-saga.ts +293 -0
  509. package/src/setup/paseo/json-publisher.ts +263 -0
  510. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  511. package/src/setup/paseo/paseo-ownership.ts +224 -0
  512. package/src/setup/paseo/paseo-setup.ts +253 -0
  513. package/src/setup/paseo/provider-config.ts +139 -0
  514. package/src/setup/paseo/remove.ts +183 -0
  515. package/src/setup/paseo/result-types.ts +79 -0
  516. package/src/setup/paseo/setup-deps.ts +175 -0
  517. package/src/setup/paseo/skills-bridge.ts +260 -0
  518. package/src/skill-state/active-state.ts +21 -11
  519. package/src/slash-commands/acp-builtins.ts +9 -1
  520. package/src/slash-commands/builtin-registry.ts +98 -16
  521. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  522. package/src/slash-commands/helpers/mcp.ts +9 -3
  523. package/src/slash-commands/helpers/usage-report.ts +61 -56
  524. package/src/slash-commands/types.ts +7 -2
  525. package/src/task/executor.ts +30 -11
  526. package/src/task/index.ts +66 -7
  527. package/src/task/render.ts +23 -9
  528. package/src/tools/ask-answer-registry.ts +23 -7
  529. package/src/tools/ask.ts +8 -4
  530. package/src/tools/bash-sleep-advisory.ts +115 -0
  531. package/src/tools/bash.ts +98 -18
  532. package/src/tools/browser/attach.ts +71 -3
  533. package/src/tools/browser/launch.ts +86 -13
  534. package/src/tools/browser/profile-discovery.ts +57 -7
  535. package/src/tools/browser/render.ts +8 -1
  536. package/src/tools/browser.ts +130 -21
  537. package/src/tools/computer.enforcement.test.ts +1 -1
  538. package/src/tools/computer.ts +3 -2
  539. package/src/tools/cron.ts +12 -1
  540. package/src/tools/image-gen.ts +27 -16
  541. package/src/tools/index.ts +8 -0
  542. package/src/tools/job.ts +33 -6
  543. package/src/tools/monitor.ts +41 -2
  544. package/src/tools/skill-discovery.ts +21 -29
  545. package/src/tools/subagent.ts +142 -24
  546. package/src/tools/tool-catalog.generated.ts +6 -6
  547. package/src/tools/ultragoal-ask-guard.ts +10 -3
  548. package/src/utils/herdr-pane.ts +345 -0
  549. package/src/utils/title-generator.ts +2 -0
  550. package/src/web/scrapers/index.ts +6 -4
  551. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  552. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  553. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  554. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  555. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  556. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  557. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  558. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  559. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  560. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  561. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  562. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  563. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  564. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  565. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  566. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  567. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  568. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  569. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  570. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  571. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  572. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  573. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  574. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  575. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  576. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  577. package/dist/types/session/auth-broker-config.d.ts +0 -13
  578. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  579. package/dist/types/skills/skills.test.d.ts +0 -1
  580. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  581. package/dist/types/task/token-log.test.d.ts +0 -1
  582. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  583. package/dist/types/tools/descriptors.test.d.ts +0 -1
  584. package/scripts/g005-daemon-smoke.ts +0 -180
  585. package/scripts/g011-daemon-path-smoke.ts +0 -153
  586. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  587. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  588. package/src/sdk/bus/telegram-cli.ts +0 -212
  589. package/src/sdk/elevation/allowlist.ts +0 -54
  590. package/src/sdk/elevation/audit.ts +0 -208
  591. package/src/sdk/elevation/capability.ts +0 -41
  592. package/src/sdk/elevation/digest.ts +0 -129
  593. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  594. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  595. package/src/sdk/elevation/owner.ts +0 -109
  596. package/src/session/auth-broker-config.ts +0 -109
  597. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  598. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  599. /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
@@ -0,0 +1,992 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import * as fsSync from "node:fs";
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import type {
6
+ NativeDirectoryTreeResult,
7
+ NativeDirectoryTreeSnapshot,
8
+ NativeExactUnlinkResult,
9
+ } from "@gajae-code/natives";
10
+ import { genericFileLockDirIsStale, processStartTime as portableProcessStartTime } from "../config/file-lock";
11
+ import { readLinuxProcStartTimeSync } from "./linux-proc";
12
+
13
+ /**
14
+ * The one lock implementation for a coordinator-shared session state file.
15
+ *
16
+ * The Coordinator MCP server and the runtime sidecar both read-modify-write the same
17
+ * `session-states/<id>.json`, so they must contend on one lock with one on-disk owner
18
+ * format. That format is the regular-file `<file>.lock` owner JSON the base Coordinator
19
+ * wrote. The base RUNTIME did not use it: it guarded this path with the generic
20
+ * directory-style lock, so a `<file>.lock/` DIRECTORY left by an older runtime is a real
21
+ * on-disk shape this code still has to survive.
22
+ *
23
+ * Both shapes are therefore handled, and neither is guessed at: `lstat` decides which one
24
+ * is present, a directory is evaluated by the generic protocol's own implementation, and
25
+ * anything that is neither a regular file nor a directory fails closed — a symlink, FIFO,
26
+ * socket, or device at this path is not a lock this code wrote, and reading or removing
27
+ * it would follow an attacker-chosen target.
28
+ */
29
+ const LOCK_ACQUIRE_ATTEMPTS = 12_000;
30
+ const LOCK_ACQUIRE_RETRY_MS = 5;
31
+ const LOCK_STALE_MS = 30_000;
32
+
33
+ /**
34
+ * The claim that serializes PATHNAME TRANSITIONS of `<file>.lock` among current writers.
35
+ *
36
+ * `<file>.lock` cannot serialize its own creation and removal on its own: a base writer
37
+ * that only ever `open(..., "wx")`s the path is a real contender this protocol cannot
38
+ * exclude, and interleaving its create with a reclaimer's delete is what corrupts
39
+ * ownership. So every current transition of that pathname — acquire, stale reclaim,
40
+ * write-failure cleanup, release — is made under this separate claim. Only pathname
41
+ * bookkeeping happens inside it; the caller's state-file operation never does.
42
+ *
43
+ * The claim is itself a regular owner record at `<file>.lock.transition`, in the same
44
+ * format and with the same liveness rules as the record it guards: pid and start time are
45
+ * stamped into it, so a holder that is merely paused — descheduled, stalled on the
46
+ * filesystem, stopped in a debugger — is never broken by elapsed time. An ownerless claim
47
+ * broken by age reintroduces exactly the concurrent create/unlink it exists to prevent.
48
+ *
49
+ * It needs no claim of its own because its every removal is identity-bound: `exactUnlink`
50
+ * refuses unless the record is still byte-for-byte and inode-for-inode the one that was
51
+ * judged, which is the atomic compare-and-delete `fs` does not offer. That is also why the
52
+ * path is `<file>.lock.transition` and not the generic protocol's `.lock` directory — it
53
+ * stays distinct from `<file>.lock` (whose regular-file owner format base writers read is
54
+ * unchanged) and from the outer `locks/mutation.lock` (whose generic directory semantics
55
+ * are likewise untouched).
56
+ */
57
+ const LOCK_TRANSITION_RESOURCE_SUFFIX = ".transition";
58
+
59
+ interface SessionStateLockOwner {
60
+ pid: number;
61
+ start_time: string;
62
+ token: string;
63
+ }
64
+
65
+ /**
66
+ * How a `<file>.lock` owner record can be opened safely on this platform.
67
+ *
68
+ * `posix-nofollow` proves the object's TYPE from the descriptor the bytes come off, so the
69
+ * pathname can change freely without ever being followed. Windows has neither `O_NOFOLLOW`
70
+ * nor `O_NONBLOCK`, so no such descriptor exists there and `windows-validated` brackets the
71
+ * open with `lstat`/`fstat` identity proofs instead. `unsupported` is neither, and the
72
+ * owner protocol fails closed rather than reading a pathname it cannot vouch for.
73
+ */
74
+ export type SessionStateLockOwnerAccessStrategy = "posix-nofollow" | "windows-validated" | "unsupported";
75
+
76
+ const POSIX_NOFOLLOW_AVAILABLE =
77
+ typeof fsSync.constants.O_NOFOLLOW === "number" && typeof fsSync.constants.O_NONBLOCK === "number";
78
+
79
+ /**
80
+ * Select the safe owner-record access protocol for one platform/runtime pair.
81
+ *
82
+ * Platform identity comes first. A win32 runtime may expose numeric compatibility constants
83
+ * without providing POSIX no-follow semantics, so their mere presence must never select the
84
+ * POSIX path there.
85
+ *
86
+ * @internal Pure seam for cross-platform detection tests.
87
+ */
88
+ export function detectedSessionStateLockOwnerAccessStrategy(
89
+ platform: NodeJS.Platform,
90
+ posixNoFollowAvailable: boolean,
91
+ ): SessionStateLockOwnerAccessStrategy {
92
+ if (platform === "win32") return "windows-validated";
93
+ return posixNoFollowAvailable ? "posix-nofollow" : "unsupported";
94
+ }
95
+
96
+ const DETECTED_OWNER_ACCESS_STRATEGY = detectedSessionStateLockOwnerAccessStrategy(
97
+ process.platform,
98
+ POSIX_NOFOLLOW_AVAILABLE,
99
+ );
100
+
101
+ /**
102
+ * Test seams for the windows a reclaim passes through: the moment the path's TYPE has just
103
+ * been decided and its owner has not been read yet, the stale verdict, and the FINAL
104
+ * identity validation immediately before the unlink. Production code never sets them.
105
+ */
106
+ export const SessionStateLockTestHooks: {
107
+ afterLockTypeDecision?: (lockFile: string) => void | Promise<void>;
108
+ afterStaleInspection?: (lockFile: string) => void | Promise<void>;
109
+ beforeStaleRemoval?: (lockFile: string) => void | Promise<void>;
110
+ afterTransitionStaleInspection?: (transitionFile: string) => void | Promise<void>;
111
+ beforeTransitionStaleRemoval?: (transitionFile: string) => void | Promise<void>;
112
+ afterLegacyDirectoryStaleVerdict?: (lockDir: string) => void | Promise<void>;
113
+ /**
114
+ * @internal Which owner-access strategy to exercise, so the Windows path can be proved
115
+ * on the test filesystem this suite actually runs on. Production code never sets it,
116
+ * and it is never serialized or exposed as runtime configuration.
117
+ */
118
+ ownerAccessStrategy?: SessionStateLockOwnerAccessStrategy;
119
+ /**
120
+ * @internal How an owner pid is probed for liveness. Whether a signal is permitted is a
121
+ * property of the OS and this process's privileges, so it cannot be arranged on disk.
122
+ * Production code never sets it, and it is never serialized or exposed as runtime
123
+ * configuration.
124
+ */
125
+ probeProcessSignal?: (pid: number) => void;
126
+ /**
127
+ * @internal Runs inside a NEW owner record's write, after the exclusive create has
128
+ * taken the pathname and before the record's bytes land. Throwing from it fails that
129
+ * write exactly as an I/O fault does, which is the only deterministic way to reach the
130
+ * write-failure cleanup and prove what that cleanup is authorized to delete.
131
+ * Production code never sets it, and it is never serialized or exposed as runtime
132
+ * configuration.
133
+ */
134
+ ownerRecordWriteFault?: (file: string) => void | Promise<void>;
135
+ beforeLegacyDirectoryRemoval?: (lockDir: string) => void | Promise<void>;
136
+ } = {};
137
+
138
+ /** Raised when the lock could not be acquired; callers map it to their own refusal. */
139
+ export class SessionStateLockUnavailableError extends Error {
140
+ constructor(cause?: unknown) {
141
+ super("Coordinator session state lock is unavailable.");
142
+ this.name = "SessionStateLockUnavailableError";
143
+ if (cause !== undefined) this.cause = cause;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The identity-bound deletion primitives this lock is built on.
149
+ *
150
+ * There is no portable atomic compare-and-delete in `fs`: validating a record and then
151
+ * unlinking its PATHNAME is always two syscalls, and a successor that claims the path in
152
+ * between loses its brand-new lock to the first reclaimer. These natives close that hole
153
+ * for real — every removal is descriptor-relative, no-follow, and refuses unless the
154
+ * object still carries the exact `dev`/`ino`/`nlink`/`size`/`mtimeNs`/SHA-256 identity the
155
+ * caller proved. A replacement is therefore never deleted, by construction rather than by
156
+ * a narrower window.
157
+ */
158
+ export type SessionStateLockNativeBindings = Pick<
159
+ typeof import("@gajae-code/natives"),
160
+ "exactRemoveDirectoryTree" | "exactUnlink" | "snapshotDirectoryTree"
161
+ >;
162
+
163
+ /** How the deletion primitives are obtained. Throwing means they are unavailable. */
164
+ type SessionStateLockNativeLoader = () => SessionStateLockNativeBindings;
165
+
166
+ let injectedNativeLoader: SessionStateLockNativeLoader | undefined;
167
+ let loadedNativeBindings: SessionStateLockNativeBindings | undefined;
168
+
169
+ /**
170
+ * @internal The seam focused TS tests bind a faithful in-process implementation to.
171
+ *
172
+ * It exists because the deletion contract — "refuse unless the object on disk still has
173
+ * exactly this identity" — is the thing under test, and a test double that merely reports
174
+ * success would assert nothing.
175
+ *
176
+ * A LOADER rather than a value, so the unavailable case needs no special production
177
+ * branch: a loader that throws is indistinguishable from an addon that will not load,
178
+ * which is the only way to observe that path where the addon is present. Passing
179
+ * `undefined` restores the real addon.
180
+ */
181
+ export function setSessionStateLockNativeBindings(load: SessionStateLockNativeLoader | undefined): void {
182
+ injectedNativeLoader = load;
183
+ }
184
+
185
+ /**
186
+ * Resolve the deletion primitives, loading the addon on first real use.
187
+ *
188
+ * @throws {SessionStateLockUnavailableError} when they cannot be loaded. There is no
189
+ * `fs.rm` fallback: an unavailable identity-bound delete means this process cannot prove
190
+ * what it would be deleting, and deleting anyway is the exact defect these primitives fix.
191
+ */
192
+ function nativeSessionStateLock(): SessionStateLockNativeBindings {
193
+ try {
194
+ if (injectedNativeLoader) return injectedNativeLoader();
195
+ if (!loadedNativeBindings)
196
+ loadedNativeBindings = require("@gajae-code/natives") as SessionStateLockNativeBindings;
197
+ return loadedNativeBindings;
198
+ } catch (error) {
199
+ throw error instanceof SessionStateLockUnavailableError ? error : new SessionStateLockUnavailableError(error);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Start time stamped into a NEW owner record.
205
+ *
206
+ * Linux `/proc` is preferred because it is the value base owners on that platform were
207
+ * written from, so a base reader still recognizes this incarnation. Everywhere else the
208
+ * portable `ps` value is used instead of giving up and writing `unknown`, which would
209
+ * make PID reuse undetectable.
210
+ */
211
+ function ownerStartTime(pid: number): string {
212
+ return readLinuxProcStartTimeSync(pid) ?? portableProcessStartTime(pid) ?? "unknown";
213
+ }
214
+
215
+ function validLockOwner(value: unknown): value is SessionStateLockOwner {
216
+ if (!value || typeof value !== "object") return false;
217
+ const owner = value as Partial<SessionStateLockOwner>;
218
+ return (
219
+ typeof owner.pid === "number" &&
220
+ Number.isSafeInteger(owner.pid) &&
221
+ owner.pid > 0 &&
222
+ typeof owner.start_time === "string" &&
223
+ typeof owner.token === "string" &&
224
+ owner.token.length > 0
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Whether the process holding `owner` is still the incarnation that took the lock.
230
+ *
231
+ * Owner records exist in three vintages: base `unknown`, base Linux `/proc` ticks, and
232
+ * the portable `ps` timestamp written today. A recorded value matching EITHER reader is
233
+ * the same incarnation. Only when a reader actually produced a value and NO reader agreed
234
+ * is the mismatch proved — an unreadable or unknown start time is indeterminate, and
235
+ * indeterminate never means "safe to steal".
236
+ */
237
+ function sameOwnerIncarnation(owner: SessionStateLockOwner): boolean {
238
+ if (owner.start_time === "unknown" || owner.start_time.length === 0) return true;
239
+ const procStartTime = readLinuxProcStartTimeSync(owner.pid);
240
+ if (procStartTime !== null && procStartTime === owner.start_time) return true;
241
+ const psStartTime = portableProcessStartTime(owner.pid);
242
+ if (psStartTime !== null && psStartTime === owner.start_time) return true;
243
+ return procStartTime === null && psStartTime === null;
244
+ }
245
+
246
+ /**
247
+ * What one liveness probe of an owner pid can prove.
248
+ *
249
+ * `process.kill(pid, 0)` answers three different questions at once and only ONE of its
250
+ * answers means the owner is gone. `ESRCH` is that proof. `EPERM` is its opposite — the
251
+ * process exists, this one just may not signal it, which is the ordinary answer for an
252
+ * owner running as another user, under a different container UID, or behind a sandbox
253
+ * policy. Anything else is a question the OS declined to answer.
254
+ *
255
+ * The generic `mutation.lock` protocol classifies the same three answers, but its helper is
256
+ * private to that protocol and takes no probe seam; duplicating the classification here
257
+ * keeps this lock's semantics provable without widening the generic lock's surface.
258
+ */
259
+ type OwnerProcessLiveness = "alive" | "dead" | "unknown";
260
+
261
+ function probeOwnerProcess(pid: number): OwnerProcessLiveness {
262
+ try {
263
+ const probe = SessionStateLockTestHooks.probeProcessSignal;
264
+ if (probe) probe(pid);
265
+ else process.kill(pid, 0);
266
+ return "alive";
267
+ } catch (error) {
268
+ const code = (error as NodeJS.ErrnoException).code;
269
+ if (code === "ESRCH") return "dead";
270
+ return code === "EPERM" ? "alive" : "unknown";
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Whether the lock at hand still has an owner, for reclaim purposes.
276
+ *
277
+ * Only `dead` — a probe that PROVED the pid is gone, or a live pid whose recorded
278
+ * incarnation is provably not the one running — ever authorizes deleting the record.
279
+ * `unknown` liveness is reported as an owner, because a probe the OS refused to answer is
280
+ * not evidence of death, and the exact-identity unlink cannot undo a false verdict: the
281
+ * record still is the record that was judged, so the compare-and-delete matches and a live
282
+ * holder loses its lock.
283
+ */
284
+ function lockOwnerIsAlive(value: unknown): boolean {
285
+ if (!validLockOwner(value)) return false;
286
+ const owner = value;
287
+ const liveness = probeOwnerProcess(owner.pid);
288
+ if (liveness === "dead") return false;
289
+ if (liveness === "unknown") return true;
290
+ return sameOwnerIncarnation(owner);
291
+ }
292
+
293
+ /**
294
+ * The immutable identity of the EXACT owner bytes one decision was made from.
295
+ *
296
+ * Every field is read from the descriptor the bytes came off, never re-derived from the
297
+ * pathname afterwards, so it names one inode incarnation and one payload. `sha256` is over
298
+ * the bytes that were actually read, which is what makes "the record I judged" and "the
299
+ * record I am about to delete" the same provable object rather than the same string.
300
+ */
301
+ interface LockOwnerSnapshot {
302
+ dev: bigint;
303
+ ino: bigint;
304
+ nlink: bigint;
305
+ size: bigint;
306
+ mtimeNs: bigint;
307
+ sha256: string;
308
+ bytes: string;
309
+ }
310
+
311
+ /**
312
+ * No-follow, non-blocking read flags.
313
+ *
314
+ * `lstat` deciding the path is a regular file and a later `readFile(path)` are two
315
+ * syscalls on a MUTABLE pathname: in between, the path can become a symlink whose target
316
+ * this process would then read and reclaim, or a FIFO whose open never returns. Opening
317
+ * `O_RDONLY | O_NONBLOCK | O_NOFOLLOW` once and proving the TYPE from that descriptor
318
+ * removes the window entirely — a link refuses to open, a writerless FIFO opens without
319
+ * blocking and is then rejected by `fstat`.
320
+ */
321
+ const POSIX_OWNER_READ_FLAGS = POSIX_NOFOLLOW_AVAILABLE
322
+ ? fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK | fsSync.constants.O_NOFOLLOW
323
+ : undefined;
324
+
325
+ /** Which strategy the owner protocol runs under right now. */
326
+ function ownerAccessStrategy(): SessionStateLockOwnerAccessStrategy {
327
+ return SessionStateLockTestHooks.ownerAccessStrategy ?? DETECTED_OWNER_ACCESS_STRATEGY;
328
+ }
329
+
330
+ /** Whether two stat results name the same regular-file incarnation. */
331
+ function sameRegularFileIdentity(left: fsSync.BigIntStats, right: fsSync.BigIntStats): boolean {
332
+ return (
333
+ left.isFile() &&
334
+ right.isFile() &&
335
+ left.dev === right.dev &&
336
+ left.ino === right.ino &&
337
+ left.nlink === right.nlink &&
338
+ left.size === right.size &&
339
+ left.mtimeNs === right.mtimeNs
340
+ );
341
+ }
342
+
343
+ function ownerSnapshotFrom(stat: fsSync.BigIntStats, bytes: Buffer): LockOwnerSnapshot {
344
+ return {
345
+ dev: stat.dev,
346
+ ino: stat.ino,
347
+ nlink: stat.nlink,
348
+ size: stat.size,
349
+ mtimeNs: stat.mtimeNs,
350
+ sha256: createHash("sha256").update(bytes).digest("hex"),
351
+ bytes: bytes.toString("utf8"),
352
+ };
353
+ }
354
+
355
+ /** Capture the owner record through a descriptor whose no-follow type proof is its own. */
356
+ async function capturePosixLockOwner(lockFile: string, flags: number): Promise<LockOwnerSnapshot | null> {
357
+ let handle: fs.FileHandle;
358
+ try {
359
+ handle = await fs.open(lockFile, flags);
360
+ } catch (error) {
361
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
362
+ // `ELOOP` is the no-follow refusal itself; every other failure is equally unproven.
363
+ throw new SessionStateLockUnavailableError(error);
364
+ }
365
+ try {
366
+ const opened = await handle.stat({ bigint: true });
367
+ // Proved from the open descriptor, so this is the type of the object being read —
368
+ // not the type some earlier `lstat` saw at a pathname that has since moved on.
369
+ if (!opened.isFile())
370
+ throw new SessionStateLockUnavailableError(new Error("Lock path is not a regular owner file."));
371
+ const bytes = await handle.readFile();
372
+ const settled = await handle.stat({ bigint: true });
373
+ // The identity must bracket the read: a record rewritten while it was being read
374
+ // has no single payload, so it is reported as nothing rather than mis-identified.
375
+ if (!sameRegularFileIdentity(opened, settled) || settled.size !== BigInt(bytes.byteLength)) return null;
376
+ return ownerSnapshotFrom(settled, bytes);
377
+ } finally {
378
+ await handle.close().catch(() => undefined);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Capture the owner record where no no-follow descriptor exists.
384
+ *
385
+ * Windows offers neither `O_NOFOLLOW` nor `O_NONBLOCK`, so the type cannot be proved by
386
+ * the open itself. It is proved around it instead: an `lstat` BEFORE the open must already
387
+ * show a regular file, the opened descriptor's own `fstat` and a second `lstat` must both
388
+ * still be that same regular-file incarnation, and only then are any bytes read — from the
389
+ * handle, never from the pathname. A reparse point, a type swap, or an inode substitution
390
+ * anywhere in that bracket refuses or reports nothing, so no foreign object is ever read
391
+ * through and none is ever attributed an identity that could authorize its removal.
392
+ *
393
+ * This is selected only under the win32 strategy. It makes no claim about Unix FIFOs: a
394
+ * blocking open is precisely what `O_NONBLOCK` exists for, and that flag is what POSIX
395
+ * keeps.
396
+ */
397
+ async function captureWindowsLockOwner(lockFile: string, flags: number): Promise<LockOwnerSnapshot | null> {
398
+ let before: fsSync.BigIntStats;
399
+ try {
400
+ before = await fs.lstat(lockFile, { bigint: true });
401
+ } catch (error) {
402
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
403
+ throw new SessionStateLockUnavailableError(error);
404
+ }
405
+ if (before.isSymbolicLink())
406
+ throw new SessionStateLockUnavailableError(new Error("Lock path is a reparse point, not an owner file."));
407
+ if (!before.isFile())
408
+ throw new SessionStateLockUnavailableError(new Error("Lock path is not a regular owner file."));
409
+ let handle: fs.FileHandle;
410
+ try {
411
+ handle = await fs.open(lockFile, flags);
412
+ } catch (error) {
413
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
414
+ throw new SessionStateLockUnavailableError(error);
415
+ }
416
+ try {
417
+ const opened = await handle.stat({ bigint: true });
418
+ const relinked = await fs.lstat(lockFile, { bigint: true }).catch(() => null);
419
+ if (relinked?.isSymbolicLink() === true)
420
+ throw new SessionStateLockUnavailableError(new Error("Lock path became a reparse point under the read."));
421
+ // The open landed on the object the pre-`lstat` judged, and the pathname still
422
+ // names it. Either disagreement means the bytes have no attributable identity.
423
+ if (!sameRegularFileIdentity(before, opened)) return null;
424
+ if (!relinked || !sameRegularFileIdentity(before, relinked)) return null;
425
+ const bytes = await handle.readFile();
426
+ const settled = await handle.stat({ bigint: true });
427
+ if (!sameRegularFileIdentity(opened, settled) || settled.size !== BigInt(bytes.byteLength)) return null;
428
+ return ownerSnapshotFrom(settled, bytes);
429
+ } finally {
430
+ await handle.close().catch(() => undefined);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Capture the regular `<file>.lock` owner record, or prove nothing is there to capture.
436
+ *
437
+ * @returns `null` when the path holds no record this call can speak for: it is absent, or
438
+ * it changed underneath the read, so no identity can be attributed to the bytes obtained.
439
+ * @throws {SessionStateLockUnavailableError} when the path is occupied by something the
440
+ * owner protocol never writes, or when no strategy can read one safely here. Nothing is
441
+ * read through it and nothing is removed.
442
+ */
443
+ async function captureRegularLockOwner(lockFile: string): Promise<LockOwnerSnapshot | null> {
444
+ const strategy = ownerAccessStrategy();
445
+ if (strategy === "windows-validated") return await captureWindowsLockOwner(lockFile, fsSync.constants.O_RDONLY);
446
+ if (strategy === "posix-nofollow" && POSIX_OWNER_READ_FLAGS !== undefined)
447
+ return await capturePosixLockOwner(lockFile, POSIX_OWNER_READ_FLAGS);
448
+ throw new SessionStateLockUnavailableError(new Error("No-follow owner reads are unsupported on this platform."));
449
+ }
450
+
451
+ /** Whether two captures name the same inode incarnation holding the same bytes. */
452
+ function sameLockOwnerSnapshot(left: LockOwnerSnapshot, right: LockOwnerSnapshot): boolean {
453
+ return (
454
+ left.dev === right.dev &&
455
+ left.ino === right.ino &&
456
+ left.nlink === right.nlink &&
457
+ left.size === right.size &&
458
+ left.mtimeNs === right.mtimeNs &&
459
+ left.sha256 === right.sha256
460
+ );
461
+ }
462
+
463
+ /**
464
+ * What an identity-bound removal is allowed to conclude.
465
+ *
466
+ * `refused` is the safety verdict and is never treated as progress: the canonical pathname
467
+ * was not provably detached, so it may still hold an object this process does not own.
468
+ */
469
+ type ExactRemovalOutcome = "removed" | "absent" | "identity_mismatch" | "refused";
470
+
471
+ /**
472
+ * A single-component quarantine destination, required by the native primitive so that
473
+ * authority over a detached record survives a crash between detach and unlink.
474
+ */
475
+ function lockQuarantineName(): string {
476
+ return `.gjc-delete-session-state-lock-${randomUUID()}.json`;
477
+ }
478
+
479
+ /**
480
+ * Delete the regular owner record at `file`, but only while it is still EXACTLY `identity`.
481
+ *
482
+ * @throws {SessionStateLockUnavailableError} when the primitive itself cannot run. It is
483
+ * never downgraded to `fs.rm`: not knowing what is at the pathname is precisely the state
484
+ * in which deleting it destroys a successor's lock.
485
+ */
486
+ function exactUnlinkOwnerRecord(file: string, identity: LockOwnerSnapshot): ExactRemovalOutcome {
487
+ let result: NativeExactUnlinkResult;
488
+ try {
489
+ result = nativeSessionStateLock().exactUnlink(file, {
490
+ dev: identity.dev,
491
+ ino: identity.ino,
492
+ nlink: identity.nlink,
493
+ size: identity.size,
494
+ mtimeNs: identity.mtimeNs,
495
+ sha256: identity.sha256,
496
+ quarantineName: lockQuarantineName(),
497
+ });
498
+ } catch (error) {
499
+ throw new SessionStateLockUnavailableError(error);
500
+ }
501
+ if (result.ok) return "removed";
502
+ if (result.code === "not_found") return "absent";
503
+ if (result.code === "identity_mismatch") return "identity_mismatch";
504
+ // `cleanup_pending` means the canonical name WAS detached and only the quarantined
505
+ // copy of our own record is still around, so the pathname is free — but only when the
506
+ // retained artifact is durable and no successor or unidentified object was retained.
507
+ // Any retained successor makes this a refusal: the pathname is not ours to reuse.
508
+ if (
509
+ result.code === "cleanup_pending" &&
510
+ result.payloadDurable === true &&
511
+ result.detachedPath !== undefined &&
512
+ result.retainedSuccessorPath === undefined &&
513
+ result.retainedUnknownPath === undefined
514
+ )
515
+ return "removed";
516
+ return "refused";
517
+ }
518
+
519
+ /**
520
+ * Flags for taking an owner record: create it or fail, and never through a link.
521
+ *
522
+ * `O_EXCL` is what makes the claim exclusive, and `O_NOFOLLOW` is what keeps a pre-planted
523
+ * symlink at the path from turning that create into a write somewhere else.
524
+ *
525
+ * Windows has no `O_NOFOLLOW`, but it does not need one here: create-new semantics refuse
526
+ * ANY pre-existing final component, symlink and reparse point included, so the create can
527
+ * only ever land on an object this call brought into existence. The identity is then taken
528
+ * from that writing handle, so the record authorized for removal is the one just written
529
+ * rather than whatever the pathname resolves to afterwards.
530
+ */
531
+ const POSIX_OWNER_CREATE_FLAGS =
532
+ typeof fsSync.constants.O_NOFOLLOW === "number"
533
+ ? fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY | fsSync.constants.O_NOFOLLOW
534
+ : undefined;
535
+
536
+ const CREATE_NEW_OWNER_FLAGS = fsSync.constants.O_CREAT | fsSync.constants.O_EXCL | fsSync.constants.O_WRONLY;
537
+
538
+ function ownerCreateFlags(): number | undefined {
539
+ const strategy = ownerAccessStrategy();
540
+ if (strategy === "windows-validated") return CREATE_NEW_OWNER_FLAGS;
541
+ return strategy === "posix-nofollow" ? POSIX_OWNER_CREATE_FLAGS : undefined;
542
+ }
543
+
544
+ function newLockOwner(): SessionStateLockOwner {
545
+ return { pid: process.pid, start_time: ownerStartTime(process.pid), token: randomUUID() };
546
+ }
547
+
548
+ /**
549
+ * The object a descriptor is currently open on.
550
+ *
551
+ * `dev`/`ino` and nothing else: this names the OBJECT rather than a payload or a moment,
552
+ * which is exactly what an open descriptor pins. While the descriptor is open the inode
553
+ * cannot be recycled, so an equal pair at a pathname can only be that same object — never
554
+ * a successor that happened to land on a reused inode number.
555
+ */
556
+ interface OpenOwnerIdentity {
557
+ dev: bigint;
558
+ ino: bigint;
559
+ }
560
+
561
+ /** Identity of the regular file `handle` is open on, or `null` when it cannot be proved. */
562
+ async function openOwnerIdentity(handle: fs.FileHandle): Promise<OpenOwnerIdentity | null> {
563
+ try {
564
+ const stat = await handle.stat({ bigint: true });
565
+ return stat.isFile() ? { dev: stat.dev, ino: stat.ino } : null;
566
+ } catch {
567
+ return null;
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Retract the record a failed write left behind — and only that record.
573
+ *
574
+ * The authority to delete it belongs to the writer's OWN open file, not to whatever the
575
+ * pathname names by the time this runs. Those are different objects far more often than
576
+ * the window suggests: the partial record can be stale-reclaimed and the freed pathname
577
+ * taken by a successor between the fault and this cleanup. Capturing "whatever is there
578
+ * now" and handing it to the identity-bound unlink deletes that successor's live lock, and
579
+ * the compare-and-delete cannot object — the capture and the authorization would be the
580
+ * same foreign object.
581
+ *
582
+ * So the created descriptor is still open here, and it stays open: it is what makes the
583
+ * comparison sound instead of merely likely, because the created inode cannot be recycled
584
+ * underneath a successor while this process holds it. Only when a fresh no-follow capture
585
+ * of the pathname proves to be that same still-open object is the existing exact unlink
586
+ * allowed to run — and it then closes the remaining capture-to-unlink race on the bytes.
587
+ *
588
+ * Anything short of that proof — a descriptor that no longer answers, a capture that
589
+ * refuses or reports a type swap, a different object at the path — leaves the pathname
590
+ * exactly as it is for the normal stale protocol.
591
+ */
592
+ async function retractFailedOwnerRecord(
593
+ file: string,
594
+ handle: fs.FileHandle,
595
+ created: OpenOwnerIdentity | null,
596
+ ): Promise<void> {
597
+ if (!created) return;
598
+ const stillOpen = await openOwnerIdentity(handle);
599
+ if (!stillOpen || stillOpen.dev !== created.dev || stillOpen.ino !== created.ino) return;
600
+ const current = await captureRegularLockOwner(file).catch(() => null);
601
+ if (!current || current.dev !== created.dev || current.ino !== created.ino) return;
602
+ exactUnlinkOwnerRecord(file, current);
603
+ }
604
+
605
+ /**
606
+ * Take `file` as a regular owner record and capture the exact identity of the bytes just
607
+ * written, from the SAME descriptor that wrote them.
608
+ *
609
+ * That identity is the only thing that will ever authorize deleting this record again, so
610
+ * it has to describe the object on disk rather than the string that was handed over. The
611
+ * same descriptor is what authorizes retracting the record when the write FAILS: it is
612
+ * held open across the whole cleanup, so the object this call created cannot be confused
613
+ * with a successor that took the pathname after a stale reclaim freed it.
614
+ *
615
+ * The deletion primitive is resolved BEFORE the record is created. A record this process
616
+ * cannot ever remove is worse than no record at all: it is indistinguishable from a live
617
+ * holder, so it would strand the pathname for every later contender instead of failing the
618
+ * one call that could not proceed.
619
+ *
620
+ * @throws `EEXIST` (or `EISDIR`/`EPERM`) unchanged, so callers can tell contention apart
621
+ * from failure.
622
+ */
623
+ async function createOwnerLock(file: string, owner: SessionStateLockOwner): Promise<LockOwnerSnapshot> {
624
+ const flags = ownerCreateFlags();
625
+ if (flags === undefined)
626
+ throw new SessionStateLockUnavailableError(
627
+ new Error("Safe owner record creation is unsupported on this platform."),
628
+ );
629
+ nativeSessionStateLock();
630
+ const handle = await fs.open(file, flags);
631
+ const bytes = Buffer.from(JSON.stringify(owner), "utf8");
632
+ // Taken from the SAME descriptor the exclusive create produced, before anything is
633
+ // written through it. `O_EXCL` proves the object did not exist a moment ago, so this
634
+ // pair names the record this call brought into existence and nothing else.
635
+ let created: OpenOwnerIdentity | null = null;
636
+ try {
637
+ created = await openOwnerIdentity(handle);
638
+ await SessionStateLockTestHooks.ownerRecordWriteFault?.(file);
639
+ await handle.writeFile(bytes);
640
+ const stat = await handle.stat({ bigint: true });
641
+ if (!stat.isFile() || stat.size !== BigInt(bytes.byteLength))
642
+ throw new SessionStateLockUnavailableError(new Error("Owner record did not land as a regular file."));
643
+ return ownerSnapshotFrom(stat, bytes);
644
+ } catch (error) {
645
+ // Deliberately BEFORE any close: releasing the descriptor first would free the
646
+ // created inode for reuse and turn a successor into a match.
647
+ try {
648
+ await retractFailedOwnerRecord(file, handle, created);
649
+ } catch (cleanupError) {
650
+ throw new AggregateError(
651
+ [error, cleanupError],
652
+ "Owner record write failed and its identity-bound cleanup also failed.",
653
+ );
654
+ }
655
+ throw error;
656
+ } finally {
657
+ await handle.close().catch(() => undefined);
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Give up an owner record this process holds.
663
+ *
664
+ * A mismatch means something replaced our record while we held it. That successor is left
665
+ * strictly alone and the caller is told the lock is no longer trustworthy, because the
666
+ * alternative — deleting whatever is there now — is the exact bug this protocol exists to
667
+ * remove.
668
+ */
669
+ function releaseOwnerLock(file: string, held: LockOwnerSnapshot): void {
670
+ const outcome = exactUnlinkOwnerRecord(file, held);
671
+ if (outcome === "removed" || outcome === "absent") return;
672
+ throw new SessionStateLockUnavailableError(new Error(`Owner record could not be released: ${outcome}.`));
673
+ }
674
+
675
+ /**
676
+ * Reclaim a regular owner record whose owner is dead, or whose malformed bytes have
677
+ * outlived the stale window.
678
+ *
679
+ * The identity is re-captured immediately before the delete, and the delete itself refuses
680
+ * unless the object still carries it. That second half is what makes this safe against a
681
+ * base or legacy writer, which takes no claim and can create the pathname at any instant:
682
+ * a successor is reported as `identity_mismatch` and survives untouched, and the caller
683
+ * simply retries.
684
+ */
685
+ async function reclaimStaleOwnerRecord(
686
+ file: string,
687
+ hooks: {
688
+ afterInspection?: (file: string) => void | Promise<void>;
689
+ beforeRemoval?: (file: string) => void | Promise<void>;
690
+ },
691
+ ): Promise<void> {
692
+ const snapshot = await captureRegularLockOwner(file);
693
+ if (!snapshot) return;
694
+ let owner: unknown;
695
+ try {
696
+ owner = JSON.parse(snapshot.bytes);
697
+ } catch {
698
+ owner = null;
699
+ }
700
+ if (!validLockOwner(owner)) {
701
+ // The mtime of the very inode the bytes were read from, not a fresh path `stat`.
702
+ if (Date.now() - Number(snapshot.mtimeNs / 1_000_000n) < LOCK_STALE_MS) return;
703
+ } else if (lockOwnerIsAlive(owner)) return;
704
+ await hooks.afterInspection?.(file);
705
+ const current = await captureRegularLockOwner(file);
706
+ if (!current || !sameLockOwnerSnapshot(current, snapshot)) return;
707
+ await hooks.beforeRemoval?.(file);
708
+ const outcome = exactUnlinkOwnerRecord(file, current);
709
+ // A successor that took the path in the final window keeps it; this call just loses.
710
+ if (outcome === "refused")
711
+ throw new SessionStateLockUnavailableError(new Error("Stale owner record could not be reclaimed."));
712
+ }
713
+
714
+ /**
715
+ * Run one pathname transition of `<file>.lock` with no other CURRENT contender able to
716
+ * create or remove that path.
717
+ *
718
+ * The claim is always released, including when the transition throws, so a fault leaves
719
+ * behind at most the lock file itself — which the normal stale protocol then handles.
720
+ */
721
+ async function withLockPathTransition<T>(lockFile: string, transition: () => Promise<T>): Promise<T> {
722
+ const transitionFile = `${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`;
723
+ const owner = newLockOwner();
724
+ for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
725
+ let held: LockOwnerSnapshot;
726
+ try {
727
+ held = await createOwnerLock(transitionFile, owner);
728
+ } catch (error) {
729
+ if (error instanceof SessionStateLockUnavailableError) throw error;
730
+ const code = (error as NodeJS.ErrnoException).code;
731
+ if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM")
732
+ throw new SessionStateLockUnavailableError(error);
733
+ await reclaimStaleOwnerRecord(transitionFile, {
734
+ afterInspection: SessionStateLockTestHooks.afterTransitionStaleInspection,
735
+ beforeRemoval: SessionStateLockTestHooks.beforeTransitionStaleRemoval,
736
+ });
737
+ await Bun.sleep(LOCK_ACQUIRE_RETRY_MS);
738
+ continue;
739
+ }
740
+ const outcome = await transition().then(
741
+ value => ({ ok: true as const, value }),
742
+ error => ({ ok: false as const, error }),
743
+ );
744
+ try {
745
+ releaseOwnerLock(transitionFile, held);
746
+ } catch (releaseError) {
747
+ // A claim that cannot be given up leaves the lock untrustworthy either way, so
748
+ // the typed refusal is what callers see; the transition's own fault rides along
749
+ // as the cause rather than replacing it.
750
+ if (!outcome.ok)
751
+ throw new SessionStateLockUnavailableError(
752
+ new AggregateError([outcome.error, releaseError], "Lock path transition and release both failed."),
753
+ );
754
+ throw releaseError;
755
+ }
756
+ if (!outcome.ok) throw outcome.error;
757
+ return outcome.value;
758
+ }
759
+ throw new SessionStateLockUnavailableError();
760
+ }
761
+
762
+ /**
763
+ * Reclaim the base regular-file `<file>.lock` when its owner is dead, or when a malformed
764
+ * record has outlived the stale window.
765
+ *
766
+ * Two independent guarantees, because one contender class each defeats the other one:
767
+ * the transition claim keeps CURRENT writers of this protocol out of the create/delete
768
+ * window, and the identity-bound delete keeps a BASE writer — which takes no claim and
769
+ * just creates the pathname — from having its brand-new lock unlinked.
770
+ */
771
+ async function reclaimStaleRegularLock(lockFile: string): Promise<void> {
772
+ await withLockPathTransition(
773
+ lockFile,
774
+ async () =>
775
+ await reclaimStaleOwnerRecord(lockFile, {
776
+ afterInspection: SessionStateLockTestHooks.afterStaleInspection,
777
+ beforeRemoval: SessionStateLockTestHooks.beforeStaleRemoval,
778
+ }),
779
+ );
780
+ }
781
+
782
+ /**
783
+ * Whether two tree captures describe the same directory down to every entry.
784
+ *
785
+ * Structural equality over the evidence the native primitive itself consumes: the root's
786
+ * own `dev`/`ino`, and each entry's position, kind, inode identity, and content hash. A
787
+ * successor that recreated the path is a different root inode; a successor that reused it
788
+ * differs in some entry. Neither can look like survival.
789
+ */
790
+ function sameDirectoryTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
791
+ if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno) return false;
792
+ if (left.entries.length !== right.entries.length) return false;
793
+ return left.entries.every((entry, index) => {
794
+ const other = right.entries[index];
795
+ return (
796
+ other !== undefined &&
797
+ entry.relativePath === other.relativePath &&
798
+ entry.kind === other.kind &&
799
+ entry.dev === other.dev &&
800
+ entry.ino === other.ino &&
801
+ entry.nlink === other.nlink &&
802
+ entry.size === other.size &&
803
+ entry.mtimeNs === other.mtimeNs &&
804
+ entry.ctimeNs === other.ctimeNs &&
805
+ entry.sha256 === other.sha256
806
+ );
807
+ });
808
+ }
809
+
810
+ /**
811
+ * Capture the exact tree at `lockDir`, or prove there is nothing there to capture.
812
+ *
813
+ * @returns `null` when the path is gone. A symlink, a special file, or anything else that
814
+ * cannot be described entry-by-entry is not a legacy lock directory.
815
+ * @throws {SessionStateLockUnavailableError} when the tree exists but cannot be described
816
+ * exactly. A tree that cannot be described exactly cannot be removed exactly, so its bytes
817
+ * stay where they are and the caller is told the lock is unusable.
818
+ */
819
+ function captureLegacyDirectoryTree(
820
+ native: SessionStateLockNativeBindings,
821
+ lockDir: string,
822
+ ): NativeDirectoryTreeSnapshot | null {
823
+ let captured: NativeDirectoryTreeResult;
824
+ try {
825
+ captured = native.snapshotDirectoryTree(lockDir);
826
+ } catch (error) {
827
+ throw new SessionStateLockUnavailableError(error);
828
+ }
829
+ if (captured.ok && captured.snapshot) return captured.snapshot;
830
+ if (captured.code === "not_found") return null;
831
+ throw new SessionStateLockUnavailableError(
832
+ new Error(`Legacy lock directory could not be captured: ${captured.code ?? "unknown"}.`),
833
+ );
834
+ }
835
+
836
+ /**
837
+ * Evaluate a `<file>.lock/` DIRECTORY left behind by a base runtime that guarded this same
838
+ * state file with the generic directory-style lock.
839
+ *
840
+ * A directory at this path makes an exclusive create fail `EISDIR` forever, which is
841
+ * exactly the stranding this shared lock exists to prevent — in the other direction.
842
+ *
843
+ * The VERDICT stays with the generic implementation, which owns that format: duplicating
844
+ * its parser and liveness rules is how a live owner gets reaped by a timestamp it never
845
+ * wrote. Only the REMOVAL is taken over, because that protocol can offer nothing better
846
+ * than re-reading a token and then unlinking a pathname — and a successor can change the
847
+ * tree underneath a token that still reads the same.
848
+ *
849
+ * But the verdict is rendered against a PATHNAME, and the object it judged is not the
850
+ * object that would be deleted. A legacy writer takes no transition claim, so it can
851
+ * remove the judged directory and create a brand-new LIVE one at the same path; capturing
852
+ * "whatever is there now" hands the successor's own tree to the exact removal, which then
853
+ * matches and deletes a live lock. The compare-and-delete protected the object it was
854
+ * given — the authorization simply belonged to a different one.
855
+ *
856
+ * So the identity BRACKETS the verdict: the tree is captured before it, captured again
857
+ * after it, and removed only when both captures are the same tree. Any replacement in that
858
+ * window makes the two disagree and the reclaim declines, leaving the successor untouched.
859
+ *
860
+ * Nothing creates such a directory at this path anymore.
861
+ */
862
+ async function reclaimStaleDirectoryLock(lockFile: string): Promise<void> {
863
+ await withLockPathTransition(lockFile, async () => {
864
+ const native = nativeSessionStateLock();
865
+ const before = captureLegacyDirectoryTree(native, lockFile);
866
+ if (!before) return;
867
+ if (!(await genericFileLockDirIsStale(lockFile, LOCK_STALE_MS))) return;
868
+ await SessionStateLockTestHooks.afterLegacyDirectoryStaleVerdict?.(lockFile);
869
+ const authorized = captureLegacyDirectoryTree(native, lockFile);
870
+ // The verdict spoke for `before`; only an unchanged tree carries that authority.
871
+ if (!authorized || !sameDirectoryTreeSnapshot(before, authorized)) return;
872
+ await SessionStateLockTestHooks.beforeLegacyDirectoryRemoval?.(lockFile);
873
+ let removed: NativeExactUnlinkResult;
874
+ try {
875
+ // The SAME verified capture the verdict was bound to, so a replacement that
876
+ // lands after this point is still refused by the primitive itself.
877
+ removed = native.exactRemoveDirectoryTree(lockFile, authorized);
878
+ } catch (error) {
879
+ throw new SessionStateLockUnavailableError(error);
880
+ }
881
+ if (removed.ok || removed.code === "not_found") return;
882
+ // Current natives may finish the security-critical phase by durably scrubbing the
883
+ // authorized tree and detaching it to the one replayable `.removing` name. That
884
+ // retained cleanup authority is not a live lock: acquisition may continue only when
885
+ // the typed receipt names exactly that sibling and the original namespace is still
886
+ // absent. A successor already at the lock path remains authoritative and fails closed.
887
+ if (
888
+ removed.code === "cleanup_pending" &&
889
+ removed.payloadDurable === true &&
890
+ removed.detachedPath === `${lockFile}.removing`
891
+ ) {
892
+ try {
893
+ await fs.lstat(lockFile);
894
+ } catch (error) {
895
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
896
+ throw new SessionStateLockUnavailableError(error);
897
+ }
898
+ }
899
+ // The tree changed after it was captured, so it belongs to a successor now.
900
+ if (removed.code === "identity_mismatch") return;
901
+ throw new SessionStateLockUnavailableError(
902
+ new Error(`Legacy lock directory could not be removed: ${removed.code ?? "unknown"}.`),
903
+ );
904
+ });
905
+ }
906
+
907
+ /**
908
+ * Decide what actually occupies the lock path, without following it.
909
+ *
910
+ * @internal exported as the seam these decisions are tested through: a live legacy
911
+ * directory owner must be provably left alone without waiting out a real stale window.
912
+ */
913
+ export async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
914
+ let stat: fsSync.Stats;
915
+ try {
916
+ stat = await fs.lstat(lockFile);
917
+ } catch (error) {
918
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
919
+ throw error;
920
+ }
921
+ if (stat.isDirectory()) {
922
+ await reclaimStaleDirectoryLock(lockFile);
923
+ return;
924
+ }
925
+ // A symlink, FIFO, socket, or device is not a shape either lock protocol writes.
926
+ // Opening one follows an attacker-chosen target and a FIFO read blocks forever, so the
927
+ // path is refused outright rather than inspected or removed.
928
+ if (!stat.isFile()) throw new SessionStateLockUnavailableError();
929
+ await SessionStateLockTestHooks.afterLockTypeDecision?.(lockFile);
930
+ await reclaimStaleRegularLock(lockFile);
931
+ }
932
+
933
+ /**
934
+ * Serialize one read-modify-write of `stateFile` against every other holder of
935
+ * `<stateFile>.lock`.
936
+ *
937
+ * Taking the path, cleaning up a failed write, and releasing are all pathname transitions,
938
+ * so each runs under the transition claim — never the caller's operation, which holds the
939
+ * lock file itself and may run for as long as it needs.
940
+ */
941
+ export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
942
+ const lockFile = `${stateFile}.lock`;
943
+ const owner = newLockOwner();
944
+ await fs.mkdir(path.dirname(stateFile), { recursive: true });
945
+ for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
946
+ let held: LockOwnerSnapshot | undefined;
947
+ try {
948
+ // Contention (`EEXIST`, or `EISDIR`/`EPERM` for a legacy directory owner)
949
+ // propagates out of the claim to the evaluation below; the claim is released
950
+ // first, so the reclaim that follows can take it.
951
+ held = await withLockPathTransition(lockFile, () => createOwnerLock(lockFile, owner));
952
+ let outcome: { ok: true; value: T } | { ok: false; error: unknown };
953
+ try {
954
+ outcome = { ok: true, value: await operation() };
955
+ } catch (error) {
956
+ outcome = { ok: false, error };
957
+ }
958
+ const record = held;
959
+ // Released against the identity captured when it was written, so a record that
960
+ // is no longer ours is left for its owner rather than unlinked by name.
961
+ let releaseFailure: { error: unknown } | undefined;
962
+ try {
963
+ await withLockPathTransition(lockFile, async () => releaseOwnerLock(lockFile, record));
964
+ } catch (error) {
965
+ releaseFailure = { error };
966
+ }
967
+ if (!outcome.ok) {
968
+ if (releaseFailure)
969
+ throw new AggregateError(
970
+ [outcome.error, releaseFailure.error],
971
+ "Session state operation failed and its owner record could not be released.",
972
+ );
973
+ throw outcome.error;
974
+ }
975
+ if (releaseFailure) throw releaseFailure.error;
976
+ return outcome.value;
977
+ } catch (error) {
978
+ // A fault after the lock was taken belongs to the operation, not to acquisition.
979
+ if (held) throw error;
980
+ // A legacy `<file>.lock/` directory reports EISDIR (EPERM on some platforms);
981
+ // both are contention to be evaluated, not a hard failure.
982
+ const code = (error as NodeJS.ErrnoException).code;
983
+ if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM")
984
+ throw error instanceof SessionStateLockUnavailableError
985
+ ? error
986
+ : new SessionStateLockUnavailableError(error);
987
+ await reclaimStaleSessionStateLock(lockFile);
988
+ await Bun.sleep(LOCK_ACQUIRE_RETRY_MS);
989
+ }
990
+ }
991
+ throw new SessionStateLockUnavailableError();
992
+ }