@aiden-ade/sandbox-agent 0.1.60 → 0.1.62

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 (342) hide show
  1. package/dist/index.cjs +465 -342
  2. package/package.json +1 -1
  3. package/dist/__tests__/binding-switch-git.test.d.ts +0 -2
  4. package/dist/__tests__/binding-switch-git.test.d.ts.map +0 -1
  5. package/dist/__tests__/binding-switch-git.test.js +0 -610
  6. package/dist/__tests__/binding-switch-git.test.js.map +0 -1
  7. package/dist/__tests__/cli-ensure-install.test.d.ts +0 -2
  8. package/dist/__tests__/cli-ensure-install.test.d.ts.map +0 -1
  9. package/dist/__tests__/cli-ensure-install.test.js +0 -109
  10. package/dist/__tests__/cli-ensure-install.test.js.map +0 -1
  11. package/dist/__tests__/cli-executable.test.d.ts +0 -2
  12. package/dist/__tests__/cli-executable.test.d.ts.map +0 -1
  13. package/dist/__tests__/cli-executable.test.js +0 -129
  14. package/dist/__tests__/cli-executable.test.js.map +0 -1
  15. package/dist/__tests__/cli-help.test.d.ts +0 -2
  16. package/dist/__tests__/cli-help.test.d.ts.map +0 -1
  17. package/dist/__tests__/cli-help.test.js +0 -34
  18. package/dist/__tests__/cli-help.test.js.map +0 -1
  19. package/dist/__tests__/computer-readiness.test.d.ts +0 -2
  20. package/dist/__tests__/computer-readiness.test.d.ts.map +0 -1
  21. package/dist/__tests__/computer-readiness.test.js +0 -63
  22. package/dist/__tests__/computer-readiness.test.js.map +0 -1
  23. package/dist/__tests__/core-agent-workflow-context.test.d.ts +0 -2
  24. package/dist/__tests__/core-agent-workflow-context.test.d.ts.map +0 -1
  25. package/dist/__tests__/core-agent-workflow-context.test.js +0 -139
  26. package/dist/__tests__/core-agent-workflow-context.test.js.map +0 -1
  27. package/dist/__tests__/daemon-reconcile-ask.test.d.ts +0 -2
  28. package/dist/__tests__/daemon-reconcile-ask.test.d.ts.map +0 -1
  29. package/dist/__tests__/daemon-reconcile-ask.test.js +0 -86
  30. package/dist/__tests__/daemon-reconcile-ask.test.js.map +0 -1
  31. package/dist/__tests__/daemon-socket.integration.test.d.ts +0 -2
  32. package/dist/__tests__/daemon-socket.integration.test.d.ts.map +0 -1
  33. package/dist/__tests__/daemon-socket.integration.test.js +0 -450
  34. package/dist/__tests__/daemon-socket.integration.test.js.map +0 -1
  35. package/dist/__tests__/daemon.test.d.ts +0 -2
  36. package/dist/__tests__/daemon.test.d.ts.map +0 -1
  37. package/dist/__tests__/daemon.test.js +0 -3975
  38. package/dist/__tests__/daemon.test.js.map +0 -1
  39. package/dist/__tests__/ensure-shared-checkout-branch.test.d.ts +0 -2
  40. package/dist/__tests__/ensure-shared-checkout-branch.test.d.ts.map +0 -1
  41. package/dist/__tests__/ensure-shared-checkout-branch.test.js +0 -129
  42. package/dist/__tests__/ensure-shared-checkout-branch.test.js.map +0 -1
  43. package/dist/__tests__/event-outbox-persist-failure.test.d.ts +0 -2
  44. package/dist/__tests__/event-outbox-persist-failure.test.d.ts.map +0 -1
  45. package/dist/__tests__/event-outbox-persist-failure.test.js +0 -84
  46. package/dist/__tests__/event-outbox-persist-failure.test.js.map +0 -1
  47. package/dist/__tests__/event-outbox.test.d.ts +0 -2
  48. package/dist/__tests__/event-outbox.test.d.ts.map +0 -1
  49. package/dist/__tests__/event-outbox.test.js +0 -256
  50. package/dist/__tests__/event-outbox.test.js.map +0 -1
  51. package/dist/__tests__/fake-provider-cli.test.d.ts +0 -2
  52. package/dist/__tests__/fake-provider-cli.test.d.ts.map +0 -1
  53. package/dist/__tests__/fake-provider-cli.test.js +0 -46
  54. package/dist/__tests__/fake-provider-cli.test.js.map +0 -1
  55. package/dist/__tests__/generated-images.test.d.ts +0 -2
  56. package/dist/__tests__/generated-images.test.d.ts.map +0 -1
  57. package/dist/__tests__/generated-images.test.js +0 -132
  58. package/dist/__tests__/generated-images.test.js.map +0 -1
  59. package/dist/__tests__/lifecycle-logger.test.d.ts +0 -2
  60. package/dist/__tests__/lifecycle-logger.test.d.ts.map +0 -1
  61. package/dist/__tests__/lifecycle-logger.test.js +0 -49
  62. package/dist/__tests__/lifecycle-logger.test.js.map +0 -1
  63. package/dist/__tests__/local-skill-scan.test.d.ts +0 -2
  64. package/dist/__tests__/local-skill-scan.test.d.ts.map +0 -1
  65. package/dist/__tests__/local-skill-scan.test.js +0 -91
  66. package/dist/__tests__/local-skill-scan.test.js.map +0 -1
  67. package/dist/__tests__/local-workspace-migration.test.d.ts +0 -2
  68. package/dist/__tests__/local-workspace-migration.test.d.ts.map +0 -1
  69. package/dist/__tests__/local-workspace-migration.test.js +0 -195
  70. package/dist/__tests__/local-workspace-migration.test.js.map +0 -1
  71. package/dist/__tests__/mcp-inspect-timeout.test.d.ts +0 -2
  72. package/dist/__tests__/mcp-inspect-timeout.test.d.ts.map +0 -1
  73. package/dist/__tests__/mcp-inspect-timeout.test.js +0 -97
  74. package/dist/__tests__/mcp-inspect-timeout.test.js.map +0 -1
  75. package/dist/__tests__/mcp-readiness.test.d.ts +0 -2
  76. package/dist/__tests__/mcp-readiness.test.d.ts.map +0 -1
  77. package/dist/__tests__/mcp-readiness.test.js +0 -267
  78. package/dist/__tests__/mcp-readiness.test.js.map +0 -1
  79. package/dist/__tests__/mcp-registration-cli.test.d.ts +0 -2
  80. package/dist/__tests__/mcp-registration-cli.test.d.ts.map +0 -1
  81. package/dist/__tests__/mcp-registration-cli.test.js +0 -71
  82. package/dist/__tests__/mcp-registration-cli.test.js.map +0 -1
  83. package/dist/__tests__/mcp-registration.test.d.ts +0 -2
  84. package/dist/__tests__/mcp-registration.test.d.ts.map +0 -1
  85. package/dist/__tests__/mcp-registration.test.js +0 -426
  86. package/dist/__tests__/mcp-registration.test.js.map +0 -1
  87. package/dist/__tests__/model-scan-runner.test.d.ts +0 -2
  88. package/dist/__tests__/model-scan-runner.test.d.ts.map +0 -1
  89. package/dist/__tests__/model-scan-runner.test.js +0 -67
  90. package/dist/__tests__/model-scan-runner.test.js.map +0 -1
  91. package/dist/__tests__/network-diagnostics.test.d.ts +0 -2
  92. package/dist/__tests__/network-diagnostics.test.d.ts.map +0 -1
  93. package/dist/__tests__/network-diagnostics.test.js +0 -26
  94. package/dist/__tests__/network-diagnostics.test.js.map +0 -1
  95. package/dist/__tests__/observable-map.test.d.ts +0 -2
  96. package/dist/__tests__/observable-map.test.d.ts.map +0 -1
  97. package/dist/__tests__/observable-map.test.js +0 -54
  98. package/dist/__tests__/observable-map.test.js.map +0 -1
  99. package/dist/__tests__/provider-auth-preflight.test.d.ts +0 -2
  100. package/dist/__tests__/provider-auth-preflight.test.d.ts.map +0 -1
  101. package/dist/__tests__/provider-auth-preflight.test.js +0 -123
  102. package/dist/__tests__/provider-auth-preflight.test.js.map +0 -1
  103. package/dist/__tests__/provider-model-discovery.test.d.ts +0 -2
  104. package/dist/__tests__/provider-model-discovery.test.d.ts.map +0 -1
  105. package/dist/__tests__/provider-model-discovery.test.js +0 -472
  106. package/dist/__tests__/provider-model-discovery.test.js.map +0 -1
  107. package/dist/__tests__/remove-worktrees-if-clean.test.d.ts +0 -2
  108. package/dist/__tests__/remove-worktrees-if-clean.test.d.ts.map +0 -1
  109. package/dist/__tests__/remove-worktrees-if-clean.test.js +0 -221
  110. package/dist/__tests__/remove-worktrees-if-clean.test.js.map +0 -1
  111. package/dist/__tests__/resume-fallback-env.test.d.ts +0 -2
  112. package/dist/__tests__/resume-fallback-env.test.d.ts.map +0 -1
  113. package/dist/__tests__/resume-fallback-env.test.js +0 -28
  114. package/dist/__tests__/resume-fallback-env.test.js.map +0 -1
  115. package/dist/__tests__/run-journal.test.d.ts +0 -2
  116. package/dist/__tests__/run-journal.test.d.ts.map +0 -1
  117. package/dist/__tests__/run-journal.test.js +0 -121
  118. package/dist/__tests__/run-journal.test.js.map +0 -1
  119. package/dist/__tests__/run-start-gate.test.d.ts +0 -2
  120. package/dist/__tests__/run-start-gate.test.d.ts.map +0 -1
  121. package/dist/__tests__/run-start-gate.test.js +0 -29
  122. package/dist/__tests__/run-start-gate.test.js.map +0 -1
  123. package/dist/__tests__/runtime-capabilities-sync.test.d.ts +0 -2
  124. package/dist/__tests__/runtime-capabilities-sync.test.d.ts.map +0 -1
  125. package/dist/__tests__/runtime-capabilities-sync.test.js +0 -66
  126. package/dist/__tests__/runtime-capabilities-sync.test.js.map +0 -1
  127. package/dist/__tests__/runtime-config.test.d.ts +0 -2
  128. package/dist/__tests__/runtime-config.test.d.ts.map +0 -1
  129. package/dist/__tests__/runtime-config.test.js +0 -102
  130. package/dist/__tests__/runtime-config.test.js.map +0 -1
  131. package/dist/__tests__/runtime-presenter-heartbeat.test.d.ts +0 -2
  132. package/dist/__tests__/runtime-presenter-heartbeat.test.d.ts.map +0 -1
  133. package/dist/__tests__/runtime-presenter-heartbeat.test.js +0 -159
  134. package/dist/__tests__/runtime-presenter-heartbeat.test.js.map +0 -1
  135. package/dist/__tests__/sandbox-outbox.test.d.ts +0 -2
  136. package/dist/__tests__/sandbox-outbox.test.d.ts.map +0 -1
  137. package/dist/__tests__/sandbox-outbox.test.js +0 -214
  138. package/dist/__tests__/sandbox-outbox.test.js.map +0 -1
  139. package/dist/__tests__/sandbox-workflow-context.test.d.ts +0 -2
  140. package/dist/__tests__/sandbox-workflow-context.test.d.ts.map +0 -1
  141. package/dist/__tests__/sandbox-workflow-context.test.js +0 -396
  142. package/dist/__tests__/sandbox-workflow-context.test.js.map +0 -1
  143. package/dist/__tests__/service-manager.test.d.ts +0 -2
  144. package/dist/__tests__/service-manager.test.d.ts.map +0 -1
  145. package/dist/__tests__/service-manager.test.js +0 -50
  146. package/dist/__tests__/service-manager.test.js.map +0 -1
  147. package/dist/__tests__/sleep-inhibitor.test.d.ts +0 -2
  148. package/dist/__tests__/sleep-inhibitor.test.d.ts.map +0 -1
  149. package/dist/__tests__/sleep-inhibitor.test.js +0 -236
  150. package/dist/__tests__/sleep-inhibitor.test.js.map +0 -1
  151. package/dist/__tests__/status-local-workspace.test.d.ts +0 -2
  152. package/dist/__tests__/status-local-workspace.test.d.ts.map +0 -1
  153. package/dist/__tests__/status-local-workspace.test.js +0 -121
  154. package/dist/__tests__/status-local-workspace.test.js.map +0 -1
  155. package/dist/__tests__/suspension-detector.test.d.ts +0 -2
  156. package/dist/__tests__/suspension-detector.test.d.ts.map +0 -1
  157. package/dist/__tests__/suspension-detector.test.js +0 -112
  158. package/dist/__tests__/suspension-detector.test.js.map +0 -1
  159. package/dist/__tests__/web-presenter-parent-events.test.d.ts +0 -2
  160. package/dist/__tests__/web-presenter-parent-events.test.d.ts.map +0 -1
  161. package/dist/__tests__/web-presenter-parent-events.test.js +0 -130
  162. package/dist/__tests__/web-presenter-parent-events.test.js.map +0 -1
  163. package/dist/__tests__/workspace-relocation.test.d.ts +0 -2
  164. package/dist/__tests__/workspace-relocation.test.d.ts.map +0 -1
  165. package/dist/__tests__/workspace-relocation.test.js +0 -66
  166. package/dist/__tests__/workspace-relocation.test.js.map +0 -1
  167. package/dist/__tests__/workspace-run-lease.test.d.ts +0 -2
  168. package/dist/__tests__/workspace-run-lease.test.d.ts.map +0 -1
  169. package/dist/__tests__/workspace-run-lease.test.js +0 -230
  170. package/dist/__tests__/workspace-run-lease.test.js.map +0 -1
  171. package/dist/__tests__/ws-client-heartbeat-volatile.test.d.ts +0 -2
  172. package/dist/__tests__/ws-client-heartbeat-volatile.test.d.ts.map +0 -1
  173. package/dist/__tests__/ws-client-heartbeat-volatile.test.js +0 -61
  174. package/dist/__tests__/ws-client-heartbeat-volatile.test.js.map +0 -1
  175. package/dist/__tests__/ws-client-heartbeat.integration.test.d.ts +0 -2
  176. package/dist/__tests__/ws-client-heartbeat.integration.test.d.ts.map +0 -1
  177. package/dist/__tests__/ws-client-heartbeat.integration.test.js +0 -109
  178. package/dist/__tests__/ws-client-heartbeat.integration.test.js.map +0 -1
  179. package/dist/__tests__/ws-client-user-message-ack.integration.test.d.ts +0 -2
  180. package/dist/__tests__/ws-client-user-message-ack.integration.test.d.ts.map +0 -1
  181. package/dist/__tests__/ws-client-user-message-ack.integration.test.js +0 -167
  182. package/dist/__tests__/ws-client-user-message-ack.integration.test.js.map +0 -1
  183. package/dist/aiden-system-prompt.d.ts +0 -6
  184. package/dist/aiden-system-prompt.d.ts.map +0 -1
  185. package/dist/aiden-system-prompt.js +0 -6
  186. package/dist/aiden-system-prompt.js.map +0 -1
  187. package/dist/binding-switch-git.d.ts +0 -26
  188. package/dist/binding-switch-git.d.ts.map +0 -1
  189. package/dist/binding-switch-git.js +0 -973
  190. package/dist/binding-switch-git.js.map +0 -1
  191. package/dist/cli-ensure-install.d.ts +0 -39
  192. package/dist/cli-ensure-install.d.ts.map +0 -1
  193. package/dist/cli-ensure-install.js +0 -133
  194. package/dist/cli-ensure-install.js.map +0 -1
  195. package/dist/cli-executable.d.ts +0 -97
  196. package/dist/cli-executable.d.ts.map +0 -1
  197. package/dist/cli-executable.js +0 -432
  198. package/dist/cli-executable.js.map +0 -1
  199. package/dist/cli-help.d.ts +0 -7
  200. package/dist/cli-help.d.ts.map +0 -1
  201. package/dist/cli-help.js +0 -49
  202. package/dist/cli-help.js.map +0 -1
  203. package/dist/computer-readiness.d.ts +0 -42
  204. package/dist/computer-readiness.d.ts.map +0 -1
  205. package/dist/computer-readiness.js +0 -191
  206. package/dist/computer-readiness.js.map +0 -1
  207. package/dist/core-agent.d.ts +0 -47
  208. package/dist/core-agent.d.ts.map +0 -1
  209. package/dist/core-agent.js +0 -364
  210. package/dist/core-agent.js.map +0 -1
  211. package/dist/daemon.d.ts +0 -392
  212. package/dist/daemon.d.ts.map +0 -1
  213. package/dist/daemon.js +0 -4711
  214. package/dist/daemon.js.map +0 -1
  215. package/dist/event-outbox.d.ts +0 -83
  216. package/dist/event-outbox.d.ts.map +0 -1
  217. package/dist/event-outbox.js +0 -440
  218. package/dist/event-outbox.js.map +0 -1
  219. package/dist/fake-provider-cli.d.ts +0 -13
  220. package/dist/fake-provider-cli.d.ts.map +0 -1
  221. package/dist/fake-provider-cli.js +0 -53
  222. package/dist/fake-provider-cli.js.map +0 -1
  223. package/dist/generated-images.d.ts +0 -2
  224. package/dist/generated-images.d.ts.map +0 -1
  225. package/dist/generated-images.js +0 -2
  226. package/dist/generated-images.js.map +0 -1
  227. package/dist/index.d.ts +0 -6
  228. package/dist/index.d.ts.map +0 -1
  229. package/dist/index.js +0 -209
  230. package/dist/index.js.map +0 -1
  231. package/dist/lifecycle-logger.d.ts +0 -25
  232. package/dist/lifecycle-logger.d.ts.map +0 -1
  233. package/dist/lifecycle-logger.js +0 -73
  234. package/dist/lifecycle-logger.js.map +0 -1
  235. package/dist/local-skill-scan.d.ts +0 -21
  236. package/dist/local-skill-scan.d.ts.map +0 -1
  237. package/dist/local-skill-scan.js +0 -109
  238. package/dist/local-skill-scan.js.map +0 -1
  239. package/dist/local-workspace-migration.d.ts +0 -68
  240. package/dist/local-workspace-migration.d.ts.map +0 -1
  241. package/dist/local-workspace-migration.js +0 -157
  242. package/dist/local-workspace-migration.js.map +0 -1
  243. package/dist/mcp-readiness.d.ts +0 -59
  244. package/dist/mcp-readiness.d.ts.map +0 -1
  245. package/dist/mcp-readiness.js +0 -202
  246. package/dist/mcp-readiness.js.map +0 -1
  247. package/dist/mcp-registration.d.ts +0 -75
  248. package/dist/mcp-registration.d.ts.map +0 -1
  249. package/dist/mcp-registration.js +0 -563
  250. package/dist/mcp-registration.js.map +0 -1
  251. package/dist/model-scan-runner.d.ts +0 -15
  252. package/dist/model-scan-runner.d.ts.map +0 -1
  253. package/dist/model-scan-runner.js +0 -51
  254. package/dist/model-scan-runner.js.map +0 -1
  255. package/dist/network-diagnostics.d.ts +0 -8
  256. package/dist/network-diagnostics.d.ts.map +0 -1
  257. package/dist/network-diagnostics.js +0 -56
  258. package/dist/network-diagnostics.js.map +0 -1
  259. package/dist/observable-map.d.ts +0 -25
  260. package/dist/observable-map.d.ts.map +0 -1
  261. package/dist/observable-map.js +0 -42
  262. package/dist/observable-map.js.map +0 -1
  263. package/dist/provider-auth-preflight.d.ts +0 -22
  264. package/dist/provider-auth-preflight.d.ts.map +0 -1
  265. package/dist/provider-auth-preflight.js +0 -110
  266. package/dist/provider-auth-preflight.js.map +0 -1
  267. package/dist/provider-model-discovery.d.ts +0 -38
  268. package/dist/provider-model-discovery.d.ts.map +0 -1
  269. package/dist/provider-model-discovery.js +0 -519
  270. package/dist/provider-model-discovery.js.map +0 -1
  271. package/dist/resume-fallback-env.d.ts +0 -20
  272. package/dist/resume-fallback-env.d.ts.map +0 -1
  273. package/dist/resume-fallback-env.js +0 -30
  274. package/dist/resume-fallback-env.js.map +0 -1
  275. package/dist/run-journal.d.ts +0 -53
  276. package/dist/run-journal.d.ts.map +0 -1
  277. package/dist/run-journal.js +0 -193
  278. package/dist/run-journal.js.map +0 -1
  279. package/dist/run-start-gate.d.ts +0 -9
  280. package/dist/run-start-gate.d.ts.map +0 -1
  281. package/dist/run-start-gate.js +0 -24
  282. package/dist/run-start-gate.js.map +0 -1
  283. package/dist/runtime-backends.d.ts +0 -2
  284. package/dist/runtime-backends.d.ts.map +0 -1
  285. package/dist/runtime-backends.js +0 -2
  286. package/dist/runtime-backends.js.map +0 -1
  287. package/dist/runtime-capabilities-sync.d.ts +0 -33
  288. package/dist/runtime-capabilities-sync.d.ts.map +0 -1
  289. package/dist/runtime-capabilities-sync.js +0 -56
  290. package/dist/runtime-capabilities-sync.js.map +0 -1
  291. package/dist/runtime-config.d.ts +0 -24
  292. package/dist/runtime-config.d.ts.map +0 -1
  293. package/dist/runtime-config.js +0 -124
  294. package/dist/runtime-config.js.map +0 -1
  295. package/dist/sandbox-outbox.d.ts +0 -122
  296. package/dist/sandbox-outbox.d.ts.map +0 -1
  297. package/dist/sandbox-outbox.js +0 -180
  298. package/dist/sandbox-outbox.js.map +0 -1
  299. package/dist/sandbox.d.ts +0 -44
  300. package/dist/sandbox.d.ts.map +0 -1
  301. package/dist/sandbox.js +0 -438
  302. package/dist/sandbox.js.map +0 -1
  303. package/dist/service-manager.d.ts +0 -28
  304. package/dist/service-manager.d.ts.map +0 -1
  305. package/dist/service-manager.js +0 -259
  306. package/dist/service-manager.js.map +0 -1
  307. package/dist/sleep-inhibitor.d.ts +0 -111
  308. package/dist/sleep-inhibitor.d.ts.map +0 -1
  309. package/dist/sleep-inhibitor.js +0 -412
  310. package/dist/sleep-inhibitor.js.map +0 -1
  311. package/dist/suspension-detector.d.ts +0 -48
  312. package/dist/suspension-detector.d.ts.map +0 -1
  313. package/dist/suspension-detector.js +0 -89
  314. package/dist/suspension-detector.js.map +0 -1
  315. package/dist/types.d.ts +0 -6
  316. package/dist/types.d.ts.map +0 -1
  317. package/dist/types.js +0 -6
  318. package/dist/types.js.map +0 -1
  319. package/dist/updater.d.ts +0 -15
  320. package/dist/updater.d.ts.map +0 -1
  321. package/dist/updater.js +0 -50
  322. package/dist/updater.js.map +0 -1
  323. package/dist/version.d.ts +0 -2
  324. package/dist/version.d.ts.map +0 -1
  325. package/dist/version.js +0 -2
  326. package/dist/version.js.map +0 -1
  327. package/dist/web-presenter.d.ts +0 -55
  328. package/dist/web-presenter.d.ts.map +0 -1
  329. package/dist/web-presenter.js +0 -265
  330. package/dist/web-presenter.js.map +0 -1
  331. package/dist/workspace-relocation.d.ts +0 -22
  332. package/dist/workspace-relocation.d.ts.map +0 -1
  333. package/dist/workspace-relocation.js +0 -90
  334. package/dist/workspace-relocation.js.map +0 -1
  335. package/dist/workspace-run-lease.d.ts +0 -97
  336. package/dist/workspace-run-lease.d.ts.map +0 -1
  337. package/dist/workspace-run-lease.js +0 -241
  338. package/dist/workspace-run-lease.js.map +0 -1
  339. package/dist/ws-client.d.ts +0 -194
  340. package/dist/ws-client.d.ts.map +0 -1
  341. package/dist/ws-client.js +0 -282
  342. package/dist/ws-client.js.map +0 -1
