@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
@@ -21,6 +21,12 @@ import { DiscordNotificationDaemon } from "./discord-daemon";
21
21
 
22
22
  import { DiscordLiveProvider } from "./discord-live-provider";
23
23
  import type { DiscordProvider } from "./discord-provider";
24
+ import {
25
+ type DoctorDaemonIdentity,
26
+ type DoctorDaemonOccupancy,
27
+ type DoctorDaemonStatus,
28
+ doctorDaemonOccupancySettled,
29
+ } from "./doctor-daemon-restart";
24
30
  import { type NotificationEvent, NotificationPresentationEngine } from "./engine";
25
31
  import { SlackNotificationDaemon } from "./slack-daemon";
26
32
  import { SlackLiveProvider } from "./slack-live-provider";
@@ -299,6 +305,11 @@ export class ChatDaemonRuntime {
299
305
  #slack: SlackNotificationDaemon | undefined;
300
306
  #presentation: NotificationPresentationEngine | undefined;
301
307
  #transportHealthy: (() => boolean) | undefined;
308
+ #doctorPrepared = false;
309
+ /** Real inbound `/sdk` command dispatches currently admitted and running. */
310
+ readonly #inboundInFlight = new Set<Promise<unknown>>();
311
+ /** Real outbound provider notify/resume deliveries currently in flight. */
312
+ readonly #outboundInFlight = new Set<Promise<unknown>>();
302
313
 
303
314
  constructor(
304
315
  private readonly input: { kind: ChatDaemonKind; agentDir: string; config: ChatDaemonRuntimeConfig },
@@ -317,6 +328,24 @@ export class ChatDaemonRuntime {
317
328
  });
318
329
  }
319
330
 
