@songsid/agend 2.1.12 → 2.2.0-alpha.2

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 (436) hide show
  1. package/README.md +4 -2
  2. package/README.zh-TW.md +4 -2
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.js +68 -12
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/agent-token-header.d.ts +13 -0
  8. package/dist/agent-token-header.js +16 -0
  9. package/dist/agent-token-header.js.map +1 -0
  10. package/dist/auth/one-time-code.d.ts +19 -0
  11. package/dist/auth/one-time-code.js +49 -0
  12. package/dist/auth/one-time-code.js.map +1 -0
  13. package/dist/auth-api.d.ts +52 -0
  14. package/dist/auth-api.js +346 -0
  15. package/dist/auth-api.js.map +1 -0
  16. package/dist/backend/antigravity.js +4 -3
  17. package/dist/backend/antigravity.js.map +1 -1
  18. package/dist/backend/claude-code.d.ts +4 -0
  19. package/dist/backend/claude-code.js +97 -0
  20. package/dist/backend/claude-code.js.map +1 -1
  21. package/dist/backend/codex.js +59 -30
  22. package/dist/backend/codex.js.map +1 -1
  23. package/dist/backend/grok.js +1 -1
  24. package/dist/backend/grok.js.map +1 -1
  25. package/dist/backend/kiro-agent.d.ts +66 -0
  26. package/dist/backend/kiro-agent.js +302 -0
  27. package/dist/backend/kiro-agent.js.map +1 -0
  28. package/dist/backend/kiro-identity.d.ts +80 -0
  29. package/dist/backend/kiro-identity.js +537 -0
  30. package/dist/backend/kiro-identity.js.map +1 -0
  31. package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
  32. package/dist/backend/kiro-v2-store-worker.js +87 -0
  33. package/dist/backend/kiro-v2-store-worker.js.map +1 -0
  34. package/dist/backend/kiro-v2-store.d.ts +54 -0
  35. package/dist/backend/kiro-v2-store.js +131 -0
  36. package/dist/backend/kiro-v2-store.js.map +1 -0
  37. package/dist/backend/kiro-v3-identity.d.ts +5 -0
  38. package/dist/backend/kiro-v3-identity.js +22 -0
  39. package/dist/backend/kiro-v3-identity.js.map +1 -1
  40. package/dist/backend/kiro.d.ts +90 -7
  41. package/dist/backend/kiro.js +500 -124
  42. package/dist/backend/kiro.js.map +1 -1
  43. package/dist/backend/muse.js +1 -1
  44. package/dist/backend/muse.js.map +1 -1
  45. package/dist/backend/types.d.ts +51 -3
  46. package/dist/backend/types.js.map +1 -1
  47. package/dist/cache-analysis.d.ts +92 -0
  48. package/dist/cache-analysis.js +147 -0
  49. package/dist/cache-analysis.js.map +1 -0
  50. package/dist/cache-ledger.d.ts +108 -0
  51. package/dist/cache-ledger.js +230 -0
  52. package/dist/cache-ledger.js.map +1 -0
  53. package/dist/cache-prices.d.ts +32 -0
  54. package/dist/cache-prices.js +73 -0
  55. package/dist/cache-prices.js.map +1 -0
  56. package/dist/cache-scan.d.ts +39 -0
  57. package/dist/cache-scan.js +115 -0
  58. package/dist/cache-scan.js.map +1 -0
  59. package/dist/cache-service.d.ts +115 -0
  60. package/dist/cache-service.js +417 -0
  61. package/dist/cache-service.js.map +1 -0
  62. package/dist/channel/adapters/discord.d.ts +1 -0
  63. package/dist/channel/adapters/discord.js +29 -10
  64. package/dist/channel/adapters/discord.js.map +1 -1
  65. package/dist/channel/adapters/telegram.d.ts +1 -0
  66. package/dist/channel/adapters/telegram.js +13 -1
  67. package/dist/channel/adapters/telegram.js.map +1 -1
  68. package/dist/channel/types.d.ts +9 -1
  69. package/dist/classic-channel-manager.d.ts +18 -10
  70. package/dist/classic-channel-manager.js +45 -18
  71. package/dist/classic-channel-manager.js.map +1 -1
  72. package/dist/cli-entry.d.ts +19 -0
  73. package/dist/cli-entry.js +36 -0
  74. package/dist/cli-entry.js.map +1 -0
  75. package/dist/cli.js +471 -146
  76. package/dist/cli.js.map +1 -1
  77. package/dist/command-table.d.ts +5 -3
  78. package/dist/command-table.js +29 -27
  79. package/dist/command-table.js.map +1 -1
  80. package/dist/config-validator.js +46 -0
  81. package/dist/config-validator.js.map +1 -1
  82. package/dist/config.js +12 -0
  83. package/dist/config.js.map +1 -1
  84. package/dist/connection-secrets.d.ts +6 -0
  85. package/dist/connection-secrets.js +4 -0
  86. package/dist/connection-secrets.js.map +1 -1
  87. package/dist/daemon.d.ts +95 -4
  88. package/dist/daemon.js +748 -111
  89. package/dist/daemon.js.map +1 -1
  90. package/dist/darwin-memory.d.ts +6 -1
  91. package/dist/darwin-memory.js +17 -6
  92. package/dist/darwin-memory.js.map +1 -1
  93. package/dist/delivery-consumed-watch.d.ts +85 -0
  94. package/dist/delivery-consumed-watch.js +244 -0
  95. package/dist/delivery-consumed-watch.js.map +1 -0
  96. package/dist/delivery-outbox.d.ts +61 -0
  97. package/dist/delivery-outbox.js +120 -2
  98. package/dist/delivery-outbox.js.map +1 -1
  99. package/dist/delivery-reconciliation.d.ts +14 -5
  100. package/dist/delivery-reconciliation.js +27 -4
  101. package/dist/delivery-reconciliation.js.map +1 -1
  102. package/dist/delivery-reconciliation.test.js +35 -1
  103. package/dist/delivery-reconciliation.test.js.map +1 -1
  104. package/dist/event-log.d.ts +4 -1
  105. package/dist/event-log.js +6 -3
  106. package/dist/event-log.js.map +1 -1
  107. package/dist/event-loop-watch.d.ts +28 -0
  108. package/dist/event-loop-watch.js +75 -3
  109. package/dist/event-loop-watch.js.map +1 -1
  110. package/dist/fleet-context.d.ts +22 -2
  111. package/dist/fleet-control-audit.d.ts +5 -0
  112. package/dist/fleet-control-audit.js +7 -0
  113. package/dist/fleet-control-audit.js.map +1 -1
  114. package/dist/fleet-level-config.d.ts +2 -2
  115. package/dist/fleet-level-config.js +28 -1
  116. package/dist/fleet-level-config.js.map +1 -1
  117. package/dist/fleet-manager.d.ts +339 -12
  118. package/dist/fleet-manager.js +2623 -461
  119. package/dist/fleet-manager.js.map +1 -1
  120. package/dist/full-restart.js +2 -3
  121. package/dist/full-restart.js.map +1 -1
  122. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  123. package/dist/host-memory.d.ts +4 -0
  124. package/dist/host-memory.js.map +1 -1
  125. package/dist/install-lock.d.ts +49 -0
  126. package/dist/install-lock.js +200 -0
  127. package/dist/install-lock.js.map +1 -0
  128. package/dist/instance-config-impact.js +8 -2
  129. package/dist/instance-config-impact.js.map +1 -1
  130. package/dist/instance-lifecycle.d.ts +10 -5
  131. package/dist/instance-lifecycle.js +162 -53
  132. package/dist/instance-lifecycle.js.map +1 -1
  133. package/dist/locale.js +164 -16
  134. package/dist/locale.js.map +1 -1
  135. package/dist/login-controller.d.ts +2 -0
  136. package/dist/login-controller.js +17 -5
  137. package/dist/login-controller.js.map +1 -1
  138. package/dist/memory-pressure.d.ts +7 -8
  139. package/dist/memory-pressure.js +76 -15
  140. package/dist/memory-pressure.js.map +1 -1
  141. package/dist/monotonic-budget.d.ts +2 -0
  142. package/dist/monotonic-budget.js +28 -0
  143. package/dist/monotonic-budget.js.map +1 -0
  144. package/dist/needs-you-hub.d.ts +114 -0
  145. package/dist/needs-you-hub.js +370 -0
  146. package/dist/needs-you-hub.js.map +1 -0
  147. package/dist/needs-you-live.d.ts +116 -0
  148. package/dist/needs-you-live.js +228 -0
  149. package/dist/needs-you-live.js.map +1 -0
  150. package/dist/needs-you.d.ts +132 -0
  151. package/dist/needs-you.js +164 -0
  152. package/dist/needs-you.js.map +1 -0
  153. package/dist/node-version-guard.d.ts +30 -0
  154. package/dist/node-version-guard.js +50 -0
  155. package/dist/node-version-guard.js.map +1 -0
  156. package/dist/operator-control-paths.d.ts +4 -0
  157. package/dist/operator-control-paths.js +24 -0
  158. package/dist/operator-control-paths.js.map +1 -0
  159. package/dist/outbound-handlers.d.ts +2 -0
  160. package/dist/outbound-handlers.js +9 -2
  161. package/dist/outbound-handlers.js.map +1 -1
  162. package/dist/outbound-schemas.d.ts +5 -5
  163. package/dist/outbound-schemas.js +1 -1
  164. package/dist/outbound-schemas.js.map +1 -1
  165. package/dist/package-preimage.d.ts +26 -0
  166. package/dist/package-preimage.js +96 -0
  167. package/dist/package-preimage.js.map +1 -0
  168. package/dist/profile-control.js +4 -20
  169. package/dist/profile-control.js.map +1 -1
  170. package/dist/public-web-gateway.d.ts +18 -0
  171. package/dist/public-web-gateway.js +141 -0
  172. package/dist/public-web-gateway.js.map +1 -0
  173. package/dist/public-web-link.d.ts +53 -0
  174. package/dist/public-web-link.js +175 -0
  175. package/dist/public-web-link.js.map +1 -0
  176. package/dist/quickstart-api.d.ts +8 -1
  177. package/dist/quickstart-api.js +86 -48
  178. package/dist/quickstart-api.js.map +1 -1
  179. package/dist/quickstart.d.ts +1 -0
  180. package/dist/quickstart.js +16 -6
  181. package/dist/quickstart.js.map +1 -1
  182. package/dist/restart-guard.d.ts +52 -0
  183. package/dist/restart-guard.js +109 -0
  184. package/dist/restart-guard.js.map +1 -0
  185. package/dist/scheduler/db.d.ts +7 -1
  186. package/dist/scheduler/db.js +44 -2
  187. package/dist/scheduler/db.js.map +1 -1
  188. package/dist/scheduler/index.d.ts +1 -1
  189. package/dist/scheduler/retry-label.d.ts +11 -0
  190. package/dist/scheduler/retry-label.js +24 -0
  191. package/dist/scheduler/retry-label.js.map +1 -0
  192. package/dist/scheduler/scheduler.d.ts +55 -2
  193. package/dist/scheduler/scheduler.js +220 -8
  194. package/dist/scheduler/scheduler.js.map +1 -1
  195. package/dist/scheduler/types.d.ts +22 -0
  196. package/dist/scheduler/types.js.map +1 -1
  197. package/dist/secret-store.d.ts +5 -0
  198. package/dist/secret-store.js +20 -0
  199. package/dist/secret-store.js.map +1 -1
  200. package/dist/service-activation.d.ts +152 -0
  201. package/dist/service-activation.js +407 -0
  202. package/dist/service-activation.js.map +1 -0
  203. package/dist/service-installer.d.ts +63 -1
  204. package/dist/service-installer.js +141 -16
  205. package/dist/service-installer.js.map +1 -1
  206. package/dist/service-plan.d.ts +65 -0
  207. package/dist/service-plan.js +108 -0
  208. package/dist/service-plan.js.map +1 -0
  209. package/dist/service-restart-selection.d.ts +6 -5
  210. package/dist/service-restart-selection.js +6 -5
  211. package/dist/service-restart-selection.js.map +1 -1
  212. package/dist/settings-api.d.ts +10 -2
  213. package/dist/settings-api.js +216 -72
  214. package/dist/settings-api.js.map +1 -1
  215. package/dist/settings-baseline.d.ts +20 -0
  216. package/dist/settings-baseline.js +104 -0
  217. package/dist/settings-baseline.js.map +1 -0
  218. package/dist/settings-change.d.ts +21 -0
  219. package/dist/settings-change.js +161 -0
  220. package/dist/settings-change.js.map +1 -0
  221. package/dist/settings-confirmation.d.ts +105 -0
  222. package/dist/settings-confirmation.js +232 -0
  223. package/dist/settings-confirmation.js.map +1 -0
  224. package/dist/settings-control.d.ts +41 -0
  225. package/dist/settings-control.js +294 -0
  226. package/dist/settings-control.js.map +1 -0
  227. package/dist/settings-effect.d.ts +24 -0
  228. package/dist/settings-effect.js +169 -0
  229. package/dist/settings-effect.js.map +1 -0
  230. package/dist/settings-http-confirmation.d.ts +29 -0
  231. package/dist/settings-http-confirmation.js +123 -0
  232. package/dist/settings-http-confirmation.js.map +1 -0
  233. package/dist/settings-instance-patch.d.ts +4 -0
  234. package/dist/settings-instance-patch.js +26 -0
  235. package/dist/settings-instance-patch.js.map +1 -0
  236. package/dist/settings-request-capability.d.ts +16 -0
  237. package/dist/settings-request-capability.js +32 -0
  238. package/dist/settings-request-capability.js.map +1 -0
  239. package/dist/settings-transaction.d.ts +65 -0
  240. package/dist/settings-transaction.js +223 -0
  241. package/dist/settings-transaction.js.map +1 -0
  242. package/dist/setup-auth.d.ts +10 -18
  243. package/dist/setup-auth.js +14 -38
  244. package/dist/setup-auth.js.map +1 -1
  245. package/dist/setup-form.d.ts +1 -1
  246. package/dist/setup-form.js +75 -18
  247. package/dist/setup-form.js.map +1 -1
  248. package/dist/setup-guide.d.ts +13 -0
  249. package/dist/setup-guide.js +25 -0
  250. package/dist/setup-guide.js.map +1 -0
  251. package/dist/setup-host.d.ts +16 -0
  252. package/dist/setup-host.js +199 -35
  253. package/dist/setup-host.js.map +1 -1
  254. package/dist/setup-wizard.js +5 -1
  255. package/dist/setup-wizard.js.map +1 -1
  256. package/dist/slash-authz.d.ts +17 -3
  257. package/dist/slash-authz.js +5 -0
  258. package/dist/slash-authz.js.map +1 -1
  259. package/dist/spawn-gate.js +3 -3
  260. package/dist/spawn-gate.js.map +1 -1
  261. package/dist/sqlite-open-errors.d.ts +12 -0
  262. package/dist/sqlite-open-errors.js +13 -0
  263. package/dist/sqlite-open-errors.js.map +1 -0
  264. package/dist/statusline-watcher.d.ts +5 -0
  265. package/dist/statusline-watcher.js +2 -0
  266. package/dist/statusline-watcher.js.map +1 -1
  267. package/dist/steer-capability.d.ts +6 -0
  268. package/dist/steer-capability.js +8 -0
  269. package/dist/steer-capability.js.map +1 -1
  270. package/dist/sync-work-attribution.d.ts +14 -0
  271. package/dist/sync-work-attribution.js +43 -8
  272. package/dist/sync-work-attribution.js.map +1 -1
  273. package/dist/tips.js +10 -5
  274. package/dist/tips.js.map +1 -1
  275. package/dist/tmux-control.d.ts +57 -5
  276. package/dist/tmux-control.js +379 -61
  277. package/dist/tmux-control.js.map +1 -1
  278. package/dist/tmux-manager.d.ts +20 -6
  279. package/dist/tmux-manager.js +90 -47
  280. package/dist/tmux-manager.js.map +1 -1
  281. package/dist/tmux-read.d.ts +59 -0
  282. package/dist/tmux-read.js +213 -0
  283. package/dist/tmux-read.js.map +1 -0
  284. package/dist/topic-commands.d.ts +21 -10
  285. package/dist/topic-commands.js +205 -63
  286. package/dist/topic-commands.js.map +1 -1
  287. package/dist/transcript-delta-reader.js +2 -1
  288. package/dist/transcript-delta-reader.js.map +1 -1
  289. package/dist/tunnel/cloudflared-install.d.ts +2 -0
  290. package/dist/tunnel/cloudflared-install.js +1 -1
  291. package/dist/tunnel/cloudflared-install.js.map +1 -1
  292. package/dist/tunnel/purpose-lane.d.ts +14 -0
  293. package/dist/tunnel/purpose-lane.js +43 -0
  294. package/dist/tunnel/purpose-lane.js.map +1 -0
  295. package/dist/turn-reply-guard.d.ts +2 -0
  296. package/dist/turn-reply-guard.js +10 -1
  297. package/dist/turn-reply-guard.js.map +1 -1
  298. package/dist/types.d.ts +33 -0
  299. package/dist/ui/app.html +18 -0
  300. package/dist/ui/chat-commands.js +43 -0
  301. package/dist/ui/chat-render.js +478 -0
  302. package/dist/ui/chat-store.js +318 -0
  303. package/dist/ui/chat-strings.js +89 -0
  304. package/dist/ui/chat-thread.js +246 -0
  305. package/dist/ui/chat-tour.js +116 -0
  306. package/dist/ui/fleet-cache.js +168 -0
  307. package/dist/ui/fleet-org.js +142 -0
  308. package/dist/ui/panel-chat.js +504 -0
  309. package/dist/ui/panel-fleet.js +427 -0
  310. package/dist/ui/panel-needs.js +202 -0
  311. package/dist/ui/panel-settings.js +754 -0
  312. package/dist/ui/preview-panel.js +202 -0
  313. package/dist/ui/preview.js +270 -0
  314. package/dist/ui/settings-apply.js +239 -0
  315. package/dist/ui/settings-confirm.js +186 -0
  316. package/dist/ui/settings-dialogs.js +546 -0
  317. package/dist/ui/settings-model.js +277 -0
  318. package/dist/ui/settings-strings.js +654 -0
  319. package/dist/ui/settings-wizard.js +140 -0
  320. package/dist/ui/shared/agend-auth.js +93 -0
  321. package/dist/ui/shared/app-ctx.js +80 -0
  322. package/dist/ui/shared/app-html.js +9 -0
  323. package/dist/ui/shared/app-i18n.js +95 -0
  324. package/dist/ui/shared/app-nav.js +73 -0
  325. package/dist/ui/shared/app-needs.js +114 -0
  326. package/dist/ui/shared/app-route.js +73 -0
  327. package/dist/ui/shared/app-session.js +66 -0
  328. package/dist/ui/shared/app-shell.js +346 -0
  329. package/dist/ui/shared/app-store.js +51 -0
  330. package/dist/ui/shared/app-stream.js +201 -0
  331. package/dist/ui/shared/app.css +820 -0
  332. package/dist/ui/shared/app.js +88 -0
  333. package/dist/ui/shared/fonts/OFL.txt +92 -0
  334. package/dist/ui/shared/fonts/README.md +21 -0
  335. package/dist/ui/shared/fonts/inter.woff2 +0 -0
  336. package/dist/ui/shared/htm.module.js +1 -0
  337. package/dist/ui/shared/panel-view.js +655 -0
  338. package/dist/ui/shared/preact-hooks.module.js +2 -0
  339. package/dist/ui/shared/preact.module.js +2 -0
  340. package/dist/ui/shared/signin.js +142 -0
  341. package/dist/ui/shared/theme.js +29 -0
  342. package/dist/ui/shared/tokens.css +153 -0
  343. package/dist/ui/shared/ui-confirm.js +43 -0
  344. package/dist/ui/shared/ui-dialog.js +45 -0
  345. package/dist/ui/shared/ui-icons.js +63 -0
  346. package/dist/ui/shared/ui-menu.js +39 -0
  347. package/dist/ui/shared/ui-states.js +20 -0
  348. package/dist/ui/shared/ui-toast.js +18 -0
  349. package/dist/ui/shared/vendor/htm.LICENSE +202 -0
  350. package/dist/ui/shared/vendor/preact.LICENSE +21 -0
  351. package/dist/ui/shared/vendor/vendor.json +39 -0
  352. package/dist/ui/shared/view-strings.js +309 -0
  353. package/dist/ui/signin.html +52 -0
  354. package/dist/update-check.d.ts +6 -2
  355. package/dist/update-check.js +12 -3
  356. package/dist/update-check.js.map +1 -1
  357. package/dist/update-dispatch.d.ts +28 -0
  358. package/dist/update-dispatch.js +73 -0
  359. package/dist/update-dispatch.js.map +1 -0
  360. package/dist/update-install.d.ts +129 -0
  361. package/dist/update-install.js +262 -0
  362. package/dist/update-install.js.map +1 -0
  363. package/dist/upgrade-notices.d.ts +17 -0
  364. package/dist/upgrade-notices.js +93 -0
  365. package/dist/upgrade-notices.js.map +1 -0
  366. package/dist/usage/statusline-usage.d.ts +2 -0
  367. package/dist/usage/statusline-usage.js +14 -10
  368. package/dist/usage/statusline-usage.js.map +1 -1
  369. package/dist/view-api.d.ts +6 -1
  370. package/dist/view-api.js +62 -51
  371. package/dist/view-api.js.map +1 -1
  372. package/dist/web-api.d.ts +85 -14
  373. package/dist/web-api.js +557 -77
  374. package/dist/web-api.js.map +1 -1
  375. package/dist/web-auth.d.ts +71 -27
  376. package/dist/web-auth.js +169 -69
  377. package/dist/web-auth.js.map +1 -1
  378. package/dist/web-body.d.ts +3 -0
  379. package/dist/web-body.js +55 -0
  380. package/dist/web-body.js.map +1 -0
  381. package/dist/web-channel-echo.d.ts +9 -0
  382. package/dist/web-channel-echo.js +55 -0
  383. package/dist/web-channel-echo.js.map +1 -0
  384. package/dist/web-chat-history.d.ts +125 -0
  385. package/dist/web-chat-history.js +184 -0
  386. package/dist/web-chat-history.js.map +1 -0
  387. package/dist/web-commands.d.ts +77 -0
  388. package/dist/web-commands.js +81 -0
  389. package/dist/web-commands.js.map +1 -0
  390. package/dist/web-continuation.d.ts +7 -0
  391. package/dist/web-continuation.js +22 -0
  392. package/dist/web-continuation.js.map +1 -0
  393. package/dist/web-host-guard.d.ts +34 -0
  394. package/dist/web-host-guard.js +57 -1
  395. package/dist/web-host-guard.js.map +1 -1
  396. package/dist/web-login.d.ts +72 -0
  397. package/dist/web-login.js +132 -0
  398. package/dist/web-login.js.map +1 -0
  399. package/dist/web-org.d.ts +55 -0
  400. package/dist/web-org.js +67 -0
  401. package/dist/web-org.js.map +1 -0
  402. package/dist/web-preview.d.ts +77 -0
  403. package/dist/web-preview.js +240 -0
  404. package/dist/web-preview.js.map +1 -0
  405. package/dist/web-request-context.d.ts +10 -0
  406. package/dist/web-request-context.js +13 -0
  407. package/dist/web-request-context.js.map +1 -0
  408. package/dist/web-session.d.ts +167 -0
  409. package/dist/web-session.js +389 -0
  410. package/dist/web-session.js.map +1 -0
  411. package/dist/web-shell-routes.d.ts +59 -0
  412. package/dist/web-shell-routes.js +96 -0
  413. package/dist/web-shell-routes.js.map +1 -0
  414. package/dist/web-upload.d.ts +183 -0
  415. package/dist/web-upload.js +468 -0
  416. package/dist/web-upload.js.map +1 -0
  417. package/dist/website-tips-renderer.d.ts +2 -2
  418. package/dist/website-tips-renderer.js +3 -3
  419. package/dist/website-tips-renderer.js.map +1 -1
  420. package/launcher/agend +110 -0
  421. package/launcher/agend-agent +110 -0
  422. package/launcher/agend-agent.cjs +4 -0
  423. package/launcher/agend.cjs +4 -0
  424. package/launcher/install-admission.cjs +105 -0
  425. package/launcher/launch.cjs +64 -0
  426. package/launcher/postinstall.cjs +114 -0
  427. package/launcher/run-in-process.cjs +13 -0
  428. package/launcher/runtime-platform.cjs +86 -0
  429. package/launcher/runtime-select.cjs +217 -0
  430. package/package.json +18 -7
  431. package/scripts/preinstall-guard.cjs +55 -0
  432. package/templates/launchd.plist.ejs +2 -2
  433. package/templates/systemd.service.ejs +1 -1
  434. package/dist/ui/dashboard.html +0 -774
  435. package/dist/ui/settings.html +0 -2165
  436. package/dist/ui/view.html +0 -1139