package/dist/daemon.js DELETED
@@ -1,4711 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
3
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statfsSync, statSync, writeFileSync, } from "node:fs";
4
- import http from "node:http";
5
- import { arch, cpus, freemem, homedir, hostname, platform, release, totalmem } from "node:os";
6
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
- import { classifyCliErrorDetailed, createCliProblem } from "@alan-ai/agent-core";
8
- import { createAlanProblem } from "@alan-ai/shared/agent";
9
- import { isAlanDefaultWorkspace } from "@alan-ai/shared/constants";
10
- import { resolveAlanDefaultWorkspacePath } from "@alan-ai/shared/node/local-workspace";
11
- import { collectLocalAgentProviderLimits, } from "@alan-ai/shared/node/provider-limits";
12
- import { countNonStaleActiveRuns, deriveAcceptingRuns, isComputerReady, pickPrimaryReadinessIssue, RUNNER_RECOVERY_GRACE_MS, RUNNER_STATUS_MAX_SUPPORTED_PROTOCOL_VERSION, RUNNER_STATUS_MIN_SUPPORTED_PROTOCOL_VERSION, RUNNER_STATUS_PROTOCOL_VERSION, SYSTEM_ABORT_RUN_ERRORS, } from "@alan-ai/shared/runtime-runner-status";
13
- import { io } from "socket.io-client";
14
- import { BindingSwitchGitError, executeBindingSwitchGitRpc, isBindingSwitchGitMethod, } from "./binding-switch-git.js";
15
- import { canInstallProviderCli, ensureProviderCliInstalled } from "./cli-ensure-install.js";
16
- import { DISCOVERABLE_PROVIDER_KINDS, getDaemonCliEnvironment, resolveBackendRuntimeCommand, resolveCliExecutable, resolveProviderCliCommand, revalidateBackendRuntimeCommand, runCliVersionProbeAsync, } from "./cli-executable.js";
17
- import { buildComputerReadiness, inspectWorkspaceReadiness, WorkspaceAccessTracker, } from "./computer-readiness.js";
18
- import { CoreAgent } from "./core-agent.js";
19
- import { EncryptedEventOutbox, isBackgroundHeartbeat } from "./event-outbox.js";
20
- import { extractBrowserMediaPathHints, extractGeneratedImagesFromToolResult, extractSessionMediaFromToolResult, } from "./generated-images.js";
21
- import { scanLocalSkillsCached } from "./local-skill-scan.js";
22
- import { applyMigrationStash, preflightGitTree, preflightWorkspaceMigration, resolveDirtyTree, } from "./local-workspace-migration.js";
23
- import { prepareAlanMcpForRun } from "./mcp-readiness.js";
24
- import { unregisterAlanMcp } from "./mcp-registration.js";
25
- import { runRuntimeModelScan } from "./model-scan-runner.js";
26
- import { classifyTransportFailure } from "./network-diagnostics.js";
27
- import { ObservableMap } from "./observable-map.js";
28
- import { invalidateProviderAuthPreflight, looksLikeProviderAuthFailure, preflightProviderAuth, } from "./provider-auth-preflight.js";
29
- import { discoverProviderModels, supportsProviderModelDiscovery, } from "./provider-model-discovery.js";
30
- import { RuntimeRunJournal } from "./run-journal.js";
31
- import { RunStartGate } from "./run-start-gate.js";
32
- import { SleepInhibitor } from "./sleep-inhibitor.js";
33
- import { SuspensionDetector } from "./suspension-detector.js";
34
- // Re-exported so embedding apps (the Electron desktop) can contribute a
35
- // platform backend — notably Electron's `powerSaveBlocker`, which is the only
36
- // sleep-prevention mechanism reachable from Node on Windows.
37
- export { registerSleepInhibitorBackend, SLEEP_PREVENTION_DISABLED_ENV, } from "./sleep-inhibitor.js";
38
- import { buildModelsScanMetadata, setConnectedRuntimeSocket } from "./runtime-capabilities-sync.js";
39
- import { bindAgentConfigPathFromArgs, resolveAgentConfigPath, resolveEndpointProfileFromArgs, sanitizeAgentConfig, } from "./runtime-config.js";
40
- import { AGENT_VERSION } from "./version.js";
41
- import { discoverRelocatedWorkspace, workspaceMatchesExpectedRepositories, } from "./workspace-relocation.js";
42
- import { applyLeaseCheckResult, FRESH_LEASE_MONITOR_STATE, isLeaseCheckDue, WORKSPACE_LEASE_CHECK_INTERVAL_MS, WorkspaceRunLease, } from "./workspace-run-lease.js";
43
- const STAGING_API_URL = process.env.ALAN_STAGING_API_URL ?? "https://staging-api.tryalan.ai";
44
- const STAGING_WS_URL = process.env.ALAN_STAGING_WS_URL ?? "wss://alan-staging-ws.tryalan.ai";
45
- const PRODUCTION_API_URL = "https://api.tryalan.ai";
46
- const PRODUCTION_WS_URL = "wss://ws.tryalan.ai";
47
- const LOCAL_API_URL = "http://localhost:8400";
48
- const LOCAL_WS_URL = "ws://localhost:8401";
49
- function requiredString(params, key) {
50
- const value = params[key];
51
- if (typeof value !== "string" || value.trim() === "") {
52
- throw new Error(`${key} is required`);
53
- }
54
- return value;
55
- }
56
- const DIRTY_WORKSPACE_ACTIONS = new Set([
57
- "move_via_stash",
58
- "commit_wip",
59
- "discard",
60
- "leave",
61
- ]);
62
- function parseDirtyWorkspaceResolution(value) {
63
- if (value === undefined)
64
- return undefined;
65
- if (!value || typeof value !== "object" || Array.isArray(value)) {
66
- throw new Error("dirtyResolution must be an object");
67
- }
68
- const raw = value;
69
- const parseAction = (key) => {
70
- const action = raw[key];
71
- if (action === undefined)
72
- return undefined;
73
- if (typeof action !== "string" ||
74
- !DIRTY_WORKSPACE_ACTIONS.has(action)) {
75
- throw new Error(`${key} is not a supported dirty workspace action`);
76
- }
77
- return action;
78
- };
79
- return {
80
- sourceAction: parseAction("sourceAction"),
81
- destinationAction: parseAction("destinationAction"),
82
- sourceCommitMessage: typeof raw.sourceCommitMessage === "string" ? raw.sourceCommitMessage : undefined,
83
- destinationCommitMessage: typeof raw.destinationCommitMessage === "string" ? raw.destinationCommitMessage : undefined,
84
- confirmDiscard: raw.confirmDiscard === true,
85
- };
86
- }
87
- function assertValidGitRef(ref, label) {
88
- if (!ref || ref.startsWith("-") || ref.includes("\0")) {
89
- throw new Error(`${label} is not a safe git ref`);
90
- }
91
- const result = spawnSync("git", ["check-ref-format", "--branch", ref], {
92
- env: getDaemonCliEnvironment(),
93
- encoding: "utf8",
94
- });
95
- if (result.status !== 0)
96
- throw new Error(`${label} is not a valid git ref`);
97
- }
98
- function isPathContained(childPath, parentPath) {
99
- const rel = relative(resolve(parentPath), resolve(childPath));
100
- return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
101
- }
102
- function assertPathContained(childPath, parentPath, label) {
103
- if (!isPathContained(childPath, parentPath)) {
104
- throw new Error(`${label} escapes allowed directory`);
105
- }
106
- }
107
- function git(cwd, args) {
108
- const result = spawnSync("git", args, {
109
- cwd,
110
- env: getDaemonCliEnvironment(),
111
- encoding: "utf8",
112
- maxBuffer: 10 * 1024 * 1024,
113
- });
114
- if (result.status !== 0) {
115
- throw new Error((result.stderr || result.stdout || "git command failed").trim());
116
- }
117
- return result.stdout.trim();
118
- }
119
- function gitSafe(cwd, args) {
120
- try {
121
- return git(cwd, args);
122
- }
123
- catch {
124
- return "";
125
- }
126
- }
127
- function worktreeDir(projectPath) {
128
- const resolved = resolve(projectPath);
129
- return join(dirname(resolved), `${basename(resolved)}-alan-worktrees`);
130
- }
131
- function hasAlanWorktreeAncestor(targetPath) {
132
- let cursor = resolve(targetPath);
133
- while (true) {
134
- if (basename(cursor).endsWith("-alan-worktrees"))
135
- return true;
136
- const parent = dirname(cursor);
137
- if (parent === cursor)
138
- return false;
139
- cursor = parent;
140
- }
141
- }
142
- function pathSegment(value) {
143
- return (value
144
- .toLowerCase()
145
- .replace(/^alan\//, "")
146
- .replace(/[^a-z0-9]+/g, "-")
147
- .replace(/^-+|-+$/g, "")
148
- .slice(0, 64) || "task");
149
- }
150
- function repoName(repoUrl, localPath) {
151
- return (repoUrl
152
- .replace(/\.git$/, "")
153
- .split("/")
154
- .pop() ||
155
- basename(localPath) ||
156
- "repo");
157
- }
158
- function findWorktreeByBranch(repoPath, branchName) {
159
- const output = gitSafe(repoPath, ["worktree", "list", "--porcelain"]);
160
- const entries = output ? output.split("\n\n") : [];
161
- for (const entry of entries) {
162
- const lines = entry.split("\n");
163
- const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
164
- const branch = lines
165
- .find((line) => line.startsWith("branch "))
166
- ?.slice("branch ".length)
167
- .replace("refs/heads/", "");
168
- if (path && branch === branchName)
169
- return path;
170
- }
171
- return null;
172
- }
173
- function ensureGitWorktree(input) {
174
- assertValidGitRef(input.branchName, "branchName");
175
- assertValidGitRef(input.baseBranch, "baseBranch");
176
- const repoPath = resolve(input.repoPath);
177
- const root = input.allowedRoot ?? worktreeDir(repoPath);
178
- const existingPath = findWorktreeByBranch(input.repoPath, input.branchName);
179
- if (existingPath) {
180
- if (resolve(existingPath) === repoPath) {
181
- throw new Error(`${input.branchName} is checked out in the shared checkout`);
182
- }
183
- return existingPath;
184
- }
185
- assertPathContained(input.worktreePath, root, "worktreePath");
186
- mkdirSync(dirname(input.worktreePath), { recursive: true });
187
- if (!gitSafe(input.repoPath, ["branch", "--list", input.branchName])) {
188
- git(input.repoPath, ["branch", input.branchName, input.baseBranch]);
189
- }
190
- if (existsSync(input.worktreePath)) {
191
- if (gitSafe(input.worktreePath, ["branch", "--show-current"]) === input.branchName) {
192
- return input.worktreePath;
193
- }
194
- throw new Error("worktree path already exists for a different checkout");
195
- }
196
- git(input.repoPath, ["worktree", "add", input.worktreePath, input.branchName]);
197
- return input.worktreePath;
198
- }
199
- export function resolveProjectWorktreePaths(projectPath, gitRoot, gitWorktreePath) {
200
- const resolvedProjectPath = resolve(projectPath);
201
- const resolvedGitRoot = resolve(gitRoot);
202
- const projectRelativePath = relative(resolvedGitRoot, resolvedProjectPath);
203
- if (projectRelativePath === ".." ||
204
- projectRelativePath.startsWith(`..${sep}`) ||
205
- isAbsolute(projectRelativePath)) {
206
- throw new Error("The selected Project folder is outside its Git repository");
207
- }
208
- return {
209
- repoPath: resolvedGitRoot,
210
- projectPath: projectRelativePath
211
- ? join(resolve(gitWorktreePath), projectRelativePath)
212
- : resolve(gitWorktreePath),
213
- };
214
- }
215
- export function preflightLocalWorkspaceMigration(params) {
216
- const direction = params.direction;
217
- if (direction !== "to-checkout" && direction !== "to-worktree") {
218
- throw new Error("direction must be to-checkout or to-worktree");
219
- }
220
- const targets = Array.isArray(params.targets)
221
- ? params.targets
222
- : [];
223
- if (targets.length !== 1) {
224
- throw new Error("Dirty workspace migration currently supports exactly one repository");
225
- }
226
- const rawTarget = targets[0];
227
- const projectPath = resolve(requiredString(rawTarget, "projectPath"));
228
- const projectRootOutput = gitSafe(projectPath, ["rev-parse", "--show-toplevel"]);
229
- if (!projectRootOutput) {
230
- throw new Error("The selected Project folder is not inside a Git repository");
231
- }
232
- const projectRoot = resolve(projectRootOutput);
233
- if (direction === "to-worktree") {
234
- const result = preflightWorkspaceMigration({
235
- direction: "checkout_to_worktree",
236
- sourcePath: projectRoot,
237
- }, git);
238
- return {
239
- direction,
240
- source: result.source,
241
- destination: null,
242
- requiresResolution: result.source.isDirty,
243
- };
244
- }
245
- const requestedWorktreePath = resolve(requiredString(rawTarget, "worktreePath"));
246
- const worktreeRootOutput = gitSafe(requestedWorktreePath, ["rev-parse", "--show-toplevel"]);
247
- if (!worktreeRootOutput) {
248
- throw new Error("The task worktree is not a registered Git worktree");
249
- }
250
- const result = preflightWorkspaceMigration({
251
- direction: "worktree_to_checkout",
252
- sourcePath: resolve(worktreeRootOutput),
253
- destinationPath: projectRoot,
254
- }, git);
255
- return {
256
- direction,
257
- source: result.source,
258
- destination: result.destination,
259
- requiresResolution: result.source.isDirty || Boolean(result.destination?.isDirty),
260
- };
261
- }
262
- /**
263
- * Read-only git probe for the binding-switch "Current checkout" card. Accepts any
264
- * checkout root — a task worktree or the shared Project checkout — and reports a
265
- * dirty *count* only: porcelain paths deliberately never cross the relay.
266
- */
267
- export function statusLocalWorkspace(params) {
268
- const requestedPath = resolve(requiredString(params, "worktreePath"));
269
- const rootOutput = gitSafe(requestedPath, ["rev-parse", "--show-toplevel"]);
270
- if (!rootOutput) {
271
- throw new Error("The selected folder is not inside a Git repository");
272
- }
273
- const root = resolve(rootOutput);
274
- const tree = preflightGitTree(root, git);
275
- const headSha = gitSafe(root, ["rev-parse", "HEAD"]) || null;
276
- // `@{upstream}` fails when the branch has no tracking ref; that is a real
277
- // answer (hasUpstream: false), not a probe failure.
278
- const upstream = gitSafe(root, ["rev-parse", "--abbrev-ref", "@{upstream}"]);
279
- const hasUpstream = upstream !== "";
280
- let ahead = null;
281
- let behind = null;
282
- if (hasUpstream) {
283
- const counts = gitSafe(root, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]);
284
- const [behindRaw, aheadRaw] = counts.split(/\s+/);
285
- const parsedBehind = Number.parseInt(behindRaw ?? "", 10);
286
- const parsedAhead = Number.parseInt(aheadRaw ?? "", 10);
287
- behind = Number.isNaN(parsedBehind) ? null : parsedBehind;
288
- ahead = Number.isNaN(parsedAhead) ? null : parsedAhead;
289
- }
290
- return {
291
- branch: tree.currentBranch,
292
- headSha,
293
- dirty: tree.isDirty,
294
- filesChanged: tree.dirtyFileCount,
295
- ahead,
296
- behind,
297
- hasUpstream,
298
- };
299
- }
300
- function ensureGitWorktreeWithDirtyResolution(input, resolution) {
301
- const repoPath = resolve(input.repoPath);
302
- const before = preflightGitTree(repoPath, git);
303
- let sourceStash = null;
304
- if (before.isDirty) {
305
- const action = resolution?.sourceAction;
306
- if (!action) {
307
- throw new Error(`The Project checkout has ${before.dirtyFileCount} uncommitted file${before.dirtyFileCount === 1 ? "" : "s"}. Choose how to preserve them before moving into a worktree.`);
308
- }
309
- if (action === "commit_wip" && before.currentBranch !== input.baseBranch) {
310
- throw new Error(`Commit as WIP would stay on "${before.currentBranch ?? "the current checkout"}", not the selected base "${input.baseBranch}". Choose Move changes with task instead.`);
311
- }
312
- const resolved = resolveDirtyTree({
313
- cwd: repoPath,
314
- action,
315
- operation: "checkout-to-worktree",
316
- confirmDiscard: resolution?.confirmDiscard,
317
- }, { git });
318
- sourceStash = resolved.stash;
319
- }
320
- const existingPath = findWorktreeByBranch(repoPath, input.branchName);
321
- const worktreePath = ensureGitWorktree(input);
322
- if (!sourceStash)
323
- return worktreePath;
324
- try {
325
- applyMigrationStash({ cwd: worktreePath, stash: sourceStash }, git);
326
- return worktreePath;
327
- }
328
- catch (error) {
329
- // The destination was created solely for this migration. Restore it to a
330
- // clean state and unregister it so a failed stash apply cannot strand an
331
- // untracked dirty worktree. The named stash remains recoverable.
332
- if (!existingPath) {
333
- gitSafe(worktreePath, ["reset", "--hard", "HEAD"]);
334
- gitSafe(worktreePath, ["clean", "-fd"]);
335
- gitSafe(repoPath, ["worktree", "remove", worktreePath]);
336
- gitSafe(repoPath, ["worktree", "prune"]);
337
- }
338
- throw error;
339
- }
340
- }
341
- export function ensureWorktree(params) {
342
- const input = {
343
- projectPath: requiredString(params, "projectPath"),
344
- taskId: requiredString(params, "taskId"),
345
- branchName: requiredString(params, "branchName"),
346
- baseBranch: typeof params.baseBranch === "string" ? params.baseBranch : undefined,
347
- repoLocalPaths: Array.isArray(params.repoLocalPaths)
348
- ? params.repoLocalPaths
349
- : [],
350
- dirtyResolution: parseDirtyWorkspaceResolution(params.dirtyResolution),
351
- };
352
- if ((input.repoLocalPaths?.length ?? 0) > 1) {
353
- throw new Error("A task worktree requires exactly one Git repository.");
354
- }
355
- if (input.repoLocalPaths?.length) {
356
- const taskRoot = join(worktreeDir(input.projectPath), `${pathSegment(input.branchName)}-${input.taskId.slice(0, 8)}`);
357
- return {
358
- worktrees: input.repoLocalPaths.map((repo) => {
359
- const baseBranch = repo.branch || input.baseBranch || "main";
360
- const worktreePath = ensureGitWorktreeWithDirtyResolution({
361
- repoPath: repo.localPath,
362
- worktreePath: join(taskRoot, pathSegment(repoName(repo.repoUrl, repo.localPath))),
363
- branchName: input.branchName,
364
- baseBranch,
365
- allowedRoot: worktreeDir(input.projectPath),
366
- }, input.dirtyResolution);
367
- return {
368
- repoId: repo.repoId ?? null,
369
- repoUrl: repo.repoUrl,
370
- worktreePath,
371
- branchName: input.branchName,
372
- baseBranch,
373
- };
374
- }),
375
- ...(input.dirtyResolution ? { dirtyResolution: input.dirtyResolution } : {}),
376
- };
377
- }
378
- const gitRoot = gitSafe(resolve(input.projectPath), ["rev-parse", "--show-toplevel"]);
379
- if (!gitRoot) {
380
- throw new Error("The selected Project folder is not inside a Git repository");
381
- }
382
- const repoPath = resolve(gitRoot);
383
- const baseBranch = input.baseBranch || gitSafe(repoPath, ["branch", "--show-current"]) || "HEAD";
384
- const taskRoot = join(worktreeDir(repoPath), `${pathSegment(input.branchName)}-${input.taskId.slice(0, 8)}`);
385
- resolveProjectWorktreePaths(input.projectPath, repoPath, taskRoot);
386
- const worktreePath = ensureGitWorktreeWithDirtyResolution({
387
- repoPath,
388
- worktreePath: taskRoot,
389
- branchName: input.branchName,
390
- baseBranch,
391
- }, input.dirtyResolution);
392
- const projectWorktreePath = resolveProjectWorktreePaths(input.projectPath, repoPath, worktreePath).projectPath;
393
- return {
394
- worktrees: [
395
- {
396
- worktreePath: projectWorktreePath,
397
- branchName: input.branchName,
398
- baseBranch,
399
- },
400
- ],
401
- ...(input.dirtyResolution ? { dirtyResolution: input.dirtyResolution } : {}),
402
- };
403
- }
404
- function listRegisteredWorktrees(cwd) {
405
- return git(cwd, ["worktree", "list", "--porcelain"])
406
- .split("\n\n")
407
- .flatMap((entry) => {
408
- const lines = entry.split("\n");
409
- const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
410
- if (!path)
411
- return [];
412
- return [
413
- {
414
- path: resolve(path),
415
- branch: lines
416
- .find((line) => line.startsWith("branch "))
417
- ?.slice("branch ".length)
418
- .replace("refs/heads/", ""),
419
- },
420
- ];
421
- });
422
- }
423
- /**
424
- * Remove stale task worktrees only after every target passes a non-destructive
425
- * preflight. The server supplies persisted worktree paths; the daemon derives
426
- * each linked worktree root from Git registration instead of trusting those
427
- * paths as removal roots.
428
- *
429
- * A retry after successful removal is accepted only when the now-missing path
430
- * is under an Alan-managed worktree directory.
431
- */
432
- export function removeWorktreesIfClean(params, removeGit = git) {
433
- const projectPath = resolve(requiredString(params, "projectPath"));
434
- const targets = Array.isArray(params.targets)
435
- ? params.targets
436
- : [];
437
- if (targets.length === 0)
438
- throw new Error("At least one worktree target is required");
439
- const projectRootOutput = gitSafe(projectPath, ["rev-parse", "--show-toplevel"]);
440
- const projectRoot = projectRootOutput ? resolve(projectRootOutput) : null;
441
- const projectRegistrations = projectRoot ? listRegisteredWorktrees(projectRoot) : [];
442
- const planned = targets.map((target) => {
443
- const rawTarget = target;
444
- const requestedPath = resolve(requiredString(rawTarget, "worktreePath"));
445
- const branchName = requiredString(rawTarget, "branchName");
446
- assertValidGitRef(branchName, "branchName");
447
- const registrationSets = [];
448
- if (projectRegistrations.length > 0)
449
- registrationSets.push(projectRegistrations);
450
- if (existsSync(requestedPath)) {
451
- const requestedRoot = gitSafe(requestedPath, ["rev-parse", "--show-toplevel"]);
452
- if (!requestedRoot) {
453
- throw new Error(`The earlier task worktree is not a registered Git worktree: ${requestedPath}`);
454
- }
455
- const requestedRegistrations = listRegisteredWorktrees(resolve(requestedRoot));
456
- if (!registrationSets.some((entries) => entries.length === requestedRegistrations.length &&
457
- entries.every((entry, index) => entry.path === requestedRegistrations[index]?.path))) {
458
- registrationSets.push(requestedRegistrations);
459
- }
460
- }
461
- for (const registrations of registrationSets) {
462
- const mainRoot = registrations[0]?.path;
463
- const registeredTarget = registrations.find((entry) => requestedPath === entry.path || isPathContained(requestedPath, entry.path));
464
- if (!registeredTarget || !mainRoot)
465
- continue;
466
- if (registeredTarget.path === mainRoot) {
467
- throw new Error("Refusing to remove the Project's main checkout");
468
- }
469
- if (registeredTarget.branch !== branchName) {
470
- throw new Error(`The registered worktree branch changed from "${branchName}" to "${registeredTarget.branch ?? "detached HEAD"}".`);
471
- }
472
- const allowedRoots = [
473
- worktreeDir(mainRoot),
474
- worktreeDir(projectPath),
475
- ...(projectRoot ? [worktreeDir(projectRoot)] : []),
476
- ];
477
- if (!allowedRoots.some((root) => isPathContained(registeredTarget.path, root))) {
478
- throw new Error("The registered worktree is outside an Alan-managed worktree directory");
479
- }
480
- if (git(registeredTarget.path, ["status", "--porcelain"]).trim()) {
481
- throw new Error(`The earlier task worktree has uncommitted changes at ${requestedPath}. Commit or discard them before changing Project, branch, or worktree mode.`);
482
- }
483
- return {
484
- requestedPath,
485
- worktreeRoot: registeredTarget.path,
486
- mainRoot,
487
- branchName,
488
- replayed: false,
489
- };
490
- }
491
- if (!existsSync(requestedPath) && hasAlanWorktreeAncestor(requestedPath)) {
492
- return {
493
- requestedPath,
494
- worktreeRoot: null,
495
- mainRoot: null,
496
- branchName,
497
- replayed: true,
498
- };
499
- }
500
- throw new Error(`The earlier task worktree is not safely registered and was not changed: ${requestedPath}`);
501
- });
502
- const removalRoots = planned.flatMap((target) => target.worktreeRoot && target.mainRoot
503
- ? [{ worktreeRoot: target.worktreeRoot, mainRoot: target.mainRoot }]
504
- : []);
505
- if (new Set(removalRoots.map(({ worktreeRoot }) => worktreeRoot)).size !== removalRoots.length) {
506
- throw new Error("Duplicate worktree targets were not changed");
507
- }
508
- for (const target of removalRoots) {
509
- // Deliberately omit `-f`: Git performs a final clean-tree check at the
510
- // mutation boundary and refuses a write racing with our earlier preflight.
511
- removeGit(target.mainRoot, ["worktree", "remove", target.worktreeRoot]);
512
- gitSafe(target.mainRoot, ["worktree", "prune"]);
513
- }
514
- return {
515
- ok: true,
516
- removed: planned.map(({ requestedPath, branchName, replayed }) => ({
517
- worktreePath: requestedPath,
518
- branchName,
519
- replayed,
520
- })),
521
- };
522
- }
523
- /**
524
- * Move one clean task worktree back to its Project checkout.
525
- *
526
- * Paths may point at the Project's configured subdirectory rather than either
527
- * Git root. The daemon resolves and compares both relative subpaths before
528
- * mutation. A retry after a successful remove+checkout is idempotent, which
529
- * lets the API durably persist its prepared receipt after the filesystem move.
530
- */
531
- export function migrateWorktreeToCheckout(params, removeGit = git) {
532
- const dirtyResolution = parseDirtyWorkspaceResolution(params.dirtyResolution);
533
- const targets = Array.isArray(params.targets)
534
- ? params.targets
535
- : [];
536
- if (targets.length !== 1) {
537
- throw new Error("Multi-repository worktree migration is not supported; no repositories were changed.");
538
- }
539
- const target = targets[0];
540
- const rawTarget = target;
541
- const projectPath = resolve(requiredString(rawTarget, "projectPath"));
542
- const requestedWorktreePath = resolve(requiredString(rawTarget, "worktreePath"));
543
- const branch = requiredString(rawTarget, "branch");
544
- assertValidGitRef(branch, "branch");
545
- const projectRootOutput = gitSafe(projectPath, ["rev-parse", "--show-toplevel"]);
546
- if (!projectRootOutput) {
547
- throw new Error("The selected Project folder is not inside a Git repository");
548
- }
549
- const projectRoot = resolve(projectRootOutput);
550
- const projectRelativePath = relative(projectRoot, projectPath);
551
- if (projectRelativePath === ".." ||
552
- projectRelativePath.startsWith(`..${sep}`) ||
553
- isAbsolute(projectRelativePath)) {
554
- throw new Error("The selected Project folder is outside its Git repository");
555
- }
556
- const registered = git(projectRoot, ["worktree", "list", "--porcelain"])
557
- .split("\n\n")
558
- .map((entry) => {
559
- const lines = entry.split("\n");
560
- return {
561
- path: lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length),
562
- branch: lines
563
- .find((line) => line.startsWith("branch "))
564
- ?.slice("branch ".length)
565
- .replace("refs/heads/", ""),
566
- };
567
- });
568
- const registeredTarget = registered.find(({ path, branch: registeredBranch }) => {
569
- if (!path || registeredBranch !== branch)
570
- return false;
571
- const root = resolve(path);
572
- return requestedWorktreePath === root || isPathContained(requestedWorktreePath, root);
573
- });
574
- const currentBranch = gitSafe(projectRoot, ["branch", "--show-current"]);
575
- if (!registeredTarget?.path) {
576
- if (currentBranch === branch) {
577
- return {
578
- ok: true,
579
- replayed: true,
580
- migrated: [{ ...target, projectPath, worktreePath: requestedWorktreePath, branch }],
581
- ...(dirtyResolution ? { dirtyResolution } : {}),
582
- };
583
- }
584
- throw new Error(`The task worktree is no longer registered and the Project is not on branch "${branch}".`);
585
- }
586
- const worktreeRoot = resolve(registeredTarget.path);
587
- const worktreeRelativePath = relative(worktreeRoot, requestedWorktreePath);
588
- if (worktreeRelativePath !== projectRelativePath) {
589
- throw new Error("The configured Project and task worktree do not point at the same repository subdirectory.");
590
- }
591
- const allowedRoots = [worktreeDir(projectRoot), worktreeDir(projectPath)];
592
- if (!allowedRoots.some((allowedRoot) => isPathContained(worktreeRoot, allowedRoot))) {
593
- throw new Error("The task worktree escapes the Project worktree directory.");
594
- }
595
- if (!existsSync(worktreeRoot)) {
596
- throw new Error(`Worktree directory not found: ${worktreeRoot}`);
597
- }
598
- const sourceBefore = preflightGitTree(worktreeRoot, git);
599
- const destinationBefore = preflightGitTree(projectRoot, git);
600
- if (sourceBefore.isDirty && !dirtyResolution?.sourceAction) {
601
- throw new Error(`The task worktree has ${sourceBefore.dirtyFileCount} uncommitted file${sourceBefore.dirtyFileCount === 1 ? "" : "s"}. Choose how to preserve them before migrating.`);
602
- }
603
- if (destinationBefore.isDirty && !dirtyResolution?.destinationAction) {
604
- throw new Error(`The Project checkout has ${destinationBefore.dirtyFileCount} uncommitted file${destinationBefore.dirtyFileCount === 1 ? "" : "s"}. Choose how to preserve them before migrating.`);
605
- }
606
- if (sourceBefore.isDirty && dirtyResolution?.sourceAction === "leave") {
607
- throw new Error("Task worktree changes cannot be left behind when releasing the worktree.");
608
- }
609
- if (destinationBefore.isDirty && dirtyResolution?.destinationAction === "leave") {
610
- throw new Error("Project checkout changes cannot stay in place while switching branches.");
611
- }
612
- const destinationResolved = destinationBefore.isDirty
613
- ? resolveDirtyTree({
614
- cwd: projectRoot,
615
- action: dirtyResolution?.destinationAction ?? "leave",
616
- operation: "worktree-to-checkout-destination",
617
- confirmDiscard: dirtyResolution?.confirmDiscard,
618
- }, { git })
619
- : null;
620
- const sourceResolved = sourceBefore.isDirty
621
- ? resolveDirtyTree({
622
- cwd: worktreeRoot,
623
- action: dirtyResolution?.sourceAction ?? "leave",
624
- operation: "worktree-to-checkout-source",
625
- confirmDiscard: dirtyResolution?.confirmDiscard,
626
- }, { git })
627
- : null;
628
- try {
629
- git(projectRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
630
- }
631
- catch {
632
- throw new Error(`The target branch "${branch}" is not available in the Project.`);
633
- }
634
- // Git's own clean-tree check is the final guard against writes racing with
635
- // the preflight above. Never force a migration removal.
636
- removeGit(projectRoot, ["worktree", "remove", worktreeRoot]);
637
- gitSafe(projectRoot, ["worktree", "prune"]);
638
- try {
639
- git(projectRoot, ["checkout", branch]);
640
- }
641
- catch (checkoutError) {
642
- try {
643
- git(projectRoot, ["worktree", "add", worktreeRoot, branch]);
644
- if (sourceResolved?.stash) {
645
- applyMigrationStash({ cwd: worktreeRoot, stash: sourceResolved.stash }, git);
646
- }
647
- if (destinationResolved?.stash) {
648
- applyMigrationStash({ cwd: projectRoot, stash: destinationResolved.stash }, git);
649
- }
650
- }
651
- catch (restoreError) {
652
- const checkoutMessage = checkoutError instanceof Error ? checkoutError.message : String(checkoutError);
653
- const restoreMessage = restoreError instanceof Error ? restoreError.message : String(restoreError);
654
- throw new Error(`Project checkout failed (${checkoutMessage}) and the task worktree could not be restored (${restoreMessage}).`);
655
- }
656
- const checkoutMessage = checkoutError instanceof Error ? checkoutError.message : String(checkoutError);
657
- throw new Error(`Project checkout failed (${checkoutMessage}); restored the task worktree at ${worktreeRoot}.`);
658
- }
659
- if (sourceResolved?.stash) {
660
- try {
661
- applyMigrationStash({ cwd: projectRoot, stash: sourceResolved.stash }, git);
662
- }
663
- catch (applyError) {
664
- // Return to the exact topology we started from. The source stash is
665
- // applied back to the restored worktree; a destination stash is applied
666
- // back to its original checkout branch. If either restore fails, the
667
- // named stash remains recoverable and the error includes its OID.
668
- gitSafe(projectRoot, ["reset", "--hard", "HEAD"]);
669
- gitSafe(projectRoot, ["clean", "-fd"]);
670
- try {
671
- if (destinationBefore.currentBranch && destinationBefore.currentBranch !== branch) {
672
- git(projectRoot, ["checkout", destinationBefore.currentBranch]);
673
- }
674
- git(projectRoot, ["worktree", "add", worktreeRoot, branch]);
675
- applyMigrationStash({ cwd: worktreeRoot, stash: sourceResolved.stash }, git);
676
- if (destinationResolved?.stash) {
677
- applyMigrationStash({ cwd: projectRoot, stash: destinationResolved.stash }, git);
678
- }
679
- }
680
- catch (restoreError) {
681
- const applyMessage = applyError instanceof Error ? applyError.message : String(applyError);
682
- const restoreMessage = restoreError instanceof Error ? restoreError.message : String(restoreError);
683
- throw new Error(`Moving uncommitted changes failed (${applyMessage}); automatic workspace restoration also failed (${restoreMessage}). The named migration stash was preserved.`);
684
- }
685
- const applyMessage = applyError instanceof Error ? applyError.message : String(applyError);
686
- throw new Error(`Moving uncommitted changes failed (${applyMessage}); the original worktree and checkout were restored.`);
687
- }
688
- }
689
- return {
690
- ok: true,
691
- replayed: false,
692
- migrated: [{ ...target, projectPath, worktreePath: requestedWorktreePath, branch }],
693
- ...(dirtyResolution ? { dirtyResolution } : {}),
694
- };
695
- }
696
- /**
697
- * Put a shared checkout onto the task's working branch (cut from baseBranch)
698
- * without creating a worktree. Best-effort and non-destructive: it never
699
- * touches a dirty tree and never aborts the run — if it cannot switch cleanly
700
- * the caller logs the reason and the agent runs on the current branch.
701
- */
702
- export function ensureSharedCheckoutBranch(input) {
703
- assertValidGitRef(input.branchName, "branchName");
704
- assertValidGitRef(input.baseBranch, "baseBranch");
705
- const repoPath = resolve(input.repoPath);
706
- if (gitSafe(repoPath, ["rev-parse", "--is-inside-work-tree"]) !== "true") {
707
- return { checkedOut: false, reason: `${repoPath} is not a git work tree` };
708
- }
709
- // Already on the working branch — nothing to do.
710
- if (gitSafe(repoPath, ["branch", "--show-current"]) === input.branchName) {
711
- return { checkedOut: true };
712
- }
713
- // Never discard the user's uncommitted work in a shared checkout.
714
- if (gitSafe(repoPath, ["status", "--porcelain"]) !== "") {
715
- return { checkedOut: false, reason: "shared checkout has uncommitted changes" };
716
- }
717
- // Create the branch off the base ref the first time we see it. Resolve the
718
- // base locally, falling back to the remote-tracking ref.
719
- if (!gitSafe(repoPath, ["branch", "--list", input.branchName])) {
720
- const localBase = gitSafe(repoPath, ["rev-parse", "--verify", "--quiet", input.baseBranch]);
721
- const remoteBase = localBase
722
- ? ""
723
- : gitSafe(repoPath, ["rev-parse", "--verify", "--quiet", `origin/${input.baseBranch}`]);
724
- const base = localBase ? input.baseBranch : remoteBase ? `origin/${input.baseBranch}` : "";
725
- if (!base) {
726
- return { checkedOut: false, reason: `base branch ${input.baseBranch} not found` };
727
- }
728
- git(repoPath, ["branch", input.branchName, base]);
729
- }
730
- git(repoPath, ["checkout", input.branchName]);
731
- return { checkedOut: true };
732
- }
733
- export function resolveSharedCheckoutTargets(input) {
734
- const fallbackBaseBranch = input.sharedCheckout.baseBranch || "main";
735
- if (input.sharedCheckout.repos?.length) {
736
- const repos = [];
737
- for (const repo of input.sharedCheckout.repos) {
738
- if (!repo.repoPath.trim())
739
- continue;
740
- repos.push({
741
- repoPath: repo.repoPath,
742
- baseBranch: repo.baseBranch?.trim() || fallbackBaseBranch,
743
- });
744
- }
745
- if (repos.length > 0)
746
- return repos;
747
- }
748
- const repoPaths = input.sharedCheckout.repoPaths && input.sharedCheckout.repoPaths.length > 0
749
- ? input.sharedCheckout.repoPaths
750
- : [input.cwd];
751
- const repos = [];
752
- for (const repoPath of repoPaths) {
753
- if (!repoPath.trim())
754
- continue;
755
- repos.push({ repoPath, baseBranch: fallbackBaseBranch });
756
- }
757
- return repos;
758
- }
759
- export function resolveRuntimeCwd(payload) {
760
- const multiRepoWorktree = payload.worktreeInfo?.allWorktrees?.[0]?.worktreePath;
761
- if (multiRepoWorktree)
762
- return dirname(multiRepoWorktree);
763
- if (isAlanDefaultWorkspace(payload.projectPath))
764
- return resolveAlanDefaultWorkspacePath();
765
- return payload.worktreeInfo?.worktreePath ?? payload.projectPath ?? process.cwd();
766
- }
767
- function resolveWorkspaceRunLeaseTargets(payload, cwd, hasVerifiedRepository) {
768
- if (isAlanDefaultWorkspace(payload.projectPath))
769
- return [];
770
- const allWorktrees = payload.worktreeInfo?.allWorktrees;
771
- if (allWorktrees?.length) {
772
- return allWorktrees.map((worktree) => ({
773
- workspacePath: worktree.worktreePath,
774
- expectedBranch: worktree.branchName,
775
- }));
776
- }
777
- if (payload.worktreeInfo?.worktreePath) {
778
- return [
779
- {
780
- workspacePath: payload.worktreeInfo.worktreePath,
781
- expectedBranch: payload.worktreeInfo.branchName,
782
- },
783
- ];
784
- }
785
- if (payload.sharedCheckout?.branchName) {
786
- return resolveSharedCheckoutTargets({ cwd, sharedCheckout: payload.sharedCheckout }).map(({ repoPath }) => ({
787
- workspacePath: repoPath,
788
- expectedBranch: payload.sharedCheckout?.branchName,
789
- }));
790
- }
791
- return hasVerifiedRepository ? [{ workspacePath: cwd }] : [];
792
- }
793
- function correlationLogFields(input) {
794
- return Object.fromEntries(Object.entries({
795
- taskId: input.taskId,
796
- conversationId: input.conversationId,
797
- runId: input.runId,
798
- messageId: input.messageId,
799
- cloudEnvironmentId: input.cloudEnvironmentId,
800
- provider: input.backendKind,
801
- backendKind: input.backendKind,
802
- agentVersion: input.agentVersion,
803
- })
804
- .filter((entry) => {
805
- const value = entry[1];
806
- return typeof value === "string" && value.trim().length > 0;
807
- })
808
- .map(([key, value]) => [key, value.trim()]));
809
- }
810
- export async function runLocalDaemonRecoveryLadder(input) {
811
- const healthy = (status) => status.connected === true && !status.recovering && !status.restartRecommended;
812
- const waitForRecovery = async () => {
813
- const timeoutMs = input.options?.connectTimeoutMs ?? 12_000;
814
- const pollMs = input.options?.pollMs ?? 500;
815
- const deadline = Date.now() + timeoutMs;
816
- let status = await input.getStatus();
817
- while (Date.now() < deadline) {
818
- if (healthy(status) || input.stopWaiting?.(status))
819
- return status;
820
- await sleep(pollMs);
821
- status = await input.getStatus();
822
- }
823
- return status;
824
- };
825
- const needsRestartBusy = (status) => {
826
- const count = input.blockingRunCount(status);
827
- return {
828
- ...status,
829
- activeRunCount: count,
830
- recoveryCode: "needs_restart_busy",
831
- error: `Connection is broken and fixing it will interrupt ${count} active run${count === 1 ? "" : "s"}`,
832
- };
833
- };
834
- let status = input.initialStatus;
835
- if (healthy(status) || input.stopWaiting?.(status))
836
- return status;
837
- if (await input.redial().catch(() => false)) {
838
- status = await waitForRecovery();
839
- if (healthy(status) || input.stopWaiting?.(status))
840
- return status;
841
- }
842
- if (input.blockingRunCount(status) > 0)
843
- return needsRestartBusy(status);
844
- try {
845
- status = await input.restart();
846
- }
847
- catch (error) {
848
- return {
849
- ...status,
850
- recoveryCode: "daemon_recovery_failed",
851
- error: error instanceof Error ? error.message : "Local daemon restart failed",
852
- };
853
- }
854
- if (healthy(status) || input.stopWaiting?.(status))
855
- return status;
856
- if (input.blockingRunCount(status) > 0)
857
- return needsRestartBusy(status);
858
- status = await waitForRecovery();
859
- if (healthy(status) || input.stopWaiting?.(status))
860
- return status;
861
- if (input.blockingRunCount(status) > 0)
862
- return needsRestartBusy(status);
863
- return {
864
- ...status,
865
- recoveryCode: "daemon_recovery_failed",
866
- error: "The local daemon exhausted its reconnect and safe-restart recovery budget.",
867
- };
868
- }
869
- const LOCAL_SERVICE_OWNER_VERSION = 1;
870
- const LOCAL_SERVICE_CHALLENGE_TTL_MS = 5_000;
871
- function localServiceOwnerPayload(owner) {
872
- return [
873
- owner.version,
874
- owner.serviceId,
875
- owner.installationId,
876
- owner.runtimeId,
877
- owner.port,
878
- owner.pid,
879
- owner.startedAtMs,
880
- owner.challenge,
881
- ].join("\0");
882
- }
883
- function signLocalServiceOwner(secret, owner) {
884
- return createHmac("sha256", secret).update(localServiceOwnerPayload(owner)).digest("hex");
885
- }
886
- function signLocalServiceAdoption(secret, serviceId, challenge) {
887
- return createHmac("sha256", secret)
888
- .update(["adopt", serviceId, challenge].join("\0"))
889
- .digest("hex");
890
- }
891
- function equalHexDigest(actual, expected) {
892
- if (!/^[a-f0-9]{64}$/i.test(actual) || !/^[a-f0-9]{64}$/i.test(expected))
893
- return false;
894
- return timingSafeEqual(Buffer.from(actual, "hex"), Buffer.from(expected, "hex"));
895
- }
896
- function parseLocalServiceOwner(value) {
897
- if (!value || typeof value !== "object")
898
- return null;
899
- const owner = value;
900
- if (owner.version !== LOCAL_SERVICE_OWNER_VERSION ||
901
- typeof owner.serviceId !== "string" ||
902
- typeof owner.installationId !== "string" ||
903
- typeof owner.runtimeId !== "string" ||
904
- typeof owner.port !== "number" ||
905
- typeof owner.pid !== "number" ||
906
- typeof owner.startedAtMs !== "number" ||
907
- typeof owner.challenge !== "string" ||
908
- typeof owner.signature !== "string") {
909
- return null;
910
- }
911
- return owner;
912
- }
913
- async function probeLocalDaemonEndpoint(port, token, timeoutMs = 750) {
914
- try {
915
- const headers = {};
916
- if (token)
917
- headers.authorization = `Bearer ${token}`;
918
- const response = await fetch(`http://127.0.0.1:${port}/status`, {
919
- headers,
920
- signal: AbortSignal.timeout(timeoutMs),
921
- });
922
- if (response.status === 401)
923
- return { state: "listening_unauthorized" };
924
- if (!response.ok)
925
- return { state: "absent" };
926
- const body = (await response.json());
927
- if (!body.runtimeId)
928
- return { state: "absent" };
929
- return { state: "running", status: body };
930
- }
931
- catch {
932
- return { state: "absent" };
933
- }
934
- }
935
- export async function probeLocalDaemon(port, token, timeoutMs = 750) {
936
- const probe = await probeLocalDaemonEndpoint(port, token, timeoutMs);
937
- return probe.state === "running" ? probe.status : null;
938
- }
939
- async function adoptLocalDaemonControl(input) {
940
- const timeoutMs = input.timeoutMs ?? 750;
941
- try {
942
- const ownerResponse = await fetch(`http://127.0.0.1:${input.port}/owner`, {
943
- headers: { accept: "application/json" },
944
- signal: AbortSignal.timeout(timeoutMs),
945
- });
946
- if (!ownerResponse.ok)
947
- return null;
948
- const owner = parseLocalServiceOwner(await ownerResponse.json());
949
- if (!owner ||
950
- owner.serviceId !== input.serviceId ||
951
- owner.port !== input.port ||
952
- (input.installationId && owner.installationId !== input.installationId)) {
953
- return null;
954
- }
955
- const { signature, ...unsignedOwner } = owner;
956
- const expectedSignature = signLocalServiceOwner(input.serviceSecret, unsignedOwner);
957
- if (!equalHexDigest(signature, expectedSignature))
958
- return null;
959
- const proof = signLocalServiceAdoption(input.serviceSecret, input.serviceId, owner.challenge);
960
- const adoptionResponse = await fetch(`http://127.0.0.1:${input.port}/adopt`, {
961
- method: "POST",
962
- headers: {
963
- accept: "application/json",
964
- "x-alan-service-id": input.serviceId,
965
- "x-alan-adoption-challenge": owner.challenge,
966
- "x-alan-adoption-proof": proof,
967
- },
968
- signal: AbortSignal.timeout(timeoutMs),
969
- });
970
- if (!adoptionResponse.ok)
971
- return null;
972
- const adopted = (await adoptionResponse.json());
973
- if (typeof adopted.localApiToken !== "string" || adopted.localApiToken.length < 32)
974
- return null;
975
- const status = await probeLocalDaemon(input.port, adopted.localApiToken, timeoutMs);
976
- if (!status || status.runtimeId !== owner.runtimeId)
977
- return null;
978
- return { localApiToken: adopted.localApiToken, status };
979
- }
980
- catch {
981
- return null;
982
- }
983
- }
984
- /**
985
- * Reclaims local control only when this config proves it owns the incumbent
986
- * daemon. A process that merely occupies the port cannot trigger replacement
987
- * or learn the bearer token.
988
- */
989
- export async function adoptConfiguredLocalDaemon(args = []) {
990
- bindConfigPath(args);
991
- const stored = readConfig();
992
- const port = getLocalApiPort(args, stored);
993
- if (stored.localApiToken) {
994
- const existing = await probeLocalDaemon(port, stored.localApiToken);
995
- if (existing)
996
- return existing;
997
- }
998
- if (!stored.localServiceId || !stored.localServiceSecret)
999
- return null;
1000
- const adopted = await adoptLocalDaemonControl({
1001
- port,
1002
- serviceId: stored.localServiceId,
1003
- serviceSecret: stored.localServiceSecret,
1004
- installationId: stored.installationId,
1005
- });
1006
- if (!adopted)
1007
- return null;
1008
- // Do not overwrite credentials that may have changed while the handshake
1009
- // was in flight. The owner identity itself must still match.
1010
- const latest = readConfig();
1011
- if (latest.localServiceId !== stored.localServiceId ||
1012
- latest.localServiceSecret !== stored.localServiceSecret) {
1013
- return null;
1014
- }
1015
- writeConfig({ ...latest, localApiPort: port, localApiToken: adopted.localApiToken });
1016
- return adopted.status;
1017
- }
1018
- export async function isLocalDaemonListening(port, token) {
1019
- const probe = await probeLocalDaemonEndpoint(port, token);
1020
- return probe.state !== "absent";
1021
- }
1022
- function describeOccupiedLocalDaemon(port) {
1023
- return (`local daemon API port ${port} is already in use. ` +
1024
- "If Alan desktop is open, it manages the daemon automatically. " +
1025
- "Quit Alan desktop or stop the existing daemon before starting another.");
1026
- }
1027
- /**
1028
- * Last config path bound via bindAgentConfigPathFromArgs. Keeps late/async
1029
- * daemon code on the active profile's config even if the supervisor restores
1030
- * ALAN_AGENT_CONFIG_PATH after startup (the desktop supervisor does).
1031
- */
1032
- let boundAgentConfigPath = null;
1033
- function bindConfigPath(args) {
1034
- boundAgentConfigPath = bindAgentConfigPathFromArgs(args);
1035
- return boundAgentConfigPath;
1036
- }
1037
- function getConfigPath() {
1038
- return (process.env.ALAN_AGENT_CONFIG_PATH ??
1039
- boundAgentConfigPath ??
1040
- resolveAgentConfigPath({ profile: "production" }));
1041
- }
1042
- function getEndpointDefaultsPath() {
1043
- return process.env.ALAN_AGENT_ENDPOINTS_PATH ?? join(dirname(getConfigPath()), "endpoints.json");
1044
- }
1045
- function readConfig() {
1046
- const configPath = getConfigPath();
1047
- if (!existsSync(configPath))
1048
- return {};
1049
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
1050
- const sanitized = sanitizeAgentConfig(parsed);
1051
- if (Object.keys(parsed).some((key) => !Object.hasOwn(sanitized, key))) {
1052
- writeConfig(sanitized);
1053
- }
1054
- return sanitized;
1055
- }
1056
- function readConfigForRegistration() {
1057
- try {
1058
- return readConfig();
1059
- }
1060
- catch {
1061
- // Registration is also the repair path for malformed/unreadable config.
1062
- return {};
1063
- }
1064
- }
1065
- function readEndpointDefaults() {
1066
- const endpointsPath = getEndpointDefaultsPath();
1067
- if (!existsSync(endpointsPath))
1068
- return {};
1069
- try {
1070
- return JSON.parse(readFileSync(endpointsPath, "utf8"));
1071
- }
1072
- catch {
1073
- return {};
1074
- }
1075
- }
1076
- function writeConfig(config) {
1077
- const configPath = getConfigPath();
1078
- mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
1079
- const pendingPath = `${configPath}.pending-${process.pid}-${randomBytes(6).toString("hex")}`;
1080
- try {
1081
- writeFileSync(pendingPath, JSON.stringify(sanitizeAgentConfig(config), null, 2), { mode: 0o600 });
1082
- chmodSync(pendingPath, 0o600);
1083
- renameSync(pendingPath, configPath);
1084
- }
1085
- finally {
1086
- rmSync(pendingPath, { force: true });
1087
- }
1088
- }
1089
- function maintenanceLeasePath(configPath) {
1090
- return `${configPath}.maintenance-lease.json`;
1091
- }
1092
- export function readLocalDaemonMaintenanceLease(configPath, nowMs = Date.now()) {
1093
- const path = maintenanceLeasePath(configPath);
1094
- if (!existsSync(path))
1095
- return null;
1096
- try {
1097
- const lease = JSON.parse(readFileSync(path, "utf8"));
1098
- if (lease.version !== 1 ||
1099
- typeof lease.id !== "string" ||
1100
- (lease.reason !== "credential_cleanup" &&
1101
- lease.reason !== "daemon_startup" &&
1102
- lease.reason !== "watchdog") ||
1103
- !((lease.reason === "credential_cleanup" && lease.expiresAtMs === null) ||
1104
- (typeof lease.expiresAtMs === "number" && Number.isFinite(lease.expiresAtMs)))) {
1105
- throw new Error("invalid maintenance lease");
1106
- }
1107
- // Credential cleanup is a durable drain intent, not a watchdog timeout. Old
1108
- // versions wrote a finite expiry, so ignore it for this reason during
1109
- // rolling upgrades as well as writing null for all new cleanup leases.
1110
- if (lease.reason !== "credential_cleanup" && lease.expiresAtMs <= nowMs) {
1111
- releaseOwnedJsonFile(path, lease.id);
1112
- return null;
1113
- }
1114
- return lease;
1115
- }
1116
- catch {
1117
- // A partial/corrupt lease must fail closed for one short watchdog window,
1118
- // not fence dispatch forever.
1119
- const fallback = {
1120
- version: 1,
1121
- id: "invalid",
1122
- reason: "watchdog",
1123
- expiresAtMs: nowMs + 5_000,
1124
- };
1125
- try {
1126
- writeFileSync(path, JSON.stringify(fallback), { mode: 0o600 });
1127
- chmodSync(path, 0o600);
1128
- }
1129
- catch {
1130
- // The in-memory fallback still fences this decision safely.
1131
- }
1132
- return fallback;
1133
- }
1134
- }
1135
- export function acquireLocalDaemonMaintenanceLease(configPath, reason, ttlMs = 5_000) {
1136
- const existing = readLocalDaemonMaintenanceLease(configPath);
1137
- if (existing)
1138
- return { acquired: false, lease: existing };
1139
- const lease = {
1140
- version: 1,
1141
- id: randomUUID(),
1142
- reason,
1143
- expiresAtMs: reason === "credential_cleanup"
1144
- ? null
1145
- : Date.now() + Math.max(1_000, Math.min(ttlMs, 60_000)),
1146
- };
1147
- const path = maintenanceLeasePath(configPath);
1148
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1149
- try {
1150
- writeFileSync(path, JSON.stringify(lease), { mode: 0o600, flag: "wx" });
1151
- chmodSync(path, 0o600);
1152
- return { acquired: true, lease };
1153
- }
1154
- catch (error) {
1155
- const raced = readLocalDaemonMaintenanceLease(configPath);
1156
- if (raced)
1157
- return { acquired: false, lease: raced };
1158
- throw error;
1159
- }
1160
- }
1161
- export function releaseLocalDaemonMaintenanceLease(configPath, leaseId) {
1162
- releaseOwnedJsonFile(maintenanceLeasePath(configPath), leaseId);
1163
- }
1164
- function daemonOwnerLeasePath(configPath) {
1165
- return `${configPath}.daemon-owner.json`;
1166
- }
1167
- function readLocalDaemonOwnerLease(configPath) {
1168
- const path = daemonOwnerLeasePath(configPath);
1169
- if (!existsSync(path))
1170
- return null;
1171
- try {
1172
- const lease = JSON.parse(readFileSync(path, "utf8"));
1173
- if (lease.version !== 1 ||
1174
- typeof lease.id !== "string" ||
1175
- typeof lease.daemonSessionId !== "string" ||
1176
- !Number.isSafeInteger(lease.pid) ||
1177
- lease.pid <= 0 ||
1178
- typeof lease.startedAtMs !== "number" ||
1179
- !Number.isFinite(lease.startedAtMs) ||
1180
- typeof lease.runtimeId !== "string" ||
1181
- typeof lease.localServiceId !== "string" ||
1182
- !Number.isSafeInteger(lease.localApiPort) ||
1183
- lease.localApiPort < 0 ||
1184
- lease.localApiPort > 65_535 ||
1185
- typeof lease.localApiToken !== "string" ||
1186
- lease.localApiToken.length === 0) {
1187
- throw new Error("invalid daemon owner lease");
1188
- }
1189
- return lease;
1190
- }
1191
- catch (error) {
1192
- throw new Error(`Cannot safely read daemon owner lease: ${error instanceof Error ? error.message : String(error)}`);
1193
- }
1194
- }
1195
- async function acquireLocalDaemonOwnerLease(input) {
1196
- const existing = readLocalDaemonOwnerLease(input.configPath);
1197
- if (existing) {
1198
- const endpoint = existing.localApiPort > 0
1199
- ? await probeLocalDaemonEndpoint(existing.localApiPort, existing.localApiToken)
1200
- : { state: "absent" };
1201
- if (endpoint.state === "running") {
1202
- const matchesOwner = endpoint.status.runtimeId === existing.runtimeId &&
1203
- endpoint.status.localServiceId === existing.localServiceId &&
1204
- endpoint.status.daemonSessionId === existing.daemonSessionId;
1205
- if (!matchesOwner) {
1206
- throw new Error("Daemon owner lease does not match the authenticated local endpoint");
1207
- }
1208
- return { acquired: false, lease: existing };
1209
- }
1210
- if (endpoint.state === "listening_unauthorized") {
1211
- return { acquired: false, lease: existing };
1212
- }
1213
- // PID existence alone is not proof of daemon ownership: after a crash or
1214
- // reboot the OS can reuse the recorded PID for an unrelated process. The
1215
- // authenticated endpoint is authoritative. If it is absent, an apparently
1216
- // live owner only blocks takeover while accepted or pending work still
1217
- // needs protection. The startup maintenance lease already serializes this
1218
- // decision against another daemon that is concurrently starting.
1219
- const journal = inspectLocalDaemonRunJournal(input.configPath);
1220
- if (isProviderProcessAlive(existing.pid) && !journal.safeToRestart) {
1221
- return { acquired: false, lease: existing };
1222
- }
1223
- // The authenticated endpoint is absent and no live work prevents recovery.
1224
- // Replace the stale owner; the new owner confirms recovery only after bind.
1225
- releaseOwnedJsonFile(daemonOwnerLeasePath(input.configPath), existing.id);
1226
- }
1227
- if (!existing && input.storedLocalApiPort && input.storedLocalApiToken) {
1228
- const storedEndpoint = await probeLocalDaemonEndpoint(input.storedLocalApiPort, input.storedLocalApiToken);
1229
- if (storedEndpoint.state === "running") {
1230
- throw new Error("The stored local endpoint already owns this daemon config");
1231
- }
1232
- if (storedEndpoint.state === "listening_unauthorized") {
1233
- throw new Error("The stored local endpoint rejected this daemon config token");
1234
- }
1235
- }
1236
- const lease = {
1237
- version: 1,
1238
- id: randomUUID(),
1239
- daemonSessionId: input.daemonSessionId,
1240
- pid: process.pid,
1241
- startedAtMs: Date.now(),
1242
- runtimeId: input.runtimeId,
1243
- localServiceId: input.localServiceId,
1244
- localApiPort: input.localApiPort,
1245
- localApiToken: input.localApiToken,
1246
- };
1247
- const path = daemonOwnerLeasePath(input.configPath);
1248
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1249
- try {
1250
- writeFileSync(path, JSON.stringify(lease), { mode: 0o600, flag: "wx" });
1251
- chmodSync(path, 0o600);
1252
- return { acquired: true, lease };
1253
- }
1254
- catch (error) {
1255
- const raced = readLocalDaemonOwnerLease(input.configPath);
1256
- if (raced)
1257
- return { acquired: false, lease: raced };
1258
- throw error;
1259
- }
1260
- }
1261
- function updateLocalDaemonOwnerEndpoint(configPath, lease, localApiPort) {
1262
- const current = readLocalDaemonOwnerLease(configPath);
1263
- if (current?.id !== lease.id)
1264
- throw new Error("Daemon ownership changed before local API bind");
1265
- const updated = { ...lease, localApiPort };
1266
- const path = daemonOwnerLeasePath(configPath);
1267
- const pendingPath = `${path}.pending-${lease.id}`;
1268
- try {
1269
- writeFileSync(pendingPath, JSON.stringify(updated), { mode: 0o600 });
1270
- chmodSync(pendingPath, 0o600);
1271
- renameSync(pendingPath, path);
1272
- return updated;
1273
- }
1274
- finally {
1275
- rmSync(pendingPath, { force: true });
1276
- }
1277
- }
1278
- function releaseLocalDaemonOwnerLease(configPath, leaseId) {
1279
- releaseOwnedJsonFile(daemonOwnerLeasePath(configPath), leaseId);
1280
- }
1281
- /**
1282
- * Atomically take the current pathname before inspecting ownership. A newer
1283
- * replacement can therefore never be removed by an older owner's release.
1284
- */
1285
- function releaseOwnedJsonFile(path, ownerId, ownerField = "id") {
1286
- const releasePath = `${path}.release-${process.pid}-${randomBytes(6).toString("hex")}`;
1287
- try {
1288
- renameSync(path, releasePath);
1289
- }
1290
- catch (error) {
1291
- if (error.code === "ENOENT")
1292
- return;
1293
- throw error;
1294
- }
1295
- try {
1296
- const content = readFileSync(releasePath, "utf8");
1297
- let capturedOwnerId = null;
1298
- try {
1299
- const captured = JSON.parse(content);
1300
- capturedOwnerId = typeof captured[ownerField] === "string" ? captured[ownerField] : null;
1301
- }
1302
- catch {
1303
- // Preserve an invalid replacement too; readers deliberately fail closed.
1304
- }
1305
- if (capturedOwnerId === ownerId)
1306
- return;
1307
- // Restore only if nobody acquired a still-newer lease in the short rename
1308
- // window. `wx` prevents this older release from overwriting that owner.
1309
- try {
1310
- writeFileSync(path, content, { mode: 0o600, flag: "wx" });
1311
- chmodSync(path, 0o600);
1312
- }
1313
- catch (error) {
1314
- if (error.code !== "EEXIST")
1315
- throw error;
1316
- }
1317
- }
1318
- finally {
1319
- rmSync(releasePath, { force: true });
1320
- }
1321
- }
1322
- export function inspectLocalDaemonRunJournal(configPath) {
1323
- const nowMs = Date.now();
1324
- const path = `${configPath}.run-journal.json`;
1325
- const pendingPath = `${configPath}.pending-runs.json`;
1326
- try {
1327
- let activeRunCount = 0;
1328
- for (const artifact of [path, pendingPath]) {
1329
- if (!existsSync(artifact))
1330
- continue;
1331
- const parsed = JSON.parse(readFileSync(artifact, "utf8"));
1332
- if (parsed.version !== 1 || !Array.isArray(parsed.entries))
1333
- throw new Error("invalid journal");
1334
- if (artifact === path) {
1335
- activeRunCount += parsed.entries.filter((entry) => {
1336
- if (!entry || typeof entry !== "object")
1337
- throw new Error("invalid run journal entry");
1338
- const journalEntry = entry;
1339
- if (typeof journalEntry.runId !== "string" ||
1340
- journalEntry.runId.length === 0 ||
1341
- typeof journalEntry.conversationId !== "string" ||
1342
- journalEntry.conversationId.length === 0 ||
1343
- typeof journalEntry.acceptedAtMs !== "number" ||
1344
- !Number.isFinite(journalEntry.acceptedAtMs)) {
1345
- throw new Error("invalid run journal identity");
1346
- }
1347
- const interruptedAtMs = journalEntry.interruptedAtMs;
1348
- if (interruptedAtMs === undefined)
1349
- return true;
1350
- if (typeof interruptedAtMs !== "number" || !Number.isFinite(interruptedAtMs)) {
1351
- throw new Error("invalid interrupted journal marker");
1352
- }
1353
- return false;
1354
- }).length;
1355
- continue;
1356
- }
1357
- const legacyStartedAtMs = statSync(pendingPath).mtimeMs;
1358
- activeRunCount += parsed.entries.filter((entry) => {
1359
- if (!entry || typeof entry !== "object")
1360
- throw new Error("invalid pending run entry");
1361
- const startedAtMs = entry.startedAtMs;
1362
- if (startedAtMs === undefined) {
1363
- return nowMs - legacyStartedAtMs <= PENDING_RUN_START_TTL_MS;
1364
- }
1365
- if (typeof startedAtMs !== "number" || !Number.isFinite(startedAtMs)) {
1366
- throw new Error("invalid pending run timestamp");
1367
- }
1368
- return nowMs - startedAtMs <= PENDING_RUN_START_TTL_MS;
1369
- }).length;
1370
- }
1371
- return { safeToRestart: activeRunCount === 0, activeRunCount };
1372
- }
1373
- catch {
1374
- return { safeToRestart: false, activeRunCount: 1 };
1375
- }
1376
- }
1377
- function pendingSetupIntentPath() {
1378
- return `${getConfigPath()}.setup-pending`;
1379
- }
1380
- function setupTokenHash(setupToken) {
1381
- return createHash("sha256").update(setupToken).digest("hex");
1382
- }
1383
- function getOrCreateSetupAttemptId(setupToken) {
1384
- const intentPath = pendingSetupIntentPath();
1385
- const tokenHash = setupTokenHash(setupToken);
1386
- if (existsSync(intentPath)) {
1387
- try {
1388
- const stored = JSON.parse(readFileSync(intentPath, "utf8"));
1389
- if (stored.setupTokenHash === tokenHash &&
1390
- typeof stored.registrationAttemptId === "string" &&
1391
- stored.registrationAttemptId.length > 0) {
1392
- return stored.registrationAttemptId;
1393
- }
1394
- }
1395
- catch {
1396
- // Replace malformed or stale setup intent below without touching active config.
1397
- }
1398
- }
1399
- const registrationAttemptId = randomUUID();
1400
- mkdirSync(dirname(intentPath), { recursive: true, mode: 0o700 });
1401
- const writePath = `${intentPath}.pending-${process.pid}-${randomBytes(6).toString("hex")}`;
1402
- try {
1403
- writeFileSync(writePath, JSON.stringify({ setupTokenHash: tokenHash, registrationAttemptId }, null, 2), { mode: 0o600 });
1404
- chmodSync(writePath, 0o600);
1405
- renameSync(writePath, intentPath);
1406
- }
1407
- finally {
1408
- rmSync(writePath, { force: true });
1409
- }
1410
- return registrationAttemptId;
1411
- }
1412
- function clearPendingSetupIntent() {
1413
- rmSync(pendingSetupIntentPath(), { force: true });
1414
- }
1415
- function pendingDeviceAuthorizationPath() {
1416
- return `${getConfigPath()}.device-auth-pending`;
1417
- }
1418
- function deviceAuthorizationOperationPath() {
1419
- return `${getConfigPath()}.device-auth-operation.json`;
1420
- }
1421
- function parseDeviceAuthorizationOperationContent(content) {
1422
- try {
1423
- const lease = JSON.parse(content);
1424
- if (lease.version !== 1 ||
1425
- typeof lease.id !== "string" ||
1426
- typeof lease.expiresAtMs !== "number" ||
1427
- !Number.isFinite(lease.expiresAtMs)) {
1428
- return null;
1429
- }
1430
- return lease;
1431
- }
1432
- catch {
1433
- return null;
1434
- }
1435
- }
1436
- function restoreCapturedFileUnlessReplaced(path, content) {
1437
- try {
1438
- writeFileSync(path, content, { mode: 0o600, flag: "wx" });
1439
- chmodSync(path, 0o600);
1440
- }
1441
- catch (error) {
1442
- if (error.code !== "EEXIST")
1443
- throw error;
1444
- }
1445
- }
1446
- function quarantineCorruptDeviceAuthorizationOperation(path) {
1447
- const quarantinePath = `${path}.corrupt-${process.pid}-${randomBytes(6).toString("hex")}`;
1448
- try {
1449
- renameSync(path, quarantinePath);
1450
- }
1451
- catch (error) {
1452
- if (error.code === "ENOENT")
1453
- return null;
1454
- throw error;
1455
- }
1456
- try {
1457
- const capturedContent = readFileSync(quarantinePath, "utf8");
1458
- // The pathname may have been replaced after the failed parse but before
1459
- // this atomic capture. Preserve it if what we actually captured is valid.
1460
- if (parseDeviceAuthorizationOperationContent(capturedContent)) {
1461
- restoreCapturedFileUnlessReplaced(path, capturedContent);
1462
- }
1463
- }
1464
- finally {
1465
- rmSync(quarantinePath, { force: true });
1466
- }
1467
- if (!existsSync(path))
1468
- return null;
1469
- return parseDeviceAuthorizationOperationContent(readFileSync(path, "utf8"));
1470
- }
1471
- function readDeviceAuthorizationOperation() {
1472
- const path = deviceAuthorizationOperationPath();
1473
- if (!existsSync(path))
1474
- return null;
1475
- const parsed = parseDeviceAuthorizationOperationContent(readFileSync(path, "utf8"));
1476
- const lease = parsed ?? quarantineCorruptDeviceAuthorizationOperation(path);
1477
- if (!lease)
1478
- return null;
1479
- if (lease.expiresAtMs <= Date.now()) {
1480
- releaseOwnedJsonFile(path, lease.id);
1481
- return null;
1482
- }
1483
- return lease;
1484
- }
1485
- function acquireDeviceAuthorizationOperation() {
1486
- const existing = readDeviceAuthorizationOperation();
1487
- if (existing)
1488
- return null;
1489
- const lease = {
1490
- version: 1,
1491
- id: randomUUID(),
1492
- expiresAtMs: Date.now() + 60_000,
1493
- };
1494
- const path = deviceAuthorizationOperationPath();
1495
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1496
- try {
1497
- writeFileSync(path, JSON.stringify(lease), { mode: 0o600, flag: "wx" });
1498
- chmodSync(path, 0o600);
1499
- return lease;
1500
- }
1501
- catch (error) {
1502
- if (error.code === "EEXIST")
1503
- return null;
1504
- throw error;
1505
- }
1506
- }
1507
- function extendDeviceAuthorizationOperation(operationId, expiresAtMs) {
1508
- const current = readDeviceAuthorizationOperation();
1509
- if (current?.id !== operationId) {
1510
- throw new Error("browser authorization operation ownership was lost");
1511
- }
1512
- const path = deviceAuthorizationOperationPath();
1513
- const pendingPath = `${path}.pending-${process.pid}-${randomBytes(6).toString("hex")}`;
1514
- try {
1515
- writeFileSync(pendingPath, JSON.stringify({ ...current, expiresAtMs: Math.max(expiresAtMs, Date.now() + 60_000) }), { mode: 0o600 });
1516
- chmodSync(pendingPath, 0o600);
1517
- // The current operation is still unexpired, so a compliant peer cannot
1518
- // replace it between the owner check and this atomic renewal.
1519
- renameSync(pendingPath, path);
1520
- }
1521
- finally {
1522
- rmSync(pendingPath, { force: true });
1523
- }
1524
- }
1525
- function assertDeviceAuthorizationOperationOwner(operationId) {
1526
- if (readDeviceAuthorizationOperation()?.id !== operationId) {
1527
- throw new Error("browser authorization operation ownership was lost");
1528
- }
1529
- }
1530
- function clearPendingDeviceAuthorization(operationId) {
1531
- const path = pendingDeviceAuthorizationPath();
1532
- if (!operationId) {
1533
- rmSync(path, { force: true });
1534
- return;
1535
- }
1536
- if (!existsSync(path))
1537
- return;
1538
- try {
1539
- const pending = JSON.parse(readFileSync(path, "utf8"));
1540
- if (pending.operationId !== operationId)
1541
- return;
1542
- }
1543
- catch {
1544
- return;
1545
- }
1546
- releaseOwnedJsonFile(path, operationId, "operationId");
1547
- }
1548
- function readPendingDeviceAuthorization(apiUrl) {
1549
- const path = pendingDeviceAuthorizationPath();
1550
- if (!existsSync(path))
1551
- return null;
1552
- try {
1553
- const pending = JSON.parse(readFileSync(path, "utf8"));
1554
- const expiresAtMs = Date.parse(pending.expiresAt ?? "");
1555
- if (pending.apiUrl !== apiUrl ||
1556
- typeof pending.deviceCode !== "string" ||
1557
- typeof pending.userCode !== "string" ||
1558
- typeof pending.verificationUri !== "string" ||
1559
- typeof pending.installationId !== "string" ||
1560
- (pending.operationId !== undefined && typeof pending.operationId !== "string") ||
1561
- !Number.isFinite(pending.intervalSeconds) ||
1562
- !Number.isFinite(expiresAtMs) ||
1563
- expiresAtMs <= Date.now()) {
1564
- return null;
1565
- }
1566
- return pending;
1567
- }
1568
- catch {
1569
- return null;
1570
- }
1571
- }
1572
- function writePendingDeviceAuthorization(pending) {
1573
- const path = pendingDeviceAuthorizationPath();
1574
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
1575
- const writePath = `${path}.pending-${process.pid}-${randomBytes(6).toString("hex")}`;
1576
- try {
1577
- writeFileSync(writePath, JSON.stringify(pending, null, 2), { mode: 0o600 });
1578
- chmodSync(writePath, 0o600);
1579
- renameSync(writePath, path);
1580
- }
1581
- finally {
1582
- rmSync(writePath, { force: true });
1583
- }
1584
- }
1585
- function removeAlanOwnedLocalArtifacts() {
1586
- const configPath = getConfigPath();
1587
- const directory = dirname(configPath);
1588
- const configName = basename(configPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1589
- const alanAtomicSidecarPattern = new RegExp(`^${configName}(?:|\\.setup-pending|\\.device-auth-pending|\\.device-auth-operation\\.json|\\.maintenance-lease\\.json|\\.event-outbox\\.enc)\\.(?:pending|release|corrupt)-\\d+-[a-f0-9]{12}$`);
1590
- // This is the complete Alan-owned local credential/state inventory. Keep
1591
- // third-party provider and MCP config outside this list: logout must never
1592
- // delete user-owned tool configuration.
1593
- const artifacts = new Set([
1594
- `${configPath}.pending`,
1595
- `${configPath}.last-known-good`,
1596
- pendingSetupIntentPath(),
1597
- pendingDeviceAuthorizationPath(),
1598
- deviceAuthorizationOperationPath(),
1599
- `${configPath}.event-outbox.enc`,
1600
- `${configPath}.run-journal.json`,
1601
- `${configPath}.run-journal.json.pending`,
1602
- `${configPath}.pending-runs.json`,
1603
- `${configPath}.pending-runs.json.pending`,
1604
- maintenanceLeasePath(configPath),
1605
- ]);
1606
- if (existsSync(directory)) {
1607
- for (const entry of readdirSync(directory)) {
1608
- if (alanAtomicSidecarPattern.test(entry))
1609
- artifacts.add(join(directory, entry));
1610
- }
1611
- }
1612
- for (const artifact of artifacts)
1613
- rmSync(artifact, { force: true });
1614
- // Remove the durable credential file last so a partial cleanup never leaves
1615
- // hidden credential sidecars behind while claiming logout succeeded.
1616
- rmSync(configPath, { force: true });
1617
- }
1618
- function argValue(args, name) {
1619
- const index = args.indexOf(name);
1620
- if (index === -1)
1621
- return undefined;
1622
- return args[index + 1];
1623
- }
1624
- function resolveEndpointProfile(args) {
1625
- const profile = resolveEndpointProfileFromArgs(args);
1626
- return profile;
1627
- }
1628
- function resolveEndpoints(args, stored = {}) {
1629
- const requestedProfile = resolveEndpointProfile(args);
1630
- const profileApiUrl = requestedProfile === "local"
1631
- ? LOCAL_API_URL
1632
- : requestedProfile === "staging"
1633
- ? STAGING_API_URL
1634
- : PRODUCTION_API_URL;
1635
- const profileWsUrl = requestedProfile === "local"
1636
- ? LOCAL_WS_URL
1637
- : requestedProfile === "staging"
1638
- ? STAGING_WS_URL
1639
- : PRODUCTION_WS_URL;
1640
- const endpointDefaults = readEndpointDefaults();
1641
- const apiUrl = argValue(args, "--api-url") ??
1642
- process.env.ALAN_API_URL ??
1643
- stored.apiUrl ??
1644
- endpointDefaults.apiUrl ??
1645
- profileApiUrl;
1646
- const wsUrl = argValue(args, "--ws-url") ??
1647
- process.env.ALAN_WS_URL ??
1648
- stored.wsUrl ??
1649
- endpointDefaults.wsUrl ??
1650
- profileWsUrl;
1651
- const endpointProfile = apiUrl === profileApiUrl && wsUrl === profileWsUrl
1652
- ? requestedProfile
1653
- : apiUrl === endpointDefaults.apiUrl &&
1654
- wsUrl === endpointDefaults.wsUrl &&
1655
- endpointDefaults.endpointProfile
1656
- ? endpointDefaults.endpointProfile
1657
- : "custom";
1658
- return { apiUrl, wsUrl, endpointProfile };
1659
- }
1660
- function isLocalEndpoint(url) {
1661
- return Boolean(url && /^(https?|wss?):\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/.test(url));
1662
- }
1663
- /**
1664
- * A stored config claiming the production or staging profile must point at
1665
- * that profile's endpoints (or the deployment's endpoints.json overrides).
1666
- * A corrupted config — e.g. production profile with an ephemeral localhost
1667
- * wsUrl — previously made the daemon dial a dead socket forever while the
1668
- * computer still looked Online in Settings.
1669
- */
1670
- function checkStoredEndpointConsistency(stored, endpointDefaults) {
1671
- const profile = stored.endpointProfile;
1672
- if (profile !== "production" && profile !== "staging")
1673
- return null;
1674
- const builtInApiUrl = profile === "production" ? PRODUCTION_API_URL : STAGING_API_URL;
1675
- const builtInWsUrl = profile === "production" ? PRODUCTION_WS_URL : STAGING_WS_URL;
1676
- if (stored.apiUrl === builtInApiUrl && stored.wsUrl === builtInWsUrl)
1677
- return null;
1678
- const defaultsMatchProfile = endpointDefaults.endpointProfile === profile;
1679
- const expectedApiUrl = (defaultsMatchProfile ? endpointDefaults.apiUrl : undefined) ?? builtInApiUrl;
1680
- const expectedWsUrl = (defaultsMatchProfile ? endpointDefaults.wsUrl : undefined) ?? builtInWsUrl;
1681
- const issues = [];
1682
- if (stored.wsUrl && stored.wsUrl !== expectedWsUrl) {
1683
- issues.push(`wsUrl ${stored.wsUrl} (expected ${expectedWsUrl})`);
1684
- }
1685
- if (stored.apiUrl && stored.apiUrl !== expectedApiUrl) {
1686
- issues.push(`apiUrl ${stored.apiUrl} (expected ${expectedApiUrl})`);
1687
- }
1688
- if (issues.length === 0)
1689
- return null;
1690
- return {
1691
- detail: `Stored config claims the ${profile} profile but has ${issues.join(" and ")}.`,
1692
- expectedWsUrl,
1693
- expectedApiUrl,
1694
- };
1695
- }
1696
- function getLocalApiPort(args, stored) {
1697
- const raw = argValue(args, "--local-api-port") ?? process.env.ALAN_AGENT_LOCAL_API_PORT;
1698
- if (raw)
1699
- return Number.parseInt(raw, 10);
1700
- const storedPort = stored.localApiPort;
1701
- if (storedPort && storedPort > 0)
1702
- return storedPort;
1703
- return 47831;
1704
- }
1705
- function sleep(ms) {
1706
- if (ms <= 0)
1707
- return Promise.resolve();
1708
- return new Promise((resolve) => setTimeout(resolve, ms));
1709
- }
1710
- function normalizeBackendKind(backendKind) {
1711
- if (backendKind === "codex")
1712
- return "codex_app_server";
1713
- return backendKind;
1714
- }
1715
- function providerModelCacheKey(provider, executable) {
1716
- return `${provider}:${executable}`;
1717
- }
1718
- /**
1719
- * Populated by refreshProviderVersions() on its own timer, never by
1720
- * discoverCapabilities() itself — probing `--version` synchronously on every
1721
- * heartbeat was the perf regression fixed in 726c388e3.
1722
- */
1723
- const providerVersionCache = new Map();
1724
- const providerModelCache = new Map();
1725
- const providerAvailabilitySeen = new Set();
1726
- const providerProbesInFlight = new Map();
1727
- const providerModelProbesInFlight = new Map();
1728
- export const VERSION_REFRESH_INTERVAL_MS = 20 * 60 * 1000;
1729
- function refreshSingleProviderVersion(provider, executable, env) {
1730
- const inFlight = providerProbesInFlight.get(provider);
1731
- if (inFlight)
1732
- return inFlight;
1733
- const probe = (async () => {
1734
- try {
1735
- const result = await runCliVersionProbeAsync(executable, env);
1736
- if (result.error || result.status !== 0)
1737
- return;
1738
- const version = (result.stdout || result.stderr).split("\n")[0]?.trim();
1739
- if (version)
1740
- providerVersionCache.set(provider, version);
1741
- }
1742
- finally {
1743
- providerProbesInFlight.delete(provider);
1744
- }
1745
- })();
1746
- providerProbesInFlight.set(provider, probe);
1747
- return probe;
1748
- }
1749
- export async function refreshProviderVersions() {
1750
- const env = getDaemonCliEnvironment();
1751
- await Promise.allSettled(DISCOVERABLE_PROVIDER_KINDS.map((provider) => {
1752
- const executable = resolveProviderCliCommand(provider, env);
1753
- if (!executable)
1754
- return Promise.resolve();
1755
- return refreshSingleProviderVersion(provider, executable, env);
1756
- }));
1757
- }
1758
- function refreshSingleProviderModels(provider, executable, env) {
1759
- if (!supportsProviderModelDiscovery(provider))
1760
- return Promise.resolve();
1761
- const cacheKey = providerModelCacheKey(provider, executable);
1762
- const inFlight = providerModelProbesInFlight.get(cacheKey);
1763
- if (inFlight)
1764
- return inFlight;
1765
- const probe = (async () => {
1766
- try {
1767
- const models = await discoverProviderModels(provider, executable, env);
1768
- if (models.length > 0 || !providerModelCache.has(cacheKey)) {
1769
- providerModelCache.set(cacheKey, models);
1770
- }
1771
- }
1772
- finally {
1773
- providerModelProbesInFlight.delete(cacheKey);
1774
- }
1775
- })();
1776
- providerModelProbesInFlight.set(cacheKey, probe);
1777
- return probe;
1778
- }
1779
- export async function refreshProviderModels(providers = DISCOVERABLE_PROVIDER_KINDS) {
1780
- const env = getDaemonCliEnvironment();
1781
- await Promise.allSettled(providers.map((provider) => {
1782
- const executable = resolveProviderCliCommand(provider, env);
1783
- if (!executable)
1784
- return Promise.resolve();
1785
- return refreshSingleProviderModels(provider, executable, env);
1786
- }));
1787
- }
1788
- /** One-shot capability scan for cloud boot hooks and `alan-agent scan-models`. */
1789
- export async function scanAgentCapabilities(providers = [...DISCOVERABLE_PROVIDER_KINDS]) {
1790
- await refreshProviderVersions();
1791
- await refreshProviderModels(providers);
1792
- return discoverCapabilities();
1793
- }
1794
- export async function printScanModels(args) {
1795
- const json = args.includes("--json");
1796
- const providersFlag = args.find((arg) => arg.startsWith("--providers="));
1797
- const providers = providersFlag
1798
- ? providersFlag
1799
- .slice("--providers=".length)
1800
- .split(",")
1801
- .map((provider) => provider.trim())
1802
- .filter(Boolean)
1803
- : undefined;
1804
- const capabilities = await scanAgentCapabilities(providers ?? [...DISCOVERABLE_PROVIDER_KINDS]);
1805
- if (json) {
1806
- process.stdout.write(`${JSON.stringify(capabilities)}\n`);
1807
- return;
1808
- }
1809
- for (const agent of capabilities.agents ?? []) {
1810
- const modelSummary = agent.models?.length ? agent.models.join(", ") : "none";
1811
- process.stdout.write(`${agent.provider}: ${agent.available ? "available" : "missing"} models=[${modelSummary}]\n`);
1812
- }
1813
- }
1814
- function scheduleRuntimeModelScan(getBaseMetadata, options) {
1815
- const stored = readConfig();
1816
- if (!stored.runtimeId || !stored.runtimeToken || !stored.apiUrl)
1817
- return;
1818
- void runRuntimeModelScan({
1819
- refreshModels: refreshProviderModels,
1820
- getCapabilities: () => discoverCapabilities(),
1821
- getBaseMetadata: async () => (await getBaseMetadata()),
1822
- publishCredentials: {
1823
- apiUrl: stored.apiUrl,
1824
- runtimeId: stored.runtimeId,
1825
- runtimeToken: stored.runtimeToken,
1826
- },
1827
- }, options);
1828
- }
1829
- export function discoverCapabilities() {
1830
- const now = new Date().toISOString();
1831
- const env = getDaemonCliEnvironment();
1832
- return {
1833
- agents: DISCOVERABLE_PROVIDER_KINDS.map((provider) => {
1834
- const executable = resolveProviderCliCommand(provider, env);
1835
- const available = Boolean(executable);
1836
- if (available && executable && !providerAvailabilitySeen.has(provider)) {
1837
- providerAvailabilitySeen.add(provider);
1838
- void refreshSingleProviderVersion(provider, executable, env);
1839
- }
1840
- else if (!available) {
1841
- providerAvailabilitySeen.delete(provider);
1842
- }
1843
- const version = providerVersionCache.get(provider);
1844
- const models = executable
1845
- ? (providerModelCache.get(providerModelCacheKey(provider, executable)) ?? [])
1846
- : [];
1847
- return {
1848
- provider,
1849
- available,
1850
- ...(version ? { version } : {}),
1851
- models,
1852
- lastCheckedAt: now,
1853
- };
1854
- }),
1855
- hasGit: Boolean(resolveCliExecutable("git", env)),
1856
- hasTerminal: true,
1857
- supportsFilesystem: true,
1858
- };
1859
- }
1860
- const PENDING_RUN_START_TTL_MS = 10 * 60_000;
1861
- class DurablePendingRunStarts extends Map {
1862
- path;
1863
- daemonSessionId;
1864
- startedAtByRunId = new Map();
1865
- constructor(path, daemonSessionId) {
1866
- super();
1867
- this.path = path;
1868
- this.daemonSessionId = daemonSessionId;
1869
- }
1870
- set(runId, conversationId) {
1871
- if (!this.startedAtByRunId.has(runId))
1872
- this.startedAtByRunId.set(runId, Date.now());
1873
- super.set(runId, conversationId);
1874
- this.persist();
1875
- return this;
1876
- }
1877
- delete(runId) {
1878
- const deleted = super.delete(runId);
1879
- if (deleted) {
1880
- this.startedAtByRunId.delete(runId);
1881
- this.persist();
1882
- }
1883
- return deleted;
1884
- }
1885
- /** Remove artifacts inherited from a previous process after local API ownership is proven. */
1886
- discardRecovered() {
1887
- this.persist();
1888
- }
1889
- persist() {
1890
- if (this.size === 0) {
1891
- rmSync(this.path, { force: true });
1892
- return;
1893
- }
1894
- const pendingPath = `${this.path}.pending`;
1895
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
1896
- try {
1897
- writeFileSync(pendingPath, JSON.stringify({
1898
- version: 1,
1899
- entries: [...this].map(([runId, conversationId]) => ({
1900
- runId,
1901
- conversationId,
1902
- startedAtMs: this.startedAtByRunId.get(runId) ?? Date.now(),
1903
- daemonSessionId: this.daemonSessionId,
1904
- })),
1905
- }), { mode: 0o600 });
1906
- chmodSync(pendingPath, 0o600);
1907
- renameSync(pendingPath, this.path);
1908
- }
1909
- finally {
1910
- rmSync(pendingPath, { force: true });
1911
- }
1912
- }
1913
- }
1914
- const RUNNER_RECOVERY_GRACE_MS_LOCAL = RUNNER_RECOVERY_GRACE_MS;
1915
- const USER_INTERRUPTED_RESULT = {
1916
- success: false,
1917
- summary: "Interrupted by user",
1918
- filesModified: [],
1919
- planFilesCreated: [],
1920
- iterations: 0,
1921
- error: "Interrupted by user",
1922
- errorKind: "user",
1923
- };
1924
- /**
1925
- * Build the terminal result for an aborted run. Only reason "user" may emit the
1926
- * "Interrupted by user" text the UI renders as "Stopped by user"; system-initiated
1927
- * aborts (watchdog timeout, dead provider, stalled runner) carry distinct messages.
1928
- */
1929
- function buildAbortResult(reason) {
1930
- if (reason === "user")
1931
- return USER_INTERRUPTED_RESULT;
1932
- const message = SYSTEM_ABORT_RUN_ERRORS[reason];
1933
- return {
1934
- success: false,
1935
- summary: message,
1936
- filesModified: [],
1937
- planFilesCreated: [],
1938
- iterations: 0,
1939
- error: message,
1940
- errorKind: reason,
1941
- };
1942
- }
1943
- function abortActiveAgent(entry, options) {
1944
- const awaitingAsk = (entry.presenter.pendingAskUserToolIds?.size ?? 0) > 0;
1945
- if (awaitingAsk && !options?.userInitiated) {
1946
- return;
1947
- }
1948
- const reason = options?.reason ?? (options?.userInitiated ? "user" : "runner_stalled");
1949
- entry.presenter.onComplete(buildAbortResult(reason));
1950
- entry.agent.kill();
1951
- }
1952
- function isProviderProcessAlive(pid) {
1953
- if (pid === null || pid <= 0)
1954
- return false;
1955
- try {
1956
- process.kill(pid, 0);
1957
- return true;
1958
- }
1959
- catch {
1960
- return false;
1961
- }
1962
- }
1963
- function buildActiveRunSlots(activeAgents, nowMs = Date.now()) {
1964
- return [...activeAgents.entries()].map(([runId, entry]) => {
1965
- const staleByGrace = nowMs - entry.lastActivityAtMs >= RUNNER_RECOVERY_GRACE_MS_LOCAL;
1966
- const staleByDeadPid = entry.providerPid !== null && !isProviderProcessAlive(entry.providerPid);
1967
- const stale = staleByGrace || staleByDeadPid;
1968
- return {
1969
- runId,
1970
- conversationId: entry.conversationId,
1971
- stage: entry.stage,
1972
- providerPid: entry.providerPid,
1973
- acceptedAtMs: entry.acceptedAtMs,
1974
- lastActivityAtMs: entry.lastActivityAtMs,
1975
- stale,
1976
- };
1977
- });
1978
- }
1979
- function buildRunnerStatusPayload(input) {
1980
- const nowMs = input.nowMs ?? Date.now();
1981
- const activeRuns = buildActiveRunSlots(input.activeAgents, nowMs);
1982
- const { activeRunCount, staleRunCount } = countNonStaleActiveRuns(activeRuns, nowMs);
1983
- const computerReadiness = buildComputerReadiness({
1984
- nowMs,
1985
- runtimeId: input.runtimeId,
1986
- endpointProfile: input.endpointProfile,
1987
- configPath: input.configPath,
1988
- configEndpointProfile: input.endpointProfile,
1989
- endpointMismatchDetail: input.endpointMismatchDetail,
1990
- connected: input.connected,
1991
- localApiHealthy: true,
1992
- runtimeAuthValid: input.runtimeAuthValid,
1993
- workspacePath: input.workspacePath,
1994
- providerAvailable: input.providerAvailable,
1995
- protocolCompatible: true,
1996
- });
1997
- // allowWarn: a warn is degraded-but-functional (e.g. self-healed endpoint
1998
- // mismatch) and must not block run acceptance.
1999
- const computerReady = isComputerReady(computerReadiness, { allowWarn: true });
2000
- const pastDisconnectGrace = input.disconnectedSinceMs !== null &&
2001
- nowMs - input.disconnectedSinceMs >= RUNNER_RECOVERY_GRACE_MS_LOCAL;
2002
- const restartRecommended = !input.connected && activeRunCount === 0 && staleRunCount === 0 && pastDisconnectGrace;
2003
- const recovering = !input.connected && !restartRecommended;
2004
- const acceptingRuns = deriveAcceptingRuns({
2005
- connected: input.connected,
2006
- activeRunCount,
2007
- computerReady,
2008
- });
2009
- const primaryIssue = pickPrimaryReadinessIssue(computerReadiness);
2010
- return {
2011
- daemonSessionId: input.daemonSessionId,
2012
- daemonEpoch: input.daemonEpoch,
2013
- heartbeatSeq: input.heartbeatSeq,
2014
- connected: input.connected,
2015
- acceptingRuns,
2016
- recovering,
2017
- restartRecommended,
2018
- activeRuns,
2019
- interruptedRunIds: input.interruptedRunIds ?? [],
2020
- activeRunCount,
2021
- staleRunCount,
2022
- lastRunActivityAtMs: activeRuns.reduce((latest, run) => {
2023
- const value = run.lastActivityAtMs ?? run.acceptedAtMs ?? null;
2024
- return value === null ? latest : latest === null ? value : Math.max(latest, value);
2025
- }, null),
2026
- disconnectedSinceMs: input.disconnectedSinceMs,
2027
- endpointProfile: input.endpointProfile,
2028
- configPath: input.configPath,
2029
- protocolVersion: RUNNER_STATUS_PROTOCOL_VERSION,
2030
- protocolMinVersion: RUNNER_STATUS_MIN_SUPPORTED_PROTOCOL_VERSION,
2031
- protocolMaxVersion: RUNNER_STATUS_MAX_SUPPORTED_PROTOCOL_VERSION,
2032
- computerReadiness,
2033
- primaryMessage: primaryIssue?.message,
2034
- primaryAction: primaryIssue?.action ?? null,
2035
- };
2036
- }
2037
- function summarizeRunnerStatus(input) {
2038
- const runnerStatus = buildRunnerStatusPayload(input);
2039
- if (input.maintenanceReason) {
2040
- runnerStatus.acceptingRuns = false;
2041
- runnerStatus.primaryMessage =
2042
- input.maintenanceReason === "credential_cleanup"
2043
- ? "This computer is finishing logout."
2044
- : "This computer is under short-lived maintenance.";
2045
- runnerStatus.primaryAction = null;
2046
- }
2047
- return {
2048
- activeRunIds: runnerStatus.activeRuns.map((run) => run.runId),
2049
- activeRunCount: runnerStatus.activeRunCount,
2050
- acceptingRuns: runnerStatus.acceptingRuns,
2051
- recovering: runnerStatus.recovering,
2052
- restartRecommended: runnerStatus.restartRecommended,
2053
- staleRunCount: runnerStatus.staleRunCount,
2054
- lastRunActivityAtMs: runnerStatus.lastRunActivityAtMs ?? null,
2055
- disconnectedSinceMs: runnerStatus.disconnectedSinceMs ?? null,
2056
- runnerStatus,
2057
- };
2058
- }
2059
- const DELIVERY_LOST_ERROR = "Runtime disconnected from backend; agent run delivery was lost";
2060
- function buildApplicationRunFailure(input) {
2061
- const problem = createAlanProblem({
2062
- domain: "application",
2063
- code: input.code,
2064
- id: randomUUID(),
2065
- title: "Alan could not complete the agent run",
2066
- detail: input.detail,
2067
- occurredAt: new Date().toISOString(),
2068
- severity: "terminal",
2069
- scope: { conversationId: input.conversationId, runId: input.runId },
2070
- recovery: {
2071
- mode: input.code === "app.workspace.missing" ? "after_user_action" : "manual",
2072
- action: input.detail,
2073
- safe: false,
2074
- },
2075
- origin: { component: input.component, phase: input.phase },
2076
- transport: {
2077
- kind: input.phase === "delivery" ? "websocket" : "internal",
2078
- },
2079
- });
2080
- const result = {
2081
- success: false,
2082
- summary: input.detail,
2083
- filesModified: [],
2084
- planFilesCreated: [],
2085
- iterations: 0,
2086
- error: input.detail,
2087
- };
2088
- return {
2089
- problem,
2090
- result,
2091
- events: [
2092
- { type: "application_problem", problem },
2093
- {
2094
- type: "agent_run_finished",
2095
- result,
2096
- failureDomain: "application",
2097
- causeProblemId: problem.id,
2098
- },
2099
- // Legacy completion projection. Never author session_error for a new
2100
- // application-domain failure.
2101
- { type: "query_complete", result, ts: Date.now() },
2102
- ],
2103
- };
2104
- }
2105
- /**
2106
- * On reconnect: replay the real events buffered for each run that produced activity while the
2107
- * socket was down (Gap 1), then emit the generic delivery_lost session_error ONLY for runs the
2108
- * daemon can no longer account for — i.e. gone from `activeAgents` with no buffered terminal.
2109
- *
2110
- * A run still present in `activeAgents` is alive by definition: the daemon is still executing it,
2111
- * its remaining output will stream and terminate normally. Emitting a synthetic delivery_lost for
2112
- * such a run fails a live run on the server and wipes the API accumulator mid-turn — the rest of
2113
- * the turn then streams live but is never persisted (issue #3). Those runs are skipped here and
2114
- * their stage is restored so a later disconnect re-tracks them.
2115
- */
2116
- export function flushPendingDeliveryLostEvents(input) {
2117
- if (!input.socket.connected)
2118
- return;
2119
- input.eventOutbox.flush(input.socket);
2120
- if (input.pending.length === 0)
2121
- return;
2122
- for (const item of input.pending) {
2123
- // A terminal event was already replayed above for this run — do not also send the generic
2124
- // delivery_lost error, which would overwrite a real (possibly successful) outcome.
2125
- if (input.eventOutbox.hasTerminalEvent(item.runId))
2126
- continue;
2127
- const activeEntry = input.activeAgents.get(item.runId);
2128
- if (activeEntry) {
2129
- // Still executing — do not fail it. Undo the disconnect-time stage flip so the next
2130
- // disconnect re-adds it to the pending set.
2131
- if (activeEntry.stage === "delivery_lost")
2132
- activeEntry.stage = "running";
2133
- continue;
2134
- }
2135
- const failure = buildApplicationRunFailure({
2136
- conversationId: item.conversationId,
2137
- runId: item.runId,
2138
- code: "app.transport.disconnected",
2139
- detail: DELIVERY_LOST_ERROR,
2140
- component: "agent-manager-daemon",
2141
- phase: "delivery",
2142
- });
2143
- for (const event of failure.events) {
2144
- input.eventOutbox.enqueue(item.conversationId, item.runId, event);
2145
- }
2146
- input.pushLog(`delivery_lost run=${item.runId}`);
2147
- }
2148
- input.eventOutbox.flush(input.socket);
2149
- input.pending.length = 0;
2150
- }
2151
- /** Exported for unit tests — periodic stale/dead-provider reconciliation. */
2152
- export function reconcileActiveRuns(input) {
2153
- const nowMs = input.nowMs ?? Date.now();
2154
- for (const [runId, entry] of input.activeAgents.entries()) {
2155
- const staleByGrace = nowMs - entry.lastActivityAtMs >= RUNNER_RECOVERY_GRACE_MS_LOCAL;
2156
- const staleByDeadPid = entry.providerPid !== null && !isProviderProcessAlive(entry.providerPid);
2157
- const awaitingAsk = (entry.presenter.pendingAskUserToolIds?.size ?? 0) > 0;
2158
- if (!staleByGrace && !staleByDeadPid)
2159
- continue;
2160
- if (awaitingAsk && staleByGrace && !staleByDeadPid)
2161
- continue;
2162
- if (staleByDeadPid && awaitingAsk) {
2163
- // The provider process is gone while an ask is still displayed. We used to
2164
- // kill + drop the run with NO terminal event, so it zombied as `running`
2165
- // until the watchdog and the user's later answer landed nowhere (no live
2166
- // stdin, no terminal, no error). Emit a terminal now so the run finalizes:
2167
- // the ask card survives because its tool_use stays unanswered, and with the
2168
- // run no longer in-flight the answer re-dispatches as a fresh user turn
2169
- // (message_resume) instead of racing a dead stdin. The awaiting-ask abort
2170
- // guard is deliberately bypassed here — there is no live CLI left to keep
2171
- // stdin open for, unlike the live-provider staleByGrace case above.
2172
- entry.presenter.onComplete(buildAbortResult("provider_exited"));
2173
- entry.agent.kill();
2174
- input.activeAgents.delete(runId);
2175
- input.pushLog?.(`reconciled dead provider pid run=${runId} (awaiting ask — finalized for resume)`);
2176
- continue;
2177
- }
2178
- abortActiveAgent(entry, { reason: staleByDeadPid ? "provider_exited" : "runner_stalled" });
2179
- input.activeAgents.delete(runId);
2180
- if (staleByDeadPid) {
2181
- input.pushLog?.(`reconciled dead provider pid run=${runId}`);
2182
- }
2183
- else {
2184
- input.pushLog?.(`recovered stale run=${runId}`);
2185
- }
2186
- }
2187
- }
2188
- function collectRuntimeMetadata() {
2189
- const cpuList = cpus();
2190
- const metadata = {
2191
- platform: platform(),
2192
- arch: arch(),
2193
- osVersion: release(),
2194
- agentVersion: AGENT_VERSION,
2195
- memoryBytes: totalmem(),
2196
- memoryFreeBytes: freemem(),
2197
- };
2198
- if (cpuList.length > 0) {
2199
- metadata.cpuModel = cpuList[0]?.model;
2200
- metadata.cpuCores = cpuList.length;
2201
- }
2202
- try {
2203
- const disk = statfsSync(homedir());
2204
- metadata.diskFreeBytes = disk.bavail * disk.bsize;
2205
- metadata.diskTotalBytes = disk.blocks * disk.bsize;
2206
- }
2207
- catch {
2208
- // Disk stats are best-effort and may be unavailable in restricted runtimes.
2209
- }
2210
- return metadata;
2211
- }
2212
- async function collectRuntimeMetadataWithProviderLimits() {
2213
- const metadata = collectRuntimeMetadata();
2214
- try {
2215
- const agentProviderLimits = await collectLocalAgentProviderLimits(getDaemonCliEnvironment());
2216
- if (Object.keys(agentProviderLimits).length > 0) {
2217
- metadata.agentProviderLimits = agentProviderLimits;
2218
- }
2219
- }
2220
- catch {
2221
- // Best-effort only. Missing or expired provider credentials should not affect runtime presence.
2222
- }
2223
- return metadata;
2224
- }
2225
- export class RuntimePresenter {
2226
- socket;
2227
- conversationId;
2228
- runId;
2229
- eventOutbox;
2230
- cwd;
2231
- touchRunnerActivity;
2232
- onRunComplete;
2233
- onRunStarted;
2234
- verifyWorkspaceLease;
2235
- onProviderAuthFailure;
2236
- backendKind;
2237
- pendingAskUserToolIds;
2238
- emittedGeneratedImageKeys = new Set();
2239
- emittedSessionMediaKeys = new Set();
2240
- toolNamesById = new Map();
2241
- toolInputsById = new Map();
2242
- browserRecordingPathHints = new Set();
2243
- applicationProblem;
2244
- terminalFenced = false;
2245
- constructor(socket, conversationId, runId, eventOutbox, cwd, touchRunnerActivity, onRunComplete, onRunStarted, verifyWorkspaceLease,
2246
- /** Drop sticky provider-auth ready when the CLI reports a clear sign-out. */
2247
- onProviderAuthFailure, backendKind = "unknown_cli") {
2248
- this.socket = socket;
2249
- this.conversationId = conversationId;
2250
- this.runId = runId;
2251
- this.eventOutbox = eventOutbox;
2252
- this.cwd = cwd;
2253
- this.touchRunnerActivity = touchRunnerActivity;
2254
- this.onRunComplete = onRunComplete;
2255
- this.onRunStarted = onRunStarted;
2256
- this.verifyWorkspaceLease = verifyWorkspaceLease;
2257
- this.onProviderAuthFailure = onProviderAuthFailure;
2258
- this.backendKind = backendKind;
2259
- }
2260
- emitEvent(event) {
2261
- if (this.terminalFenced)
2262
- return;
2263
- this.touchRunnerActivity?.();
2264
- // background_work heartbeats only prove liveness to a listener that is
2265
- // currently connected — while offline there is nobody to prove it to.
2266
- // Enqueuing them anyway would durably occupy outbox slots every ~10s for
2267
- // the whole offline stretch, flooding the per-run cap and evicting real
2268
- // transcript content (including `start`) ahead of reconnect. Drop it;
2269
- // another heartbeat follows in 10s and will be retried the same way.
2270
- if (isBackgroundHeartbeat(event) && !this.socket.connected)
2271
- return;
2272
- this.eventOutbox.enqueue(this.conversationId, this.runId, event);
2273
- this.eventOutbox.flush(this.socket);
2274
- }
2275
- failWorkspaceLease(failure) {
2276
- return this.failApplicationProblem({
2277
- code: "app.workspace.missing",
2278
- detail: failure.message,
2279
- component: "agent-manager-workspace-lease",
2280
- phase: "delivery",
2281
- });
2282
- }
2283
- failApplicationProblem(input) {
2284
- if (this.terminalFenced)
2285
- return false;
2286
- this.terminalFenced = true;
2287
- const failure = buildApplicationRunFailure({
2288
- conversationId: this.conversationId,
2289
- runId: this.runId,
2290
- ...input,
2291
- });
2292
- this.applicationProblem = failure.problem;
2293
- for (const event of failure.events) {
2294
- this.eventOutbox.enqueue(this.conversationId, this.runId, event);
2295
- }
2296
- this.eventOutbox.flush(this.socket);
2297
- this.onRunComplete?.();
2298
- return true;
2299
- }
2300
- isTerminalFenced() {
2301
- return this.terminalFenced;
2302
- }
2303
- onStart(config) {
2304
- this.onRunStarted?.();
2305
- this.emitEvent({ type: "start", task: config.task, ts: Date.now() });
2306
- }
2307
- onLog(message) {
2308
- console.error(message);
2309
- }
2310
- onNotice(kind, message) {
2311
- this.emitEvent({ type: "session_notice", kind, message, ts: Date.now() });
2312
- }
2313
- onAssistantText(text, parentToolUseId) {
2314
- this.emitEvent({
2315
- type: "text",
2316
- text,
2317
- ts: Date.now(),
2318
- ...(parentToolUseId ? { parentToolUseId } : {}),
2319
- });
2320
- }
2321
- onThinking(text, parentToolUseId) {
2322
- this.emitEvent({
2323
- type: "thinking",
2324
- text,
2325
- ts: Date.now(),
2326
- ...(parentToolUseId ? { parentToolUseId } : {}),
2327
- });
2328
- }
2329
- onToolUse(tool, input, toolId, parentToolUseId) {
2330
- if (this.verifyWorkspaceLease && !this.verifyWorkspaceLease())
2331
- return;
2332
- this.toolNamesById.set(toolId, tool);
2333
- this.toolInputsById.set(toolId, input);
2334
- for (const path of extractBrowserMediaPathHints(undefined, this.cwd, tool, {
2335
- toolInput: input,
2336
- })) {
2337
- this.browserRecordingPathHints.add(path);
2338
- }
2339
- this.emitEvent({
2340
- type: "tool_use",
2341
- id: toolId,
2342
- name: tool,
2343
- input,
2344
- ts: Date.now(),
2345
- ...(parentToolUseId ? { parentToolUseId } : {}),
2346
- });
2347
- }
2348
- onToolResult(toolId, content, isError, parentToolUseId) {
2349
- if (this.verifyWorkspaceLease && !this.verifyWorkspaceLease())
2350
- return;
2351
- this.emitEvent({
2352
- type: "tool_result",
2353
- id: toolId,
2354
- content,
2355
- ts: Date.now(),
2356
- ...(isError ? { is_error: true } : {}),
2357
- ...(parentToolUseId ? { parentToolUseId } : {}),
2358
- });
2359
- if (isError)
2360
- return;
2361
- const toolName = this.toolNamesById.get(toolId);
2362
- const toolInput = this.toolInputsById.get(toolId);
2363
- const options = { toolInput, pathHints: [...this.browserRecordingPathHints] };
2364
- for (const image of extractGeneratedImagesFromToolResult(toolId, content, this.cwd, toolName, options)) {
2365
- this.emitGeneratedImage(image);
2366
- }
2367
- for (const media of extractSessionMediaFromToolResult(toolId, content, this.cwd, toolName, options)) {
2368
- this.emitSessionMedia(media);
2369
- if (media.sourcePath)
2370
- this.browserRecordingPathHints.delete(media.sourcePath);
2371
- }
2372
- }
2373
- onGeneratedImage(image) {
2374
- this.emitGeneratedImage(image);
2375
- }
2376
- emitGeneratedImage(image) {
2377
- const key = image.sourceHash ?? `${image.sourcePath ?? image.id}:${image.size}`;
2378
- if (this.emittedGeneratedImageKeys.has(key))
2379
- return;
2380
- this.emittedGeneratedImageKeys.add(key);
2381
- this.emitEvent(image);
2382
- }
2383
- onSessionMedia(media) {
2384
- this.emitSessionMedia(media);
2385
- }
2386
- emitSessionMedia(media) {
2387
- const key = media.sourceHash ?? `${media.sourcePath ?? media.id}:${media.size}`;
2388
- if (this.emittedSessionMediaKeys.has(key))
2389
- return;
2390
- this.emittedSessionMediaKeys.add(key);
2391
- this.emitEvent(media);
2392
- }
2393
- onTurnComplete(content) {
2394
- this.emitEvent({ type: "turn_complete", content, ts: Date.now() });
2395
- }
2396
- onUsageUpdate(usage) {
2397
- this.emitEvent({ type: "usage", usage });
2398
- }
2399
- onAskUser(id, questions) {
2400
- this.emitEvent({ type: "ask_user", id, questions });
2401
- }
2402
- onTodoWrite(todos) {
2403
- this.emitEvent({ type: "todo_write", todos });
2404
- }
2405
- onExitPlanMode(id, input, planFilePath) {
2406
- this.emitEvent({
2407
- type: "exit_plan_mode",
2408
- id,
2409
- input,
2410
- ...(planFilePath ? { planFilePath } : {}),
2411
- });
2412
- }
2413
- onCheckpoint(id) {
2414
- this.emitEvent({ type: "checkpoint", id });
2415
- }
2416
- onActivity(status, evidence) {
2417
- this.emitEvent({ type: "activity", status, ts: Date.now(), ...(evidence ? { evidence } : {}) });
2418
- }
2419
- onOpenBrowserTab(browserId, url) {
2420
- this.emitEvent({ type: "open_browser_tab", browserId, ...(url ? { url } : {}) });
2421
- }
2422
- recordRawTranscript() {
2423
- // Durable daemon transcripts stay with the underlying CLI runtime.
2424
- }
2425
- onError(message, isAgentError = true) {
2426
- if (this.onProviderAuthFailure && looksLikeProviderAuthFailure(message)) {
2427
- this.onProviderAuthFailure();
2428
- }
2429
- // Parser/provider errors are observations. Some harnesses report a retryable
2430
- // error and then finish successfully; treating this callback as terminal
2431
- // prematurely releases the run and leaves a later success as a second
2432
- // terminal. The run finalizer below is the sole canonical terminal emitter.
2433
- if (isAgentError) {
2434
- const problem = createCliProblem(classifyCliErrorDetailed(message, 1), {
2435
- harness: this.backendKind,
2436
- phase: "stream",
2437
- finality: "observation",
2438
- outcomeCertainty: "effect_unknown",
2439
- scope: { conversationId: this.conversationId, runId: this.runId },
2440
- });
2441
- this.emitEvent({ type: "agent_problem_observed", problem });
2442
- }
2443
- else {
2444
- this.applicationProblem = createAlanProblem({
2445
- domain: "application",
2446
- code: /local path no longer exists/i.test(message)
2447
- ? "app.workspace.missing"
2448
- : "app.internal",
2449
- id: randomUUID(),
2450
- title: "Alan could not start the agent run",
2451
- detail: message,
2452
- occurredAt: new Date().toISOString(),
2453
- severity: "terminal",
2454
- scope: { conversationId: this.conversationId, runId: this.runId },
2455
- recovery: {
2456
- mode: /local path no longer exists/i.test(message) ? "after_user_action" : "manual",
2457
- action: message,
2458
- safe: false,
2459
- },
2460
- origin: { component: "agent-manager", phase: "dispatch" },
2461
- transport: { kind: "internal" },
2462
- });
2463
- this.emitEvent({ type: "application_problem", problem: this.applicationProblem });
2464
- }
2465
- // Temporary nonterminal compatibility for consumers that still understand
2466
- // the old generic error event. `session_error` is intentionally not emitted
2467
- // here because legacy clients treat it as terminal.
2468
- this.emitEvent({ type: "error", message, isAgentError });
2469
- }
2470
- onComplete(result) {
2471
- this.emitEvent({
2472
- type: "agent_run_finished",
2473
- result,
2474
- ...(result.problem
2475
- ? { failureDomain: "agent_cli", causeProblemId: result.problem.id }
2476
- : this.applicationProblem
2477
- ? {
2478
- failureDomain: "application",
2479
- causeProblemId: this.applicationProblem.id,
2480
- }
2481
- : {}),
2482
- });
2483
- // query_complete is the sole legacy completion projection. Do not author a
2484
- // parallel session_error: migrated clients would first retain the typed
2485
- // cause, then have it overwritten by the untyped compatibility event.
2486
- this.emitEvent({ type: "query_complete", result, ts: Date.now() });
2487
- this.onRunComplete?.();
2488
- }
2489
- }
2490
- export async function setupDaemon(args) {
2491
- bindConfigPath(args);
2492
- const previousConfig = readConfigForRegistration();
2493
- const { apiUrl, wsUrl, endpointProfile } = resolveEndpoints(args);
2494
- const teamId = argValue(args, "--team") ?? process.env.ALAN_TEAM_ID;
2495
- const setupToken = argValue(args, "--setup-token") ?? process.env.ALAN_RUNTIME_SETUP_TOKEN;
2496
- const token = argValue(args, "--token") ?? process.env.ALAN_SETUP_TOKEN;
2497
- const displayName = argValue(args, "--name") ?? hostname();
2498
- const scope = argValue(args, "--scope") ?? process.env.ALAN_RUNTIME_SCOPE ?? (setupToken ? "team" : "user");
2499
- const installationId = argValue(args, "--installation-id") ?? previousConfig.installationId ?? randomUUID();
2500
- if (scope !== "team" && scope !== "user") {
2501
- throw new Error("setup --scope must be either 'team' or 'user'");
2502
- }
2503
- if (!setupToken && !token) {
2504
- throw new Error("setup requires --setup-token <token> or --token <Alan access token>.\n" +
2505
- "For normal setup, copy the setup token from Alan and run: alan-agent setup --setup-token <token>\n" +
2506
- "For browser authorization, run: alan-agent login");
2507
- }
2508
- const registrationAttemptId = setupToken ? getOrCreateSetupAttemptId(setupToken) : randomUUID();
2509
- const registrationUrl = `${apiUrl}/public/runtimes${setupToken ? "/register-with-setup-token" : ""}`;
2510
- const registrationInit = {
2511
- method: "POST",
2512
- headers: {
2513
- "content-type": "application/json",
2514
- ...(setupToken ? {} : { authorization: `Bearer ${token}` }),
2515
- },
2516
- body: JSON.stringify({
2517
- ...(teamId ? { teamId } : {}),
2518
- ...(setupToken ? { setupToken, registrationAttemptId } : {}),
2519
- displayName,
2520
- hostname: hostname(),
2521
- runtimeKind: "machine",
2522
- managementKind: "user_managed",
2523
- hostKind: "daemon",
2524
- lifecycle: "durable",
2525
- capabilities: discoverCapabilities(),
2526
- metadata: {
2527
- ...(await collectRuntimeMetadataWithProviderLimits()),
2528
- installationId,
2529
- },
2530
- }),
2531
- };
2532
- let response;
2533
- let transportError;
2534
- for (let attempt = 1; attempt <= 3; attempt += 1) {
2535
- try {
2536
- response = await fetch(registrationUrl, registrationInit);
2537
- transportError = undefined;
2538
- }
2539
- catch (error) {
2540
- transportError = error;
2541
- }
2542
- const retriableStatus = response && (response.status === 408 || response.status === 429 || response.status >= 500);
2543
- if ((response && (!retriableStatus || response.ok)) || attempt === 3)
2544
- break;
2545
- await new Promise((resolve) => setTimeout(resolve, attempt * 100));
2546
- }
2547
- if (!response) {
2548
- const detail = transportError instanceof Error ? transportError.message : String(transportError);
2549
- throw new Error(`runtime registration failed: unable to reach Alan (${detail})`);
2550
- }
2551
- if (!response.ok) {
2552
- const responseBody = await response.text();
2553
- let errorCode;
2554
- try {
2555
- const parsed = JSON.parse(responseBody);
2556
- if (typeof parsed.error === "string")
2557
- errorCode = parsed.error;
2558
- }
2559
- catch {
2560
- // Preserve non-JSON server responses in the setup failure below.
2561
- }
2562
- if (setupToken && response.status === 401 && errorCode === "setup_token_expired") {
2563
- clearPendingSetupIntent();
2564
- console.info("[alan-agent] Setup link expired; continuing with browser authorization");
2565
- await loginWithDeviceCode(args);
2566
- return;
2567
- }
2568
- throw new Error(`runtime registration failed: ${response.status} ${responseBody}`);
2569
- }
2570
- const body = (await response.json());
2571
- writeConfig({
2572
- apiUrl,
2573
- wsUrl,
2574
- endpointProfile,
2575
- runtimeId: body.runtime.id,
2576
- runtimeToken: body.runtimeToken,
2577
- runtimeRenewalToken: body.runtimeRenewalToken,
2578
- localApiPort: getLocalApiPort(args, {}),
2579
- localApiToken: previousConfig.localApiToken ?? randomBytes(24).toString("hex"),
2580
- localServiceId: previousConfig.localServiceId ?? randomUUID(),
2581
- localServiceSecret: previousConfig.localServiceSecret ?? randomBytes(32).toString("base64url"),
2582
- eventSpoolKey: previousConfig.eventSpoolKey ?? randomBytes(32).toString("base64url"),
2583
- daemonEpoch: previousConfig.daemonEpoch,
2584
- displayName: body.runtime.displayName ?? displayName,
2585
- installationId,
2586
- });
2587
- if (setupToken)
2588
- clearPendingSetupIntent();
2589
- console.info("[alan-agent] Runtime registered", {
2590
- runtimeId: body.runtime.id,
2591
- configPath: getConfigPath(),
2592
- });
2593
- }
2594
- export async function loginWithDeviceCode(args) {
2595
- bindConfigPath(args);
2596
- const operation = acquireDeviceAuthorizationOperation();
2597
- if (!operation) {
2598
- throw new Error("browser authorization is already in progress in another Alan process; finish or cancel it first");
2599
- }
2600
- try {
2601
- if (args.includes("--cancel")) {
2602
- clearPendingDeviceAuthorization();
2603
- console.info("[alan-agent] Browser authorization canceled");
2604
- return;
2605
- }
2606
- const previousConfig = readConfigForRegistration();
2607
- const { apiUrl, wsUrl, endpointProfile } = resolveEndpoints(args);
2608
- const displayName = argValue(args, "--name") ?? hostname();
2609
- const maxPolls = Number.parseInt(argValue(args, "--max-polls") ?? "300", 10);
2610
- const resumed = readPendingDeviceAuthorization(apiUrl);
2611
- const installationId = resumed?.installationId ?? previousConfig.installationId ?? randomUUID();
2612
- let authorization = resumed
2613
- ? { ...resumed, operationId: operation.id }
2614
- : null;
2615
- if (!authorization) {
2616
- // This process owns the operation lease, so an invalid/expired abandoned
2617
- // sidecar can be removed before starting a replacement authorization.
2618
- clearPendingDeviceAuthorization();
2619
- const start = await fetch(`${apiUrl}/public/runtimes/device-authorizations`, {
2620
- method: "POST",
2621
- headers: { "content-type": "application/json" },
2622
- body: JSON.stringify({
2623
- displayName,
2624
- hostname: hostname(),
2625
- capabilities: discoverCapabilities(),
2626
- metadata: {
2627
- ...(await collectRuntimeMetadataWithProviderLimits()),
2628
- installationId,
2629
- },
2630
- }),
2631
- signal: AbortSignal.timeout(30_000),
2632
- });
2633
- if (!start.ok) {
2634
- throw new Error(`device authorization failed: ${start.status} ${await start.text()}`);
2635
- }
2636
- const started = (await start.json());
2637
- authorization = {
2638
- ...started,
2639
- apiUrl,
2640
- installationId,
2641
- operationId: operation.id,
2642
- intervalSeconds: Math.max(0, started.intervalSeconds ?? 2),
2643
- };
2644
- if (!Number.isFinite(Date.parse(authorization.expiresAt))) {
2645
- throw new Error("device authorization returned an invalid expiry");
2646
- }
2647
- }
2648
- else {
2649
- console.info("[alan-agent] Resuming browser authorization");
2650
- }
2651
- // Keep exclusive ownership through the authorization's complete lifetime,
2652
- // including a grace window for the final token exchange and config write.
2653
- extendDeviceAuthorizationOperation(operation.id, Date.parse(authorization.expiresAt) + 60_000);
2654
- writePendingDeviceAuthorization(authorization);
2655
- const intervalMs = authorization.intervalSeconds * 1000;
2656
- console.info(`[alan-agent] Open ${authorization.verificationUri}`);
2657
- console.info(`[alan-agent] Enter code ${authorization.userCode}`);
2658
- for (let attempt = 0; attempt < maxPolls; attempt += 1) {
2659
- if (attempt > 0)
2660
- await sleep(intervalMs);
2661
- if (Date.parse(authorization.expiresAt) <= Date.now()) {
2662
- clearPendingDeviceAuthorization(operation.id);
2663
- throw new Error("device authorization expired before approval; run alan-agent login again");
2664
- }
2665
- assertDeviceAuthorizationOperationOwner(operation.id);
2666
- const token = await fetch(`${apiUrl}/public/runtimes/device-authorizations/token`, {
2667
- method: "POST",
2668
- headers: { "content-type": "application/json" },
2669
- body: JSON.stringify({ deviceCode: authorization.deviceCode }),
2670
- signal: AbortSignal.timeout(30_000),
2671
- });
2672
- if (token.status === 428)
2673
- continue;
2674
- if (!token.ok) {
2675
- if (token.status === 400 || token.status === 401) {
2676
- clearPendingDeviceAuthorization(operation.id);
2677
- }
2678
- throw new Error(`device authorization token exchange failed: ${token.status} ${await token.text()}`);
2679
- }
2680
- const body = (await token.json());
2681
- assertDeviceAuthorizationOperationOwner(operation.id);
2682
- const ownedPending = readPendingDeviceAuthorization(apiUrl);
2683
- if (ownedPending?.operationId !== operation.id) {
2684
- throw new Error("browser authorization sidecar ownership was lost before finalization");
2685
- }
2686
- writeConfig({
2687
- apiUrl,
2688
- wsUrl,
2689
- endpointProfile,
2690
- runtimeId: body.runtime.id,
2691
- runtimeToken: body.runtimeToken,
2692
- runtimeRenewalToken: body.runtimeRenewalToken,
2693
- localApiPort: getLocalApiPort(args, {}),
2694
- localApiToken: previousConfig.localApiToken ?? randomBytes(24).toString("hex"),
2695
- localServiceId: previousConfig.localServiceId ?? randomUUID(),
2696
- localServiceSecret: previousConfig.localServiceSecret ?? randomBytes(32).toString("base64url"),
2697
- eventSpoolKey: previousConfig.eventSpoolKey ?? randomBytes(32).toString("base64url"),
2698
- daemonEpoch: previousConfig.daemonEpoch,
2699
- displayName: body.runtime.displayName ?? displayName,
2700
- installationId,
2701
- });
2702
- clearPendingDeviceAuthorization(operation.id);
2703
- console.info("[alan-agent] Runtime registered", {
2704
- runtimeId: body.runtime.id,
2705
- configPath: getConfigPath(),
2706
- });
2707
- return;
2708
- }
2709
- throw new Error("device authorization timed out before approval");
2710
- }
2711
- finally {
2712
- releaseOwnedJsonFile(deviceAuthorizationOperationPath(), operation.id);
2713
- }
2714
- }
2715
- export async function startDaemon(args) {
2716
- bindConfigPath(args);
2717
- const stored = readConfig();
2718
- const installationId = stored.installationId ?? randomUUID();
2719
- const configPath = getConfigPath();
2720
- const endpointProfile = stored.endpointProfile ?? resolveEndpointProfile(args);
2721
- const daemonSessionId = randomUUID();
2722
- let heartbeatSeq = 0;
2723
- const runtimeId = argValue(args, "--runtime-id") ?? process.env.ALAN_RUNTIME_ID ?? stored.runtimeId;
2724
- const runtimeTokenOverride = argValue(args, "--token") ?? process.env.ALAN_RUNTIME_TOKEN;
2725
- let runtimeToken = runtimeTokenOverride ?? stored.runtimeToken;
2726
- const runtimeRenewalToken = runtimeTokenOverride ? undefined : stored.runtimeRenewalToken;
2727
- const wsUrlOverride = argValue(args, "--ws-url") ?? process.env.ALAN_WS_URL;
2728
- // Explicit CLI/env overrides are trusted as-is; only the stored config is
2729
- // validated against its claimed profile and self-healed when it disagrees.
2730
- const endpointMismatch = wsUrlOverride
2731
- ? null
2732
- : checkStoredEndpointConsistency(stored, readEndpointDefaults());
2733
- let wsUrl = wsUrlOverride ?? stored.wsUrl;
2734
- if (endpointMismatch && stored.wsUrl && stored.wsUrl !== endpointMismatch.expectedWsUrl) {
2735
- console.warn("[alan-agent] Stored config does not match its endpoint profile — connecting with the profile endpoints instead. Re-run setup to repair the stored config.", { configPath: getConfigPath(), detail: endpointMismatch.detail });
2736
- wsUrl = endpointMismatch.expectedWsUrl;
2737
- }
2738
- let localApiPort = getLocalApiPort(args, stored);
2739
- const localApiToken = stored.localApiToken ?? randomBytes(24).toString("hex");
2740
- const localServiceId = stored.localServiceId ?? randomUUID();
2741
- const localServiceSecret = stored.localServiceSecret ?? randomBytes(32).toString("base64url");
2742
- const localServiceStartedAtMs = Date.now();
2743
- const eventSpoolKey = stored.eventSpoolKey ?? randomBytes(32).toString("base64url");
2744
- if (stored.daemonEpoch !== undefined &&
2745
- (!Number.isSafeInteger(stored.daemonEpoch) ||
2746
- stored.daemonEpoch < 0 ||
2747
- stored.daemonEpoch >= Number.MAX_SAFE_INTEGER)) {
2748
- throw new Error("daemon config contains an invalid durable epoch; repair the runtime config");
2749
- }
2750
- const daemonEpoch = (stored.daemonEpoch ?? 0) + 1;
2751
- if (!runtimeId || !runtimeToken || !wsUrl) {
2752
- throw new Error("daemon requires runtimeId, runtime token, and wsUrl. Run setup first or pass --runtime-id/--token/--ws-url.");
2753
- }
2754
- const startupLeaseResult = acquireLocalDaemonMaintenanceLease(configPath, "daemon_startup", 60_000);
2755
- if (!startupLeaseResult.acquired) {
2756
- throw new Error(startupLeaseResult.lease.reason === "credential_cleanup"
2757
- ? "Cannot start the daemon while credential cleanup is in progress"
2758
- : "Another process is already starting or maintaining this daemon config");
2759
- }
2760
- let ownerLeaseResult;
2761
- try {
2762
- ownerLeaseResult = await acquireLocalDaemonOwnerLease({
2763
- configPath,
2764
- daemonSessionId,
2765
- runtimeId,
2766
- localServiceId,
2767
- localApiPort,
2768
- localApiToken,
2769
- storedLocalApiPort: stored.localApiPort,
2770
- storedLocalApiToken: stored.localApiToken,
2771
- });
2772
- }
2773
- catch (error) {
2774
- releaseLocalDaemonMaintenanceLease(configPath, startupLeaseResult.lease.id);
2775
- throw error;
2776
- }
2777
- if (!ownerLeaseResult.acquired) {
2778
- releaseLocalDaemonMaintenanceLease(configPath, startupLeaseResult.lease.id);
2779
- throw new Error(`Another process already owns this daemon config (pid ${ownerLeaseResult.lease.pid})`);
2780
- }
2781
- let ownerLease = ownerLeaseResult.lease;
2782
- let startupLeaseHeld = true;
2783
- let ownerLeaseHeld = true;
2784
- const releaseStartupLease = () => {
2785
- if (!startupLeaseHeld)
2786
- return;
2787
- startupLeaseHeld = false;
2788
- releaseLocalDaemonMaintenanceLease(configPath, startupLeaseResult.lease.id);
2789
- };
2790
- const releaseOwnerLease = () => {
2791
- if (!ownerLeaseHeld)
2792
- return;
2793
- ownerLeaseHeld = false;
2794
- releaseLocalDaemonOwnerLease(configPath, ownerLease.id);
2795
- };
2796
- // Observable so anything derived from "is any run active?" stays correct.
2797
- // Membership changes from dispatch, completion, abort, reconnect
2798
- // reconciliation, the local HTTP API and shutdown; subscribing to the map
2799
- // itself means none of those sites can forget to update the derived state.
2800
- const activeAgents = new ObservableMap();
2801
- const pendingRunStarts = new DurablePendingRunStarts(`${configPath}.pending-runs.json`, daemonSessionId);
2802
- const maintenanceReason = () => readLocalDaemonMaintenanceLease(configPath)?.reason;
2803
- const runStartGate = new RunStartGate();
2804
- let disconnectedSinceMs = null;
2805
- let lastConnectError = null;
2806
- // Machine-readable code from the backend's socket.io middleware error
2807
- // (err.data.code), e.g. "invalid_runtime_token". Preferred over regex on the
2808
- // human-readable message; older servers that don't set it leave this null.
2809
- let lastConnectErrorCode = null;
2810
- const pendingDeliveryLost = [];
2811
- const currentCapabilities = () => discoverCapabilities();
2812
- const currentMetadata = async () => ({
2813
- ...(await collectRuntimeMetadataWithProviderLimits()),
2814
- installationId,
2815
- });
2816
- const recentLogs = [];
2817
- const pushLog = (message) => {
2818
- recentLogs.push(`${new Date().toISOString()} ${message}`);
2819
- if (recentLogs.length > 200)
2820
- recentLogs.splice(0, recentLogs.length - 200);
2821
- };
2822
- // Hold an OS sleep assertion for exactly as long as at least one run is
2823
- // active. Without this the machine idle-sleeps mid-run, freezing the socket,
2824
- // the heartbeat and the provider CLI together until the backend watchdog
2825
- // fails the run as unresponsive.
2826
- const sleepInhibitor = new SleepInhibitor({ log: pushLog });
2827
- activeAgents.onChange = () => {
2828
- sleepInhibitor.setActive(activeAgents.size > 0);
2829
- };
2830
- if (sleepInhibitor.backendNames().length > 0) {
2831
- pushLog(`sleep-inhibitor backends=${sleepInhibitor.backendNames().join(",")}`);
2832
- }
2833
- // Per-run Alan MCP config is written into shared CLI config (e.g.
2834
- // ~/.claude.json) at run start and carries a run-scoped x-alan-run-reference.
2835
- // It must be removed once no run needs it, or the inactive reference lingers
2836
- // and poisons unrelated local CLI sessions (401 "Inactive MCP run reference").
2837
- // Refcount by runId so concurrent runs sharing one home don't tear the entry
2838
- // out from under each other — only the last run out cleans up.
2839
- const alanMcpRegisteredRuns = new Set();
2840
- const alanMcpRegisteredBackends = new Set();
2841
- const finalizeAlanMcpForRun = (runId) => {
2842
- if (!alanMcpRegisteredRuns.delete(runId))
2843
- return;
2844
- if (alanMcpRegisteredRuns.size > 0)
2845
- return;
2846
- const backends = [...alanMcpRegisteredBackends];
2847
- alanMcpRegisteredBackends.clear();
2848
- void (async () => {
2849
- for (const backend of backends) {
2850
- try {
2851
- const { removedPaths } = await unregisterAlanMcp(backend);
2852
- if (removedPaths.length > 0) {
2853
- pushLog(`mcp-unregistered backend=${backend} run=${runId}`);
2854
- }
2855
- }
2856
- catch (err) {
2857
- // Best-effort: the next run's registration overwrites the entry.
2858
- pushLog(`mcp-unregister-failed backend=${backend} ${err.message}`);
2859
- }
2860
- }
2861
- })();
2862
- };
2863
- const workspaceAccessTracker = new WorkspaceAccessTracker();
2864
- const recheckWorkspaceIssue = () => {
2865
- const previousPath = workspaceAccessTracker.recoveryPath();
2866
- const readiness = workspaceAccessTracker.recheck();
2867
- if (previousPath && readiness?.ready)
2868
- pushLog(`workspace-access-restored path=${previousPath}`);
2869
- return workspaceAccessTracker.recoveryPath() ?? undefined;
2870
- };
2871
- let eventOutbox;
2872
- let runJournal;
2873
- try {
2874
- eventOutbox = new EncryptedEventOutbox(`${configPath}.event-outbox.enc`, eventSpoolKey, pushLog);
2875
- runJournal = new RuntimeRunJournal(`${configPath}.run-journal.json`);
2876
- }
2877
- catch (error) {
2878
- releaseOwnerLease();
2879
- releaseStartupLease();
2880
- throw error;
2881
- }
2882
- const workspaceRelocations = new Map();
2883
- for (const relocation of stored.workspaceRelocations ?? []) {
2884
- if (relocation?.conversationId &&
2885
- relocation.runId &&
2886
- relocation.previousPath &&
2887
- relocation.canonicalPath) {
2888
- workspaceRelocations.set(resolve(relocation.previousPath), relocation);
2889
- }
2890
- }
2891
- if (stored.localApiPort !== localApiPort ||
2892
- stored.localApiToken !== localApiToken ||
2893
- stored.localServiceId !== localServiceId ||
2894
- stored.localServiceSecret !== localServiceSecret ||
2895
- stored.eventSpoolKey !== eventSpoolKey ||
2896
- stored.daemonEpoch !== daemonEpoch ||
2897
- stored.installationId !== installationId) {
2898
- // Persist only local API details plus first-run identity. Identity/wsUrl
2899
- // overrides from CLI args or env are per-invocation and must never clobber
2900
- // an existing stored config for the active profile.
2901
- try {
2902
- writeConfig({
2903
- ...stored,
2904
- runtimeId: stored.runtimeId ?? runtimeId,
2905
- runtimeToken: stored.runtimeToken ?? runtimeToken,
2906
- wsUrl: stored.wsUrl ?? wsUrl,
2907
- localApiPort,
2908
- localApiToken,
2909
- localServiceId,
2910
- localServiceSecret,
2911
- eventSpoolKey,
2912
- daemonEpoch,
2913
- installationId,
2914
- });
2915
- }
2916
- catch (error) {
2917
- releaseOwnerLease();
2918
- releaseStartupLease();
2919
- throw error;
2920
- }
2921
- }
2922
- let socket;
2923
- try {
2924
- socket = io(wsUrl, {
2925
- query: { type: "runtime", runtimeId, hostKind: "daemon", agentVersion: AGENT_VERSION },
2926
- auth: { token: runtimeToken },
2927
- transports: ["websocket"],
2928
- reconnection: true,
2929
- reconnectionAttempts: Infinity,
2930
- reconnectionDelay: 1000,
2931
- reconnectionDelayMax: 5000,
2932
- timeout: 20_000,
2933
- upgrade: false,
2934
- extraHeaders: { "ngrok-skip-browser-warning": "1" },
2935
- });
2936
- setConnectedRuntimeSocket(socket);
2937
- }
2938
- catch (error) {
2939
- releaseOwnerLease();
2940
- releaseStartupLease();
2941
- throw error;
2942
- }
2943
- let runtimeRenewalInFlight = null;
2944
- const renewRuntimeLease = () => {
2945
- if (runtimeRenewalInFlight)
2946
- return runtimeRenewalInFlight;
2947
- const attempt = (async () => {
2948
- if (!stored.apiUrl || !runtimeRenewalToken)
2949
- return false;
2950
- let response = null;
2951
- let transportError = null;
2952
- for (let retry = 1; retry <= 3; retry += 1) {
2953
- try {
2954
- response = await fetch(`${stored.apiUrl}/public/runtimes/${runtimeId}/tokens/renew`, {
2955
- method: "POST",
2956
- headers: {
2957
- authorization: `Bearer ${runtimeRenewalToken}`,
2958
- "content-type": "application/json",
2959
- },
2960
- body: "{}",
2961
- signal: AbortSignal.timeout(10_000),
2962
- });
2963
- transportError = null;
2964
- }
2965
- catch (error) {
2966
- response = null;
2967
- transportError = error;
2968
- }
2969
- const retriable = !response || response.status === 408 || response.status === 429 || response.status >= 500;
2970
- if (!retriable || retry === 3)
2971
- break;
2972
- await sleep(retry * 250);
2973
- }
2974
- if (!response) {
2975
- lastConnectErrorCode = "backend_unreachable";
2976
- lastConnectError = `Runtime renewal could not reach Alan: ${transportError instanceof Error ? transportError.message : String(transportError)}`;
2977
- pushLog(`runtime-renewal-transport-failed ${lastConnectError}`);
2978
- return false;
2979
- }
2980
- if (!response.ok) {
2981
- lastConnectErrorCode =
2982
- response.status === 401
2983
- ? "runtime_renewal_rejected"
2984
- : response.status === 403
2985
- ? "runtime_disabled"
2986
- : response.status === 410
2987
- ? "runtime_deleted"
2988
- : response.status >= 500
2989
- ? "backend_unreachable"
2990
- : "runtime_renewal_failed";
2991
- lastConnectError = `Runtime renewal failed: ${response.status} ${await response.text()}`;
2992
- pushLog(`runtime-renewal-rejected ${lastConnectError}`);
2993
- return false;
2994
- }
2995
- const body = (await response.json());
2996
- if (typeof body.runtimeToken !== "string" || !body.runtimeToken.startsWith("alan_runtime_")) {
2997
- lastConnectErrorCode = "runtime_renewal_failed";
2998
- lastConnectError = "Runtime renewal returned an invalid access token";
2999
- pushLog("runtime-renewal-invalid-response");
3000
- return false;
3001
- }
3002
- const current = readConfig();
3003
- if (current.runtimeId !== runtimeId || current.runtimeRenewalToken !== runtimeRenewalToken) {
3004
- lastConnectErrorCode = "runtime_renewal_superseded";
3005
- lastConnectError = "Runtime credentials changed while renewal was in progress";
3006
- pushLog("runtime-renewal-superseded");
3007
- return false;
3008
- }
3009
- try {
3010
- writeConfig({ ...current, runtimeToken: body.runtimeToken });
3011
- }
3012
- catch (error) {
3013
- lastConnectErrorCode = "config_write_failed";
3014
- lastConnectError = `Runtime renewal could not save its replacement: ${error.message}`;
3015
- pushLog(`runtime-renewal-config-write-failed ${error.message}`);
3016
- return false;
3017
- }
3018
- runtimeToken = body.runtimeToken;
3019
- socket.auth = { token: body.runtimeToken };
3020
- lastConnectError = null;
3021
- lastConnectErrorCode = null;
3022
- pushLog("runtime-renewal-succeeded");
3023
- socket.connect();
3024
- return true;
3025
- })();
3026
- runtimeRenewalInFlight = attempt;
3027
- void attempt
3028
- .finally(() => {
3029
- if (runtimeRenewalInFlight === attempt)
3030
- runtimeRenewalInFlight = null;
3031
- })
3032
- .catch(() => { });
3033
- return attempt;
3034
- };
3035
- const relocationInFlight = new Set();
3036
- const persistWorkspaceRelocations = () => {
3037
- const current = readConfig();
3038
- writeConfig({
3039
- ...current,
3040
- workspaceRelocations: workspaceRelocations.size > 0 ? [...workspaceRelocations.values()] : undefined,
3041
- });
3042
- };
3043
- const flushWorkspaceRelocations = () => {
3044
- if (!socket.connected)
3045
- return;
3046
- for (const [key, relocation] of workspaceRelocations) {
3047
- if (relocationInFlight.has(key))
3048
- continue;
3049
- relocationInFlight.add(key);
3050
- socket.emit("runtime.workspace.relocated", relocation, (result) => {
3051
- relocationInFlight.delete(key);
3052
- if (!result?.ok || !result.persisted)
3053
- return;
3054
- const current = workspaceRelocations.get(key);
3055
- if (current?.canonicalPath !== relocation.canonicalPath)
3056
- return;
3057
- workspaceRelocations.delete(key);
3058
- try {
3059
- persistWorkspaceRelocations();
3060
- pushLog(`workspace-relocation-persisted path=${relocation.canonicalPath}`);
3061
- }
3062
- catch (error) {
3063
- workspaceRelocations.set(key, relocation);
3064
- pushLog(`workspace-relocation-ack-save-failed ${error.message}`);
3065
- }
3066
- });
3067
- }
3068
- };
3069
- const emitRunnerPresence = () => {
3070
- if (!socket.connected)
3071
- return;
3072
- reconcileActiveRuns({ activeAgents, pushLog });
3073
- heartbeatSeq += 1;
3074
- const runnerSummary = summarizeRunnerStatus({
3075
- connected: socket.connected,
3076
- activeAgents,
3077
- disconnectedSinceMs,
3078
- daemonSessionId,
3079
- daemonEpoch,
3080
- heartbeatSeq,
3081
- interruptedRunIds: runJournal.recoveredRunIds(),
3082
- endpointProfile,
3083
- configPath,
3084
- runtimeId,
3085
- runtimeAuthValid: Boolean(runtimeToken),
3086
- endpointMismatchDetail: endpointMismatch?.detail ?? null,
3087
- workspacePath: recheckWorkspaceIssue(),
3088
- maintenanceReason: maintenanceReason(),
3089
- });
3090
- void currentMetadata().then((metadata) => {
3091
- socket.emit("runtime.heartbeat", {
3092
- capabilities: currentCapabilities(),
3093
- metadata: {
3094
- ...metadata,
3095
- runnerStatus: runnerSummary.runnerStatus,
3096
- },
3097
- });
3098
- });
3099
- };
3100
- const heartbeat = setInterval(() => {
3101
- if (!socket.connected)
3102
- return;
3103
- emitRunnerPresence();
3104
- flushWorkspaceRelocations();
3105
- }, 15_000);
3106
- // Report host suspension to the backend.
3107
- //
3108
- // Sleep prevention cannot win every time (lid close on battery, forced
3109
- // sleep), and when the host freezes so does the socket, the heartbeat above
3110
- // and the provider CLI. The backend sees only silence and fails the run as
3111
- // unresponsive. Telling it "I was frozen for N ms" lets it grant the runtime
3112
- // a grace window instead of blaming the agent for a gap it did not cause.
3113
- //
3114
- // The wake is held until the socket is actually up: the suspension that needs
3115
- // reporting is precisely the one that killed the connection, so emitting
3116
- // eagerly would drop the message exactly when it matters.
3117
- let pendingWakeGapMs = null;
3118
- const flushWakeGap = () => {
3119
- if (pendingWakeGapMs === null || !socket.connected)
3120
- return;
3121
- const gapMs = pendingWakeGapMs;
3122
- pendingWakeGapMs = null;
3123
- socket.emit("runtime.wake", { gapMs });
3124
- pushLog(`runtime-wake reported gapMs=${gapMs}`);
3125
- };
3126
- const suspensionDetector = new SuspensionDetector({
3127
- onSuspensionDetected: (gapMs) => {
3128
- // Keep the longest gap if several elapse before we reconnect.
3129
- pendingWakeGapMs = Math.max(pendingWakeGapMs ?? 0, gapMs);
3130
- pushLog(`host-suspension detected gapMs=${gapMs}`);
3131
- flushWakeGap();
3132
- },
3133
- });
3134
- suspensionDetector.start();
3135
- void refreshProviderVersions();
3136
- const versionRefresh = setInterval(() => {
3137
- void refreshProviderVersions();
3138
- }, VERSION_REFRESH_INTERVAL_MS);
3139
- // How many consecutive TRANSIENT lease-probe failures (git slow/failed while
3140
- // the workspace path still exists) are tolerated before the run is failed.
3141
- // Definitive changes (path/repo/worktree/branch swap, genuinely-missing path)
3142
- // still fail on the first observation. See notes/agent-daemon-app-nap.md for
3143
- // why this — not a caffeinate/App-Nap exemption — is the right fix for OS
3144
- // throttling of the monitor's own timers.
3145
- const parsedMaxTransient = Number(process.env.ALAN_WORKSPACE_LEASE_MAX_TRANSIENT_FAILURES);
3146
- const WORKSPACE_LEASE_MAX_TRANSIENT_FAILURES = Number.isFinite(parsedMaxTransient) && parsedMaxTransient >= 1
3147
- ? Math.floor(parsedMaxTransient)
3148
- : 3;
3149
- const WORKSPACE_LEASE_FAILURE_BACKOFF_BASE_MS = 1_000;
3150
- const WORKSPACE_LEASE_FAILURE_BACKOFF_MAX_MS = 15_000;
3151
- const stopRunForWorkspaceLease = (runId, entry, failure) => {
3152
- entry.stage = "failed";
3153
- if (!entry.presenter.failWorkspaceLease(failure))
3154
- return;
3155
- entry.agent.kill();
3156
- activeAgents.delete(runId);
3157
- pushLog(`workspace-lease-lost run=${runId} code=${failure.code}`);
3158
- };
3159
- const leaseCheckPolicy = {
3160
- maxTransientFailures: WORKSPACE_LEASE_MAX_TRANSIENT_FAILURES,
3161
- backoffBaseMs: WORKSPACE_LEASE_FAILURE_BACKOFF_BASE_MS,
3162
- backoffMaxMs: WORKSPACE_LEASE_FAILURE_BACKOFF_MAX_MS,
3163
- };
3164
- const verifyActiveWorkspaceLease = (runId, entry, nowMs) => {
3165
- const lease = entry.workspaceLease;
3166
- if (!lease)
3167
- return true;
3168
- const state = entry.leaseCheck ?? { ...FRESH_LEASE_MONITOR_STATE };
3169
- entry.leaseCheck = state;
3170
- // Backoff: under sustained transient failure, probe less often than every
3171
- // second so a contended disk / coalesced timer is not hammered each tick.
3172
- if (!isLeaseCheckDue(state, nowMs))
3173
- return true;
3174
- const decision = applyLeaseCheckResult({
3175
- state,
3176
- detailed: lease.verifyDetailed(),
3177
- nowMs,
3178
- policy: leaseCheckPolicy,
3179
- });
3180
- entry.leaseCheck = decision.state;
3181
- if (decision.action === "fail" && decision.failure) {
3182
- stopRunForWorkspaceLease(runId, entry, decision.failure);
3183
- return false;
3184
- }
3185
- if (decision.action === "transient" && decision.failure) {
3186
- pushLog(`workspace-lease-transient run=${runId} streak=${decision.state.transientStreak}/${WORKSPACE_LEASE_MAX_TRANSIENT_FAILURES} code=${decision.failure.code} backoff_ms=${decision.backoffMs ?? 0}`);
3187
- }
3188
- return true;
3189
- };
3190
- const workspaceLeaseMonitor = setInterval(() => {
3191
- const nowMs = Date.now();
3192
- for (const [runId, entry] of activeAgents) {
3193
- verifyActiveWorkspaceLease(runId, entry, nowMs);
3194
- }
3195
- }, WORKSPACE_LEASE_CHECK_INTERVAL_MS);
3196
- workspaceLeaseMonitor.unref?.();
3197
- socket.on("connect", () => {
3198
- disconnectedSinceMs = null;
3199
- lastConnectError = null;
3200
- lastConnectErrorCode = null;
3201
- setConnectedRuntimeSocket(socket);
3202
- pushLog(`connected runtime=${runtimeId}`);
3203
- console.info("[alan-agent] Daemon connected", { runtimeId, wsUrl, version: AGENT_VERSION });
3204
- flushWorkspaceRelocations();
3205
- flushPendingDeliveryLostEvents({
3206
- socket,
3207
- activeAgents,
3208
- pending: pendingDeliveryLost,
3209
- eventOutbox,
3210
- pushLog,
3211
- });
3212
- reconcileActiveRuns({ activeAgents, pushLog });
3213
- void currentMetadata().then((metadata) => {
3214
- const runnerSummary = summarizeRunnerStatus({
3215
- connected: true,
3216
- activeAgents,
3217
- disconnectedSinceMs,
3218
- daemonSessionId,
3219
- daemonEpoch,
3220
- heartbeatSeq,
3221
- interruptedRunIds: runJournal.recoveredRunIds(),
3222
- endpointProfile,
3223
- configPath,
3224
- runtimeId,
3225
- runtimeAuthValid: Boolean(runtimeToken),
3226
- endpointMismatchDetail: endpointMismatch?.detail ?? null,
3227
- workspacePath: recheckWorkspaceIssue(),
3228
- maintenanceReason: maintenanceReason(),
3229
- });
3230
- socket.emit("runtime.hello", {
3231
- capabilities: currentCapabilities(),
3232
- metadata: {
3233
- ...metadata,
3234
- runnerStatus: runnerSummary.runnerStatus,
3235
- },
3236
- });
3237
- // Emitted after hello so the backend has already marked this runtime
3238
- // online and can attribute the wake to a known connection.
3239
- flushWakeGap();
3240
- scheduleRuntimeModelScan(() => Promise.resolve(metadata));
3241
- });
3242
- });
3243
- let lastConnectErrorLogAt = 0;
3244
- socket.on("connect_error", (error) => {
3245
- if (!socket.connected && disconnectedSinceMs === null)
3246
- disconnectedSinceMs = Date.now();
3247
- lastConnectError = error.message;
3248
- const errorCode = error?.data?.code;
3249
- // Middleware rejections carry a server code. A connect_error without one is
3250
- // a transport/service reachability failure, not evidence that the local
3251
- // daemon or Computer is broken.
3252
- lastConnectErrorCode =
3253
- typeof errorCode === "string" ? errorCode : classifyTransportFailure(error);
3254
- pushLog(`connect_error ${error.message}${lastConnectErrorCode ? ` code=${lastConnectErrorCode}` : ""}`);
3255
- if (lastConnectErrorCode === "invalid_runtime_token" && runtimeRenewalToken) {
3256
- void renewRuntimeLease().catch((renewalError) => {
3257
- lastConnectErrorCode = "runtime_renewal_failed";
3258
- lastConnectError = `Runtime renewal failed unexpectedly: ${renewalError.message}`;
3259
- pushLog(`runtime-renewal-unexpected-failure ${renewalError.message}`);
3260
- });
3261
- return;
3262
- }
3263
- const now = Date.now();
3264
- if (now - lastConnectErrorLogAt < 5000)
3265
- return;
3266
- lastConnectErrorLogAt = now;
3267
- const wsHint = isLocalEndpoint(wsUrl)
3268
- ? ` Nothing is listening at ${wsUrl}. For local dev run setup with --profile local and start the stack (WS on ws://localhost:8401).`
3269
- : ` Verify wsUrl in ${getConfigPath()} and that you can reach ${wsUrl}.`;
3270
- console.warn("[alan-agent] Daemon connection error:", `${error.message}.${wsHint}`);
3271
- });
3272
- socket.on("disconnect", (reason) => {
3273
- setConnectedRuntimeSocket(null);
3274
- relocationInFlight.clear();
3275
- eventOutbox.disconnect();
3276
- if (disconnectedSinceMs === null)
3277
- disconnectedSinceMs = Date.now();
3278
- pushLog(`disconnected ${reason}`);
3279
- console.info("[alan-agent] Daemon disconnected:", reason);
3280
- for (const [runId, entry] of activeAgents.entries()) {
3281
- if (entry.stage === "completed" ||
3282
- entry.stage === "failed" ||
3283
- entry.stage === "aborted" ||
3284
- entry.stage === "delivery_lost") {
3285
- continue;
3286
- }
3287
- entry.stage = "delivery_lost";
3288
- pendingDeliveryLost.push({ conversationId: entry.conversationId, runId });
3289
- }
3290
- });
3291
- socket.on("desktop:rpc", async (message) => {
3292
- if (!message?.id || !message.method)
3293
- return;
3294
- try {
3295
- const params = message.params ?? {};
3296
- const result = message.method === "worktree.ensure"
3297
- ? ensureWorktree(params)
3298
- : message.method === "worktree.remove_if_clean"
3299
- ? removeWorktreesIfClean(params)
3300
- : message.method === "worktree.migrate_to_checkout"
3301
- ? migrateWorktreeToCheckout(params)
3302
- : message.method === "worktree.migration_preflight"
3303
- ? preflightLocalWorkspaceMigration(params)
3304
- : message.method === "worktree.status"
3305
- ? statusLocalWorkspace(params)
3306
- : isBindingSwitchGitMethod(message.method)
3307
- ? await executeBindingSwitchGitRpc(message.method, params)
3308
- : (() => {
3309
- throw new Error(`Method not found: ${message.method}`);
3310
- })();
3311
- socket.emit("desktop:rpc", { jsonrpc: "2.0", id: message.id, result });
3312
- }
3313
- catch (error) {
3314
- const stableCode = error instanceof BindingSwitchGitError ? error.code : "runtime_operation_failed";
3315
- const stableDetails = error instanceof BindingSwitchGitError ? error.details : undefined;
3316
- socket.emit("desktop:rpc", {
3317
- jsonrpc: "2.0",
3318
- id: message.id,
3319
- error: {
3320
- code: -32000,
3321
- message: error.message,
3322
- data: { ...stableDetails, code: stableCode },
3323
- },
3324
- });
3325
- }
3326
- });
3327
- socket.on("agent.execute", async (payload) => {
3328
- if (!payload?.runId || !payload.conversationId || typeof payload.content !== "string") {
3329
- return;
3330
- }
3331
- const isTaskWorkspacePayload = Boolean(payload.worktreeInfo?.worktreePath || payload.worktreeInfo?.allWorktrees?.length);
3332
- if ((payload.worktreeInfo?.allWorktrees?.length ?? 0) > 1 ||
3333
- (isTaskWorkspacePayload && (payload.repoLocalPaths?.length ?? 0) > 1)) {
3334
- socket.emit("agent.rejected", {
3335
- runId: payload.runId,
3336
- message: "A task worktree requires exactly one Git repository.",
3337
- code: "single_repository_worktree_required",
3338
- });
3339
- return;
3340
- }
3341
- const existingRun = activeAgents.get(payload.runId);
3342
- if (existingRun) {
3343
- if (existingRun.conversationId !== payload.conversationId) {
3344
- socket.emit("agent.rejected", {
3345
- runId: payload.runId,
3346
- message: "Run ID is already bound to another conversation",
3347
- code: "run_id_conflict",
3348
- });
3349
- return;
3350
- }
3351
- // The backend may redeliver after the original acceptance ACK is lost.
3352
- // Re-acknowledge the existing execution without spawning a second agent.
3353
- pushLog(`duplicate dispatch re-acknowledged run=${payload.runId}`);
3354
- socket.emit("agent.accepted", { runId: payload.runId });
3355
- return;
3356
- }
3357
- const preflightingConversationId = pendingRunStarts.get(payload.runId);
3358
- if (preflightingConversationId) {
3359
- if (preflightingConversationId !== payload.conversationId) {
3360
- socket.emit("agent.rejected", {
3361
- runId: payload.runId,
3362
- message: "Run ID is already bound to another conversation",
3363
- code: "run_id_conflict",
3364
- });
3365
- }
3366
- return;
3367
- }
3368
- const recoveredRun = runJournal.get(payload.runId);
3369
- if (recoveredRun) {
3370
- if (recoveredRun.conversationId !== payload.conversationId) {
3371
- socket.emit("agent.rejected", {
3372
- runId: payload.runId,
3373
- message: "Run ID is already bound to another conversation",
3374
- code: "run_id_conflict",
3375
- });
3376
- return;
3377
- }
3378
- const recovery = runJournal.assessRecovery(payload.runId, (pid) => isProviderProcessAlive(pid));
3379
- pushLog(`recovered dispatch fenced run=${payload.runId} recovery=${recovery?.reason ?? "unknown"}${recovery && "providerPid" in recovery ? ` pid=${recovery.providerPid}` : ""}`);
3380
- socket.emit("agent.rejected", {
3381
- runId: payload.runId,
3382
- message: "This run was interrupted by a daemon restart and will not be executed twice.",
3383
- code: "run_interrupted_by_restart",
3384
- });
3385
- return;
3386
- }
3387
- const maintenanceLease = readLocalDaemonMaintenanceLease(configPath);
3388
- if (maintenanceLease) {
3389
- socket.emit("agent.rejected", {
3390
- runId: payload.runId,
3391
- message: maintenanceLease.reason === "credential_cleanup"
3392
- ? "This computer is finishing logout and cannot start another run."
3393
- : "This computer is under short-lived maintenance; retry shortly.",
3394
- code: maintenanceLease.reason === "credential_cleanup"
3395
- ? "runtime_logout_pending"
3396
- : "runtime_maintenance_pending",
3397
- });
3398
- return;
3399
- }
3400
- const backendKind = normalizeBackendKind(payload.backendKind);
3401
- let runtimeCommand = resolveBackendRuntimeCommand(backendKind ?? payload.backendKind);
3402
- if (!runtimeCommand && backendKind) {
3403
- // A cached negative resolution is never authoritative for a run: startup
3404
- // provider discovery can probe a CLI before the sandbox's deferred
3405
- // installer drops the binary, and that miss must not reject runs after
3406
- // the binary lands. Drop the cache entry and re-resolve from disk.
3407
- runtimeCommand = revalidateBackendRuntimeCommand(backendKind);
3408
- }
3409
- if (!runtimeCommand && backendKind && canInstallProviderCli(backendKind)) {
3410
- // Fresh sandboxes install provider CLIs DEFERRED (~40-90s after agents can
3411
- // start), so an early run can race the installer and hit a missing binary.
3412
- // Install the pinned CLI on demand (at most once per daemon lifetime) and
3413
- // re-resolve before rejecting the run. No presenter exists pre-accept, so
3414
- // installation progress surfaces through the daemon log only. Fence the
3415
- // runId while awaiting so a backend redelivery of the same dispatch does
3416
- // not start a second execution once the install completes.
3417
- pendingRunStarts.set(payload.runId, payload.conversationId);
3418
- try {
3419
- const installed = await ensureProviderCliInstalled(backendKind, {
3420
- notify: (message) => {
3421
- pushLog(message);
3422
- console.info(`[alan-agent] ${message}`);
3423
- },
3424
- });
3425
- if (installed) {
3426
- runtimeCommand = revalidateBackendRuntimeCommand(backendKind);
3427
- }
3428
- }
3429
- finally {
3430
- pendingRunStarts.delete(payload.runId);
3431
- }
3432
- }
3433
- if (!runtimeCommand && backendKind) {
3434
- socket.emit("agent.rejected", {
3435
- runId: payload.runId,
3436
- message: `CLI command not found for ${backendKind}. Install the provider CLI and restart the daemon.`,
3437
- code: "provider_unavailable",
3438
- });
3439
- return;
3440
- }
3441
- let cwd = resolveRuntimeCwd(payload);
3442
- const explicitWorkspacePath = payload.projectPath ??
3443
- payload.worktreeInfo?.worktreePath ??
3444
- payload.worktreeInfo?.allWorktrees?.[0]?.worktreePath;
3445
- const workspaceRequired = Boolean(explicitWorkspacePath);
3446
- if (isAlanDefaultWorkspace(payload.projectPath)) {
3447
- try {
3448
- mkdirSync(cwd, { recursive: true });
3449
- }
3450
- catch {
3451
- // inspectWorkspaceReadiness below preserves the filesystem cause.
3452
- }
3453
- }
3454
- let workspaceReadiness = inspectWorkspaceReadiness(workspaceRequired ? cwd : undefined);
3455
- const expectedRepoUrls = [
3456
- ...(payload.repoLocalPaths ?? [])
3457
- .filter((repo) => explicitWorkspacePath ? resolve(repo.localPath) === resolve(explicitWorkspacePath) : false)
3458
- .map((repo) => repo.repoUrl),
3459
- ...(payload.localRepoUrls ?? []),
3460
- ];
3461
- if (workspaceReadiness.code === "workspace_missing" &&
3462
- explicitWorkspacePath &&
3463
- !payload.worktreeInfo) {
3464
- const previousPath = resolve(explicitWorkspacePath);
3465
- const remembered = workspaceRelocations.get(previousPath);
3466
- const rememberedPath = remembered?.canonicalPath;
3467
- const relocated = rememberedPath &&
3468
- workspaceMatchesExpectedRepositories({
3469
- workspacePath: rememberedPath,
3470
- expectedRepoUrls,
3471
- })
3472
- ? resolve(rememberedPath)
3473
- : discoverRelocatedWorkspace({
3474
- missingPath: explicitWorkspacePath,
3475
- approvedWorkspacePaths: payload.approvedWorkspacePaths,
3476
- expectedRepoUrls,
3477
- });
3478
- if (relocated) {
3479
- if (!remembered || resolve(remembered.canonicalPath) !== relocated) {
3480
- workspaceRelocations.set(previousPath, {
3481
- conversationId: payload.conversationId,
3482
- runId: payload.runId,
3483
- previousPath,
3484
- canonicalPath: relocated,
3485
- });
3486
- try {
3487
- persistWorkspaceRelocations();
3488
- }
3489
- catch (error) {
3490
- workspaceRelocations.delete(previousPath);
3491
- socket.emit("agent.rejected", {
3492
- runId: payload.runId,
3493
- message: `Recovered project path could not be saved safely: ${error.message}`,
3494
- code: "workspace_relocation_persist_failed",
3495
- });
3496
- return;
3497
- }
3498
- }
3499
- cwd = relocated;
3500
- workspaceReadiness = inspectWorkspaceReadiness(cwd);
3501
- pushLog(`workspace-relocated run=${payload.runId} path=${relocated}`);
3502
- flushWorkspaceRelocations();
3503
- }
3504
- }
3505
- if (workspaceReadiness.ready && expectedRepoUrls.length > 0 && !isAlanDefaultWorkspace(cwd)) {
3506
- // For multi-worktree tasks the cwd is the non-git parent CONTAINER of the
3507
- // per-repo worktrees (resolveRuntimeCwd → dirname(allWorktrees[0])), so a
3508
- // git-identity check on the cwd itself always fails. Verify each worktree
3509
- // path instead — every one must resolve to an expected repository. Single
3510
- // (or no) worktree tasks fall back to checking the cwd directly.
3511
- const worktreePaths = payload.worktreeInfo?.allWorktrees
3512
- ?.map((worktree) => worktree.worktreePath)
3513
- .filter((worktreePath) => Boolean(worktreePath));
3514
- const repositoryMismatch = worktreePaths && worktreePaths.length > 0
3515
- ? worktreePaths.some((worktreePath) => !workspaceMatchesExpectedRepositories({
3516
- workspacePath: worktreePath,
3517
- expectedRepoUrls,
3518
- }))
3519
- : !workspaceMatchesExpectedRepositories({ workspacePath: cwd, expectedRepoUrls });
3520
- if (repositoryMismatch) {
3521
- socket.emit("agent.rejected", {
3522
- runId: payload.runId,
3523
- message: "This folder belongs to a different repository. Choose the correct project folder and try again.",
3524
- code: "workspace_repository_mismatch",
3525
- });
3526
- return;
3527
- }
3528
- }
3529
- if (workspaceRequired)
3530
- workspaceAccessTracker.track(cwd, workspaceReadiness);
3531
- const runnerSummary = summarizeRunnerStatus({
3532
- connected: socket.connected,
3533
- activeAgents,
3534
- disconnectedSinceMs,
3535
- daemonSessionId,
3536
- daemonEpoch,
3537
- heartbeatSeq,
3538
- interruptedRunIds: runJournal.recoveredRunIds(),
3539
- endpointProfile,
3540
- configPath,
3541
- runtimeId,
3542
- runtimeAuthValid: Boolean(runtimeToken),
3543
- endpointMismatchDetail: endpointMismatch?.detail ?? null,
3544
- workspacePath: recheckWorkspaceIssue(),
3545
- maintenanceReason: maintenanceReason(),
3546
- nowMs: Date.now(),
3547
- });
3548
- if (!socket.connected) {
3549
- socket.emit("agent.rejected", {
3550
- runId: payload.runId,
3551
- message: "Daemon is disconnected from backend",
3552
- code: "ws_disconnected",
3553
- });
3554
- return;
3555
- }
3556
- if (!runnerSummary.acceptingRuns) {
3557
- const primaryIssue = pickPrimaryReadinessIssue(runnerSummary.runnerStatus.computerReadiness ?? []);
3558
- socket.emit("agent.rejected", {
3559
- runId: payload.runId,
3560
- message: primaryIssue?.message ??
3561
- (runnerSummary.activeRunCount > 0
3562
- ? "Daemon is busy with an active run"
3563
- : "Daemon is not ready to accept runs"),
3564
- code: primaryIssue?.code ??
3565
- (runnerSummary.activeRunCount > 0 ? "runner_busy" : "runner_not_ready"),
3566
- });
3567
- return;
3568
- }
3569
- if (!workspaceReadiness.ready) {
3570
- socket.emit("agent.rejected", {
3571
- runId: payload.runId,
3572
- message: workspaceReadiness.message,
3573
- code: workspaceReadiness.code ?? "workspace_missing",
3574
- });
3575
- return;
3576
- }
3577
- pendingRunStarts.set(payload.runId, payload.conversationId);
3578
- const rawReleaseRunStart = await runStartGate.acquire();
3579
- let runStartReleased = false;
3580
- const releaseRunStart = () => {
3581
- if (runStartReleased)
3582
- return;
3583
- runStartReleased = true;
3584
- rawReleaseRunStart();
3585
- };
3586
- if (!socket.connected) {
3587
- pendingRunStarts.delete(payload.runId);
3588
- releaseRunStart();
3589
- socket.emit("agent.rejected", {
3590
- runId: payload.runId,
3591
- message: "Daemon disconnected while preparing the provider",
3592
- code: "ws_disconnected",
3593
- });
3594
- return;
3595
- }
3596
- const correlation = correlationLogFields({
3597
- taskId: payload.taskId ?? payload.taskMeta?.id,
3598
- conversationId: payload.conversationId,
3599
- runId: payload.runId,
3600
- messageId: payload.messageId,
3601
- cloudEnvironmentId: payload.cloudEnvironmentId,
3602
- backendKind,
3603
- agentVersion: AGENT_VERSION,
3604
- });
3605
- const alanMcp = payload.alanMcp;
3606
- if (!alanMcp) {
3607
- pendingRunStarts.delete(payload.runId);
3608
- releaseRunStart();
3609
- pushLog(`mcp-contract-missing run=${payload.runId}`);
3610
- console.warn("[alan-agent] Run-scoped Alan MCP contract is missing", correlation);
3611
- socket.emit("agent.rejected", {
3612
- runId: payload.runId,
3613
- message: "Alan did not receive the run-scoped tools contract. Start a new run and try again.",
3614
- code: "mcp_config_invalid",
3615
- });
3616
- return;
3617
- }
3618
- // Auth status (~3s for Cursor) and MCP file prep used to run sequentially
3619
- // before accept. Overlap them; MCP registration itself is file-first so it
3620
- // no longer awaits a live `mcp list` on the critical path.
3621
- const authPromise = backendKind && runtimeCommand && !payload.providerApiKey
3622
- ? preflightProviderAuth({
3623
- backendKind,
3624
- executable: runtimeCommand,
3625
- env: getDaemonCliEnvironment(),
3626
- }).catch(() => ({ status: "unknown" }))
3627
- : Promise.resolve({ status: "unknown" });
3628
- const mcpPromise = backendKind
3629
- ? prepareAlanMcpForRun(backendKind, alanMcp, {
3630
- // Observe mode resolves after the run has already been accepted, so
3631
- // the verdict is logged here rather than folded into the result.
3632
- onObservedResult: (failure) => {
3633
- pushLog(`mcp-readiness-observed run=${payload.runId} code=${failure.code}`);
3634
- console.warn("[alan-agent] Alan MCP readiness observed failure", {
3635
- ...correlation,
3636
- code: failure.code,
3637
- stage: failure.stage,
3638
- });
3639
- },
3640
- })
3641
- : Promise.resolve(null);
3642
- const [providerAuth, mcpOutcome] = await Promise.all([authPromise, mcpPromise]);
3643
- if (providerAuth.status === "auth_required") {
3644
- pendingRunStarts.delete(payload.runId);
3645
- releaseRunStart();
3646
- socket.emit("agent.rejected", {
3647
- runId: payload.runId,
3648
- message: providerAuth.message ?? "The selected provider requires sign-in.",
3649
- code: "provider_auth_required",
3650
- });
3651
- return;
3652
- }
3653
- if (backendKind && mcpOutcome && !mcpOutcome.ok) {
3654
- pendingRunStarts.delete(payload.runId);
3655
- releaseRunStart();
3656
- const readinessCode = mcpOutcome.code;
3657
- pushLog(`mcp-readiness-failed run=${payload.runId} code=${readinessCode}`);
3658
- console.warn("[alan-agent] Required Alan MCP readiness failed", {
3659
- ...correlation,
3660
- code: readinessCode,
3661
- stage: mcpOutcome.stage,
3662
- });
3663
- socket.emit("agent.rejected", {
3664
- runId: payload.runId,
3665
- message: mcpOutcome.message,
3666
- code: readinessCode.replace(/^app\./, "").replaceAll(".", "_"),
3667
- });
3668
- return;
3669
- }
3670
- if (backendKind && alanMcp && mcpOutcome && mcpOutcome.ok && "paths" in mcpOutcome) {
3671
- pushLog(`mcp-ready run=${payload.runId} verification=${"verification" in mcpOutcome ? mcpOutcome.verification : "file"}`);
3672
- console.info("[alan-agent] Alan MCP ready for run", {
3673
- ...correlation,
3674
- paths: mcpOutcome.paths,
3675
- verification: "verification" in mcpOutcome ? mcpOutcome.verification : "file",
3676
- providerLoadStatus: "providerLoadStatus" in mcpOutcome ? mcpOutcome.providerLoadStatus : undefined,
3677
- });
3678
- }
3679
- // Close the TOCTOU window for a dispatch that began preflight just before
3680
- // an external watchdog acquired maintenance. No journal/provider side
3681
- // effects have started yet, so it is safe to reject here.
3682
- const maintenanceLeaseBeforeJournal = readLocalDaemonMaintenanceLease(configPath);
3683
- if (maintenanceLeaseBeforeJournal) {
3684
- pendingRunStarts.delete(payload.runId);
3685
- releaseRunStart();
3686
- socket.emit("agent.rejected", {
3687
- runId: payload.runId,
3688
- message: maintenanceLeaseBeforeJournal.reason === "credential_cleanup"
3689
- ? "This computer is finishing logout and cannot start another run."
3690
- : "This computer is under short-lived maintenance; retry shortly.",
3691
- code: maintenanceLeaseBeforeJournal.reason === "credential_cleanup"
3692
- ? "runtime_logout_pending"
3693
- : "runtime_maintenance_pending",
3694
- });
3695
- return;
3696
- }
3697
- try {
3698
- runJournal.record({
3699
- runId: payload.runId,
3700
- conversationId: payload.conversationId,
3701
- acceptedAtMs: Date.now(),
3702
- });
3703
- }
3704
- catch {
3705
- pendingRunStarts.delete(payload.runId);
3706
- releaseRunStart();
3707
- pushLog(`run-journal-write-failed run=${payload.runId}`);
3708
- socket.emit("agent.rejected", {
3709
- runId: payload.runId,
3710
- message: "Alan could not safely record this run before starting it. Check disk access.",
3711
- code: "run_journal_unavailable",
3712
- });
3713
- return;
3714
- }
3715
- socket.emit("agent.accepted", { runId: payload.runId });
3716
- pushLog(`accepted run=${payload.runId}`);
3717
- console.info("[alan-agent] Agent run accepted", correlation);
3718
- // Shared-checkout runs (no worktree): put the checkout(s) onto the task's
3719
- // working branch cut from base, so the agent never commits onto the base
3720
- // branch directly. Best-effort — a dirty tree is left untouched.
3721
- if (!payload.worktreeInfo?.worktreePath && payload.sharedCheckout?.branchName) {
3722
- const repoTargets = resolveSharedCheckoutTargets({
3723
- cwd,
3724
- sharedCheckout: payload.sharedCheckout,
3725
- });
3726
- for (const { repoPath, baseBranch } of repoTargets) {
3727
- try {
3728
- const result = ensureSharedCheckoutBranch({
3729
- repoPath,
3730
- branchName: payload.sharedCheckout.branchName,
3731
- baseBranch,
3732
- });
3733
- if (!result.checkedOut) {
3734
- pushLog(`shared-branch skipped run=${payload.runId} ${repoPath}: ${result.reason}`);
3735
- }
3736
- }
3737
- catch (error) {
3738
- pushLog(`shared-branch error run=${payload.runId} ${repoPath} ${error.message}`);
3739
- }
3740
- }
3741
- }
3742
- const workspaceLeaseCapture = WorkspaceRunLease.capture(resolveWorkspaceRunLeaseTargets(payload, cwd, expectedRepoUrls.length > 0));
3743
- if (workspaceLeaseCapture.failure) {
3744
- const failure = workspaceLeaseCapture.failure;
3745
- const runFailure = buildApplicationRunFailure({
3746
- conversationId: payload.conversationId,
3747
- runId: payload.runId,
3748
- code: "app.workspace.missing",
3749
- detail: failure.message,
3750
- component: "agent-manager-workspace-lease",
3751
- phase: "dispatch",
3752
- });
3753
- for (const event of runFailure.events) {
3754
- eventOutbox.enqueue(payload.conversationId, payload.runId, event);
3755
- }
3756
- eventOutbox.flush(socket);
3757
- pendingRunStarts.delete(payload.runId);
3758
- releaseRunStart();
3759
- try {
3760
- runJournal.remove(payload.runId);
3761
- }
3762
- catch {
3763
- pushLog(`run-journal-remove-failed run=${payload.runId}`);
3764
- }
3765
- pushLog(`workspace-lease-unavailable run=${payload.runId} code=${failure.code}`);
3766
- return;
3767
- }
3768
- const nowMs = Date.now();
3769
- const touchActivity = () => {
3770
- const entry = activeAgents.get(payload.runId);
3771
- if (entry)
3772
- entry.lastActivityAtMs = Date.now();
3773
- };
3774
- const releaseActiveRun = () => {
3775
- if (!activeAgents.delete(payload.runId))
3776
- return;
3777
- console.info("[alan-agent] Agent run finished", correlation);
3778
- };
3779
- const presenter = new RuntimePresenter(socket, payload.conversationId, payload.runId, eventOutbox, cwd, touchActivity, releaseActiveRun, () => {
3780
- const entry = activeAgents.get(payload.runId);
3781
- if (entry)
3782
- entry.stage = "running";
3783
- }, () => {
3784
- const entry = activeAgents.get(payload.runId);
3785
- return entry ? verifyActiveWorkspaceLease(payload.runId, entry, Date.now()) : false;
3786
- }, () => {
3787
- if (backendKind && runtimeCommand) {
3788
- invalidateProviderAuthPreflight(backendKind, runtimeCommand);
3789
- }
3790
- }, backendKind);
3791
- let agent;
3792
- try {
3793
- agent = new CoreAgent(presenter, {
3794
- backendKind,
3795
- runtimeCommand,
3796
- providerApiKey: payload.providerApiKey,
3797
- onProviderSpawned: (pid) => {
3798
- releaseRunStart();
3799
- const entry = activeAgents.get(payload.runId);
3800
- if (!entry)
3801
- return;
3802
- entry.providerPid = pid;
3803
- entry.stage = "provider_started";
3804
- entry.lastActivityAtMs = Date.now();
3805
- try {
3806
- runJournal.recordProviderSpawned(payload.runId, pid, entry.lastActivityAtMs);
3807
- }
3808
- catch {
3809
- // The accepted binding is already durable and continues to fence
3810
- // redelivery. PID evidence is diagnostic only, so never kill or
3811
- // fail a running provider because this optional update failed.
3812
- pushLog(`run-journal-provider-evidence-write-failed run=${payload.runId}`);
3813
- }
3814
- },
3815
- });
3816
- }
3817
- catch (error) {
3818
- pendingRunStarts.delete(payload.runId);
3819
- releaseRunStart();
3820
- try {
3821
- runJournal.remove(payload.runId);
3822
- }
3823
- catch {
3824
- pushLog(`run-journal-remove-failed run=${payload.runId}`);
3825
- }
3826
- const message = error instanceof Error ? error.message : String(error);
3827
- socket.emit("agent.rejected", {
3828
- runId: payload.runId,
3829
- message: `Provider startup failed: ${message}`,
3830
- code: "provider_start_failed",
3831
- });
3832
- return;
3833
- }
3834
- activeAgents.set(payload.runId, {
3835
- agent,
3836
- presenter,
3837
- lastActivityAtMs: nowMs,
3838
- acceptedAtMs: nowMs,
3839
- stage: "provider_starting",
3840
- providerPid: null,
3841
- conversationId: payload.conversationId,
3842
- workspaceLease: workspaceLeaseCapture.lease,
3843
- });
3844
- pendingRunStarts.delete(payload.runId);
3845
- // Track the per-run Alan MCP registration only now that every early-return
3846
- // gate has passed and the run is committed to agent.run(); its .finally()
3847
- // is the single teardown site, so the refcount can never leak.
3848
- if (payload.alanMcp && backendKind) {
3849
- alanMcpRegisteredRuns.add(payload.runId);
3850
- alanMcpRegisteredBackends.add(backendKind);
3851
- }
3852
- void agent
3853
- .run({
3854
- task: payload.content,
3855
- maxIterations: payload.maxIterations ?? 50,
3856
- teamPath: cwd,
3857
- cwd,
3858
- backendKind,
3859
- runtimeCommand,
3860
- agentId: payload.agentId,
3861
- agentPrompt: payload.agentPrompt,
3862
- mode: (payload.mode ?? "agent"),
3863
- selectedModel: payload.model,
3864
- selectedContextWindow: payload.selectedContextWindow ?? null,
3865
- selectedEffortLevel: payload.selectedEffortLevel ?? null,
3866
- providerSessionId: payload.providerSessionId,
3867
- resumeFallbackContext: payload.resumeFallbackContext,
3868
- taskMeta: payload.taskMeta,
3869
- prMeta: payload.prMeta,
3870
- conversationId: payload.conversationId,
3871
- alanMcp: payload.alanMcp,
3872
- taskId: payload.taskId ?? payload.taskMeta?.id,
3873
- teamId: payload.teamId,
3874
- currentUser: payload.currentUser,
3875
- workflowTools: payload.workflowTools,
3876
- images: payload.images,
3877
- files: payload.files,
3878
- })
3879
- .catch((error) => {
3880
- if (presenter.isTerminalFenced())
3881
- return;
3882
- const message = error instanceof Error ? error.message : String(error);
3883
- pushLog(`failed run=${payload.runId} ${message}`);
3884
- console.error("[alan-agent] Agent run failed", {
3885
- ...correlation,
3886
- error: message,
3887
- });
3888
- // Mid-run sign-out (or expired CLI session) must drop sticky ready so
3889
- // the next send re-probes and surfaces provider_auth_required cleanly
3890
- // instead of accepting again and failing the same way.
3891
- if (backendKind && runtimeCommand && looksLikeProviderAuthFailure(message)) {
3892
- invalidateProviderAuthPreflight(backendKind, runtimeCommand);
3893
- }
3894
- presenter.failApplicationProblem({
3895
- code: "app.internal",
3896
- detail: message,
3897
- component: "agent-manager-daemon",
3898
- phase: "delivery",
3899
- });
3900
- })
3901
- .finally(() => {
3902
- releaseRunStart();
3903
- try {
3904
- runJournal.remove(payload.runId);
3905
- }
3906
- catch {
3907
- pushLog(`run-journal-remove-failed run=${payload.runId}`);
3908
- }
3909
- finalizeAlanMcpForRun(payload.runId);
3910
- releaseActiveRun();
3911
- });
3912
- });
3913
- socket.on("agent.abort", (payload) => {
3914
- if (!payload?.runId)
3915
- return;
3916
- const entry = activeAgents.get(payload.runId);
3917
- if (!entry)
3918
- return;
3919
- // Only server-side timeouts are accepted as a non-user reason; everything
3920
- // else (including legacy payloads without a reason) is a user stop.
3921
- const reason = payload.reason === "watchdog_timeout" ? "watchdog_timeout" : "user";
3922
- abortActiveAgent(entry, { userInitiated: true, reason });
3923
- activeAgents.delete(payload.runId);
3924
- pushLog(`aborted run=${payload.runId} reason=${reason}`);
3925
- console.info("[alan-agent] Agent run aborted", {
3926
- runId: payload.runId,
3927
- reason,
3928
- agentVersion: AGENT_VERSION,
3929
- });
3930
- });
3931
- socket.on("agent.tool_response", (payload) => {
3932
- if (!payload?.toolId || typeof payload.response !== "string")
3933
- return;
3934
- // Scope delivery to the run/conversation that owns the answer. Fanning the write
3935
- // out to every active agent lets a foreign CLI accept it (sendToolResponse returns
3936
- // true on ANY successful stdin write, without verifying tool ownership) so the
3937
- // intended run never receives its answer and the foreign run gets a garbage
3938
- // tool_result. Prefer the explicit runId, then match conversationId, and only fall
3939
- // back to the full set when neither identifier is present (legacy payloads).
3940
- const entries = payload.runId
3941
- ? [[payload.runId, activeAgents.get(payload.runId)]]
3942
- : payload.conversationId
3943
- ? [...activeAgents.entries()].filter(([, entry]) => entry.conversationId === payload.conversationId)
3944
- : [...activeAgents.entries()];
3945
- const delivered = entries.some(([runId, entry]) => {
3946
- if (!entry || !verifyActiveWorkspaceLease(runId, entry, Date.now()))
3947
- return false;
3948
- return entry.agent.sendToolResponse(payload.toolId, payload.response) === true;
3949
- });
3950
- if (!delivered) {
3951
- pushLog(`tool_response missed tool=${payload.toolId}`);
3952
- // Surface the lost delivery to the conversation instead of hanging
3953
- // silently until a timeout: the agent process is gone or its stdin is
3954
- // closed, so the user's answer can only arrive as a new message.
3955
- // Resolve the conversation even when the runId is stale (e.g. the run was
3956
- // retried or already reaped) so the error still lands in the right chat.
3957
- const fallbackRun = [...activeAgents.entries()][0];
3958
- const resolvedRunId = payload.runId ?? entries[0]?.[0] ?? fallbackRun?.[0];
3959
- const conversationId = payload.conversationId ??
3960
- (payload.runId ? activeAgents.get(payload.runId)?.conversationId : undefined) ??
3961
- fallbackRun?.[1].conversationId;
3962
- if (conversationId && resolvedRunId) {
3963
- eventOutbox.enqueue(conversationId, resolvedRunId, {
3964
- type: "error",
3965
- message: "Your answer could not be delivered to the running agent — its input channel is closed. Send your answer as a new message to continue.",
3966
- });
3967
- eventOutbox.flush(socket);
3968
- }
3969
- }
3970
- });
3971
- socket.on("runtime.models.scan", (payload) => {
3972
- if (!payload?.requestId)
3973
- return;
3974
- const requestId = payload.requestId;
3975
- void (async () => {
3976
- try {
3977
- await runRuntimeModelScan({
3978
- refreshModels: refreshProviderModels,
3979
- getCapabilities: () => discoverCapabilities(),
3980
- getBaseMetadata: async () => (await currentMetadata()),
3981
- publishCredentials: (() => {
3982
- const stored = readConfig();
3983
- if (!stored.runtimeId || !stored.runtimeToken || !stored.apiUrl)
3984
- return undefined;
3985
- return {
3986
- apiUrl: stored.apiUrl,
3987
- runtimeId: stored.runtimeId,
3988
- runtimeToken: stored.runtimeToken,
3989
- };
3990
- })(),
3991
- }, { force: payload.force === true || payload.refresh === true });
3992
- const metadata = await currentMetadata();
3993
- socket.emit("runtime.models.result", {
3994
- requestId,
3995
- capabilities: discoverCapabilities(),
3996
- metadata: {
3997
- ...metadata,
3998
- ...buildModelsScanMetadata("complete"),
3999
- },
4000
- });
4001
- }
4002
- catch (error) {
4003
- socket.emit("runtime.models.result", {
4004
- requestId,
4005
- error: error instanceof Error ? error.message : String(error),
4006
- });
4007
- }
4008
- })();
4009
- });
4010
- socket.on("runtime.skills.scan", (payload) => {
4011
- if (!payload?.requestId)
4012
- return;
4013
- const requestId = payload.requestId;
4014
- try {
4015
- // A Project pinned to the default workspace arrives as the
4016
- // `alan://workspace` sentinel, not a path. Resolve it the same way
4017
- // dispatch does so those runtimes scan real skills instead of nothing.
4018
- const projectPath = isAlanDefaultWorkspace(payload.projectPath)
4019
- ? resolveAlanDefaultWorkspacePath()
4020
- : payload.projectPath;
4021
- const result = scanLocalSkillsCached(homedir(), projectPath, {
4022
- refresh: payload.refresh === true,
4023
- });
4024
- socket.emit("runtime.skills.result", {
4025
- requestId,
4026
- skills: result.skills,
4027
- errors: result.errors,
4028
- });
4029
- }
4030
- catch (error) {
4031
- socket.emit("runtime.skills.result", {
4032
- requestId,
4033
- error: error instanceof Error ? error.message : String(error),
4034
- });
4035
- }
4036
- });
4037
- const adoptionChallenges = new Map();
4038
- const localServer = http.createServer((req, res) => {
4039
- if (req.method === "GET" && req.url === "/owner") {
4040
- const now = Date.now();
4041
- for (const [challenge, expiresAt] of adoptionChallenges) {
4042
- if (expiresAt <= now)
4043
- adoptionChallenges.delete(challenge);
4044
- }
4045
- while (adoptionChallenges.size >= 32) {
4046
- const oldest = adoptionChallenges.keys().next().value;
4047
- if (!oldest)
4048
- break;
4049
- adoptionChallenges.delete(oldest);
4050
- }
4051
- const challenge = randomBytes(24).toString("base64url");
4052
- adoptionChallenges.set(challenge, now + LOCAL_SERVICE_CHALLENGE_TTL_MS);
4053
- const unsignedOwner = {
4054
- version: LOCAL_SERVICE_OWNER_VERSION,
4055
- serviceId: localServiceId,
4056
- installationId,
4057
- runtimeId,
4058
- port: localApiPort,
4059
- pid: process.pid,
4060
- startedAtMs: localServiceStartedAtMs,
4061
- challenge,
4062
- };
4063
- res.writeHead(200, { "cache-control": "no-store", "content-type": "application/json" }).end(JSON.stringify({
4064
- ...unsignedOwner,
4065
- signature: signLocalServiceOwner(localServiceSecret, unsignedOwner),
4066
- }));
4067
- return;
4068
- }
4069
- if (req.method === "POST" && req.url === "/adopt") {
4070
- const serviceId = req.headers["x-alan-service-id"];
4071
- const challenge = req.headers["x-alan-adoption-challenge"];
4072
- const proof = req.headers["x-alan-adoption-proof"];
4073
- const expiresAt = typeof challenge === "string" ? adoptionChallenges.get(challenge) : undefined;
4074
- if (typeof challenge === "string")
4075
- adoptionChallenges.delete(challenge);
4076
- const expectedProof = typeof challenge === "string"
4077
- ? signLocalServiceAdoption(localServiceSecret, localServiceId, challenge)
4078
- : "";
4079
- if (serviceId !== localServiceId ||
4080
- typeof challenge !== "string" ||
4081
- typeof proof !== "string" ||
4082
- !expiresAt ||
4083
- expiresAt <= Date.now() ||
4084
- !equalHexDigest(proof, expectedProof)) {
4085
- res
4086
- .writeHead(403, { "cache-control": "no-store", "content-type": "application/json" })
4087
- .end(JSON.stringify({ error: "Owner proof rejected" }));
4088
- return;
4089
- }
4090
- res
4091
- .writeHead(200, { "cache-control": "no-store", "content-type": "application/json" })
4092
- .end(JSON.stringify({ localApiToken }));
4093
- return;
4094
- }
4095
- const auth = req.headers.authorization;
4096
- if (auth !== `Bearer ${localApiToken}`) {
4097
- res
4098
- .writeHead(401, { "content-type": "application/json" })
4099
- .end(JSON.stringify({ error: "Unauthorized" }));
4100
- return;
4101
- }
4102
- if (req.method === "GET" && req.url === "/status") {
4103
- reconcileActiveRuns({ activeAgents, pushLog });
4104
- const runnerSummary = summarizeRunnerStatus({
4105
- connected: socket.connected,
4106
- activeAgents,
4107
- disconnectedSinceMs,
4108
- daemonSessionId,
4109
- daemonEpoch,
4110
- heartbeatSeq,
4111
- interruptedRunIds: runJournal.recoveredRunIds(),
4112
- endpointProfile,
4113
- configPath,
4114
- runtimeId,
4115
- runtimeAuthValid: Boolean(runtimeToken),
4116
- endpointMismatchDetail: endpointMismatch?.detail ?? null,
4117
- workspacePath: recheckWorkspaceIssue(),
4118
- maintenanceReason: maintenanceReason(),
4119
- });
4120
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
4121
- configured: true,
4122
- runtimeId,
4123
- wsUrl,
4124
- connected: socket.connected,
4125
- activeRunIds: runnerSummary.activeRunIds,
4126
- activeRunCount: runnerSummary.activeRunCount,
4127
- acceptingRuns: runnerSummary.acceptingRuns,
4128
- recovering: runnerSummary.recovering,
4129
- restartRecommended: runnerSummary.restartRecommended,
4130
- staleRunCount: runnerSummary.staleRunCount,
4131
- lastRunActivityAtMs: runnerSummary.lastRunActivityAtMs,
4132
- disconnectedSinceMs: runnerSummary.disconnectedSinceMs,
4133
- lastConnectError,
4134
- lastConnectErrorCode,
4135
- daemonSessionId,
4136
- heartbeatSeq,
4137
- endpointProfile,
4138
- configPath,
4139
- runnerStatus: runnerSummary.runnerStatus,
4140
- computerReadiness: runnerSummary.runnerStatus.computerReadiness,
4141
- workspaceRecoveryPath: workspaceAccessTracker.recoveryPath(),
4142
- localApiPort,
4143
- localServiceId,
4144
- version: AGENT_VERSION,
4145
- }));
4146
- return;
4147
- }
4148
- if (req.method === "POST" && req.url === "/prepare_logout") {
4149
- const acquired = acquireLocalDaemonMaintenanceLease(configPath, "credential_cleanup", 60_000);
4150
- if (!acquired.acquired && acquired.lease.reason !== "credential_cleanup") {
4151
- res
4152
- .writeHead(423, { "content-type": "application/json" })
4153
- .end(JSON.stringify({ error: "maintenance_in_progress" }));
4154
- return;
4155
- }
4156
- reconcileActiveRuns({ activeAgents, pushLog });
4157
- const activeRunCount = activeAgents.size + pendingRunStarts.size;
4158
- emitRunnerPresence();
4159
- if (activeRunCount > 0) {
4160
- res
4161
- .writeHead(409, { "content-type": "application/json" })
4162
- .end(JSON.stringify({ error: "active_runs", activeRunCount }));
4163
- return;
4164
- }
4165
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
4166
- return;
4167
- }
4168
- if (req.method === "POST" && req.url === "/workspace/recheck") {
4169
- const previousPath = workspaceAccessTracker.recoveryPath();
4170
- recheckWorkspaceIssue();
4171
- emitRunnerPresence();
4172
- const readiness = workspaceAccessTracker.currentReadiness() ?? {
4173
- ready: true,
4174
- message: "Workspace path is accessible.",
4175
- action: null,
4176
- };
4177
- res
4178
- .writeHead(200, { "content-type": "application/json" })
4179
- .end(JSON.stringify({ ok: readiness.ready, path: previousPath, readiness }));
4180
- return;
4181
- }
4182
- if (req.method === "GET" && req.url === "/capabilities") {
4183
- res
4184
- .writeHead(200, { "content-type": "application/json" })
4185
- .end(JSON.stringify(discoverCapabilities()));
4186
- return;
4187
- }
4188
- if (req.method === "GET" && req.url === "/logs") {
4189
- res
4190
- .writeHead(200, { "content-type": "application/json" })
4191
- .end(JSON.stringify({ lines: recentLogs }));
4192
- return;
4193
- }
4194
- if (req.method === "POST" && req.url === "/stop") {
4195
- for (const entry of activeAgents.values()) {
4196
- abortActiveAgent(entry, { userInitiated: true });
4197
- }
4198
- activeAgents.clear();
4199
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
4200
- return;
4201
- }
4202
- if (req.method === "POST" && req.url === "/reconnect") {
4203
- // Non-killing socket recovery: tear down and re-dial the WS without
4204
- // stopping the daemon process or aborting active runs. Heals endpoint
4205
- // drift and stuck sockets that a mere reconnection backoff won't clear,
4206
- // while a long-running CLI run keeps executing untouched.
4207
- pushLog("reconnect requested via local API");
4208
- // Clear the poisoned-connect signal so a caller polling /status right
4209
- // after a successful re-dial doesn't still see the pre-reconnect error.
4210
- lastConnectError = null;
4211
- lastConnectErrorCode = null;
4212
- socket.disconnect();
4213
- socket.connect();
4214
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
4215
- return;
4216
- }
4217
- if (req.method === "POST" && req.url?.startsWith("/stop_run")) {
4218
- const runId = new URL(req.url, "http://127.0.0.1").searchParams.get("runId");
4219
- if (!runId) {
4220
- res
4221
- .writeHead(400, { "content-type": "application/json" })
4222
- .end(JSON.stringify({ error: "runId is required" }));
4223
- return;
4224
- }
4225
- const entry = activeAgents.get(runId);
4226
- if (!entry) {
4227
- res
4228
- .writeHead(404, { "content-type": "application/json" })
4229
- .end(JSON.stringify({ error: "Run not active" }));
4230
- return;
4231
- }
4232
- abortActiveAgent(entry, { userInitiated: true });
4233
- activeAgents.delete(runId);
4234
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
4235
- return;
4236
- }
4237
- if (req.method === "GET" && req.url?.startsWith("/inspect_run")) {
4238
- const runId = new URL(req.url, "http://127.0.0.1").searchParams.get("runId");
4239
- const entry = runId ? activeAgents.get(runId) : undefined;
4240
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(entry
4241
- ? {
4242
- runId,
4243
- conversationId: entry.conversationId,
4244
- stage: entry.stage,
4245
- providerPid: entry.providerPid,
4246
- lastActivityAtMs: entry.lastActivityAtMs,
4247
- acceptedAtMs: entry.acceptedAtMs,
4248
- }
4249
- : { runId, active: false }));
4250
- return;
4251
- }
4252
- if (req.method === "POST" && req.url === "/shutdown") {
4253
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
4254
- setImmediate(queueFullShutdown);
4255
- return;
4256
- }
4257
- res
4258
- .writeHead(404, { "content-type": "application/json" })
4259
- .end(JSON.stringify({ error: "Not found" }));
4260
- });
4261
- const occupiedProbe = await probeLocalDaemonEndpoint(localApiPort, localApiToken);
4262
- if (occupiedProbe.state === "running") {
4263
- clearInterval(heartbeat);
4264
- clearInterval(versionRefresh);
4265
- clearInterval(workspaceLeaseMonitor);
4266
- suspensionDetector.stop();
4267
- socket.disconnect();
4268
- releaseOwnerLease();
4269
- releaseStartupLease();
4270
- throw new Error(`${describeOccupiedLocalDaemon(localApiPort)} (runtime ${occupiedProbe.status.runtimeId})`);
4271
- }
4272
- if (occupiedProbe.state === "listening_unauthorized") {
4273
- clearInterval(heartbeat);
4274
- clearInterval(versionRefresh);
4275
- clearInterval(workspaceLeaseMonitor);
4276
- suspensionDetector.stop();
4277
- socket.disconnect();
4278
- releaseOwnerLease();
4279
- releaseStartupLease();
4280
- throw new Error(`${describeOccupiedLocalDaemon(localApiPort)} The token in ${getConfigPath()} no longer matches the running daemon; quit Alan desktop or run setup again.`);
4281
- }
4282
- const localApiListening = new Promise((resolve, reject) => {
4283
- const onError = (error) => {
4284
- clearInterval(heartbeat);
4285
- clearInterval(versionRefresh);
4286
- clearInterval(workspaceLeaseMonitor);
4287
- suspensionDetector.stop();
4288
- socket.disconnect();
4289
- if (error.code === "EADDRINUSE") {
4290
- void probeLocalDaemonEndpoint(localApiPort).then((probe) => {
4291
- if (probe.state !== "absent") {
4292
- reject(new Error(describeOccupiedLocalDaemon(localApiPort)));
4293
- return;
4294
- }
4295
- reject(new Error(`local daemon API port ${localApiPort} is already in use by another process. ` +
4296
- "Otherwise free the port or pass `--local-api-port` with a different value."));
4297
- });
4298
- return;
4299
- }
4300
- reject(error);
4301
- };
4302
- localServer.once("error", onError);
4303
- localServer.listen(localApiPort, "127.0.0.1", () => {
4304
- localServer.off("error", onError);
4305
- const address = localServer.address();
4306
- if (address && typeof address === "object")
4307
- localApiPort = address.port;
4308
- pushLog(`local_api listening port=${localApiPort}`);
4309
- console.info("[alan-agent] Local daemon API listening", { port: localApiPort });
4310
- resolve();
4311
- });
4312
- });
4313
- try {
4314
- await localApiListening;
4315
- }
4316
- catch (error) {
4317
- releaseOwnerLease();
4318
- releaseStartupLease();
4319
- throw error;
4320
- }
4321
- try {
4322
- ownerLease = updateLocalDaemonOwnerEndpoint(configPath, ownerLease, localApiPort);
4323
- // The config-scoped owner is held and its authenticated endpoint is now
4324
- // bound. Only now may prior journal entries be persisted as interrupted.
4325
- runJournal.confirmRecoveryOwnership();
4326
- pendingRunStarts.discardRecovered();
4327
- }
4328
- catch (error) {
4329
- clearInterval(heartbeat);
4330
- clearInterval(versionRefresh);
4331
- clearInterval(workspaceLeaseMonitor);
4332
- suspensionDetector.stop();
4333
- socket.disconnect();
4334
- await new Promise((resolve) => localServer.close(() => resolve()));
4335
- releaseOwnerLease();
4336
- releaseStartupLease();
4337
- throw error;
4338
- }
4339
- releaseStartupLease();
4340
- let stopped = false;
4341
- const stop = async () => {
4342
- if (stopped)
4343
- return;
4344
- stopped = true;
4345
- try {
4346
- clearInterval(heartbeat);
4347
- clearInterval(versionRefresh);
4348
- clearInterval(workspaceLeaseMonitor);
4349
- suspensionDetector.stop();
4350
- for (const entry of activeAgents.values()) {
4351
- abortActiveAgent(entry, { userInitiated: true });
4352
- }
4353
- activeAgents.clear();
4354
- socket.disconnect();
4355
- await new Promise((resolve) => {
4356
- localServer.close(() => resolve());
4357
- });
4358
- }
4359
- finally {
4360
- // Release the sleep assertion even if teardown above threw; a leaked
4361
- // helper would keep the machine awake indefinitely.
4362
- sleepInhibitor.dispose();
4363
- releaseOwnerLease();
4364
- }
4365
- };
4366
- const controller = {
4367
- runtimeId,
4368
- wsUrl,
4369
- localApiPort,
4370
- localApiToken,
4371
- stop,
4372
- };
4373
- const queueFullShutdown = () => {
4374
- void stop().then(() => controller.whenExternallyStopped?.());
4375
- };
4376
- return controller;
4377
- }
4378
- export async function stopDaemon(args) {
4379
- bindConfigPath(args);
4380
- const stored = readConfig();
4381
- const localApiPort = getLocalApiPort(args, stored);
4382
- let localApiToken = stored.localApiToken;
4383
- let probe = localApiToken
4384
- ? await probeLocalDaemonEndpoint(localApiPort, localApiToken)
4385
- : { state: "listening_unauthorized" };
4386
- if (!localApiToken || probe.state === "listening_unauthorized") {
4387
- const adopted = await adoptConfiguredLocalDaemon(args);
4388
- if (adopted) {
4389
- localApiToken = readConfig().localApiToken;
4390
- probe = { state: "running", status: adopted };
4391
- }
4392
- }
4393
- if (!localApiToken)
4394
- throw new Error("No local API token in config. Run setup or login first.");
4395
- if (probe.state === "listening_unauthorized") {
4396
- throw new Error(`Daemon is listening on port ${localApiPort} but rejected this config token. ` +
4397
- "Quit Alan desktop or rerun setup/login to refresh config.");
4398
- }
4399
- if (probe.state === "absent") {
4400
- console.info("[alan-agent] No daemon listening", { port: localApiPort });
4401
- return;
4402
- }
4403
- const response = await fetch(`http://127.0.0.1:${localApiPort}/shutdown`, {
4404
- method: "POST",
4405
- headers: { authorization: `Bearer ${localApiToken}` },
4406
- signal: AbortSignal.timeout(5000),
4407
- });
4408
- if (!response.ok) {
4409
- throw new Error(`Failed to stop daemon: HTTP ${response.status}`);
4410
- }
4411
- console.info("[alan-agent] Daemon stopped", {
4412
- port: localApiPort,
4413
- runtimeId: probe.status.runtimeId,
4414
- });
4415
- }
4416
- export async function runDaemon(args) {
4417
- bindConfigPath(args);
4418
- const stored = readConfig();
4419
- const localApiPort = getLocalApiPort(args, stored);
4420
- const localApiToken = stored.localApiToken;
4421
- const existingProbe = await probeLocalDaemonEndpoint(localApiPort, localApiToken);
4422
- if (existingProbe.state === "running") {
4423
- console.info("[alan-agent] Daemon already running", {
4424
- port: localApiPort,
4425
- runtimeId: existingProbe.status.runtimeId,
4426
- connected: existingProbe.status.connected,
4427
- hint: "If Alan desktop is open, it manages the daemon automatically.",
4428
- });
4429
- return;
4430
- }
4431
- if (existingProbe.state === "listening_unauthorized") {
4432
- const adopted = await adoptConfiguredLocalDaemon(args);
4433
- if (adopted) {
4434
- console.info("[alan-agent] Daemon already running", {
4435
- port: localApiPort,
4436
- runtimeId: adopted.runtimeId,
4437
- connected: adopted.connected,
4438
- adopted: true,
4439
- });
4440
- return;
4441
- }
4442
- console.info("[alan-agent] Daemon already running", {
4443
- port: localApiPort,
4444
- hint: `Alan desktop is using a different local API token than ${getConfigPath()}. Quit the desktop app or rerun setup if you need a fresh daemon.`,
4445
- });
4446
- return;
4447
- }
4448
- const controller = await startDaemon(args);
4449
- const shutdown = () => {
4450
- void controller.stop().finally(() => process.exit(0));
4451
- };
4452
- controller.whenExternallyStopped = shutdown;
4453
- process.once("SIGINT", shutdown);
4454
- process.once("SIGTERM", shutdown);
4455
- await new Promise(() => { });
4456
- }
4457
- export async function recoverDaemonConnection(args = [], options) {
4458
- bindConfigPath(args);
4459
- const getStatus = async () => {
4460
- const config = readConfig();
4461
- const localApiPort = getLocalApiPort(args, config);
4462
- let live = config.localApiToken
4463
- ? await probeLocalDaemon(localApiPort, config.localApiToken)
4464
- : null;
4465
- if (!live && config.localServiceId && config.localServiceSecret) {
4466
- live = await adoptConfiguredLocalDaemon(args);
4467
- }
4468
- if (live)
4469
- return { ...live, running: true };
4470
- return {
4471
- configured: Boolean(config.runtimeId && config.runtimeToken && config.wsUrl),
4472
- running: false,
4473
- connected: false,
4474
- activeRunCount: 0,
4475
- activeRunIds: [],
4476
- staleRunCount: 0,
4477
- };
4478
- };
4479
- const initialStatus = await getStatus();
4480
- const recovered = await runLocalDaemonRecoveryLadder({
4481
- initialStatus,
4482
- getStatus,
4483
- redial: async () => {
4484
- const config = readConfig();
4485
- if (!config.localApiToken)
4486
- return false;
4487
- const localApiPort = getLocalApiPort(args, config);
4488
- const response = await fetch(`http://127.0.0.1:${localApiPort}/reconnect`, {
4489
- method: "POST",
4490
- headers: { authorization: `Bearer ${config.localApiToken}` },
4491
- signal: AbortSignal.timeout(5000),
4492
- });
4493
- return response.ok;
4494
- },
4495
- restart: async () => {
4496
- const beforeRestart = await getStatus();
4497
- const blockingRunCount = beforeRestart.activeRunCount ??
4498
- Math.max(0, (beforeRestart.activeRunIds?.length ?? 0) - (beforeRestart.staleRunCount ?? 0));
4499
- if (blockingRunCount > 0)
4500
- return beforeRestart;
4501
- if (beforeRestart.running) {
4502
- await stopDaemon(args);
4503
- for (let attempt = 0; attempt < 50; attempt += 1) {
4504
- await sleep(50);
4505
- if (!(await getStatus()).running)
4506
- break;
4507
- }
4508
- if ((await getStatus()).running) {
4509
- throw new Error("The existing local daemon did not stop within the recovery budget.");
4510
- }
4511
- }
4512
- await startDaemon(args);
4513
- return getStatus();
4514
- },
4515
- blockingRunCount: (status) => status.activeRunCount ??
4516
- Math.max(0, (status.activeRunIds?.length ?? 0) - (status.staleRunCount ?? 0)),
4517
- options,
4518
- });
4519
- console.info(JSON.stringify({ recovery: recovered }, null, 2));
4520
- return recovered;
4521
- }
4522
- async function drainAndStopLocalDaemonBeforeCredentialCleanup(args, stored) {
4523
- const localApiPort = getLocalApiPort(args, stored);
4524
- let localApiToken = stored.localApiToken;
4525
- let probe = await probeLocalDaemonEndpoint(localApiPort, localApiToken);
4526
- if (probe.state === "listening_unauthorized") {
4527
- const adopted = await adoptConfiguredLocalDaemon(args);
4528
- if (adopted) {
4529
- localApiToken = readConfig().localApiToken;
4530
- probe = { state: "running", status: adopted };
4531
- }
4532
- }
4533
- if (probe.state === "absent")
4534
- return;
4535
- if (probe.state === "listening_unauthorized" || !localApiToken) {
4536
- throw new Error(`Logout cannot verify the local daemon on port ${localApiPort}. Stop the local service before removing credentials.`);
4537
- }
4538
- const prepare = await fetch(`http://127.0.0.1:${localApiPort}/prepare_logout`, {
4539
- method: "POST",
4540
- headers: { authorization: `Bearer ${localApiToken}` },
4541
- signal: AbortSignal.timeout(5000),
4542
- });
4543
- if (prepare.status === 409) {
4544
- const body = (await prepare.json().catch(() => ({})));
4545
- const activeRunCount = typeof body.activeRunCount === "number" && Number.isSafeInteger(body.activeRunCount)
4546
- ? Math.max(1, body.activeRunCount)
4547
- : 1;
4548
- throw new Error(`Logout is deferred while ${activeRunCount} active run${activeRunCount === 1 ? "" : "s"} finish. New runs are paused; retry logout afterward.`);
4549
- }
4550
- if (!prepare.ok) {
4551
- throw new Error(`Logout could not safely drain the local daemon (HTTP ${prepare.status}). Stop or update the local service before removing credentials.`);
4552
- }
4553
- const shutdown = await fetch(`http://127.0.0.1:${localApiPort}/shutdown`, {
4554
- method: "POST",
4555
- headers: { authorization: `Bearer ${localApiToken}` },
4556
- signal: AbortSignal.timeout(5000),
4557
- });
4558
- if (!shutdown.ok) {
4559
- throw new Error(`Logout could not stop the local daemon (HTTP ${shutdown.status}).`);
4560
- }
4561
- for (let attempt = 0; attempt < 50; attempt += 1) {
4562
- await sleep(50);
4563
- if ((await probeLocalDaemonEndpoint(localApiPort, localApiToken, 250)).state === "absent") {
4564
- return;
4565
- }
4566
- }
4567
- throw new Error("Logout could not confirm that the local daemon stopped; credentials were kept.");
4568
- }
4569
- export async function rotateRuntimeToken(args) {
4570
- bindConfigPath(args);
4571
- const stored = readConfig();
4572
- const apiUrl = argValue(args, "--api-url") ?? process.env.ALAN_API_URL ?? stored.apiUrl;
4573
- const runtimeId = argValue(args, "--runtime-id") ?? process.env.ALAN_RUNTIME_ID ?? stored.runtimeId;
4574
- const token = argValue(args, "--token") ?? process.env.ALAN_SETUP_TOKEN;
4575
- if (!apiUrl || !runtimeId || !token) {
4576
- throw new Error("token rotate requires configured api/runtime plus --token <alan PAT>");
4577
- }
4578
- const response = await fetch(`${apiUrl}/public/runtimes/${runtimeId}/tokens/rotate`, {
4579
- method: "POST",
4580
- headers: { authorization: `Bearer ${token}` },
4581
- });
4582
- if (!response.ok) {
4583
- throw new Error(`runtime token rotation failed: HTTP ${response.status}`);
4584
- }
4585
- const body = (await response.json());
4586
- writeConfig({
4587
- ...stored,
4588
- apiUrl,
4589
- runtimeId,
4590
- runtimeToken: body.runtimeToken,
4591
- runtimeRenewalToken: body.runtimeRenewalToken,
4592
- });
4593
- console.info("[alan-agent] Runtime token rotated", { runtimeId });
4594
- }
4595
- export async function revokeRuntimeToken(args) {
4596
- bindConfigPath(args);
4597
- const stored = readConfig();
4598
- const apiUrl = argValue(args, "--api-url") ?? process.env.ALAN_API_URL ?? stored.apiUrl;
4599
- const runtimeId = argValue(args, "--runtime-id") ?? process.env.ALAN_RUNTIME_ID ?? stored.runtimeId;
4600
- const token = argValue(args, "--token") ?? process.env.ALAN_SETUP_TOKEN;
4601
- if (!apiUrl || !runtimeId || !token) {
4602
- throw new Error("token revoke requires configured api/runtime plus --token <alan PAT>");
4603
- }
4604
- await drainAndStopLocalDaemonBeforeCredentialCleanup(args, stored);
4605
- const response = await fetch(`${apiUrl}/public/runtimes/${runtimeId}/tokens/revoke`, {
4606
- method: "POST",
4607
- headers: { authorization: `Bearer ${token}` },
4608
- });
4609
- if (!response.ok) {
4610
- throw new Error(`runtime token revoke failed: HTTP ${response.status}`);
4611
- }
4612
- removeAlanOwnedLocalArtifacts();
4613
- console.info("[alan-agent] Runtime token revoked", { runtimeId });
4614
- }
4615
- export async function logoutDaemon(args = []) {
4616
- bindConfigPath(args);
4617
- const stored = readConfig();
4618
- await drainAndStopLocalDaemonBeforeCredentialCleanup(args, stored);
4619
- removeAlanOwnedLocalArtifacts();
4620
- console.info("[alan-agent] Runtime config removed", { configPath: getConfigPath() });
4621
- }
4622
- export async function printStatus(args = []) {
4623
- bindConfigPath(args);
4624
- const config = readConfig();
4625
- const configured = Boolean(config.runtimeId && config.runtimeToken && config.wsUrl);
4626
- const warnings = [];
4627
- if ((isLocalEndpoint(config.apiUrl) || isLocalEndpoint(config.wsUrl)) &&
4628
- config.endpointProfile !== "local") {
4629
- warnings.push("Configured endpoint points at localhost. Use --profile local only for local development.");
4630
- }
4631
- const localApiPort = config.localApiPort && config.localApiPort > 0
4632
- ? config.localApiPort
4633
- : getLocalApiPort([], config);
4634
- let localDaemonProbe = config.localApiToken
4635
- ? await probeLocalDaemonEndpoint(localApiPort, config.localApiToken)
4636
- : { state: "absent" };
4637
- if (localDaemonProbe.state !== "running" && config.localServiceId && config.localServiceSecret) {
4638
- const adopted = await adoptConfiguredLocalDaemon(args);
4639
- if (adopted)
4640
- localDaemonProbe = { state: "running", status: adopted };
4641
- }
4642
- const localDaemonRunning = localDaemonProbe.state === "running";
4643
- console.info(JSON.stringify({
4644
- mode: "runtime",
4645
- configured,
4646
- runtimeId: config.runtimeId ?? null,
4647
- apiUrl: config.apiUrl ?? null,
4648
- wsUrl: config.wsUrl ?? null,
4649
- endpointProfile: config.endpointProfile ?? null,
4650
- localApiPort: config.localApiPort ?? null,
4651
- localDaemonRunning,
4652
- hasLocalApiToken: Boolean(config.localApiToken),
4653
- configPath: getConfigPath(),
4654
- note: configured
4655
- ? localDaemonRunning
4656
- ? "Local daemon is running. Stop it with: alan-agent stop"
4657
- : "Durable runtime config is present. Start it with: alan-agent daemon"
4658
- : "No durable runtime configured. Cloud sandbox sessions are launched by Alan with session env vars and do not appear in this local config.",
4659
- warnings,
4660
- }, null, 2));
4661
- }
4662
- export async function printDoctor(args = []) {
4663
- bindConfigPath(args);
4664
- const config = readConfig();
4665
- const capabilities = discoverCapabilities();
4666
- let synced = false;
4667
- let syncError = null;
4668
- if (args.includes("--sync")) {
4669
- if (!config.apiUrl || !config.runtimeId || !config.runtimeToken) {
4670
- throw new Error("doctor --sync requires configured apiUrl, runtimeId, and runtimeToken. Run setup first.");
4671
- }
4672
- const response = await fetch(`${config.apiUrl}/public/runtimes/${config.runtimeId}/capabilities/self`, {
4673
- method: "PATCH",
4674
- headers: {
4675
- authorization: `Bearer ${config.runtimeToken}`,
4676
- "content-type": "application/json",
4677
- },
4678
- body: JSON.stringify({
4679
- capabilities,
4680
- metadata: {
4681
- ...(await collectRuntimeMetadataWithProviderLimits()),
4682
- ...(config.installationId ? { installationId: config.installationId } : {}),
4683
- },
4684
- }),
4685
- });
4686
- if (!response.ok) {
4687
- syncError = `${response.status} ${await response.text()}`;
4688
- throw new Error(`doctor sync failed: ${syncError}`);
4689
- }
4690
- synced = true;
4691
- }
4692
- console.info(JSON.stringify({
4693
- version: AGENT_VERSION,
4694
- configPath: getConfigPath(),
4695
- configured: Boolean(config.runtimeId && config.runtimeToken && config.wsUrl),
4696
- runtimeId: config.runtimeId ?? null,
4697
- apiUrl: config.apiUrl ?? null,
4698
- wsUrl: config.wsUrl ?? null,
4699
- endpointProfile: config.endpointProfile ?? null,
4700
- warnings: (isLocalEndpoint(config.apiUrl) || isLocalEndpoint(config.wsUrl)) &&
4701
- config.endpointProfile !== "local"
4702
- ? [
4703
- "Configured endpoint points at localhost. Use --profile local only for local development.",
4704
- ]
4705
- : [],
4706
- synced,
4707
- syncError,
4708
- capabilities,
4709
- }, null, 2));
4710
- }
4711
- //# sourceMappingURL=daemon.js.map