331
+ #trackInbound<T>(work: Promise<T>): Promise<T> {
332
+ this.#inboundInFlight.add(work);
333
+ void work.then(
334
+ () => this.#inboundInFlight.delete(work),
335
+ () => this.#inboundInFlight.delete(work),
336
+ );
337
+ return work;
338
+ }
339
+
340
+ #trackOutbound<T>(work: Promise<T>): Promise<T> {
341
+ this.#outboundInFlight.add(work);
342
+ void work.then(
343
+ () => this.#outboundInFlight.delete(work),
344
+ () => this.#outboundInFlight.delete(work),
345
+ );
346
+ return work;
347
+ }
348
+
320
349
  async start(): Promise<void> {
321
350
  const replayReady = Promise.withResolvers<void>();
322
351
  this.#replayReady = replayReady.promise;
@@ -443,6 +472,51 @@ export class ChatDaemonRuntime {
443
472
  return this.#transportHealthy?.() ?? false;
444
473
  }
445
474
 
475
+ /** Doctor reservation hook: stop admitting new `/sdk` command dispatch. Synchronous fence. */
476
+ prepareDoctorRestart(): boolean {
477
+ this.#doctorPrepared = true;
478
+ return true;
479
+ }
480
+
481
+ cancelDoctorRestart(): void {
482
+ if (!this.#stopping) this.#doctorPrepared = false;
483
+ }
484
+
485
+ /**
486
+ * Real runtime occupancy, not synthetic placeholders. `attached` is live
487
+ * session attachments; `inflight` is attachment (re)publication barriers
488
+ * currently resolving; `inbound` is admitted `/sdk` command dispatches
489
+ * (including operator `bindExistingRoot` binds) actually executing;
490
+ * `outbound` is real Discord/Slack notify/resume provider deliveries in
491
+ * flight; `cleanup` is provider close/retire work that has not yet
492
+ * settled.
493
+ */
494
+ doctorOccupancy(): DoctorDaemonOccupancy {
495
+ return {
496
+ attached: this.#attachments.size,
497
+ inflight: this.#attachmentBarriers.size,
498
+ inbound: this.#inboundInFlight.size,
499
+ outbound: this.#outboundInFlight.size,
500
+ cleanup: this.#cleanupWork.size + this.#retirementWork.size,
501
+ };
502
+ }
503
+
504
+ doctorRestartStatus(identity: DoctorDaemonIdentity, requestId: string, leaseExpiresAt: number): DoctorDaemonStatus {
505
+ const occupancy = this.doctorOccupancy();
506
+ return {
507
+ ...identity,
508
+ requestId,
509
+ phase: this.#doctorPrepared ? "prepared" : "idle",
510
+ admitting: !this.#doctorPrepared && !this.#stopping,
511
+ occupancy,
512
+ leaseExpiresAt,
513
+ };
514
+ }
515
+
516
+ doctorRestartReady(): boolean {
517
+ return doctorDaemonOccupancySettled(this.doctorOccupancy());
518
+ }
519
+
446
520
  /** Reconcile indexed attachments and optionally wait for their replay tails. */
447
521
  async reconcile(options: { waitForReplay?: boolean } = {}): Promise<void> {
448
522
  await this.#router.reconcile(options);
@@ -487,8 +561,19 @@ export class ChatDaemonRuntime {
487
561
 
488
562
  /** Adopt an operator-supplied Slack root through the provider's presentation store. */
489
563
  async bindExistingRoot(request: ChatDaemonCommandBindInput): Promise<ChatDaemonCommandOutcome> {
564
+ // Operator binds admit new provider-facing work exactly like /sdk command
565
+ // dispatch: they must be refused under the same doctor admission fence, not
566
+ // only the slash-command path.
567
+ if (this.#doctorPrepared) return { ok: false, certainty: "rejected", code: "target_not_configured" };
490
568
  const slack = this.#slack;
491
569
  if (!slack) return { ok: false, certainty: "rejected", code: "target_not_configured" };
570
+ return await this.#trackInbound(this.#bindExistingRoot(slack, request));
571
+ }
572
+
573
+ async #bindExistingRoot(
574
+ slack: SlackNotificationDaemon,
575
+ request: ChatDaemonCommandBindInput,
576
+ ): Promise<ChatDaemonCommandOutcome> {
492
577
  try {
493
578
  const bound = await slack.bindExistingRoot(request.sessionId, request.rootTs, request.commitAuthority);
494
579
  if (!bound.rootTs || bound.endpointGeneration === undefined)
@@ -662,23 +747,27 @@ export class ChatDaemonRuntime {
662
747
  : undefined;
663
748
  if (typeof content !== "string") return;
664
749
  if (this.#discord)
665
- await this.#discord.notify({
666
- sessionId,
667
- endpointGeneration: attachment.generation,
668
- attachmentAuthorityId: attachment.authorityId,
669
- content,
670
- ...(publicationId === undefined ? {} : { publicationId }),
671
- ...(notification.type === "action_needed"
672
- ? { actionId: notification.id, options: notification.options }
673
- : {}),
674
- });
750
+ await this.#trackOutbound(
751
+ this.#discord.notify({
752
+ sessionId,
753
+ endpointGeneration: attachment.generation,
754
+ attachmentAuthorityId: attachment.authorityId,
755
+ content,
756
+ ...(publicationId === undefined ? {} : { publicationId }),
757
+ ...(notification.type === "action_needed"
758
+ ? { actionId: notification.id, options: notification.options }
759
+ : {}),
760
+ }),
761
+ );
675
762
  if (this.#slack)
676
- await this.#slack.notify(
677
- sessionId,
678
- content,
679
- notification.type === "action_needed" && notification.kind === "ask" ? notification.id : undefined,
680
- attachment.generation,
681
- publicationId,
763
+ await this.#trackOutbound(
764
+ this.#slack.notify(
765
+ sessionId,
766
+ content,
767
+ notification.type === "action_needed" && notification.kind === "ask" ? notification.id : undefined,
768
+ attachment.generation,
769
+ publicationId,
770
+ ),
682
771
  );
683
772
  }
684
773
 
@@ -721,16 +810,18 @@ export class ChatDaemonRuntime {
721
810
  publicationId?: string,
722
811
  ): Promise<void> {
723
812
  if (this.#discord) {
724
- await this.#discord.resume(sessionId, generation, attachmentAuthorityId);
725
- await this.#discord.notify({
726
- sessionId,
727
- endpointGeneration: generation,
728
- attachmentAuthorityId,
729
- content,
730
- ...(publicationId === undefined ? {} : { publicationId }),
731
- });
813
+ await this.#trackOutbound(this.#discord.resume(sessionId, generation, attachmentAuthorityId));
814
+ await this.#trackOutbound(
815
+ this.#discord.notify({
816
+ sessionId,
817
+ endpointGeneration: generation,
818
+ attachmentAuthorityId,
819
+ content,
820
+ ...(publicationId === undefined ? {} : { publicationId }),
821
+ }),
822
+ );
732
823
  }