package/dist/daemon.js CHANGED
@@ -35,8 +35,10 @@ import { TurnReplyGuard } from "./turn-reply-guard.js";
35
35
  import { t } from "./locale.js";
36
36
  import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
37
37
  import { deliveryContentDigest } from "./delivery-outbox.js";
38
+ import { consumedWatches } from "./delivery-consumed-watch.js";
38
39
  import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
39
40
  import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
41
+ import { measureSyncWork, noteSyncWork } from "./sync-work-attribution.js";
40
42
  import { TranscriptDeltaReader } from "./transcript-delta-reader.js";
41
43
  import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
42
44
  import { codexLiveRowMatches } from "./backend/codex.js";
@@ -69,12 +71,12 @@ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
69
71
  const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
70
72
  const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. If you already replied to this message, do nothing. Do not reply to this system instruction except through the react or reply tool.";
71
73
  /** Point a resumed CLI at its one backend-native instruction source. */
72
- /** The outbox evidence for a delivery the CLI's transcript shows taken (#758). */
74
+ /** The outbox evidence for a delivery the CLI's transcript shows taken (#758; #1201 absorbed). */
73
75
  function transcriptMarkerEvidence(kind) {
74
- return kind === "user" ? "transcript-marker" : "transcript-marker-queued";
76
+ return kind === "user" ? "transcript-marker" : kind === "absorbed" ? "transcript-marker-absorbed" : "transcript-marker-queued";
75
77
  }