733
- if (this.#slack) await this.#slack.resume(sessionId, content, generation, publicationId);
824
+ if (this.#slack) await this.#trackOutbound(this.#slack.resume(sessionId, content, generation, publicationId));
734
825
  }
735
826
 
736
827
  async #runChatCommand(
@@ -741,6 +832,31 @@ export class ChatDaemonRuntime {
741
832
  idempotencyKey: string = randomUUID(),
742
833
  beforeDispatch?: () => void,
743
834
  dispatchFence?: <T>(dispatch: () => Promise<T>) => Promise<T>,
835
+ ): Promise<boolean> {
836
+ // Fence BEFORE any admitted async work: a prepared doctor restart must
837
+ // refuse this dispatch synchronously, never merely self-report admitting.
838
+ if (this.#doctorPrepared) throw new ChatDeliveryError("pre_send");
839
+ return await this.#trackInbound(
840
+ this.#runChatCommandBody(
841
+ transport,
842
+ sessionId,
843
+ content,
844
+ expectedAttachment,
845
+ idempotencyKey,
846
+ beforeDispatch,
847
+ dispatchFence,
848
+ ),
849
+ );
850
+ }
851
+
852
+ async #runChatCommandBody(
853
+ transport: ChatTransport,
854
+ sessionId: string,
855
+ content: string,
856
+ expectedAttachment: SessionAttachment,
857
+ idempotencyKey: string,
858
+ beforeDispatch: (() => void) | undefined,
859
+ dispatchFence: (<T>(dispatch: () => Promise<T>) => Promise<T>) | undefined,
744
860
  ): Promise<boolean> {
745
861
  const match = /^\/sdk\s+(control|query|global)\s+([^\s]+)(?:\s+(.+))?\s*$/.exec(content);
746
862
  if (!match) return false;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared, hold-only cross-process startup/maintenance exclusion for the D8/D9
3
+ * doctor daemon-service flows.
4
+ *
5
+ * This guard NEVER publishes ownership or service state and NEVER starts,
6
+ * signals, or claims a daemon owner. It is purely a mutual-exclusion fence,
7
+ * built on the same authoritative file-lock machinery every other durable
8
+ * daemon lock in this codebase uses ({@link withFileLock}): bounded retry with
9
+ * backoff, honoring an optional `AbortSignal`, and exact-lease release on the
10
+ * way out (never a blind recursive `rm`, and never a PID kill).
11
+ *
12
+ * Why a separate guard file: `withFileLock` serializes on `<path>.lock`, a
13
+ * name distinct from every existing canonical artifact for a service
14
+ * (`telegram-daemon.lock`/`.state.json`/`.steal`, or `owner.lock`/`state.json`
15
+ * for Discord/Slack). Acquiring this guard therefore can never collide with,
16
+ * be satisfied by, or be mistaken for an ordinary ownership/publication lock,
17
+ * and ordinary publication code paths are free to acquire their own locks
18
+ * independently — this primitive only fences bulk-maintenance and startup
19
+ * repair work (e.g. detaching a stale Telegram `.steal` transition marker)
20
+ * against a concurrent ordinary `ensure`, and vice versa.
21
+ *
22
+ * If a caller already holds this guard, it must route any further
23
+ * `withDaemonStartupExclusion` acquisition for the SAME `(agentDir, owner)`
24
+ * pair through nested async work that never itself re-enters this function —
25
+ * `withFileLock`'s underlying acquisition has no reentrant/owner-token
26
+ * concept visible to callers, so a second acquisition attempt from the same
27
+ * async context is indistinguishable from unrelated contention and will
28
+ * deadlock against itself. Nothing in this module grants, threads, or
29
+ * consults any bypass flag or ambient/AsyncLocalStorage authority to avoid
30
+ * that; the caller is responsible for not awaiting a child operation that
31
+ * itself needs this same guard while still holding it.
32
+ */
33
+ import * as path from "node:path";
34
+ import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
35
+ import { CHAT_DAEMON_DIRECTORY } from "../service-artifact-paths";
36
+ import { daemonPaths } from "./daemon-paths";
37
+
38
+ export type DaemonStartupExclusionOwner = "telegram" | "discord" | "slack";
39
+
40
+ export interface DaemonStartupExclusionOptions {
41
+ /** Abort acquisition early; never releases a lease this call did not itself acquire. */
42
+ signal?: AbortSignal;
43
+ /** Upper bound on total wait while contended. Defaults to {@link DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS}. */
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ /** Matches the production wall-time bound already established for other durable daemon locks. */
48
+ export const DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS = 5_000;
49
+ const RETRY_DELAY_MS = 100;
50
+
51
+ /**
52
+ * The canonical per-service guard path. Lives inside the SAME per-service
53
+ * directory as the service's ordinary owner/state artifacts (so it inherits
54
+ * that directory's existing parent-permission posture) but under a filename
55
+ * reserved exclusively for this guard — never a name any ownership,
56
+ * publication, or transition-marker code path reads or writes.
57
+ *
58
+ * This function only computes a path; it performs no filesystem I/O and
59
+ * creates nothing. (`withFileLock` itself creates the missing parent
60
+ * directory chain, mode 0o700, purely as lock-acquisition scaffolding — an
61
+ * empty directory carries no service state.)
62
+ */
63
+ export function daemonStartupExclusionPath(agentDir: string, owner: DaemonStartupExclusionOwner): string {
64
+ const root = path.resolve(agentDir);
65
+ const dir = owner === "telegram" ? daemonPaths(root).dir : path.join(root, CHAT_DAEMON_DIRECTORY, owner);
66
+ return path.join(dir, ".startup-exclusion");
67
+ }
68
+
69
+ /**
70
+ * Run `operation` while holding the cross-process startup/maintenance
71
+ * exclusion for `(agentDir, owner)`. Bounded: acquisition retries back off
72
+ * for at most `timeoutMs` (default {@link DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS})
73
+ * and additionally honors `signal` if supplied — whichever bound is hit first
74
+ * rejects the acquisition. A rejected/timed-out/aborted acquisition never held
75
+ * a lease and therefore never releases one; it cannot touch a foreign owner's
76
+ * lock. On success, the lease this call acquired is released exactly once,
77
+ * through the same identity-bound release primitive `withFileLock` uses for
78
+ * every other durable lock in this codebase — never a recursive directory
79
+ * removal, and never a process signal.
80
+ *
81
+ * This primitive itself never reads, writes, or removes any owner/state/lock
82
+ * file belonging to the service; it only fences concurrent callers of this
83
+ * same function (and, by construction, is never consulted by ordinary
84
+ * ownership-acquisition or publication code, which uses its own distinct
85
+ * lock file).
86
+ */
87
+ export async function withDaemonStartupExclusion<T>(
88
+ agentDir: string,
89
+ owner: DaemonStartupExclusionOwner,
90
+ operation: () => Promise<T>,
91
+ options: DaemonStartupExclusionOptions = {},
92
+ ): Promise<T> {
93
+ return await withFileLock(daemonStartupExclusionPath(agentDir, owner), operation, exclusionOptions(options));
94
+ }
95
+
96
+ function exclusionOptions(options: DaemonStartupExclusionOptions): FileLockOptions {
97
+ const requested = options.timeoutMs ?? DAEMON_STARTUP_EXCLUSION_DEFAULT_TIMEOUT_MS;
98
+ if (!Number.isFinite(requested) || requested <= 0 || requested > 120_000)
99
+ throw new RangeError("invalid startup exclusion timeout");
100
+ const timeoutMs = Math.ceil(requested);
101
+ const retries = Math.max(Math.ceil(timeoutMs / RETRY_DELAY_MS), 1);
102
+ const deadline = AbortSignal.timeout(timeoutMs);
103
+ const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
104
+ const lockOptions: FileLockOptions = {
105
+ signal,
106
+ retries,
107
+ retryDelayMs: RETRY_DELAY_MS,
108
+ };
109
+ return lockOptions;
110
+ }
111
+
112
+ /** Retain the same exclusion across an existing acquire/release lifecycle. */
113
+ export async function acquireDaemonStartupExclusion(
114
+ agentDir: string,
115
+ owner: DaemonStartupExclusionOwner,
116
+ options: DaemonStartupExclusionOptions = {},
117
+ ): Promise<() => Promise<void>> {
118
+ return await acquireFileLock(daemonStartupExclusionPath(agentDir, owner), exclusionOptions(options));
119
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Doctor-side caller for the owner-driven daemon restart protocol (D8).
3
+ *
4
+ * The owner already implements prepare/commit/cancel against its own admission
5
+ * fence and occupancy (`chat-daemon-cli.ts`, `telegram-daemon-cli.ts`); this
6
+ * module is the counterpart that a `gjc doctor --fix --repair
7
+ * service.restart-owned` invocation drives. It never signals, kills, or reloads
8
+ * anything: the only lever is the durable control request the owner polls, and
9
+ * the only proof of the owner's exit is a positive kernel observation of its
10
+ * exact incarnation.
11
+ *
12
+ * The successor is started through the product's own sanctioned start path,
13
+ * never a doctor-private spawn, so the existing startup exclusion stays the
14
+ * single authority for who may own the slot.
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import type { Settings } from "../../config/settings";
18
+ import { observeProcessIncarnation } from "../broker/process-incarnation";
19
+ import {
20
+ type ChatDaemonKind,
21
+ type ChatDaemonState,
22
+ chatDaemonGeneration,
23
+ clearChatDoctorControlRequest,
24
+ ensureDiscordDaemon,
25
+ ensureSlackDaemon,
26
+ readChatDaemonState,
27
+ readChatDoctorControlRequest,
28
+ writeChatDoctorControlRequest,
29
+ } from "./chat-daemon-control";
30
+ import type { DoctorDaemonControlRequest, DoctorDaemonIdentity, DoctorDaemonOwner } from "./doctor-daemon-restart";
31
+ import { ensureTelegramDaemonRunning, readDaemonState } from "./telegram-daemon";
32
+ import { DAEMON_GENERATION } from "./telegram-daemon-contract";
33
+ import {
34
+ clearTelegramDoctorControlRequest,
35
+ readTelegramDoctorControlRequest,
36
+ writeTelegramDoctorControlRequest,
37
+ } from "./telegram-daemon-control";
38
+
39
+ /**
40
+ * Per-owner adapter over the two control-file surfaces. Both owners implement
41
+ * the same prepare/commit/cancel protocol; only the accessor signatures and the
42
+ * sanctioned start path differ, so this is a shape adapter, never a second
43
+ * protocol.
44
+ */
45
+ /**
46
+ * A published owner this doctor can drive, an owner that predates the current
47
+ * restart protocol (which requires the disclosed one-time manual transition and
48
+ * must never be signalled), or no record at all. The three are distinct: a
49
+ * pre-protocol incumbent is present and must not be reported as absent.
50
+ */
51
+ type OwnerObservation =
52
+ | { readonly kind: "current"; readonly pid: number; readonly identity: DoctorDaemonIdentity }
53
+ | { readonly kind: "pre_protocol" }
54
+ | { readonly kind: "absent" };
55
+
56
+ interface OwnerAdapter {
57
+ readonly owner: DoctorDaemonOwner;
58
+ observe(): Promise<OwnerObservation>;
59
+ read(): Promise<DoctorDaemonControlRequest | undefined>;
60
+ write(request: DoctorDaemonControlRequest): Promise<void>;
61
+ clear(requestId: string): Promise<void>;
62
+ /** The product's own sanctioned start path; never a doctor-private spawn. */
63
+ start(): Promise<boolean>;
64
+ }
65
+
66
+ const POLL_MS = 100;
67
+ const DEFAULT_DEADLINE_MS = 30_000;
68
+
69
+ export interface DoctorDaemonRestartOptions {
70
+ readonly agentDir: string;
71
+ readonly owner: DoctorDaemonOwner;
72
+ readonly settings: Settings;
73
+ readonly requestId?: string;
74
+ readonly deadlineMs?: number;
75
+ /** Bounded natural-completion wait; idle attached sessions still count as workload. */
76
+ readonly drainSeconds?: number;
77
+ }
78
+
79
+ /**
80
+ * Typed before/after-effect outcome. Every refusal names the exact stage it
81
+ * stopped at, so a caller never has to infer whether an effect began.
82
+ */
83
+ export type DoctorDaemonRestartOutcome =
84
+ | {
85
+ kind: "restarted";
86
+ requestId: string;
87
+ oldOwner: DoctorDaemonIdentity;
88
+ successor: DoctorDaemonIdentity;
89
+ adopted: boolean;
90
+ }
91
+ | { kind: "owner_unavailable"; reason: "no_state" | "owner_not_confirmed_live" | "unsupported_incumbent_protocol" }
92
+ | { kind: "prepare_refused"; reason: "control_write_failed" | "not_acknowledged" }
93
+ // The owner enforces occupancy itself and simply does not exit while work
94
+ // remains, which this client observes as `old_owner_exit_timeout`. A failed
95
+ // commit here therefore means only that the durable request could not be
96
+ // written, and must not claim to describe occupancy.
97
+ | { kind: "commit_refused"; reason: "control_write_failed" }
98
+ | { kind: "old_owner_exit_timeout"; requestId: string; oldOwner: DoctorDaemonIdentity }
99
+ | {
100
+ kind: "successor_refused";
101
+ requestId: string;
102
+ oldOwner: DoctorDaemonIdentity;
103
+ reason: "start_refused" | "publication_timeout";
104
+ };
105
+
106
+ function identityOf(owner: DoctorDaemonOwner, state: ChatDaemonState): DoctorDaemonIdentity {
107
+ return { owner, ownerId: state.ownerId, generation: state.generation, incarnation: state.incarnation };
108
+ }
109
+
110
+ function chatAdapter(options: DoctorDaemonRestartOptions, kind: ChatDaemonKind): OwnerAdapter {
111
+ return {
112
+ owner: kind,
113
+ async observe(): Promise<OwnerObservation> {
114
+ const state = await readChatDaemonState(options.agentDir, kind);
115
+ if (!state || state.stoppedAt !== undefined) return { kind: "absent" };
116
+ // Present but older than the current restart protocol: the operator must
117
+ // perform the disclosed one-time manual transition; doctor never signals it.
118
+ if (state.generation !== chatDaemonGeneration(kind)) return { kind: "pre_protocol" };
119
+ return { kind: "current", pid: state.pid, identity: identityOf(kind, state) };
120
+ },
121
+ read: () => readChatDoctorControlRequest(options.agentDir, kind),
122
+ write: request => writeChatDoctorControlRequest(options.agentDir, kind, request),
123
+ clear: requestId => clearChatDoctorControlRequest(options.agentDir, kind, requestId),
124
+ async start() {
125
+ const ensured =
126
+ kind === "discord"
127
+ ? await ensureDiscordDaemon(options.settings)
128
+ : await ensureSlackDaemon(options.settings);
129
+ return ensured !== "disabled";
130
+ },
131
+ };
132
+ }
133
+
134
+ function telegramAdapter(options: DoctorDaemonRestartOptions): OwnerAdapter {
135
+ const settings = options.settings;
136
+ return {
137
+ owner: "telegram",
138
+ async observe(): Promise<OwnerObservation> {
139
+ const state = await readDaemonState(settings);
140
+ if (!state || state.stoppedAt !== undefined) return { kind: "absent" };
141
+ if (state.generation !== DAEMON_GENERATION) return { kind: "pre_protocol" };
142
+ // A provisional owner is physically live but not attachable as ready, so it
143
+ // is not a restartable incumbent.
144
+ if (state.ownershipPhase !== undefined && state.ownershipPhase !== "ready") return { kind: "absent" };
145
+ return {
146
+ kind: "current",
147
+ pid: state.pid,
148
+ identity: {
149
+ owner: "telegram",
150
+ ownerId: state.ownerId,
151
+ generation: state.generation,
152
+ incarnation: state.incarnation,
153
+ },
154
+ };
155
+ },
156
+ read: () => readTelegramDoctorControlRequest(settings),
157
+ write: request => writeTelegramDoctorControlRequest(settings, request),
158
+ clear: requestId => clearTelegramDoctorControlRequest(settings, requestId),
159
+ async start() {
160
+ // The product's own ensure path resolves the configured destination and
161
+ // holds the existing setup lease; doctor never supplies a token or chat and
162
+ // never refreshes a credential.
163
+ const ensured = await ensureTelegramDaemonRunning({ settings });
164
+ return ensured === "owner_spawned" || ensured === "attached";
165
+ },
166
+ };
167
+ }
168
+
169
+ /** Positive kernel absence only: a changed or missing record is never death proof. */
170
+ function confirmedExited(pid: number, incarnation: string): boolean {
171
+ const observation = observeProcessIncarnation(pid);
172
+ if (observation.status === "absent") return true;
173
+ // A live pid whose incarnation differs is a reused pid, so the original
174
+ // incarnation is genuinely gone; `unknown` stays inconclusive.
175
+ return observation.status === "present" && observation.incarnation !== incarnation;
176
+ }
177
+
178
+ async function request(
179
+ adapter: OwnerAdapter,
180
+ identity: DoctorDaemonIdentity,
181
+ requestId: string,
182
+ action: DoctorDaemonControlRequest["action"],
183
+ leaseExpiresAt: number,
184
+ ): Promise<boolean> {
185
+ try {
186
+ await adapter.write({ version: 1, requestId, action, leaseExpiresAt, createdAt: Date.now(), ...identity });
187
+ return true;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Owner-side prepare/commit, positive-exit-confirmed cleanup, then exactly one
195
+ * successor started through the product's own ensure path.
196
+ *
197
+ * Ordering is fixed: prepare (owner closes admission) → occupancy settles →
198
+ * commit → owner exits under its own gate → positive absence proof → successor
199
+ * start or adoption. A commit is never issued before the owner acknowledged the
200
+ * prepare, and a successor is never started before the predecessor is proven
201
+ * gone.
202
+ */
203
+ export async function restartDaemonForDoctor(options: DoctorDaemonRestartOptions): Promise<DoctorDaemonRestartOutcome> {
204
+ const adapter = options.owner === "telegram" ? telegramAdapter(options) : chatAdapter(options, options.owner);
205
+ const deadlineAt = Date.now() + (options.deadlineMs ?? DEFAULT_DEADLINE_MS);
206
+ const before = await adapter.observe();
207
+ if (before.kind === "pre_protocol") return { kind: "owner_unavailable", reason: "unsupported_incumbent_protocol" };
208
+ if (before.kind === "absent") return { kind: "owner_unavailable", reason: "no_state" };
209
+ if (confirmedExited(before.pid, before.identity.incarnation))
210
+ return { kind: "owner_unavailable", reason: "owner_not_confirmed_live" };
211
+ const identity = before.identity;
212
+ const requestId = options.requestId ?? randomUUID();
213
+ if (!(await request(adapter, identity, requestId, "prepare", deadlineAt)))
214
+ return { kind: "prepare_refused", reason: "control_write_failed" };
215
+
216
+ // The owner consumes a prepare without rewriting it, so the request simply
217
+ // staying present proves nothing: this process wrote it. What IS observable to
218
+ // a non-parent caller is that the owner remains the same live incarnation that
219
+ // can still act on it. A request that outlives its own owner, or that a third
220
+ // party removed, is never treated as accepted.
221
+ const drainUntil = Date.now() + (options.drainSeconds ?? 0) * 1_000;
222
+ let acknowledged = false;
223
+ while (Date.now() < deadlineAt) {
224
+ const current = await adapter.read();
225
+ if (current?.requestId !== requestId) break;
226
+ const owner = await adapter.observe();
227
+ if (owner.kind !== "current" || owner.identity.incarnation !== identity.incarnation) break;
228
+ if (!confirmedExited(before.pid, identity.incarnation)) {
229
+ acknowledged = true;
230
+ break;
231
+ }
232
+ await Bun.sleep(POLL_MS);
233
+ }
234
+ if (!acknowledged) {
235
+ // An owner that already consumed the prepare has closed admission, and only a
236
+ // `cancel` under the same request id reopens it — deleting the control file
237
+ // reopens nothing and would strand a healthy daemon refusing all work. Cancel
238
+ // when the addressed owner is still there (it unlinks the file itself);
239
+ // otherwise there is nobody to reopen and the stale file is just removed.
240
+ const owner = await adapter.observe();
241
+ if (owner.kind === "current" && owner.identity.incarnation === identity.incarnation)
242
+ await request(adapter, identity, requestId, "cancel", deadlineAt);
243
+ else await adapter.clear(requestId);
244
+ return { kind: "prepare_refused", reason: "not_acknowledged" };
245
+ }
246
+
247
+ // `--drain` is bounded natural completion only. The owner itself refuses the
248
+ // commit while any workload remains (an idle attached session counts), so this
249
+ // wait never detaches or retires anything on its behalf.
250
+ if (Date.now() < drainUntil)
251
+ await Bun.sleep(Math.min(drainUntil - Date.now(), Math.max(0, deadlineAt - Date.now())));
252
+
253
+ if (!(await request(adapter, identity, requestId, "commit", deadlineAt))) {
254
+ await adapter.clear(requestId);
255
+ return { kind: "commit_refused", reason: "control_write_failed" };
256
+ }
257
+
258
+ while (Date.now() < deadlineAt) {
259
+ if (confirmedExited(before.pid, identity.incarnation)) break;
260
+ await Bun.sleep(POLL_MS);
261
+ }
262
+ if (!confirmedExited(before.pid, identity.incarnation))
263
+ // The commit is durable and the owner may still retire under its own gate;
264
+ // never cancel it here and never force the exit.
265
+ return { kind: "old_owner_exit_timeout", requestId, oldOwner: identity };
266
+
267
+ // An already-published successor of the same generation is adopted rather than
268
+ // starting a second one.
269
+ const published = await adapter.observe();
270
+ if (published.kind === "current" && published.identity.incarnation !== identity.incarnation) {
271
+ await adapter.clear(requestId);
272
+ return { kind: "restarted", requestId, oldOwner: identity, successor: published.identity, adopted: true };
273
+ }
274
+
275
+ try {
276
+ if (!(await adapter.start()))
277
+ return { kind: "successor_refused", requestId, oldOwner: identity, reason: "start_refused" };
278
+ } catch {
279
+ return { kind: "successor_refused", requestId, oldOwner: identity, reason: "start_refused" };
280
+ }
281
+
282
+ while (Date.now() < deadlineAt) {
283
+ const successor = await adapter.observe();
284
+ if (successor.kind === "current" && successor.identity.incarnation !== identity.incarnation) {
285
+ await adapter.clear(requestId);
286
+ return { kind: "restarted", requestId, oldOwner: identity, successor: successor.identity, adopted: false };
287
+ }
288
+ await Bun.sleep(POLL_MS);
289
+ }
290
+ return { kind: "successor_refused", requestId, oldOwner: identity, reason: "publication_timeout" };
291
+ }