76
- export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
77
- const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
78
+ export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir, backendSource) {
79
+ const source = backendSource ? backendSource : binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
78
80
  ? "AGENTS.md"
79
81
  : binaryName === "kiro-cli"
80
82
  ? `.kiro/steering/agend-${instanceName}.md`
@@ -597,11 +599,15 @@ const INPUT_RESIDUE_POLL_MS = 100;
597
599
  /** #829: how many of the latest pastes can prove a draft is AgEnD's own. */
598
600
  const RECENT_PASTES_KEPT = 8;
599
601
  /** Build a status holder from channel metadata, keeping chat and thread apart. */
600
- function channelStatus(meta) {
602
+ export function channelStatus(meta) {
601
603
  const chatId = meta.chat_id;
602
604
  const messageId = meta.message_id;
603
- if (!chatId || !messageId)
605
+ // A web chat message has no platform chat when the fleet has no channel, and still gets its reports:
606
+ // they are the dashboard's ticks (the fleet routes them by the message id, never to a platform).
607
+ if (!messageId || (!chatId && meta.source !== "web"))
604
608
  return undefined;
609
+ if (!chatId)
610
+ return { chatId: "", messageId };
605
611
  const threadId = meta.thread_id || undefined;
606
612
  return threadId ? { chatId, messageId, threadId } : { chatId, messageId };
607
613
  }
@@ -609,6 +615,26 @@ const FIRST_ENTER_SETTLE_MS = 1_750;
609
615
  const FIRST_DELIVERY_WINDOW_MS = 5_000;
610
616
  /** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
611
617
  const NATIVE_QUEUE_PASTE_VERIFY_MS = 2_000;
618
+ /** #1405: how long a steer's paste gets to show in a mode-switched composer before its one Enter (captures included). */
619
+ const STEER_PASTE_VISIBLE_MS = 2_000;
620
+ /** `promise`'s value if it settles within `ms`, else undefined — the late answer is dropped, never awaited (#1405). */
621
+ async function withinMs(promise, ms) {
622
+ if (ms <= 0) {
623
+ promise.catch(() => { });
624
+ return undefined;
625
+ }
626
+ let timer;
627
+ try {
628
+ return await Promise.race([promise, new Promise(resolve => { timer = setTimeout(() => resolve(undefined), ms); })]);
629
+ }
630
+ catch {
631
+ return undefined;
632
+ }
633
+ finally {
634
+ if (timer)
635
+ clearTimeout(timer);
636
+ }
637
+ }
612
638
  /** Enter-confirmation poll: 10 × 200ms ≈ 2s of observed silence before giving up. */
613
639
  const CONFIRM_BUSY_POLLS = 10;
614
640
  const CONFIRM_BUSY_POLL_MS = 200;
@@ -1142,6 +1168,8 @@ export class Daemon extends EventEmitter {
1142
1168
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
1143
1169
  autoResolvedDialogKey = null;
1144
1170
  autoResolvedDialogGeneration = 0;
1171
+ /** Per-launch safety phases survive startup handoff, redraws and failed/uncertain key ACKs. */
1172
+ launchDialogClaims = null;
1145
1173
  /**
1146
1174
  * (#1103) Proof that THIS spawn's Codex went onto Luna Reserve: AgEnD saw (and
1147
1175
  * answered) the usage-limit menu that codex shows when it switches itself to
@@ -1732,7 +1760,7 @@ export class Daemon extends EventEmitter {
1732
1760
  this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason });
1733
1761
  }
1734
1762
  }
1735
- finishDurableDelivery(delivery, outcome, evidence) {
1763
+ finishDurableDelivery(delivery, outcome, evidence, verdict) {
1736
1764
  if (!delivery)
1737
1765
  return;
1738
1766
  // Delivered or uncertain means the pane write started: the work reached
@@ -1741,13 +1769,50 @@ export class Daemon extends EventEmitter {
1741
1769
  if (outcome !== "failed")
1742
1770
  this.noteWorkActivity();
1743
1771
  this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
1772
+ if (verdict && outcome !== "failed")
1773
+ this.startConsumedWatch(delivery, verdict);
1774
+ }
1775
+ /**
1776
+ * #1201: a steer or a native-queue hand-off was accepted into the CLI's input (or may have been) — not read. Watch
1777
+ * the transcript from this attempt's checkpoint for the moment the CLI takes it, and record that on the delivery;
1778
+ * an `uncertain` row becomes `delivered` then. Only claude-code and codex write a transcript AgEnD can read. The
1779
+ * watch belongs to this CLI: a stop, pause or respawn ends it (restart reconciliation owns the row after that).
1780
+ */
1781
+ startConsumedWatch(delivery, verdict) {
1782
+ const checkpoint = verdict.transcriptCheckpoint;
1783
+ const outbox = this.deliveryOutbox;
1784
+ if (!checkpoint || !outbox?.markConsumed)
1785
+ return;
1786
+ if (verdict.submissionMode !== "steer" && verdict.submissionMode !== "native_queue_handoff")
1787
+ return;
1788
+ if (!["claude-code", "codex"].includes(checkpoint.backend))
1789
+ return;
1790
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, boot = this.bootId;
1791
+ consumedWatches.start({
1792
+ deliveryId: delivery.deliveryId,
1793
+ attemptNo: delivery.attemptNo,
1794
+ backend: checkpoint.backend,
1795
+ path: checkpoint.path,
1796
+ offset: checkpoint.offset,
1797
+ current: () => !this.deliveryWritesStopping && spawn === this.spawnGeneration && fence === this.launchFenceEpoch,
1798
+ consumed: (via, evidence) => {
1799
+ try {
1800
+ const result = outbox.markConsumed(delivery.deliveryId, boot, delivery.attemptNo, via, evidence);
1801
+ this.logger.info({ deliveryId: delivery.deliveryId, via, result }, "The CLI's transcript shows the delivery consumed");
1802
+ }
1803
+ catch (err) {
1804
+ this.logger.warn({ err, deliveryId: delivery.deliveryId }, "Could not record the consumed signal (the row keeps its state)");
1805
+ }
1806
+ },
1807
+ logger: this.logger,
1808
+ });
1744
1809
  }
1745
1810
  finishDurableSubmission(delivery, verdict) {
1746
1811
  // Legacy backends may accept a pane write without enough pane evidence to
1747
1812
  // claim submission. Keep that row inspectable instead of recording a false
1748
1813
  // delivered state; reconciliation can classify it in a later phase.
1749
1814
  if (verdict.proof === "unverified") {
1750
- this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
1815
+ this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`, verdict);
1751
1816
  return;
1752
1817
  }
1753
1818
  // A steer into a busy pane of a CLI whose input row cannot be read: the paste and the one Enter went through and the
@@ -1755,7 +1820,14 @@ export class Daemon extends EventEmitter {
1755
1820
  // it", and delivery_status carries delivery_mode=steer — and it is all this class of backend can ever show for a steer
1756
1821
  // (#1197). Labelled, so it is never mistaken for a proof that the input row was cleared.
1757
1822
  if (verdict.proof === "steer-marker-on-pane") {
1758
- this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
1823
+ this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable", verdict);
1824
+ return;
1825
+ }
1826
+ // A steer into a mode-switched composer (kiro TUI, #1405): the box held the paste before the one Enter and read as
1827
+ // its empty placeholder after it — taken by the CLI (steered into the turn, held for its end on "queue", or a new
1828
+ // turn on "idle"). Accepted, not read; labelled with what the composer showed.
1829
+ if (verdict.proof?.startsWith("steer-composer-")) {
1830
+ this.finishDurableDelivery(delivery, "delivered", `steer-accepted; composer emptied (${verdict.proof.slice("steer-composer-".length)})`, verdict);
1759
1831
  return;
1760
1832
  }
1761
1833
  // A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
@@ -1771,7 +1843,7 @@ export class Daemon extends EventEmitter {
1771
1843
  return;
1772
1844
  }
1773
1845
  this.finishDurableDelivery(delivery, "delivered", verdict.transcriptProof
1774
- ? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof");
1846
+ ? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof", verdict);
1775
1847
  }
1776
1848
  /**
1777
1849
  * Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
@@ -1798,7 +1870,7 @@ export class Daemon extends EventEmitter {
1798
1870
  if (this.deliveryWritesStopping)
1799
1871
  return; // reconciliation owns the row now
1800
1872
  const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
1801
- if (found === "user" || found === "queued") {
1873
+ if (found === "user" || found === "absorbed" || found === "queued") {
1802
1874
  outcome = "delivered";
1803
1875
  evidence = transcriptMarkerEvidence(found);
1804
1876
  break;
@@ -1861,6 +1933,14 @@ export class Daemon extends EventEmitter {
1861
1933
  seedActivityNow(now = Date.now()) {
1862
1934
  this.autoPauseController.recordActivity(now);
1863
1935
  }
1936
+ /**
1937
+ * Whether this launch takes a steer into a running turn, for a backend that answers per launch (kiro's TUI vs its
1938
+ * legacy UI, #1405); undefined for every other backend, whose name decides (steer-capability.ts). The backend reads
1939
+ * what it last launched — no probe, no fork, safe on the fleet loop.
1940
+ */
1941
+ launchSupportsSteer() {
1942
+ return this.backend?.supportsSteer?.();
1943
+ }
1864
1944
  /**
1865
1945
  * Phase 2b (design §1.4): why this awake instance is not taking input, in
1866
1946
  * words for a notice, or null when nothing known holds it. Read from the
@@ -1904,7 +1984,19 @@ export class Daemon extends EventEmitter {
1904
1984
  this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
1905
1985
  });
1906
1986
  }
1907
- async start() {
1987
+ startupAdmission;
1988
+ async start(admission) {
1989
+ this.startupAdmission = admission;
1990
+ try {
1991
+ admission?.();
1992
+ await this.startAdmitted();
1993
+ admission?.();
1994
+ }
1995
+ finally {
1996
+ this.startupAdmission = undefined;
1997
+ }
1998
+ }
1999
+ async startAdmitted() {
1908
2000
  ensureInstanceDir(this.instanceDir);
1909
2001
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
1910
2002
  this.logger.info(`Starting ${this.name}`);
@@ -1961,6 +2053,7 @@ export class Daemon extends EventEmitter {
1961
2053
  this.emit("error", err);
1962
2054
  });
1963
2055
  await this.ipcServer.listen();
2056
+ this.startupAdmission?.();
1964
2057
  ipcListening = true;
1965
2058
  // Permanent IPC dispatcher: routes responses to pending requests by type+id key
1966
2059
  this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
@@ -2032,7 +2125,8 @@ export class Daemon extends EventEmitter {
2032
2125
  });
2033
2126
  // 2. Tmux — ensure session, create window if not alive
2034
2127
  await TmuxManager.ensureSession(this.tmuxSessionName);
2035
- this.tmux = new TmuxManager(this.tmuxSessionName, "", resolveTmuxLogicalSize(this.config.terminal));
2128
+ this.startupAdmission?.();
2129
+ this.tmux = new TmuxManager(this.tmuxSessionName, "", resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
2036
2130
  // Strategy A: always start fresh Claude window (MCP server has no reconnection)
2037
2131
  // Kill any existing window from previous run
2038
2132
  const windowIdFile = join(this.instanceDir, "window-id");
@@ -2041,16 +2135,21 @@ export class Daemon extends EventEmitter {
2041
2135
  if (savedId) {
2042
2136
  const oldTmux = new TmuxManager(this.tmuxSessionName, savedId);
2043
2137
  if (await oldTmux.isWindowAlive()) {
2138
+ this.startupAdmission?.();
2044
2139
  await this.checkpointSessionId();
2140
+ this.startupAdmission?.();
2045
2141
  await oldTmux.killWindow();
2142
+ this.startupAdmission?.();
2046
2143
  this.logger.info({ savedId }, "Killed old tmux window for fresh start");
2047
2144
  }
2048
2145
  }
2049
2146
  }
2050
2147
  const resumed = await this.spawnClaudeWindow();
2148
+ this.startupAdmission?.();
2051
2149
  this.isNewSession = !resumed;
2052
2150
  if (!resumed) {
2053
- await this.injectSnapshotMessage();
2151
+ await this.injectSnapshotMessage(this.startupAdmission);
2152
+ this.startupAdmission?.();
2054
2153
  }
2055
2154
  else {
2056
2155
  // Clean up stale snapshot file — resume restored full context, snapshot not needed
@@ -2074,6 +2173,7 @@ export class Daemon extends EventEmitter {
2074
2173
  // we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
2075
2174
  // keeps the writer attached after size resets.
2076
2175
  await this.attachPipePaneLog();
2176
+ this.startupAdmission?.();
2077
2177
  // 4. Transcript monitor. claude-code is handled inside the monitor
2078
2178
  // (statusline transcript); codex/kiro/opencode read their CLI's own
2079
2179
  // conversation store via a pluggable source. Backends with no known
@@ -2084,6 +2184,7 @@ export class Daemon extends EventEmitter {
2084
2184
  // Baseline in the isolate before this daemon accepts new work. A
2085
2185
  // stopped/replaced launch may never re-arm polling after this await.
2086
2186
  await transcriptMonitor.initialize();
2187
+ this.startupAdmission?.();
2087
2188
  if (this.startupAborted || this.launchFenceEpoch !== transcriptFence || this.transcriptMonitor !== transcriptMonitor) {
2088
2189
  transcriptMonitor.stop();
2089
2190
  return;
@@ -2589,7 +2690,7 @@ export class Daemon extends EventEmitter {
2589
2690
  // Stop the loop permanently — otherwise every tick triggers a respawn, whose
2590
2691
  // writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
2591
2692
  // spamming errors every ~30s forever.
2592
- if (!existsSync(this.instanceDir)) {
2693
+ if (!measureSyncWork("daemon.healthCheck", () => existsSync(this.instanceDir))) {
2593
2694
  this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
2594
2695
  this.healthCheckPaused = true;
2595
2696
  this.healthCheckTimer = null;
@@ -2601,16 +2702,26 @@ export class Daemon extends EventEmitter {
2601
2702
  return;
2602
2703
  }
2603
2704
  // The CLI owns the MCP server process, so the daemon can only observe it.
2604
- this.checkMcpServerAlive();
2705
+ measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
2605
2706
  // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
2606
2707
  const cliLabel = this.backend?.binaryName ?? "CLI";
2607
- let paneStatus = await this.tmux.getPaneStatus();
2708
+ const healthTmux = this.tmux;
2709
+ const healthOwner = this.interactionOwner();
2710
+ const healthCurrent = () => this.tmux === healthTmux
2711
+ && sameInteractionOwner(healthOwner, this.interactionOwner())
2712
+ && !this.runtimeMonitorsFrozen && !this.healthCheckPaused && !this.spawning
2713
+ && !this.isPaused && this.pauseWakeState !== "waking";
2714
+ const discardStaleHealth = () => {
2715
+ if (healthCurrent())
2716
+ return false;
2717
+ scheduleNext();
2718
+ return true;
2719
+ };
2720
+ let paneStatus = await healthTmux.getPaneStatus();
2608
2721
  // Auto-pause intentionally exits the pane process. A health tick that
2609
2722
  // began just before pause must not classify that exit as a crash.
2610
- if (this.isPaused || this.pauseWakeState === "waking") {
2611
- scheduleNext();
2723
+ if (discardStaleHealth())
2612
2724
  return;
2613
- }
2614
2725
  if (paneStatus?.alive) {
2615
2726
  this.windowQueryFailureTicks = 0;
2616
2727
  this.stormWindow?.noteWindowAlive(this.name);
@@ -2620,7 +2731,7 @@ export class Daemon extends EventEmitter {
2620
2731
  // 10–100 MiB log runs off the event loop, and this tick must not wait for
2621
2732
  // it (#1161) — scheduleNext() below is not delayed by a rotation.
2622
2733
  if (!this.config.lightweight) {
2623
- void rotateLogIfNeededAsync(join(this.instanceDir, "output.log"));
2734
+ void measureSyncWork("daemon.healthCheck", () => rotateLogIfNeededAsync(join(this.instanceDir, "output.log")));
2624
2735
  }
2625
2736
  scheduleNext();
2626
2737
  return;
@@ -2632,7 +2743,11 @@ export class Daemon extends EventEmitter {
2632
2743
  // and needs no recheck.
2633
2744
  if (paneStatus === null) {
2634
2745
  await new Promise(r => setTimeout(r, 1500));
2635
- paneStatus = await this.tmux.getPaneStatus();
2746
+ if (discardStaleHealth())
2747
+ return;
2748
+ paneStatus = await healthTmux.getPaneStatus();
2749
+ if (discardStaleHealth())
2750
+ return;
2636
2751
  if (paneStatus?.alive) {
2637
2752
  this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
2638
2753
  this.windowQueryFailureTicks = 0;
@@ -2647,12 +2762,17 @@ export class Daemon extends EventEmitter {
2647
2762
  this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
2648
2763
  // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
2649
2764
  if (paneStatus && exitCode === 0) {
2650
- this.setProcessStatus("stopped");
2651
2765
  // Status 0 is not proof of a clean exit: a codex that hits a quota
2652
2766
  // wall exits 0 too. Capture what it printed before the window goes.
2653
- this.logPaneDeath(cliLabel, exitCode, await this.capturePaneOutput());
2767
+ const output = await this.capturePaneOutput();
2768
+ if (discardStaleHealth())
2769
+ return;
2770
+ this.setProcessStatus("stopped");
2771
+ this.logPaneDeath(cliLabel, exitCode, output);
2654
2772
  this.logger.info("CLI exited normally (code 0) — pausing health check");
2655
- await this.tmux.killWindow();
2773
+ await healthTmux.killWindow();
2774
+ if (discardStaleHealth())
2775
+ return;
2656
2776
  this.healthCheckPaused = true;
2657
2777
  this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.", 0);
2658
2778
  return;
@@ -2665,12 +2785,15 @@ export class Daemon extends EventEmitter {
2665
2785
  let nullReason;
2666
2786
  if (!paneStatus) {
2667
2787
  const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
2788
+ if (discardStaleHealth())
2789
+ return;
2668
2790
  // A server may have already restarted before this daemon's health
2669
2791
  // tick. The new PID is the durable generation boundary: treat it as
2670
2792
  // a fleet storm even though `has-session` is true again.
2671
- const generationChanged = serverAlive
2672
- ? this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName)) === true
2673
- : false;
2793
+ const serverPid = serverAlive ? await TmuxManager.getServerPid(this.tmuxSessionName) : null;
2794
+ if (discardStaleHealth())
2795
+ return;
2796
+ const generationChanged = serverAlive && this.stormWindow?.observeServerAlive(serverPid) === true;
2674
2797
  if (generationChanged)
2675
2798
  this.emit("tmux_server_crash", this.name);
2676
2799
  if (!serverAlive || generationChanged || this.stormWindow?.needsRecovery(this.name)) {
@@ -2688,13 +2811,17 @@ export class Daemon extends EventEmitter {
2688
2811
  // The fleet breaker owns the delay and extends it on every new
2689
2812
  // server generation. Do not schedule a competing fixed timer.
2690
2813
  await this.stormWindow?.waitForSpawnAllowed();
2814
+ if (discardStaleHealth())
2815
+ return;
2691
2816
  }
2692
2817
  else {
2693
2818
  // null but server alive: window-level disappearance. Probe whether
2694
2819
  // the window truly no longer exists vs a transient query glitch.
2695
2820
  nullReason = "no_window";
2696
2821
  try {
2697
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
2822
+ const windows = await TmuxManager.listWindowsStrict(this.tmuxSessionName, this.controlClient);
2823
+ if (discardStaleHealth())
2824
+ return;
2698
2825
  this.windowQueryFailureTicks = 0;
2699
2826
  const currentWindowId = this.tmux.getWindowId();
2700
2827
  if (windows.some(w => w.id === currentWindowId)) {
@@ -2735,22 +2862,26 @@ export class Daemon extends EventEmitter {
2735
2862
  else {
2736
2863
  this.logger.warn({ exitCode }, `${cliLabel} process exited`);
2737
2864
  }
2738
- this.setProcessStatus("crashed");
2739
2865
  // Capture last output before killing. Best-effort even when the pane is
2740
2866
  // gone (paneStatus null) — gives the crash record something to diagnose
2741
2867
  // from instead of an empty lastOutput.
2742
2868
  let lastOutput;
2743
2869
  try {
2744
- const raw = await this.tmux.capturePaneWithHistory(50);
2870
+ const raw = await healthTmux.capturePaneWithHistory(50);
2745
2871
  // Strip ANSI escape codes for readability
2746
2872
  const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
2747
2873
  lastOutput = cleaned.trimEnd() || undefined;
2748
2874
  }
2749
2875
  catch { /* best effort — pane may already be gone */ }
2876
+ if (discardStaleHealth())
2877
+ return;
2878
+ this.setProcessStatus("crashed");
2750
2879
  this.logPaneDeath(cliLabel, exitCode, lastOutput);
2751
2880
  // Kill the dead window (remain-on-exit keeps it around) before respawn
2752
2881
  if (paneStatus) {
2753
- await this.tmux.killWindow();
2882
+ await healthTmux.killWindow();
2883
+ if (discardStaleHealth())
2884
+ return;
2754
2885
  }
2755
2886
  // Detect claude-code background session conflict — recover without counting as crash
2756
2887
  if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
@@ -3037,8 +3168,9 @@ export class Daemon extends EventEmitter {
3037
3168
  const readyPattern = this.backend.getReadyPattern();
3038
3169
  const busyPattern = this.backend.getBusyPattern?.() ?? null;
3039
3170
  this.errorMonitorTimer = setInterval(async () => {
3040
- if (!this.tmux || this.spawning)
3171
+ if (!this.tmux || this.spawning || this.deliveryWritesStopping)
3041
3172
  return;
3173
+ const pollTmux = this.tmux;
3042
3174
  // This poll belongs to the spawn and the monitors it started under. Every await below can outlive them (a stop,
3043
3175
  // a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
3044
3176
  const pollSpawn = this.spawnGeneration;
@@ -3047,15 +3179,21 @@ export class Daemon extends EventEmitter {
3047
3179
  const captureOrder = ++this.interactionCaptureSerial;
3048
3180
  const captureMono = performance.now();
3049
3181
  const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
3050
- || !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
3182
+ || !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning
3183
+ || this.deliveryWritesStopping || this.tmux !== pollTmux;
3184
+ let scanStartedAt = null;
3185
+ const endScan = () => { if (scanStartedAt !== null)
3186
+ noteSyncWork("daemon.errorMonitorScan", scanStartedAt); scanStartedAt = null; };
3051
3187
  try {
3052
- const alive = await this.tmux.isWindowAlive();
3188
+ const alive = await pollTmux.isWindowAlive();
3053
3189
  if (stale())
3054
3190
  return;
3055
3191
  if (!alive)
3056
3192
  return;
3057
3193
  const captureAt = Date.now();
3058
- const pane = await this.tmux.capturePane();
3194
+ const pane = await pollTmux.capturePane();
3195
+ // The scan of this pane is synchronous until it answers a dialog (an await) or ends: attributed (#1235).
3196
+ scanStartedAt = performance.now();
3059
3197
  if (stale())
3060
3198
  return;
3061
3199
  if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
@@ -3130,9 +3268,7 @@ export class Daemon extends EventEmitter {
3130
3268
  continue;
3131
3269
  }
3132
3270
  const autoKey = dialog.autoResolutionKey;
3133
- if (dialog.verifyAfterKeys && autoKey
3134
- && this.autoResolvedDialogGeneration === this.spawnGeneration
3135
- && this.autoResolvedDialogKey === autoKey) {
3271
+ if (dialog.verifyAfterKeys && autoKey && this.hasAutoDialogClaim(dialog)) {
3136
3272
  // A previous poll sent the safety choice but the CLI has not
3137
3273
  // repainted yet. Never send another Enter into the same screen.
3138
3274
  continue;
@@ -3143,6 +3279,7 @@ export class Daemon extends EventEmitter {
3143
3279
  // (not queue) when the pane is busy: this poller runs every 5s, and the
3144
3280
  // dialog will still be on screen next tick.
3145
3281
  let resolved = false;
3282
+ endScan();
3146
3283
  const dismissed = await this.paneWriteLock.tryRun(async () => {
3147
3284
  // Re-read under the lock: the first capture may have gone stale
3148
3285
  // while a delivery was finishing. A stale danger menu must never
@@ -3150,7 +3287,7 @@ export class Daemon extends EventEmitter {
3150
3287
  const currentAt = Date.now();
3151
3288
  const currentMono = performance.now();
3152
3289
  const currentOrder = ++this.interactionCaptureSerial;
3153
- const currentPane = await this.tmux.capturePane();
3290
+ const currentPane = await pollTmux.capturePane();
3154
3291
  // Every await below can outlive the spawn / monitors this poll started under: after each one, before
3155
3292
  // any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
3156
3293
  if (stale())
@@ -3174,28 +3311,26 @@ export class Daemon extends EventEmitter {
3174
3311
  // not evidence that the old request was answered, and the one-minute fallback must keep running.
3175
3312
  if (this.dialogAnswers?.key === Daemon.answerKey(dialog) && this.dialogAnswers.screen !== before)
3176
3313
  this.endDialogEpisode(dialog.requestIdentity !== undefined);
3177
- if (dialog.verifyAfterKeys && autoKey
3178
- && this.autoResolvedDialogGeneration === this.spawnGeneration
3179
- && this.autoResolvedDialogKey === autoKey)
3314
+ if (dialog.verifyAfterKeys && autoKey && this.hasAutoDialogClaim(dialog))
3180
3315
  return;
3181
- if (dialog.verifyAfterKeys && autoKey) {
3182
- this.autoResolvedDialogGeneration = this.spawnGeneration;
3183
- this.autoResolvedDialogKey = autoKey;
3184
- }
3316
+ if (dialog.verifyAfterKeys && autoKey)
3317
+ this.claimAutoDialog(dialog);
3185
3318
  this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
3186
3319
  const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
3187
3320
  for (const key of dialog.keys) {
3321
+ if (stale())
3322
+ return;
3188
3323
  let sent = false;
3189
3324
  if (SPECIAL_KEYS.has(key)) {
3190
- sent = await this.tmux.sendSpecialKey(key);
3325
+ sent = await pollTmux.sendSpecialKey(key);
3191
3326
  }
3192
3327
  else {
3193
- sent = await this.tmux.pasteText(key, this.systemPasteOptions());
3328
+ sent = await pollTmux.pasteText(key, this.systemPasteOptions());
3194
3329
  }
3195
3330
  if (stale())
3196
3331
  return;
3197
3332
  if (!sent) {
3198
- if (dialog.verifyAfterKeys && autoKey) {
3333
+ if (dialog.verifyAfterKeys && autoKey && !dialog.oncePerLaunch) {
3199
3334
  this.autoResolvedDialogGeneration = 0;
3200
3335
  this.autoResolvedDialogKey = null;
3201
3336
  }
@@ -3210,7 +3345,7 @@ export class Daemon extends EventEmitter {
3210
3345
  const afterKeysAt = Date.now();
3211
3346
  const afterKeysMono = performance.now();
3212
3347
  const afterKeysOrder = ++this.interactionCaptureSerial;
3213
- const afterKeysPane = await this.tmux.capturePane();
3348
+ const afterKeysPane = await pollTmux.capturePane();
3214
3349
  if (stale())
3215
3350
  return;
3216
3351
  const dialogStillActive = dialog.inputBlocked
@@ -3240,7 +3375,7 @@ export class Daemon extends EventEmitter {
3240
3375
  // backend-named identity any difference on the screen (a ticking timer) proves nothing either way: nothing
3241
3376
  // is reported and the parked clock is left alone. Two requests that look exactly alike cannot be told apart.
3242
3377
  await new Promise(r => setTimeout(r, DIALOG_ANSWER_SETTLE_MS));
3243
- const afterPane = await this.tmux.capturePane();
3378
+ const afterPane = await pollTmux.capturePane();
3244
3379
  if (stale())
3245
3380
  return; // (nothing was touched since the last check: only a read)
3246
3381
  const stillThere = Daemon.dialogMatches(dialog, afterPane);
@@ -3278,6 +3413,9 @@ export class Daemon extends EventEmitter {
3278
3413
  this.unverifyInteraction(captureMono, captureOrder);
3279
3414
  // capturePane can fail if window is transitioning — ignore
3280
3415
  }
3416
+ finally {
3417
+ endScan();
3418
+ }
3281
3419
  }, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
3282
3420
  }
3283
3421
  /** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
@@ -4662,7 +4800,26 @@ export class Daemon extends EventEmitter {
4662
4800
  */
4663
4801
  maybeConfirmReplyGuardIdle(pane) {
4664
4802
  const pending = this.replyGuardIdleConfirm;
4665
- if (!pending || this.isPaused || performance.now() < pending.confirmAt)
4803
+ // #1377: if a successful settle already cleared the confirmation window,
4804
+ // check if completionDelivered became true — if so, complete the guard
4805
+ // on the next steady-idle tick so the guard doesn't persist indefinitely.
4806
+ // Also handle a late failure ACK (replyAttemptFailed) that arrived after
4807
+ // an idle edge with busyObserved=false: no confirm window was armed at that
4808
+ // edge (no_busy_since_arm returned early), so failure must be reported here.
4809
+ if (!pending) {
4810
+ const turn = this.turnReplyGuard.snapshot();
4811
+ if (!turn || turn.cancelledByUser || turn.phase !== "awaiting")
4812
+ return;
4813
+ if (turn.completionDelivered) {
4814
+ this.turnReplyGuard.complete(turn.generation);
4815
+ }
4816
+ else if (turn.replyAttemptFailed) {
4817
+ this.turnReplyGuard.complete(turn.generation);
4818
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4819
+ }
4820
+ return;
4821
+ }
4822
+ if (this.isPaused || performance.now() < pending.confirmAt)
4666
4823
  return;
4667
4824
  this.maybeProxyReplyOnTurnEnd(pane, true);
4668
4825
  }
@@ -4750,14 +4907,6 @@ export class Daemon extends EventEmitter {
4750
4907
  this.turnReplyGuard.complete(turn.generation);
4751
4908
  return;
4752
4909
  }
4753
- if (turn.replyAttempted) {
4754
- // A provider timeout can be "applied, then timed out". Retrying it would
4755
- // risk a duplicate; report the unknown result and stop here.
4756
- this.clearReplyGuardConfirm();
4757
- this.turnReplyGuard.complete(turn.generation);
4758
- this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4759
- return;
4760
- }
4761
4910
  // #1241: an idle edge is not proof the turn ended. Two gates before the
4762
4911
  // recovery prompt goes anywhere near the CLI's input:
4763
4912
  // 1. work must have been observed after this generation armed — otherwise
@@ -4766,6 +4915,14 @@ export class Daemon extends EventEmitter {
4766
4915
  // 2. the edge must persist: the first qualifying edge only arms a
4767
4916
  // confirmation window, and recovery starts only when steady idle past
4768
4917
  // the window still shows no reply and no further work.
4918
+ // Exception (#1377): a definitively failed reply (adapter returned an
4919
+ // error) is known-bad now — no need to observe busy or wait 60s.
4920
+ if (turn.replyAttemptFailed) {
4921
+ this.clearReplyGuardConfirm();
4922
+ this.turnReplyGuard.complete(turn.generation);
4923
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4924
+ return;
4925
+ }
4769
4926
  if (!turn.busyObserved) {
4770
4927
  this.logger.info({
4771
4928
  correlationId: turn.target.correlationId,
@@ -4797,6 +4954,15 @@ export class Daemon extends EventEmitter {
4797
4954
  return;
4798
4955
  }
4799
4956
  this.clearReplyGuardConfirm();
4957
+ if (turn.replyAttempted) {
4958
+ // A reply was attempted but its deliver result is unknown (in-flight
4959
+ // IPC or a provider timeout). Retrying risks a duplicate; report the
4960
+ // unknown result and stop here. (#1377: moved after the confirm window
4961
+ // so an in-flight settle gets the same 60s deferral as a no-reply turn.)
4962
+ this.turnReplyGuard.complete(turn.generation);
4963
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
4964
+ return;
4965
+ }
4800
4966
  this.startReplyRecovery(turn, "no_valid_call");
4801
4967
  }
4802
4968
  isClaudeCodeBackend() {
@@ -5129,22 +5295,27 @@ export class Daemon extends EventEmitter {
5129
5295
  return;
5130
5296
  }
5131
5297
  this.statePollInFlight = true;
5298
+ let evaluationStartedAt = null;
5132
5299
  const captureStartedAt = Date.now();
5133
5300
  const interactionCaptureAt = performance.now();
5134
5301
  const interactionCaptureOrder = ++this.interactionCaptureSerial;
5135
5302
  const interactionOwner = this.interactionOwner();
5303
+ const captureTmux = this.tmux;
5136
5304
  const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
5137
- const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
5305
+ const currentDeliveryCapture = () => this.tmux === captureTmux && captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
5138
5306
  && !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
5139
5307
  try {
5140
5308
  const pane = reason === "interaction_confirmation"
5141
- ? await this.tmux.capturePane(1_000) : await this.tmux.capturePane();
5309
+ ? await captureTmux.capturePane(1_000) : await captureTmux.capturePane();
5142
5310
  if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
5143
5311
  return;
5144
5312
  // Delivery's unknown-footer proof also awaits the TTY mode. Validate
5145
5313
  // output and launch freshness AFTER both awaits, before accepting it.
5146
5314
  const deliveryCandidate = reason === "delivery_idle_gate"
5147
5315
  ? await this.probeDeliveryIdleFallback(pane) : null;
5316
+ // Everything from here to the finally is synchronous: the evaluation of this pane (#1235 attribution — a sweep
5317
+ // runs it for every instance in a row).
5318
+ evaluationStartedAt = performance.now();
5148
5319
  // An old capture must not retire a new launch's transient guard either.
5149
5320
  // This check is only live here, after the probe's await: the identical
5150
5321
  // check before the probe was dead (no await in between, so the early
@@ -5376,6 +5547,8 @@ export class Daemon extends EventEmitter {
5376
5547
  }
5377
5548
  finally {
5378
5549
  this.statePollInFlight = false;
5550
+ if (evaluationStartedAt !== null)
5551
+ noteSyncWork(`daemon.stateEvaluate:${reason}`, evaluationStartedAt);
5379
5552
  }
5380
5553
  }
5381
5554
  handleInstancePaneOutput(event) {
@@ -5896,7 +6069,7 @@ export class Daemon extends EventEmitter {
5896
6069
  this.markTurnStarted(meta, formatted, deliveryEpoch);
5897
6070
  }
5898
6071
  else if (durableAttempt && verdict.paneWriteStarted) {
5899
- this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
6072
+ this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
5900
6073
  }
5901
6074
  else if (durableAttempt && verdict.durableBeginCommitted) {
5902
6075
  this.abortDurableDelivery(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
@@ -5910,8 +6083,9 @@ export class Daemon extends EventEmitter {
5910
6083
  }
5911
6084
  }
5912
6085
  catch (err) {
6086
+ // The verdict goes along: a write that started and then threw is still a hand-off the CLI may consume (#1201).
5913
6087
  if (durableAttempt && verdict.paneWriteStarted)
5914
- this.finishDurableDelivery(durableAttempt, "uncertain", err.message);
6088
+ this.finishDurableDelivery(durableAttempt, "uncertain", err.message, verdict);
5915
6089
  else if (durableAttempt && verdict.durableBeginCommitted)
5916
6090
  this.abortDurableDelivery(durableAttempt, err.message);
5917
6091
  else if (durableAttempt)
@@ -6018,7 +6192,7 @@ export class Daemon extends EventEmitter {
6018
6192
  if (this.pasteQueueDepth > 3) {
6019
6193
  this.logger.warn({ depth: this.pasteQueueDepth }, "Message delivery queue backing up");
6020
6194
  }
6021
- if (wasQueued && chatId && messageId) {
6195
+ if (wasQueued) {
6022
6196
  const queuedStatus = channelStatus(meta);
6023
6197
  if (queuedStatus)
6024
6198
  this.emit("message_queued", queuedStatus);
@@ -6039,7 +6213,7 @@ export class Daemon extends EventEmitter {
6039
6213
  }
6040
6214
  if (this.pendingInstructionsNotice) {
6041
6215
  this.pendingInstructionsNotice = false;
6042
- await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), undefined, { deliveryEpoch });
6216
+ await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), undefined, { deliveryEpoch });
6043
6217
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
6044
6218
  return;
6045
6219
  }
@@ -6064,7 +6238,7 @@ export class Daemon extends EventEmitter {
6064
6238
  this.markTurnStarted(meta, formatted, deliveryEpoch);
6065
6239
  }
6066
6240
  else if (durableAttempt && verdict.paneWriteStarted) {
6067
- this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
6241
+ this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
6068
6242
  }
6069
6243
  else if (durableAttempt && verdict.durableBeginCommitted) {
6070
6244
  this.abortDurableDelivery(durable, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
@@ -6085,15 +6259,16 @@ export class Daemon extends EventEmitter {
6085
6259
  this.finishDurableDelivery(durable, "failed", `${verdict.phase ?? "delivery"}:${verdict.proof ?? "failed"}`);
6086
6260
  }
6087
6261
  else if (verdict.paneWriteStarted) {
6088
- this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
6262
+ this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
6089
6263
  }
6090
6264
  else {
6091
6265
  this.abortDurableDelivery(durable, "delivery did not reach pane submission");
6092
6266
  }
6093
6267
  }
6094
6268
  catch (err) {
6269
+ // The verdict goes along: a write that started and then threw is still a hand-off the CLI may consume (#1201).
6095
6270
  if (durable && verdict.paneWriteStarted)
6096
- this.finishDurableDelivery(durable, "uncertain", err.message);
6271
+ this.finishDurableDelivery(durable, "uncertain", err.message, verdict);
6097
6272
  else if (durable && verdict.durableBeginCommitted)
6098
6273
  this.abortDurableDelivery(durable, err.message);
6099
6274
  else if (durable)
@@ -6191,7 +6366,8 @@ export class Daemon extends EventEmitter {
6191
6366
  const canHandOff = (supportsQueuedInput || opts?.steer)
6192
6367
  && readiness === "busy"
6193
6368
  && (await this.probeBlockingDialog()).state === "clear"
6194
- && await this.hasPositiveDeliveryInput(true);
6369
+ && await this.hasPositiveDeliveryInput(true)
6370
+ && (!opts?.steer || (this.launchTakesSteer() && await this.steerComposerAdmits(false)));
6195
6371
  if (canHandOff) {
6196
6372
  // Native queue (codex), or an explicit /steer: hand the complete
6197
6373
  // paste+Enter transaction to the busy CLI now. For steer this is the
@@ -6290,6 +6466,13 @@ export class Daemon extends EventEmitter {
6290
6466
  }
6291
6467
  if (!(await this.hasPositiveDeliveryInput(handingOffToNativeQueue)))
6292
6468
  return "dialog";
6469
+ // #1405: a launch that no longer takes a steer is not the one the gate saw — redo from the top (bounded), where
6470
+ // the gate sends it down the idle path. The composer can also have changed mode since (the user's Ctrl+S), or
6471
+ // the turn ended.
6472
+ if (handingOffToNativeQueue && opts?.steer && !this.launchTakesSteer())
6473
+ return "spawn-started";
6474
+ if (handingOffToNativeQueue && opts?.steer && !(await this.steerComposerAdmits(true)))
6475
+ return "dialog";
6293
6476
  // #829: a CLI that restores a cancelled prompt into its input box would
6294
6477
  // have this message pasted onto it and both submitted as one. Clear it
6295
6478
  // first, or do not write at all. From here every await is fenced: a
@@ -6544,6 +6727,32 @@ export class Daemon extends EventEmitter {
6544
6727
  }
6545
6728
  return active;
6546
6729
  }
6730
+ hasAutoDialogClaim(dialog) {
6731
+ const key = dialog.autoResolutionKey;
6732
+ if (!key)
6733
+ return false;
6734
+ if (dialog.oncePerLaunch) {
6735
+ const claims = this.launchDialogClaims;
6736
+ return claims !== null && claims.spawn === this.spawnGeneration && claims.attempt === this.launchAttempt
6737
+ && claims.keys.has(key);
6738
+ }
6739
+ return this.autoResolvedDialogGeneration === this.spawnGeneration && this.autoResolvedDialogKey === key;
6740
+ }
6741
+ claimAutoDialog(dialog) {
6742
+ const key = dialog.autoResolutionKey;
6743
+ if (!key)
6744
+ return;
6745
+ if (dialog.oncePerLaunch) {
6746
+ if (!this.launchDialogClaims || this.launchDialogClaims.spawn !== this.spawnGeneration
6747
+ || this.launchDialogClaims.attempt !== this.launchAttempt) {
6748
+ this.launchDialogClaims = { spawn: this.spawnGeneration, attempt: this.launchAttempt, keys: new Set() };
6749
+ }
6750
+ this.launchDialogClaims.keys.add(key);
6751
+ return;
6752
+ }
6753
+ this.autoResolvedDialogGeneration = this.spawnGeneration;
6754
+ this.autoResolvedDialogKey = key;
6755
+ }
6547
6756
  /** Whether a runtime prompt currently owns the pane's stdin. */
6548
6757
  isInputBlocked() {
6549
6758
  return this.inputBlockedDialogKey !== null;
@@ -6605,8 +6814,7 @@ export class Daemon extends EventEmitter {
6605
6814
  // this spawn is not what the screen is waiting on any more — the entry
6606
6815
  // after it (typically a hold) describes it.
6607
6816
  if (dialog.verifyAfterKeys && dialog.autoResolutionKey
6608
- && this.autoResolvedDialogGeneration === this.spawnGeneration
6609
- && this.autoResolvedDialogKey === dialog.autoResolutionKey
6817
+ && this.hasAutoDialogClaim(dialog)
6610
6818
  && dialogs.some(other => other !== dialog && Daemon.dialogMatches(other, pane)))
6611
6819
  continue;
6612
6820
  this.trackDialogParked(dialog);
@@ -6860,6 +7068,73 @@ export class Daemon extends EventEmitter {
6860
7068
  }
6861
7069
  return true;
6862
7070
  }
7071
+ /**
7072
+ * #1405: a steer is handed to the busy pane only if THIS launch takes one — asked of the backend again at delivery,
7073
+ * not just by the hub when it was sent: a durable steer can outlive the launch it was meant for (a restart onto a
7074
+ * legacy UI or an unverified version), and must then wait for the idle prompt like any message. Backends that do not
7075
+ * answer per launch leave it to the hub's name table.
7076
+ */
7077
+ launchTakesSteer() {
7078
+ return this.backend?.supportsSteer?.() !== false;
7079
+ }
7080
+ /**
7081
+ * #1405: a steer into a CLI whose busy input steers or queues by a mode the user switches (kiro TUI) goes in only
7082
+ * while a fresh capture reads its composer as "steer" — or "idle" once the turn has ended (`allowIdle`, the pre-write
7083
+ * re-check). "queue" (the user's choice), text in the box, or a screen it cannot read: not now — the caller waits for
7084
+ * the idle prompt as for any message. AgEnD never switches the mode. Backends without the reader are not asked.
7085
+ */
7086
+ async steerComposerAdmits(allowIdle) {
7087
+ const read = this.backend?.readSteerComposer;
7088
+ if (!read)
7089
+ return true;
7090
+ if (!this.tmux)
7091
+ return false;
7092
+ let mode;
7093
+ try {
7094
+ mode = read.call(this.backend, await this.tmux.capturePane());
7095
+ }
7096
+ catch {
7097
+ return false;
7098
+ }
7099
+ return mode === "steer" || (allowIdle && mode === "idle");
7100
+ }
7101
+ /**
7102
+ * #1405: one look at a mode-switched composer for a steer's own proof: on the tmux of this write, inside `deadline`
7103
+ * (monotonic) — the capture is raced against the time left and a late answer dropped — and `current` asked after the
7104
+ * await. Null when unreadable, late or no longer this write's: never evidence of an empty or a full box. The pane
7105
+ * comes back with the mode, so what follows judges the same capture.
7106
+ */
7107
+ async steerComposerWithin(tmux, deadline, current) {
7108
+ const read = this.backend?.readSteerComposer;
7109
+ const left = deadline - performance.now();
7110
+ if (!read || left <= 0 || !current())
7111
+ return null;
7112
+ const pane = await withinMs(tmux.capturePane(Math.ceil(left)), left);
7113
+ if (pane === undefined || performance.now() > deadline || !current())
7114
+ return null;
7115
+ const mode = read.call(this.backend, pane);
7116
+ return mode === null ? null : { mode, pane };
7117
+ }
7118
+ /**
7119
+ * #1405: the composer's text is THIS delivery's paste. kiro paints a multi-line paste's first line on the `›` row (live,
7120
+ * 2.27.1 and 2.28.0, TUI v2 and v3); that row must show the payload's first line COMPLETE — for a durable steer that
7121
+ * is its `[agend-delivery-id:<this attempt's id>]` marker, so a draft, someone else's paste, our paste appended to a
7122
+ * draft, or a marker cut short (wrapped, collapsed, truncated) is never taken for it: not attributable, no Enter.
7123
+ */
7124
+ steerPasteIsOurs(pane, formatted) {
7125
+ const shown = this.backend?.readSteerComposerText?.(pane);
7126
+ if (!shown)
7127
+ return false;
7128
+ const norm = (t) => t.replace(/\s+/g, " ").trim();
7129
+ const first = norm(formatted.split("\n").find(line => line.trim() !== "") ?? "");
7130
+ if (first === "")
7131
+ return false;
7132
+ const row = norm(shown);
7133
+ // The marker line holds nothing but the marker: the row is exactly it.
7134
+ if (/^\[agend-delivery-id:[^\]\s]+\]$/.test(first))
7135
+ return row === first;
7136
+ return row === first || row.startsWith(`${first} `);
7137
+ }
6863
7138
  /**
6864
7139
  * Codex can paint a prompt before the TTY enters raw mode. Both are required.
6865
7140
  *
@@ -7516,6 +7791,10 @@ export class Daemon extends EventEmitter {
7516
7791
  async sendDeliveryEnter(phase, stillCurrent, durableAttempt) {
7517
7792
  if (!(await this.waitForInputTransientToClear(phase)))
7518
7793
  return false;
7794
+ return this.sendDeliveryEnterAfterAvailability(phase, stillCurrent, durableAttempt);
7795
+ }
7796
+ /** Write half only: callers must finish the availability wait before any final recovery ownership proof. */
7797
+ async sendDeliveryEnterAfterAvailability(phase, stillCurrent, durableAttempt) {
7519
7798
  // A recovery Enter may have waited for a transient while cancel or spawn
7520
7799
  // replaced the delivery. Check at the last point before the tmux write.
7521
7800
  if (this.deliveryWritesStopping || (stillCurrent && !stillCurrent()))
@@ -7548,6 +7827,11 @@ export class Daemon extends EventEmitter {
7548
7827
  let windowId = initialWindowId;
7549
7828
  // The spawn this write belongs to: acceptance evidence is only read while it is still the current one (#1197).
7550
7829
  const spawnAtWrite = this.spawnGeneration;
7830
+ // #1405: a composer-proven steer is bound to this write's tmux and launch: every read, the Enter and the proof ask
7831
+ // this after each await (a respawn swaps this.tmux; a stop, pause or cancel ends the caller's fence).
7832
+ const tmuxAtWrite = this.tmux;
7833
+ const steerCurrent = () => (!stillCurrent || stillCurrent()) && !this.deliveryWritesStopping
7834
+ && this.tmux === tmuxAtWrite && this.spawnGeneration === spawnAtWrite;
7551
7835
  // Bug A: paste with backoff. Transient failures are usually a stale window id
7552
7836
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
7553
7837
  const maxAttempts = 3;
@@ -7572,6 +7856,20 @@ export class Daemon extends EventEmitter {
7572
7856
  return "handoff-box-unread";
7573
7857
  return this.failDelivery(verdict, status, "paste", "handoff-input-unreadable");
7574
7858
  }
7859
+ // #1405: on that same last capture a mode-switched composer (kiro TUI) must still read "steer" — or "idle", the
7860
+ // turn over. "queue", text in the box or an unreadable row: not written; handled as the unread box above.
7861
+ if (handingOffToNativeQueue && steer && this.backend?.readSteerComposer
7862
+ && pasteBaseline?.steerComposer !== "steer" && pasteBaseline?.steerComposer !== "idle") {
7863
+ if (!verdict.durableBeginCommitted)
7864
+ return "handoff-box-unread";
7865
+ return this.failDelivery(verdict, status, "paste", "steer-composer-not-steering");
7866
+ }
7867
+ // …and the launch must still take a steer (see launchTakesSteer). Not written: before the durable begin the row
7868
+ // is retried, as a steer the gate will then send down the idle path.
7869
+ if (handingOffToNativeQueue && steer && !this.launchTakesSteer()) {
7870
+ verdict.fenced = !verdict.durableBeginCommitted;
7871
+ return false;
7872
+ }
7575
7873
  // Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
7576
7874
  // Commit the submission fence at the last possible point before the
7577
7875
  // first side effect; a crash during those waits remains safely replayable.
@@ -7595,6 +7893,7 @@ export class Daemon extends EventEmitter {
7595
7893
  return false;
7596
7894
  }
7597
7895
  verdict.durableBeginCommitted = true;
7896
+ verdict.submissionMode = attemptEvidence.submissionMode;
7598
7897
  if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
7599
7898
  verdict.transcriptCheckpoint = {
7600
7899
  backend: attemptEvidence.backend,
@@ -7664,10 +7963,50 @@ export class Daemon extends EventEmitter {
7664
7963
  await new Promise(r => setTimeout(r, fallbackMs));
7665
7964
  settle = { settleMs: fallbackMs, observedPostPasteOutput: false, capHit: false, usedFallback: true };
7666
7965
  }
7966
+ // #1405: a steer into a mode-switched composer (kiro TUI) is proven by the box alone — before the Enter it must
7967
+ // positively hold typed text, and after it be the empty placeholder again. The empty placeholder here means the
7968
+ // paste never reached the box; an unreadable box (a failed or late capture, a dialog) proves nothing: no Enter.
7969
+ const steerComposerProof = handingOffToNativeQueue && steer && !!this.backend?.readSteerComposer && tmuxAtWrite !== null;
7970
+ let steerEnterDeadline = Infinity;
7971
+ if (steerComposerProof) {
7972
+ const deadline = performance.now() + STEER_PASTE_VISIBLE_MS;
7973
+ let before = null;
7974
+ for (;;) {
7975
+ before = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
7976
+ if (before !== null || !steerCurrent() || performance.now() >= deadline)
7977
+ break;
7978
+ await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
7979
+ }
7980
+ if (!steerCurrent()) {
7981
+ // Written but not entered, and no longer this write's pane to judge: uncertain, never retried here.
7982
+ verdict.phase = "steer-paste";
7983
+ verdict.proof = "fenced-before-enter";
7984
+ return false;
7985
+ }
7986
+ // The caller's own receipt is inside the budget too: a reading whose continuation ran late is not used.
7987
+ if (performance.now() > deadline)
7988
+ return this.failDelivery(verdict, status, "steer-paste", "box-read-late");
7989
+ if (before?.mode !== "text")
7990
+ return this.failDelivery(verdict, status, "steer-paste", before === null ? "box-unread" : `box-still-${before.mode}`);
7991
+ if (!this.steerPasteIsOurs(before.pane, formatted))
7992
+ return this.failDelivery(verdict, status, "steer-paste", "box-not-this-paste");
7993
+ // …and so is the Enter: the key is sent only while the reading is still within it.
7994
+ steerEnterDeadline = deadline;
7995
+ }
7667
7996
  let enterAt = Date.now();
7668
- if (!(await this.sendDeliveryEnter("initial-submit", undefined, durableAttempt))) {
7997
+ const steerEnterCurrent = () => steerCurrent() && performance.now() <= steerEnterDeadline;
7998
+ if (!(await this.sendDeliveryEnter("initial-submit", steerComposerProof ? steerEnterCurrent : undefined, durableAttempt))) {
7999
+ if (steerComposerProof && !steerCurrent()) {
8000
+ verdict.phase = "submit-enter";
8001
+ verdict.proof = "fenced-before-enter";
8002
+ return false;
8003
+ }
8004
+ if (steerComposerProof && performance.now() > steerEnterDeadline)
8005
+ return this.failDelivery(verdict, status, "submit-enter", "pre-enter-deadline");
7669
8006
  return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
7670
8007
  }
8008
+ // The proof's whole budget, from the Enter: every capture and dialog probe inside it, a late answer dropped.
8009
+ const steerProofDeadline = performance.now() + NATIVE_QUEUE_PASTE_VERIFY_MS + POST_ENTER_PROOF_WINDOW_MS;
7671
8010
  // Kiro's legacy TUI can swallow Enter while it is still processing a large
7672
8011
  // paste — not only during the post-ready redraw (#479): on slower hosts it
7673
8012
  // happens on ordinary deliveries too (v2.1.2 stable, WSL2 + tmux 3.4).
@@ -7681,7 +8020,9 @@ export class Daemon extends EventEmitter {
7681
8020
  // re-baselined to the second Enter so leftover paste-render output between
7682
8021
  // the two cannot be what "confirms" the submission.
7683
8022
  let enterRetry = false;
7684
- if (!rawPaste && this.backend?.requiresDeliveryEnterRetry?.() === true) {
8023
+ // Not after a composer-proven steer: what a bare Enter does in kiro's TUI composer mid-turn is unverified, and the
8024
+ // box itself shows whether the one Enter took the text.
8025
+ if (!rawPaste && !steerComposerProof && this.backend?.requiresDeliveryEnterRetry?.() === true) {
7685
8026
  await new Promise(r => setTimeout(r, 1_000));
7686
8027
  const retryAt = Date.now();
7687
8028
  if (await this.sendDeliveryEnter("queue-less-defensive-retry")) {
@@ -7705,6 +8046,41 @@ export class Daemon extends EventEmitter {
7705
8046
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
7706
8047
  if (handingOffToNativeQueue) {
7707
8048
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
8049
+ if (steerComposerProof) {
8050
+ // #1405: the box is its empty placeholder again — "steer" (taken into the turn), "queue" (the user switched
8051
+ // mode meanwhile: held for the turn's end) or "idle" (the turn over: it started one) — with no dialog and the
8052
+ // same spawn and window. Kiro paints late, so a bounded poll on a monotonic deadline. Still not empty: the
8053
+ // Enter did not take it — uncertain, never re-pasted and never sent another Enter. Every capture and dialog
8054
+ // probe runs inside the budget set at the Enter; the deadline and this write's fence are asked again,
8055
+ // synchronously, right before it is accepted.
8056
+ const deadline = steerProofDeadline;
8057
+ for (;;) {
8058
+ const look = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
8059
+ const after = look?.mode;
8060
+ if (after === "steer" || after === "queue" || after === "idle") {
8061
+ const probe = await withinMs(this.probeBlockingDialog(), deadline - performance.now());
8062
+ if (probe?.state === "clear" && performance.now() <= deadline && steerCurrent() && this.getWindowId() === windowId) {
8063
+ verdict.phase = "steer-accepted";
8064
+ verdict.proof = `steer-composer-${after}`;
8065
+ if (status)
8066
+ this.emit("message_confirmed", status); // ✅ taken from the box
8067
+ return true;
8068
+ }
8069
+ }
8070
+ if (performance.now() >= deadline)
8071
+ break;
8072
+ await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
8073
+ }
8074
+ if (!steerCurrent()) {
8075
+ verdict.phase = "steer-proof";
8076
+ verdict.proof = "fenced";
8077
+ return false;
8078
+ }
8079
+ this.logger.warn("Steer: the composer was not shown empty after the Enter in time — outcome uncertain, not re-sent");
8080
+ verdict.phase = "steer-proof";
8081
+ verdict.proof = "composer-not-emptied";
8082
+ return false;
8083
+ }
7708
8084
  const proof = await this.confirmSubmitted(signature, pasteBaseline);
7709
8085
  if (proof === "submitted") {
7710
8086
  if (status)
@@ -8120,8 +8496,8 @@ export class Daemon extends EventEmitter {
8120
8496
  return this.submittedProof();
8121
8497
  }
8122
8498
  /**
8123
- * The CLI's transcript, from this attempt's checkpoint, holds this delivery's exact marker: a user entry (read) or
8124
- * one it queued (taken into its queue). Either is the CLI having taken the
8499
+ * The CLI's transcript, from this attempt's checkpoint, holds this delivery's exact marker: a user entry (read), an
8500
+ * entry Claude absorbed mid-turn, or one it queued (taken into its queue). Any of them is the CLI having taken the
8125
8501
  * message, so it outranks the pane — including a box that still shows the paste a moment after Enter (Claude paints
8126
8502
  * the submit late, captured live on 2.1.293), which on its own reads as a strand. Positive only; never a downgrade.
8127
8503
  */
@@ -8133,7 +8509,7 @@ export class Daemon extends EventEmitter {
8133
8509
  return t.provenBy;
8134
8510
  const found = await t.reader.look();
8135
8511
  // No side effect here: the caller keeps the hit and retires the guard only after its own fence check.
8136
- return found === "user" || found === "queued" ? found : null;
8512
+ return found === "user" || found === "absorbed" || found === "queued" ? found : null;
8137
8513
  }
8138
8514
  /**
8139
8515
  * A write's transcript proof: bounded looks from its checkpoint, fenced to the write — its spawn and launch, a stop,
@@ -8200,12 +8576,12 @@ export class Daemon extends EventEmitter {
8200
8576
  // read as ours, we would press Enter to "recover" it, and the turn IT
8201
8577
  // starts would confirm a message that never reached the pane.
8202
8578
  //
8203
- // For a structural box reader (claude-code), "did not already show it" needs a baseline whose box was READ: an
8204
- // unreadable one (a dialog, a redraw the reader refused) says nothing about what the box held, and counting it as
8205
- // empty would claim an older message as ours and press Enter on it (#1353 review). The prompt-row backends (codex,
8206
- // kiro) keep their existing attribution.
8579
+ // Structured evidence (Claude's box and Codex's current input/footer pair) requires both snapshots to be READ.
8580
+ // An unreadable baseline says nothing about what the box held; treating it as empty would claim an older draft
8581
+ // as ours and press Enter on it (#1353/#1359). Codex's native warning viewer can ignore the paste and first Enter,
8582
+ // then reveal an older draft when the user dismisses it. Row-only backends retain their existing attribution.
8207
8583
  const attributable = baseline != null
8208
- && (!this.backend?.readInputRow || (baseline.inputReadable && after.inputReadable));
8584
+ && (!this.structuredInputEvidence() || (baseline.inputReadable && after.inputReadable));
8209
8585
  if (after.strandedInput && (signature.unique || (attributable && baseline.strandedInput === false)))
8210
8586
  return "stranded";
8211
8587
  // A paste the CLI shows collapsed carries no signature at all. One the box did not hold before we pasted is
@@ -8318,6 +8694,7 @@ export class Daemon extends EventEmitter {
8318
8694
  strandedInput: input != null && inputShowsPastedText(input.text, signature.value),
8319
8695
  collapsedPastes: input?.collapsedPastes ?? 0,
8320
8696
  inputReadable: input != null,
8697
+ steerComposer: this.backend?.readSteerComposer?.(pane) ?? null,
8321
8698
  };
8322
8699
  }
8323
8700
  /**
@@ -8381,7 +8758,10 @@ export class Daemon extends EventEmitter {
8381
8758
  * is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
8382
8759
  * (the retries included). Without a guard it is exactly the old unconditional path.
8383
8760
  */
8384
- async submitSystemPaste(text, label, guard) {
8761
+ async submitSystemPaste(text, label, guard, admission) {
8762
+ const current = guard || admission ? () => { admission?.(); return guard?.current() ?? true; } : undefined;
8763
+ if (current && !current())
8764
+ return false;
8385
8765
  if (!this.tmux)
8386
8766
  return false;
8387
8767
  const signature = this.submissionSignature(text);
@@ -8394,32 +8774,41 @@ export class Daemon extends EventEmitter {
8394
8774
  catch {
8395
8775
  return false;
8396
8776
  }
8397
- if (!guard.current() || !guard.accept(pane))
8777
+ if (!current() || !guard.accept(pane))
8398
8778
  return false;
8399
8779
  baseline = this.paneEvidence(pane, signature);
8400
8780
  }
8401
8781
  else {
8402
8782
  baseline = await this.capturePaneEvidence(signature);
8403
8783
  }
8784
+ if (current && !current())
8785
+ return false;
8404
8786
  const pasteGeneration = this.spawnGeneration;
8405
- if (!(await this.tmux.pasteBuffer(text))) {
8787
+ const pasted = current ? await this.tmux.pasteBuffer(text, { guard: current }) : await this.tmux.pasteBuffer(text);
8788
+ if (!pasted) {
8406
8789
  this.logger.warn({ label }, "System paste failed to reach the pane");
8407
8790
  return false;
8408
8791
  }
8792
+ if (current && !current())
8793
+ return false;
8409
8794
  this.rememberPaste(text, pasteGeneration);
8410
8795
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
8411
- if (!(await this.sendDeliveryEnter(label, guard?.current)))
8796
+ if (!(await this.sendDeliveryEnter(label, current)))
8412
8797
  return false;
8413
8798
  let proof = await this.confirmSubmitted(signature, baseline);
8799
+ if (current && !current())
8800
+ return false;
8414
8801
  // A structural box reader sees the paste in the box until the CLI digests the Enter (claude-code repaints a moment
8415
8802
  // later): give that a bounded window before calling it stranded or lost, as the delivery ladder does.
8416
8803
  if (this.backend?.readInputRow) {
8417
8804
  const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
8418
8805
  while (proof !== "submitted" && Date.now() < deadline) {
8419
8806
  await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
8420
- if (guard && !guard.current())
8807
+ if (current && !current())
8421
8808
  return false;
8422
8809
  proof = await this.confirmSubmitted(signature, baseline);
8810
+ if (current && !current())
8811
+ return false;
8423
8812
  }
8424
8813
  }
8425
8814
  if (proof === "unverifiable") {
@@ -8437,18 +8826,44 @@ export class Daemon extends EventEmitter {
8437
8826
  // exists to remove — visible text is also what a strand looks like.
8438
8827
  if (this.systemPasteOptions().retryEnter) {
8439
8828
  await new Promise(r => setTimeout(r, 1_000));
8440
- await this.sendDeliveryEnter(`${label}-defensive-retry`, guard?.current);
8829
+ await this.sendDeliveryEnter(`${label}-defensive-retry`, current);
8441
8830
  }
8442
8831
  return true; // best effort, exactly as before — nothing here is verified
8443
8832
  }
8444
8833
  if (proof !== "submitted") {
8445
- // A bare Enter is a no-op at an empty prompt, so this is safe even if the
8446
- // first one did land; when the text is still in the input row it is the
8447
- // submit it never got. Re-pasting would append the text to itself.
8448
8834
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
8449
- if (!(await this.sendDeliveryEnter(`${label}-retry`, guard?.current)))
8835
+ if (current && !current())
8450
8836
  return false;
8837
+ const retryPhase = `${label}-retry`;
8838
+ if (this.structuredInputEvidence()) {
8839
+ // Both the retry delay and the Enter availability wait may outlive the
8840
+ // paste. Complete those waits BEFORE the last ownership/positive proof.
8841
+ if (!(await this.waitForInputTransientToClear(retryPhase)))
8842
+ return false;
8843
+ if (this.deliveryWritesStopping || (current && !current()))
8844
+ return false;
8845
+ proof = await this.confirmSubmitted(signature, baseline);
8846
+ if (this.deliveryWritesStopping || (current && !current()))
8847
+ return false;
8848
+ if (proof === "submitted")
8849
+ return true;
8850
+ if (proof !== "stranded") {
8851
+ this.logger.warn({ label, proof }, "System paste recovery input could not be attributed");
8852
+ return false;
8853
+ }
8854
+ // No further availability wait may invalidate this proof. The write
8855
+ // half retains the same stopping/admission fence immediately before IPC.
8856
+ if (!(await this.sendDeliveryEnterAfterAvailability(retryPhase, current)))
8857
+ return false;
8858
+ }
8859
+ else {
8860
+ // Row-only backends retain their ordinary availability wait and retry.
8861
+ if (!(await this.sendDeliveryEnter(retryPhase, current)))
8862
+ return false;
8863
+ }
8451
8864
  proof = await this.confirmSubmitted(signature, baseline);
8865
+ if (current && !current())
8866
+ return false;
8452
8867
  }
8453
8868
  if (proof !== "submitted") {
8454
8869
  this.logger.warn({ label, proof }, "System paste may not have been submitted");
@@ -8458,21 +8873,30 @@ export class Daemon extends EventEmitter {
8458
8873
  }
8459
8874
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
8460
8875
  async recoverWindow() {
8876
+ const previousTmux = this.tmux;
8877
+ const recoveryOwner = this.interactionOwner();
8878
+ const recoveryCurrent = () => !this.runtimeMonitorsFrozen
8879
+ && sameInteractionOwner(recoveryOwner, this.interactionOwner());
8461
8880
  const previousWindowId = this.tmux?.getWindowId();
8462
8881
  try {
8463
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
8882
+ const windows = await TmuxManager.listWindows(this.tmuxSessionName, this.controlClient);
8883
+ if (!recoveryCurrent() || this.tmux !== previousTmux)
8884
+ return undefined;
8464
8885
  const match = windows.find(w => w.name === this.name);
8465
8886
  if (!match)
8466
8887
  return undefined;
8467
- this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
8888
+ this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
8889
+ const recoveredTmux = this.tmux;
8468
8890
  writeFileSync(join(this.instanceDir, "window-id"), match.id);
8469
8891
  // The window we were talking to is gone; leaving it registered means the
8470
- // control client re-resolves a dead id — one tmux subprocess — on every
8892
+ // control client re-resolves a dead id — one bounded read — on every
8471
8893
  // reconnect, for the life of the fleet process.
8472
8894
  if (previousWindowId && previousWindowId !== match.id) {
8473
8895
  this.controlClient?.unregisterWindow(previousWindowId);
8474
8896
  }
8475
8897
  await this.controlClient?.registerWindow(match.id);
8898
+ if (!recoveryCurrent() || this.tmux !== recoveredTmux)
8899
+ return undefined;
8476
8900
  this.bindInstanceStateOutputListener(match.id);
8477
8901
  this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
8478
8902
  return match.id;
@@ -8581,6 +9005,31 @@ export class Daemon extends EventEmitter {
8581
9005
  // continuation for an already-answered turn.
8582
9006
  if (replyAttempt?.completionAction)
8583
9007
  clearInFlightTurnMarker(this.instanceDir);
9008
+ // #1377: a confirmed completion action (reply/react/edit_message) ends
9009
+ // the human-facing obligation immediately — the idle edge does not need
9010
+ // to confirm it. Clear any pending confirmation window so a later
9011
+ // cross-instance busy→idle cycle cannot re-arm and fire false recovery.
9012
+ // The guard itself completes on the next steady-idle tick via
9013
+ // maybeConfirmReplyGuardIdle (which checks completionDelivered when
9014
+ // replyGuardIdleConfirm is null) or at the next busy→idle edge.
9015
+ // Cancelled turns are excluded: their completion semantics are handled
9016
+ // by the idle edge's cancelledByUser check.
9017
+ // An older reply that satisfied an earlier obligation but not the latest
9018
+ // one (latestObligation > token.obligation) leaves completionDelivered
9019
+ // false and must not clear the window either.
9020
+ if (replyAttempt?.completionAction) {
9021
+ const snap = this.turnReplyGuard.snapshot();
9022
+ if (snap?.generation === replyAttempt.generation && snap.phase === "awaiting"
9023
+ && snap.completionDelivered && !snap.cancelledByUser) {
9024
+ this.clearReplyGuardConfirm();
9025
+ }
9026
+ }
9027
+ }
9028
+ else if ((error || result == null) && replyAttempt?.reply && TURN_OUTBOUND_TOOLS.has(tool)) {
9029
+ // #1377: the adapter returned a definitive error — record this so the
9030
+ // confirm window can distinguish "in-flight" (unknown) from "known
9031
+ // failed" and report unknown immediately rather than starting recovery.
9032
+ this.turnReplyGuard.settleToolAttempt(replyAttempt, false);
8584
9033
  }
8585
9034
  const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
8586
9035
  if (!sent) {
@@ -8957,7 +9406,8 @@ export class Daemon extends EventEmitter {
8957
9406
  instanceName: this.name,
8958
9407
  mcpServers: isCliMode ? {} : {
8959
9408
  "agend": {
8960
- command: "node",
9409
+ // The Node this daemon runs on (#1450 C5): the CLIs start the MCP server with it, not a `node` from PATH.
9410
+ command: process.execPath,
8961
9411
  args: [serverJs],
8962
9412
  env: mcpEnv,
8963
9413
  },
@@ -8979,7 +9429,8 @@ export class Daemon extends EventEmitter {
8979
9429
  * user input so the agent picks up where the previous session left off.
8980
9430
  * This replaces the old system-prompt injection approach.
8981
9431
  */
8982
- async injectSnapshotMessage() {
9432
+ async injectSnapshotMessage(admission) {
9433
+ admission?.();
8983
9434
  if (this.snapshotConsumed)
8984
9435
  return;
8985
9436
  const snapshot = this.buildSnapshotPrompt();
@@ -8991,11 +9442,16 @@ export class Daemon extends EventEmitter {
8991
9442
  }
8992
9443
  // Small delay to let the CLI fully render its ready prompt
8993
9444
  await new Promise(r => setTimeout(r, 1_000));
9445
+ admission?.();
8994
9446
  try {
8995
9447
  // Messages can arrive during a restart and be queued on pasteLock before the
8996
9448
  // snapshot lands; both write to the pane, so both go through the same lock.
8997
9449
  const restoreNotice = `[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`;
8998
- const injected = await this.paneWriteLock.run(() => this.submitSystemPaste(restoreNotice, "session-snapshot-restore"));
9450
+ const injected = await this.paneWriteLock.run(() => {
9451
+ admission?.();
9452
+ return this.submitSystemPaste(restoreNotice, "session-snapshot-restore", undefined, admission);
9453
+ });
9454
+ admission?.();
8999
9455
  if (!injected) {
9000
9456
  // rotation-state.json was deleted when the prompt was built, so there is
9001
9457
  // nothing left to retry from: the restore is gone either way. Say so —
@@ -9242,12 +9698,14 @@ export class Daemon extends EventEmitter {
9242
9698
  // Fresh start or not: the owner is recorded below once this CLI is up, so an
9243
9699
  // id it does not own must be out of the way first, or it would be recorded
9244
9700
  // as this backend's and resumed by the next start.
9701
+ this.startupAdmission?.();
9245
9702
  this.setAsideForeignSession();
9246
9703
  const attemptedResume = !this.skipResume;
9247
9704
  // A resume launch may get a longer budget than a fresh one (kiro: the
9248
9705
  // conversation must come back from the backend before anything paints).
9249
9706
  const resumeBudget = attemptedResume ? this.startupBudgetFor(true) : undefined;
9250
9707
  let alive = await this.trySpawn(false, resumeBudget);
9708
+ this.startupAdmission?.();
9251
9709
  if (!alive && attemptedResume) {
9252
9710
  // Resume failed. Before abandoning the session:
9253
9711
  // 1. If the backend is unreachable (fleet-level memory, or this very pane
@@ -9258,15 +9716,22 @@ export class Daemon extends EventEmitter {
9258
9716
  // 2. Otherwise, for backends that ask for it, retry resume ONCE — the
9259
9717
  // first miss is usually slowness, not a broken session.
9260
9718
  await this.noteStartupPaneForBackendOutage();
9719
+ this.startupAdmission?.();
9261
9720
  await this.failStartupIfBackendUnreachable();
9721
+ this.startupAdmission?.();
9262
9722
  if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
9263
9723
  this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
9264
9724
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9725
+ this.startupAdmission?.();
9265
9726
  await this.tmux.killWindow();
9727
+ this.startupAdmission?.();
9266
9728
  alive = await this.trySpawn(false, resumeBudget);
9729
+ this.startupAdmission?.();
9267
9730
  if (!alive) {
9268
9731
  await this.noteStartupPaneForBackendOutage();
9732
+ this.startupAdmission?.();
9269
9733
  await this.failStartupIfBackendUnreachable();
9734
+ this.startupAdmission?.();
9270
9735
  }
9271
9736
  }
9272
9737
  }
@@ -9288,7 +9753,9 @@ export class Daemon extends EventEmitter {
9288
9753
  // Keep the session and fail this attempt; the fleet retries with
9289
9754
  // backoff, which is also how the backend-outage path behaves.
9290
9755
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9756
+ this.startupAdmission?.();
9291
9757
  await this.tmux.killWindow();
9758
+ this.startupAdmission?.();
9292
9759
  throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
9293
9760
  + "— session kept, will retry");
9294
9761
  }
@@ -9305,11 +9772,16 @@ export class Daemon extends EventEmitter {
9305
9772
  // a session: nothing about a failed fresh launch says the stored
9306
9773
  // conversation is unusable.
9307
9774
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9775
+ this.startupAdmission?.();
9308
9776
  await this.tmux.killWindow();
9777
+ this.startupAdmission?.();
9309
9778
  const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
9779
+ this.startupAdmission?.();
9310
9780
  if (!retryAlive) {
9311
9781
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9782
+ this.startupAdmission?.();
9312
9783
  await this.tmux.killWindow();
9784
+ this.startupAdmission?.();
9313
9785
  throw new Error("CLI failed to start after retry");
9314
9786
  }
9315
9787
  }
@@ -9331,6 +9803,133 @@ export class Daemon extends EventEmitter {
9331
9803
  }
9332
9804
  return resumedSuccessfully;
9333
9805
  }
9806
+ /** The whole agent switch after a resume, on the monotonic clock (#906 §3). */
9807
+ static AGENT_SWITCH_BUDGET_MS = 15_000;
9808
+ static AGENT_SWITCH_POLL_MS = 500;
9809
+ /** Each tmux call of the write (load, paste, Enter, take-back) is bounded by this. */
9810
+ static AGENT_SWITCH_TMUX_OP_MS = 1_000;
9811
+ /**
9812
+ * The paste is started only with this much budget left, checked at the paste itself: the load and the paste, the
9813
+ * settle, the Enter — each bounded by AGENT_SWITCH_TMUX_OP_MS — fit in it.
9814
+ */
9815
+ static AGENT_SWITCH_WRITE_RESERVE_MS = 4_000;
9816
+ /** Between the paste and its Enter, as tmux pastes elsewhere (pasteText). */
9817
+ static AGENT_SWITCH_PASTE_SETTLE_MS = 500;
9818
+ /**
9819
+ * #906 §3: after a resume, make the CLI run as this instance's own agent. Reads the agent off the live layout
9820
+ * (backend.agentSwitch().readActive — the supported bottom layout only, never a name quoted in the conversation);
9821
+ * when it is another one, types the switch command ONCE, under the pane-write lock, and only when ONE final capture
9822
+ * taken there shows the live layout naming another agent AND an idle, input-ready pane (ready, not busy, no
9823
+ * blocking dialog, no input transient) — after the delivery path's own readiness check. It confirms only on a
9824
+ * later capture of the live layout naming ours.
9825
+ *
9826
+ * Fenced like a delivery, from entry on: the spawn generation, launch fence, the pause/wake phase it started in,
9827
+ * the same tmux window, not aborted, writes not stopping — re-checked after every await, and between the paste and
9828
+ * its Enter. The whole step, every await included, ends at one monotonic deadline: a late capture never confirms
9829
+ * and a late admission never writes; a paste whose Enter would land past it is taken back. Not confirmed in time:
9830
+ * the old setup stays, a launch warning says so, the next launch tries again. A stop, pause or respawn ends it
9831
+ * silently, confirming and removing nothing. Never throws into the spawn.
9832
+ */
9833
+ async ensureBackendAgent() {
9834
+ let sw = null;
9835
+ try {
9836
+ sw = this.backend?.agentSwitch?.() ?? null;
9837
+ }
9838
+ catch {
9839
+ sw = null;
9840
+ }
9841
+ const tmux = this.tmux;
9842
+ if (!sw || !tmux || !this.backend)
9843
+ return;
9844
+ const windowId = tmux.getWindowId();
9845
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, phase = this.pauseWakeState;
9846
+ const fenced = () => !this.startupAborted && !this.deliveryWritesStopping && !this.fatalStartupBlocked
9847
+ && (phase === "active" || phase === "waking") && this.pauseWakeState === phase
9848
+ && spawn === this.spawnGeneration && fence === this.launchFenceEpoch && this.tmux === tmux;
9849
+ const deadline = performance.now() + Daemon.AGENT_SWITCH_BUDGET_MS;
9850
+ const live = () => { this.startupAdmission?.(); return fenced() && performance.now() < deadline; };
9851
+ if (!live())
9852
+ return;
9853
+ const TIMEOUT = Symbol("deadline");
9854
+ const within = (work) => {
9855
+ const left = deadline - performance.now();
9856
+ if (left <= 0) {
9857
+ work.catch(() => { });
9858
+ return Promise.resolve(TIMEOUT);
9859
+ }
9860
+ let timer;
9861
+ return Promise.race([work, new Promise(r => { timer = setTimeout(() => r(TIMEOUT), left); })])
9862
+ .finally(() => clearTimeout(timer));
9863
+ };
9864
+ const capture = () => within(tmux.capturePane().catch(() => null));
9865
+ let wroteAt = null;
9866
+ try {
9867
+ while (live()) {
9868
+ const capturedAt = performance.now();
9869
+ const pane = await capture();
9870
+ if (pane === TIMEOUT || !live())
9871
+ break;
9872
+ const active = pane === null ? null : sw.readActive(pane);
9873
+ if (active === sw.agent && (wroteAt === null || capturedAt > wroteAt)) {
9874
+ if (sw.alreadyConfirmed && wroteAt === null)
9875
+ return; // already ours, already on record
9876
+ for (const warning of sw.confirm())
9877
+ this.emit("backend_launch_warning", { name: this.name, message: warning });
9878
+ this.logger.info({ agent: sw.agent, switched: wroteAt !== null }, "The resumed conversation runs as this instance's agent");
9879
+ return;
9880
+ }
9881
+ if (wroteAt === null && active !== null && active !== sw.agent) {
9882
+ const wrote = await within(this.paneWriteLock.run(async () => {
9883
+ // The whole write is one owned transaction under the lock. Every await is bounded; a take-back of a paste
9884
+ // whose Enter would land past the deadline happens here, before the lock is released, so it can never
9885
+ // edit a write admitted after this one.
9886
+ if (!live())
9887
+ return false;
9888
+ const readiness = await within(this.paneReadinessForDelivery(windowId));
9889
+ if (readiness !== "ready" || !live())
9890
+ return false;
9891
+ const final = await capture();
9892
+ if (final === TIMEOUT || final === null || !live())
9893
+ return false;
9894
+ const now = sw.readActive(final);
9895
+ if (now === null || now === sw.agent || this.inputTransientInPane(final) || !this.paneAuthoritativelyIdle(final))
9896
+ return false;
9897
+ // The reserve, at the paste boundary — after the final proof, which may have used some of the budget.
9898
+ if (deadline - performance.now() < Daemon.AGENT_SWITCH_WRITE_RESERVE_MS)
9899
+ return false;
9900
+ const op = Daemon.AGENT_SWITCH_TMUX_OP_MS;
9901
+ // live() is asked before every tmux mutation inside the paste, so a stop, pause, respawn or the deadline
9902
+ // between its load and its paste never reaches the pane.
9903
+ const pasted = await tmux.pasteBuffer(sw.command, { guard: live, timeoutMs: op });
9904
+ if (!pasted)
9905
+ return false;
9906
+ await new Promise(r => setTimeout(r, Daemon.AGENT_SWITCH_PASTE_SETTLE_MS));
9907
+ if (live())
9908
+ return tmux.sendSpecialKey("Enter", op);
9909
+ // Same CLI, but no time left to see it through: take the command back out of its input row (bounded).
9910
+ if (fenced())
9911
+ await tmux.deleteBackward(sw.command.length, op);
9912
+ return false;
9913
+ }));
9914
+ if (wrote === true) {
9915
+ wroteAt = performance.now();
9916
+ this.logger.info({ agent: sw.agent, was: active }, "Switching the resumed conversation to this instance's agent");
9917
+ }
9918
+ }
9919
+ if (!live())
9920
+ break;
9921
+ await within(new Promise(r => setTimeout(r, Math.min(Daemon.AGENT_SWITCH_POLL_MS, Math.max(0, deadline - performance.now())))));
9922
+ }
9923
+ if (!fenced())
9924
+ return; // a stop, pause or respawn: nothing to say, nothing to do
9925
+ this.logger.warn({ agent: sw.agent, typed: wroteAt !== null }, "The resumed conversation did not switch to this instance's agent in time");
9926
+ this.emit("backend_launch_warning", { name: this.name, message: t("kiro.switch_timeout", Daemon.AGENT_SWITCH_BUDGET_MS / 1000) });
9927
+ }
9928
+ catch (err) {
9929
+ this.startupAdmission?.();
9930
+ this.logger.warn({ err }, "The agent switch after resume failed — the shared entries stay; the next launch tries again");
9931
+ }
9932
+ }
9334
9933
  /**
9335
9934
  * Startup budget for this launch: the backend's override (resume-aware), never
9336
9935
  * below a user-configured startup_timeout_ms; undefined = trySpawn's default.
@@ -9472,13 +10071,19 @@ export class Daemon extends EventEmitter {
9472
10071
  * Returns true if CLI is ready, false if it failed or got stuck.
9473
10072
  */
9474
10073
  async trySpawn(reuseWindow = false, startupTimeoutMs) {
9475
- if (!this.spawnGate)
9476
- return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
9477
- return this.spawnGate.run({
10074
+ const ready = !this.spawnGate ? await this.trySpawnInsideGate(reuseWindow, startupTimeoutMs) : await this.spawnGate.run({
9478
10075
  instanceName: this.name,
9479
10076
  workingDirectory: this.config.working_directory,
9480
10077
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
9481
10078
  }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
10079
+ // #906: a resumed kiro conversation comes back as the agent it was saved under. Every launch path that reaches a
10080
+ // ready CLI (start, recovery, wake) switches it to this instance's own agent here, while the spawn still holds
10081
+ // deliveries — and outside the spawn gate, which it does not need.
10082
+ this.startupAdmission?.();
10083
+ if (ready)
10084
+ await this.ensureBackendAgent();
10085
+ this.startupAdmission?.();
10086
+ return ready;
9482
10087
  }
9483
10088
  /**
9484
10089
  * One fresh pane capture judged as ready && !busy && !dialog. Transcript
@@ -9662,7 +10267,7 @@ export class Daemon extends EventEmitter {
9662
10267
  // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
9663
10268
  // delivery is already in flight or queued. Without the lock the notice and
9664
10269
  // that delivery race into the same pane.
9665
- const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
10270
+ const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), "instruction-reload-notice"));
9666
10271
  if (!told) {
9667
10272
  // Recording the new instructions here would mark the agent as told about
9668
10273
  // a notice it never received, and every later restart would then skip
@@ -9683,6 +10288,7 @@ export class Daemon extends EventEmitter {
9683
10288
  catch { /* non-fatal */ }
9684
10289
  }
9685
10290
  async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
10291
+ this.startupAdmission?.();
9686
10292
  const launchFence = this.launchFenceEpoch;
9687
10293
  const backendConfig = this.buildBackendConfig();
9688
10294
  // Compare freshly-built instructions against the last value the agent was
@@ -9722,11 +10328,12 @@ export class Daemon extends EventEmitter {
9722
10328
  // A backend that has to ask its CLI something before the launch command exists (OpenCode: does
9723
10329
  // this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
9724
10330
  try {
9725
- await this.backend.prepareLaunch?.();
10331
+ await this.backend.prepareLaunch?.(backendConfig);
9726
10332
  }
9727
10333
  catch { /* unknown capability → the backend's conservative form */ }
9728
10334
  // Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
9729
10335
  // into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
10336
+ this.startupAdmission?.();
9730
10337
  if (launchFence !== this.launchFenceEpoch)
9731
10338
  throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
9732
10339
  this.backend.writeConfig(backendConfig);
@@ -9781,6 +10388,7 @@ export class Daemon extends EventEmitter {
9781
10388
  // The CLI's own shell sets a zero coredump_filter first (#1113): this pane
9782
10389
  // may belong to a tmux server the fleet did not start, which would hand
9783
10390
  // the CLI its own (full) filter instead of the fleet's.
10391
+ this.startupAdmission?.();
9784
10392
  const cmd = coredumpFilterLaunchPrefix() + `${envPrefix} ` + this.backend.buildCommand(launchConfig);
9785
10393
  // Every launched command re-arms the passive-transient check, including a
9786
10394
  // retry inside the same spawn: its load is a new one.
@@ -9803,6 +10411,7 @@ export class Daemon extends EventEmitter {
9803
10411
  if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
9804
10412
  this.emit("tmux_server_crash", this.name);
9805
10413
  }
10414
+ this.startupAdmission?.();
9806
10415
  let windowId;
9807
10416
  if (reuseWindow) {
9808
10417
  this.controlClient?.unregisterWindow(this.tmux.getWindowId());
@@ -9817,6 +10426,7 @@ export class Daemon extends EventEmitter {
9817
10426
  if (retired && retired !== windowId)
9818
10427
  this.controlClient?.unregisterWindow(retired);
9819
10428
  }
10429
+ this.startupAdmission?.();
9820
10430
  writeFileSync(join(this.instanceDir, "window-id"), windowId);
9821
10431
  // Enable remain-on-exit to capture exit codes on crash
9822
10432
  await this.tmux.setRemainOnExit().catch(err => {
@@ -9925,8 +10535,8 @@ export class Daemon extends EventEmitter {
9925
10535
  { pattern: /No, exit|No, quit|Don't trust|I accept|I trust|Yes, continue|Trust folder/i, keys: ["Enter"], description: "Generic confirmation dialog" },
9926
10536
  { pattern: /Resume Session/i, keys: ["Escape"], description: "Resume session picker — start fresh" },
9927
10537
  ];
9928
- const deadline = Date.now() + budgetMs;
9929
- const remaining = () => deadline - Date.now();
10538
+ const deadline = performance.now() + budgetMs;
10539
+ const remaining = () => deadline - performance.now();
9930
10540
  const sleep = async () => { if (remaining() > 0)
9931
10541
  await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
9932
10542
  let cleanReadyPolls = 0;
@@ -9938,13 +10548,27 @@ export class Daemon extends EventEmitter {
9938
10548
  // repainted after Enter, let the following hold-only entry report it
9939
10549
  // instead of sending a second Enter into a possibly changed screen.
9940
10550
  const attemptedSafetyChoices = new Set();
10551
+ // The scan belongs to this launch: its tmux, spawn and launch fence. A respawn, pause or stop that replaces any of
10552
+ // them retires it — a key meant for this launch's dialog must never reach the replacement (#1435 review).
10553
+ const ownerTmux = this.tmux;
10554
+ const ownerSpawn = this.spawnGeneration;
10555
+ const ownerFence = this.launchFenceEpoch;
10556
+ const owned = () => !this.deliveryWritesStopping && this.tmux === ownerTmux
10557
+ && this.spawnGeneration === ownerSpawn && this.launchFenceEpoch === ownerFence;
9941
10558
  do {
10559
+ this.startupAdmission?.();
9942
10560
  attempts++;
10561
+ if (!owned()) {
10562
+ this.logger.info("Startup dialog scan superseded by a newer launch, pause or stop — retiring without answering");
10563
+ return true;
10564
+ }
9943
10565
  let pane;
9944
10566
  try {
9945
10567
  pane = await this.tmux.capturePane();
10568
+ this.startupAdmission?.();
9946
10569
  }
9947
10570
  catch (err) {
10571
+ this.startupAdmission?.();
9948
10572
  // Transient tmux trouble is not evidence about the CLI. Returning false
9949
10573
  // here would clear the session; retry within the budget instead.
9950
10574
  captureFailures++;
@@ -9976,8 +10600,7 @@ export class Daemon extends EventEmitter {
9976
10600
  if (Daemon.dialogMatches(dialog, pane)) {
9977
10601
  if (dialog.autoResolutionKey
9978
10602
  && (attemptedSafetyChoices.has(dialog.autoResolutionKey)
9979
- || (this.autoResolvedDialogGeneration === this.spawnGeneration
9980
- && this.autoResolvedDialogKey === dialog.autoResolutionKey)))
10603
+ || this.hasAutoDialogClaim(dialog)))
9981
10604
  continue;
9982
10605
  lastDialog = dialog;
9983
10606
  this.noteCodexReserveDialog(dialog, pane);
@@ -10022,21 +10645,33 @@ export class Daemon extends EventEmitter {
10022
10645
  // delivery on `spawning`. Take the pane lock for the key sequence so a
10023
10646
  // queued message cannot be pasted into a half-dismissed trust dialog.
10024
10647
  const sent = await this.paneWriteLock.run(async () => {
10648
+ this.startupAdmission?.();
10025
10649
  // The capture above may have gone stale while waiting for the
10026
10650
  // write lock. Trust/other safety prompts must still be the
10027
10651
  // CURRENT menu, with the same safe cursor, at the instant of
10028
- // the key send. A changed pane falls through to the next scan.
10652
+ // the key send. A changed pane falls through to the next scan;
10653
+ // a pane that is no longer this launch's is not read or keyed.
10654
+ if (!owned())
10655
+ return false;
10029
10656
  if (dialog.inputBlocked) {
10030
10657
  const currentPane = await this.tmux.capturePane();
10658
+ this.startupAdmission?.();
10659
+ // The capture is an await too: a replacement launched during it must find no answer recorded as its own.
10660
+ if (!owned())
10661
+ return false;
10031
10662
  if (!Daemon.dialogMatches(dialog, currentPane))
10032
10663
  return false;
10033
10664
  }
10034
10665
  if (dialog.autoResolutionKey) {
10035
10666
  attemptedSafetyChoices.add(dialog.autoResolutionKey);
10036
- this.autoResolvedDialogGeneration = this.spawnGeneration;
10037
- this.autoResolvedDialogKey = dialog.autoResolutionKey;
10667
+ this.claimAutoDialog(dialog);
10038
10668
  }
10039
10669
  for (const key of dialog.keys) {
10670
+ this.startupAdmission?.();
10671
+ // Before every key, synchronously: the lock wait, the capture and the gap after the previous key can
10672
+ // each have seen a respawn, pause or stop.
10673
+ if (!owned())
10674
+ return false;
10040
10675
  if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
10041
10676
  if (!await this.tmux.sendSpecialKey(key))
10042
10677
  return false;
@@ -10118,6 +10753,7 @@ export class Daemon extends EventEmitter {
10118
10753
  return false;
10119
10754
  }
10120
10755
  catch (err) {
10756
+ this.startupAdmission?.();
10121
10757
  // Key sends / isWindowAlive failing: same rule — log, retry within the budget.
10122
10758
  this.logger.warn({ err }, "startup dialog scan step failed — retrying");
10123
10759
  if ((await this.paneLiveness()) === "dead")
@@ -10135,6 +10771,7 @@ export class Daemon extends EventEmitter {
10135
10771
  else {
10136
10772
  this.logger.warn({ attempts, budgetMs, captureFailures }, "Startup scan exhausted without a ready prompt or a known dialog — assuming ready (unknown CLI screen)");
10137
10773
  }
10774
+ this.startupAdmission?.();
10138
10775
  return true;
10139
10776
  }
10140
10777
  /**