@songsid/agend 2.1.12-beta.9 → 2.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/auth/one-time-code.d.ts +19 -0
  4. package/dist/auth/one-time-code.js +49 -0
  5. package/dist/auth/one-time-code.js.map +1 -0
  6. package/dist/auth-api.d.ts +52 -0
  7. package/dist/auth-api.js +346 -0
  8. package/dist/auth-api.js.map +1 -0
  9. package/dist/backend/claude-code.d.ts +4 -0
  10. package/dist/backend/claude-code.js +97 -0
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +59 -30
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-agent.d.ts +66 -0
  15. package/dist/backend/kiro-agent.js +302 -0
  16. package/dist/backend/kiro-agent.js.map +1 -0
  17. package/dist/backend/kiro-identity.d.ts +80 -0
  18. package/dist/backend/kiro-identity.js +537 -0
  19. package/dist/backend/kiro-identity.js.map +1 -0
  20. package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
  21. package/dist/backend/kiro-v2-store-worker.js +87 -0
  22. package/dist/backend/kiro-v2-store-worker.js.map +1 -0
  23. package/dist/backend/kiro-v2-store.d.ts +54 -0
  24. package/dist/backend/kiro-v2-store.js +131 -0
  25. package/dist/backend/kiro-v2-store.js.map +1 -0
  26. package/dist/backend/kiro-v3-identity.d.ts +5 -0
  27. package/dist/backend/kiro-v3-identity.js +22 -0
  28. package/dist/backend/kiro-v3-identity.js.map +1 -1
  29. package/dist/backend/kiro.d.ts +83 -7
  30. package/dist/backend/kiro.js +418 -91
  31. package/dist/backend/kiro.js.map +1 -1
  32. package/dist/backend/types.d.ts +45 -3
  33. package/dist/backend/types.js.map +1 -1
  34. package/dist/channel/adapters/discord.d.ts +1 -0
  35. package/dist/channel/adapters/discord.js +29 -10
  36. package/dist/channel/adapters/discord.js.map +1 -1
  37. package/dist/channel/adapters/telegram.d.ts +1 -0
  38. package/dist/channel/adapters/telegram.js +13 -1
  39. package/dist/channel/adapters/telegram.js.map +1 -1
  40. package/dist/channel/types.d.ts +9 -1
  41. package/dist/classic-channel-manager.d.ts +18 -10
  42. package/dist/classic-channel-manager.js +45 -18
  43. package/dist/classic-channel-manager.js.map +1 -1
  44. package/dist/cli.js +225 -127
  45. package/dist/cli.js.map +1 -1
  46. package/dist/command-table.d.ts +5 -3
  47. package/dist/command-table.js +29 -27
  48. package/dist/command-table.js.map +1 -1
  49. package/dist/config-validator.js +46 -0
  50. package/dist/config-validator.js.map +1 -1
  51. package/dist/config.js +12 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +6 -0
  54. package/dist/connection-secrets.js +4 -0
  55. package/dist/connection-secrets.js.map +1 -1
  56. package/dist/daemon.d.ts +89 -4
  57. package/dist/daemon.js +661 -77
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/darwin-memory.d.ts +6 -1
  60. package/dist/darwin-memory.js +17 -6
  61. package/dist/darwin-memory.js.map +1 -1
  62. package/dist/delivery-consumed-watch.d.ts +85 -0
  63. package/dist/delivery-consumed-watch.js +244 -0
  64. package/dist/delivery-consumed-watch.js.map +1 -0
  65. package/dist/delivery-outbox.d.ts +61 -0
  66. package/dist/delivery-outbox.js +120 -2
  67. package/dist/delivery-outbox.js.map +1 -1
  68. package/dist/delivery-reconciliation.d.ts +14 -5
  69. package/dist/delivery-reconciliation.js +27 -4
  70. package/dist/delivery-reconciliation.js.map +1 -1
  71. package/dist/delivery-reconciliation.test.js +35 -1
  72. package/dist/delivery-reconciliation.test.js.map +1 -1
  73. package/dist/event-loop-watch.d.ts +28 -0
  74. package/dist/event-loop-watch.js +75 -3
  75. package/dist/event-loop-watch.js.map +1 -1
  76. package/dist/fleet-context.d.ts +22 -2
  77. package/dist/fleet-level-config.d.ts +2 -2
  78. package/dist/fleet-level-config.js +6 -1
  79. package/dist/fleet-level-config.js.map +1 -1
  80. package/dist/fleet-manager.d.ts +293 -12
  81. package/dist/fleet-manager.js +2364 -449
  82. package/dist/fleet-manager.js.map +1 -1
  83. package/dist/host-memory.d.ts +4 -0
  84. package/dist/host-memory.js.map +1 -1
  85. package/dist/instance-config-impact.js +5 -0
  86. package/dist/instance-config-impact.js.map +1 -1
  87. package/dist/instance-lifecycle.d.ts +10 -5
  88. package/dist/instance-lifecycle.js +162 -53
  89. package/dist/instance-lifecycle.js.map +1 -1
  90. package/dist/locale.js +150 -16
  91. package/dist/locale.js.map +1 -1
  92. package/dist/login-controller.d.ts +2 -0
  93. package/dist/login-controller.js +17 -5
  94. package/dist/login-controller.js.map +1 -1
  95. package/dist/memory-pressure.d.ts +7 -8
  96. package/dist/memory-pressure.js +76 -15
  97. package/dist/memory-pressure.js.map +1 -1
  98. package/dist/monotonic-budget.d.ts +2 -0
  99. package/dist/monotonic-budget.js +28 -0
  100. package/dist/monotonic-budget.js.map +1 -0
  101. package/dist/needs-you-hub.d.ts +114 -0
  102. package/dist/needs-you-hub.js +370 -0
  103. package/dist/needs-you-hub.js.map +1 -0
  104. package/dist/needs-you-live.d.ts +116 -0
  105. package/dist/needs-you-live.js +228 -0
  106. package/dist/needs-you-live.js.map +1 -0
  107. package/dist/needs-you.d.ts +132 -0
  108. package/dist/needs-you.js +164 -0
  109. package/dist/needs-you.js.map +1 -0
  110. package/dist/node-version-guard.d.ts +30 -0
  111. package/dist/node-version-guard.js +50 -0
  112. package/dist/node-version-guard.js.map +1 -0
  113. package/dist/operator-control-paths.d.ts +4 -0
  114. package/dist/operator-control-paths.js +24 -0
  115. package/dist/operator-control-paths.js.map +1 -0
  116. package/dist/outbound-handlers.d.ts +2 -0
  117. package/dist/outbound-handlers.js +9 -2
  118. package/dist/outbound-handlers.js.map +1 -1
  119. package/dist/outbound-schemas.d.ts +5 -5
  120. package/dist/profile-control.js +4 -20
  121. package/dist/profile-control.js.map +1 -1
  122. package/dist/public-web-gateway.d.ts +18 -0
  123. package/dist/public-web-gateway.js +141 -0
  124. package/dist/public-web-gateway.js.map +1 -0
  125. package/dist/public-web-link.d.ts +53 -0
  126. package/dist/public-web-link.js +175 -0
  127. package/dist/public-web-link.js.map +1 -0
  128. package/dist/quickstart-api.d.ts +8 -1
  129. package/dist/quickstart-api.js +86 -48
  130. package/dist/quickstart-api.js.map +1 -1
  131. package/dist/quickstart.d.ts +1 -0
  132. package/dist/quickstart.js +6 -2
  133. package/dist/quickstart.js.map +1 -1
  134. package/dist/scheduler/db.d.ts +7 -1
  135. package/dist/scheduler/db.js +38 -0
  136. package/dist/scheduler/db.js.map +1 -1
  137. package/dist/scheduler/index.d.ts +1 -1
  138. package/dist/scheduler/retry-label.d.ts +11 -0
  139. package/dist/scheduler/retry-label.js +24 -0
  140. package/dist/scheduler/retry-label.js.map +1 -0
  141. package/dist/scheduler/scheduler.d.ts +55 -2
  142. package/dist/scheduler/scheduler.js +220 -8
  143. package/dist/scheduler/scheduler.js.map +1 -1
  144. package/dist/scheduler/types.d.ts +22 -0
  145. package/dist/scheduler/types.js.map +1 -1
  146. package/dist/secret-store.d.ts +5 -0
  147. package/dist/secret-store.js +20 -0
  148. package/dist/secret-store.js.map +1 -1
  149. package/dist/service-activation.d.ts +110 -0
  150. package/dist/service-activation.js +302 -0
  151. package/dist/service-activation.js.map +1 -0
  152. package/dist/service-installer.d.ts +7 -0
  153. package/dist/service-installer.js +7 -4
  154. package/dist/service-installer.js.map +1 -1
  155. package/dist/service-restart-selection.d.ts +6 -5
  156. package/dist/service-restart-selection.js +6 -5
  157. package/dist/service-restart-selection.js.map +1 -1
  158. package/dist/settings-api.d.ts +10 -2
  159. package/dist/settings-api.js +175 -72
  160. package/dist/settings-api.js.map +1 -1
  161. package/dist/settings-baseline.d.ts +20 -0
  162. package/dist/settings-baseline.js +104 -0
  163. package/dist/settings-baseline.js.map +1 -0
  164. package/dist/settings-change.d.ts +21 -0
  165. package/dist/settings-change.js +133 -0
  166. package/dist/settings-change.js.map +1 -0
  167. package/dist/settings-confirmation.d.ts +105 -0
  168. package/dist/settings-confirmation.js +232 -0
  169. package/dist/settings-confirmation.js.map +1 -0
  170. package/dist/settings-control.d.ts +41 -0
  171. package/dist/settings-control.js +294 -0
  172. package/dist/settings-control.js.map +1 -0
  173. package/dist/settings-effect.d.ts +24 -0
  174. package/dist/settings-effect.js +169 -0
  175. package/dist/settings-effect.js.map +1 -0
  176. package/dist/settings-http-confirmation.d.ts +29 -0
  177. package/dist/settings-http-confirmation.js +123 -0
  178. package/dist/settings-http-confirmation.js.map +1 -0
  179. package/dist/settings-instance-patch.d.ts +4 -0
  180. package/dist/settings-instance-patch.js +26 -0
  181. package/dist/settings-instance-patch.js.map +1 -0
  182. package/dist/settings-request-capability.d.ts +16 -0
  183. package/dist/settings-request-capability.js +32 -0
  184. package/dist/settings-request-capability.js.map +1 -0
  185. package/dist/settings-transaction.d.ts +65 -0
  186. package/dist/settings-transaction.js +223 -0
  187. package/dist/settings-transaction.js.map +1 -0
  188. package/dist/setup-auth.d.ts +10 -18
  189. package/dist/setup-auth.js +14 -38
  190. package/dist/setup-auth.js.map +1 -1
  191. package/dist/setup-form.d.ts +1 -1
  192. package/dist/setup-form.js +75 -18
  193. package/dist/setup-form.js.map +1 -1
  194. package/dist/setup-guide.d.ts +13 -0
  195. package/dist/setup-guide.js +25 -0
  196. package/dist/setup-guide.js.map +1 -0
  197. package/dist/setup-host.d.ts +16 -0
  198. package/dist/setup-host.js +197 -32
  199. package/dist/setup-host.js.map +1 -1
  200. package/dist/setup-wizard.js +3 -0
  201. package/dist/setup-wizard.js.map +1 -1
  202. package/dist/slash-authz.d.ts +17 -3
  203. package/dist/slash-authz.js +5 -0
  204. package/dist/slash-authz.js.map +1 -1
  205. package/dist/spawn-gate.js +3 -3
  206. package/dist/spawn-gate.js.map +1 -1
  207. package/dist/statusline-watcher.d.ts +5 -0
  208. package/dist/statusline-watcher.js +2 -0
  209. package/dist/statusline-watcher.js.map +1 -1
  210. package/dist/steer-capability.d.ts +6 -0
  211. package/dist/steer-capability.js +8 -0
  212. package/dist/steer-capability.js.map +1 -1
  213. package/dist/sync-work-attribution.d.ts +14 -0
  214. package/dist/sync-work-attribution.js +43 -8
  215. package/dist/sync-work-attribution.js.map +1 -1
  216. package/dist/tips.js +10 -5
  217. package/dist/tips.js.map +1 -1
  218. package/dist/tmux-control.d.ts +35 -5
  219. package/dist/tmux-control.js +291 -57
  220. package/dist/tmux-control.js.map +1 -1
  221. package/dist/tmux-manager.d.ts +20 -6
  222. package/dist/tmux-manager.js +90 -47
  223. package/dist/tmux-manager.js.map +1 -1
  224. package/dist/tmux-read.d.ts +59 -0
  225. package/dist/tmux-read.js +213 -0
  226. package/dist/tmux-read.js.map +1 -0
  227. package/dist/topic-commands.d.ts +21 -10
  228. package/dist/topic-commands.js +191 -58
  229. package/dist/topic-commands.js.map +1 -1
  230. package/dist/transcript-delta-reader.js +2 -1
  231. package/dist/transcript-delta-reader.js.map +1 -1
  232. package/dist/tunnel/cloudflared-install.d.ts +2 -0
  233. package/dist/tunnel/cloudflared-install.js +1 -1
  234. package/dist/tunnel/cloudflared-install.js.map +1 -1
  235. package/dist/tunnel/purpose-lane.d.ts +14 -0
  236. package/dist/tunnel/purpose-lane.js +43 -0
  237. package/dist/tunnel/purpose-lane.js.map +1 -0
  238. package/dist/turn-reply-guard.d.ts +2 -0
  239. package/dist/turn-reply-guard.js +10 -1
  240. package/dist/turn-reply-guard.js.map +1 -1
  241. package/dist/types.d.ts +33 -0
  242. package/dist/ui/app.html +18 -0
  243. package/dist/ui/chat-render.js +478 -0
  244. package/dist/ui/chat-store.js +289 -0
  245. package/dist/ui/chat-strings.js +69 -0
  246. package/dist/ui/chat-thread.js +209 -0
  247. package/dist/ui/chat-tour.js +116 -0
  248. package/dist/ui/fleet-org.js +142 -0
  249. package/dist/ui/panel-chat.js +418 -0
  250. package/dist/ui/panel-fleet.js +413 -0
  251. package/dist/ui/panel-needs.js +202 -0
  252. package/dist/ui/panel-settings.js +750 -0
  253. package/dist/ui/preview.js +266 -0
  254. package/dist/ui/settings-apply.js +239 -0
  255. package/dist/ui/settings-confirm.js +186 -0
  256. package/dist/ui/settings-dialogs.js +546 -0
  257. package/dist/ui/settings-model.js +277 -0
  258. package/dist/ui/settings-strings.js +652 -0
  259. package/dist/ui/settings-wizard.js +140 -0
  260. package/dist/ui/shared/agend-auth.js +93 -0
  261. package/dist/ui/shared/app-ctx.js +80 -0
  262. package/dist/ui/shared/app-html.js +9 -0
  263. package/dist/ui/shared/app-i18n.js +95 -0
  264. package/dist/ui/shared/app-nav.js +73 -0
  265. package/dist/ui/shared/app-needs.js +114 -0
  266. package/dist/ui/shared/app-route.js +73 -0
  267. package/dist/ui/shared/app-session.js +66 -0
  268. package/dist/ui/shared/app-shell.js +344 -0
  269. package/dist/ui/shared/app-store.js +51 -0
  270. package/dist/ui/shared/app-stream.js +201 -0
  271. package/dist/ui/shared/app.css +676 -0
  272. package/dist/ui/shared/app.js +88 -0
  273. package/dist/ui/shared/fonts/OFL.txt +92 -0
  274. package/dist/ui/shared/fonts/README.md +21 -0
  275. package/dist/ui/shared/fonts/inter.woff2 +0 -0
  276. package/dist/ui/shared/htm.module.js +1 -0
  277. package/dist/ui/shared/panel-view.js +655 -0
  278. package/dist/ui/shared/preact-hooks.module.js +2 -0
  279. package/dist/ui/shared/preact.module.js +2 -0
  280. package/dist/ui/shared/signin.js +130 -0
  281. package/dist/ui/shared/theme.js +29 -0
  282. package/dist/ui/shared/tokens.css +153 -0
  283. package/dist/ui/shared/ui-confirm.js +43 -0
  284. package/dist/ui/shared/ui-dialog.js +45 -0
  285. package/dist/ui/shared/ui-icons.js +61 -0
  286. package/dist/ui/shared/ui-menu.js +39 -0
  287. package/dist/ui/shared/ui-states.js +20 -0
  288. package/dist/ui/shared/ui-toast.js +18 -0
  289. package/dist/ui/shared/vendor/htm.LICENSE +202 -0
  290. package/dist/ui/shared/vendor/preact.LICENSE +21 -0
  291. package/dist/ui/shared/vendor/vendor.json +39 -0
  292. package/dist/ui/shared/view-strings.js +309 -0
  293. package/dist/ui/signin.html +51 -0
  294. package/dist/update-check.d.ts +6 -2
  295. package/dist/update-check.js +12 -3
  296. package/dist/update-check.js.map +1 -1
  297. package/dist/update-install.d.ts +71 -0
  298. package/dist/update-install.js +162 -0
  299. package/dist/update-install.js.map +1 -0
  300. package/dist/upgrade-notices.d.ts +17 -0
  301. package/dist/upgrade-notices.js +93 -0
  302. package/dist/upgrade-notices.js.map +1 -0
  303. package/dist/usage/statusline-usage.d.ts +2 -0
  304. package/dist/usage/statusline-usage.js +14 -10
  305. package/dist/usage/statusline-usage.js.map +1 -1
  306. package/dist/view-api.d.ts +6 -1
  307. package/dist/view-api.js +62 -51
  308. package/dist/view-api.js.map +1 -1
  309. package/dist/web-api.d.ts +71 -14
  310. package/dist/web-api.js +492 -77
  311. package/dist/web-api.js.map +1 -1
  312. package/dist/web-auth.d.ts +71 -27
  313. package/dist/web-auth.js +169 -69
  314. package/dist/web-auth.js.map +1 -1
  315. package/dist/web-body.d.ts +3 -0
  316. package/dist/web-body.js +55 -0
  317. package/dist/web-body.js.map +1 -0
  318. package/dist/web-channel-echo.d.ts +9 -0
  319. package/dist/web-channel-echo.js +55 -0
  320. package/dist/web-channel-echo.js.map +1 -0
  321. package/dist/web-chat-history.d.ts +125 -0
  322. package/dist/web-chat-history.js +184 -0
  323. package/dist/web-chat-history.js.map +1 -0
  324. package/dist/web-continuation.d.ts +7 -0
  325. package/dist/web-continuation.js +22 -0
  326. package/dist/web-continuation.js.map +1 -0
  327. package/dist/web-host-guard.d.ts +34 -0
  328. package/dist/web-host-guard.js +57 -1
  329. package/dist/web-host-guard.js.map +1 -1
  330. package/dist/web-login.d.ts +72 -0
  331. package/dist/web-login.js +132 -0
  332. package/dist/web-login.js.map +1 -0
  333. package/dist/web-org.d.ts +55 -0
  334. package/dist/web-org.js +67 -0
  335. package/dist/web-org.js.map +1 -0
  336. package/dist/web-preview.d.ts +77 -0
  337. package/dist/web-preview.js +240 -0
  338. package/dist/web-preview.js.map +1 -0
  339. package/dist/web-request-context.d.ts +10 -0
  340. package/dist/web-request-context.js +13 -0
  341. package/dist/web-request-context.js.map +1 -0
  342. package/dist/web-session.d.ts +167 -0
  343. package/dist/web-session.js +389 -0
  344. package/dist/web-session.js.map +1 -0
  345. package/dist/web-shell-routes.d.ts +59 -0
  346. package/dist/web-shell-routes.js +96 -0
  347. package/dist/web-shell-routes.js.map +1 -0
  348. package/dist/web-upload.d.ts +183 -0
  349. package/dist/web-upload.js +468 -0
  350. package/dist/web-upload.js.map +1 -0
  351. package/dist/website-tips-renderer.d.ts +2 -2
  352. package/dist/website-tips-renderer.js +3 -3
  353. package/dist/website-tips-renderer.js.map +1 -1
  354. package/package.json +8 -5
  355. package/scripts/preinstall-guard.cjs +36 -0
  356. package/dist/ui/dashboard.html +0 -774
  357. package/dist/ui/settings.html +0 -2165
  358. 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;
@@ -1732,7 +1758,7 @@ export class Daemon extends EventEmitter {
1732
1758
  this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason });
1733
1759
  }
1734
1760
  }
1735
- finishDurableDelivery(delivery, outcome, evidence) {
1761
+ finishDurableDelivery(delivery, outcome, evidence, verdict) {
1736
1762
  if (!delivery)
1737
1763
  return;
1738
1764
  // Delivered or uncertain means the pane write started: the work reached
@@ -1741,13 +1767,50 @@ export class Daemon extends EventEmitter {
1741
1767
  if (outcome !== "failed")
1742
1768
  this.noteWorkActivity();
1743
1769
  this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
1770
+ if (verdict && outcome !== "failed")
1771
+ this.startConsumedWatch(delivery, verdict);
1772
+ }
1773
+ /**
1774
+ * #1201: a steer or a native-queue hand-off was accepted into the CLI's input (or may have been) — not read. Watch
1775
+ * the transcript from this attempt's checkpoint for the moment the CLI takes it, and record that on the delivery;
1776
+ * an `uncertain` row becomes `delivered` then. Only claude-code and codex write a transcript AgEnD can read. The
1777
+ * watch belongs to this CLI: a stop, pause or respawn ends it (restart reconciliation owns the row after that).
1778
+ */
1779
+ startConsumedWatch(delivery, verdict) {
1780
+ const checkpoint = verdict.transcriptCheckpoint;
1781
+ const outbox = this.deliveryOutbox;
1782
+ if (!checkpoint || !outbox?.markConsumed)
1783
+ return;
1784
+ if (verdict.submissionMode !== "steer" && verdict.submissionMode !== "native_queue_handoff")
1785
+ return;
1786
+ if (!["claude-code", "codex"].includes(checkpoint.backend))
1787
+ return;
1788
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, boot = this.bootId;
1789
+ consumedWatches.start({
1790
+ deliveryId: delivery.deliveryId,
1791
+ attemptNo: delivery.attemptNo,
1792
+ backend: checkpoint.backend,
1793
+ path: checkpoint.path,
1794
+ offset: checkpoint.offset,
1795
+ current: () => !this.deliveryWritesStopping && spawn === this.spawnGeneration && fence === this.launchFenceEpoch,
1796
+ consumed: (via, evidence) => {
1797
+ try {
1798
+ const result = outbox.markConsumed(delivery.deliveryId, boot, delivery.attemptNo, via, evidence);
1799
+ this.logger.info({ deliveryId: delivery.deliveryId, via, result }, "The CLI's transcript shows the delivery consumed");
1800
+ }
1801
+ catch (err) {
1802
+ this.logger.warn({ err, deliveryId: delivery.deliveryId }, "Could not record the consumed signal (the row keeps its state)");
1803
+ }
1804
+ },
1805
+ logger: this.logger,
1806
+ });
1744
1807
  }
1745
1808
  finishDurableSubmission(delivery, verdict) {
1746
1809
  // Legacy backends may accept a pane write without enough pane evidence to
1747
1810
  // claim submission. Keep that row inspectable instead of recording a false
1748
1811
  // delivered state; reconciliation can classify it in a later phase.
1749
1812
  if (verdict.proof === "unverified") {
1750
- this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
1813
+ this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`, verdict);
1751
1814
  return;
1752
1815
  }
1753
1816
  // 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 +1818,14 @@ export class Daemon extends EventEmitter {
1755
1818
  // it", and delivery_status carries delivery_mode=steer — and it is all this class of backend can ever show for a steer
1756
1819
  // (#1197). Labelled, so it is never mistaken for a proof that the input row was cleared.
1757
1820
  if (verdict.proof === "steer-marker-on-pane") {
1758
- this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
1821
+ this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable", verdict);
1822
+ return;
1823
+ }
1824
+ // A steer into a mode-switched composer (kiro TUI, #1405): the box held the paste before the one Enter and read as
1825
+ // its empty placeholder after it — taken by the CLI (steered into the turn, held for its end on "queue", or a new
1826
+ // turn on "idle"). Accepted, not read; labelled with what the composer showed.
1827
+ if (verdict.proof?.startsWith("steer-composer-")) {
1828
+ this.finishDurableDelivery(delivery, "delivered", `steer-accepted; composer emptied (${verdict.proof.slice("steer-composer-".length)})`, verdict);
1759
1829
  return;
1760
1830
  }
1761
1831
  // A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
@@ -1771,7 +1841,7 @@ export class Daemon extends EventEmitter {
1771
1841
  return;
1772
1842
  }
1773
1843
  this.finishDurableDelivery(delivery, "delivered", verdict.transcriptProof
1774
- ? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof");
1844
+ ? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof", verdict);
1775
1845
  }
1776
1846
  /**
1777
1847
  * Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
@@ -1798,7 +1868,7 @@ export class Daemon extends EventEmitter {
1798
1868
  if (this.deliveryWritesStopping)
1799
1869
  return; // reconciliation owns the row now
1800
1870
  const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
1801
- if (found === "user" || found === "queued") {
1871
+ if (found === "user" || found === "absorbed" || found === "queued") {
1802
1872
  outcome = "delivered";
1803
1873
  evidence = transcriptMarkerEvidence(found);
1804
1874
  break;
@@ -1861,6 +1931,14 @@ export class Daemon extends EventEmitter {
1861
1931
  seedActivityNow(now = Date.now()) {
1862
1932
  this.autoPauseController.recordActivity(now);
1863
1933
  }
1934
+ /**
1935
+ * Whether this launch takes a steer into a running turn, for a backend that answers per launch (kiro's TUI vs its
1936
+ * legacy UI, #1405); undefined for every other backend, whose name decides (steer-capability.ts). The backend reads
1937
+ * what it last launched — no probe, no fork, safe on the fleet loop.
1938
+ */
1939
+ launchSupportsSteer() {
1940
+ return this.backend?.supportsSteer?.();
1941
+ }
1864
1942
  /**
1865
1943
  * Phase 2b (design §1.4): why this awake instance is not taking input, in
1866
1944
  * words for a notice, or null when nothing known holds it. Read from the
@@ -1904,7 +1982,19 @@ export class Daemon extends EventEmitter {
1904
1982
  this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
1905
1983
  });
1906
1984
  }
1907
- async start() {
1985
+ startupAdmission;
1986
+ async start(admission) {
1987
+ this.startupAdmission = admission;
1988
+ try {
1989
+ admission?.();
1990
+ await this.startAdmitted();
1991
+ admission?.();
1992
+ }
1993
+ finally {
1994
+ this.startupAdmission = undefined;
1995
+ }
1996
+ }
1997
+ async startAdmitted() {
1908
1998
  ensureInstanceDir(this.instanceDir);
1909
1999
  writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
1910
2000
  this.logger.info(`Starting ${this.name}`);
@@ -1961,6 +2051,7 @@ export class Daemon extends EventEmitter {
1961
2051
  this.emit("error", err);
1962
2052
  });
1963
2053
  await this.ipcServer.listen();
2054
+ this.startupAdmission?.();
1964
2055
  ipcListening = true;
1965
2056
  // Permanent IPC dispatcher: routes responses to pending requests by type+id key
1966
2057
  this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
@@ -2032,7 +2123,8 @@ export class Daemon extends EventEmitter {
2032
2123
  });
2033
2124
  // 2. Tmux — ensure session, create window if not alive
2034
2125
  await TmuxManager.ensureSession(this.tmuxSessionName);
2035
- this.tmux = new TmuxManager(this.tmuxSessionName, "", resolveTmuxLogicalSize(this.config.terminal));
2126
+ this.startupAdmission?.();
2127
+ this.tmux = new TmuxManager(this.tmuxSessionName, "", resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
2036
2128
  // Strategy A: always start fresh Claude window (MCP server has no reconnection)
2037
2129
  // Kill any existing window from previous run
2038
2130
  const windowIdFile = join(this.instanceDir, "window-id");
@@ -2041,16 +2133,21 @@ export class Daemon extends EventEmitter {
2041
2133
  if (savedId) {
2042
2134
  const oldTmux = new TmuxManager(this.tmuxSessionName, savedId);
2043
2135
  if (await oldTmux.isWindowAlive()) {
2136
+ this.startupAdmission?.();
2044
2137
  await this.checkpointSessionId();
2138
+ this.startupAdmission?.();
2045
2139
  await oldTmux.killWindow();
2140
+ this.startupAdmission?.();
2046
2141
  this.logger.info({ savedId }, "Killed old tmux window for fresh start");
2047
2142
  }
2048
2143
  }
2049
2144
  }
2050
2145
  const resumed = await this.spawnClaudeWindow();
2146
+ this.startupAdmission?.();
2051
2147
  this.isNewSession = !resumed;
2052
2148
  if (!resumed) {
2053
- await this.injectSnapshotMessage();
2149
+ await this.injectSnapshotMessage(this.startupAdmission);
2150
+ this.startupAdmission?.();
2054
2151
  }
2055
2152
  else {
2056
2153
  // Clean up stale snapshot file — resume restored full context, snapshot not needed
@@ -2074,6 +2171,7 @@ export class Daemon extends EventEmitter {
2074
2171
  // we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
2075
2172
  // keeps the writer attached after size resets.
2076
2173
  await this.attachPipePaneLog();
2174
+ this.startupAdmission?.();
2077
2175
  // 4. Transcript monitor. claude-code is handled inside the monitor
2078
2176
  // (statusline transcript); codex/kiro/opencode read their CLI's own
2079
2177
  // conversation store via a pluggable source. Backends with no known
@@ -2084,6 +2182,7 @@ export class Daemon extends EventEmitter {
2084
2182
  // Baseline in the isolate before this daemon accepts new work. A
2085
2183
  // stopped/replaced launch may never re-arm polling after this await.
2086
2184
  await transcriptMonitor.initialize();
2185
+ this.startupAdmission?.();
2087
2186
  if (this.startupAborted || this.launchFenceEpoch !== transcriptFence || this.transcriptMonitor !== transcriptMonitor) {
2088
2187
  transcriptMonitor.stop();
2089
2188
  return;
@@ -2589,7 +2688,7 @@ export class Daemon extends EventEmitter {
2589
2688
  // Stop the loop permanently — otherwise every tick triggers a respawn, whose
2590
2689
  // writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
2591
2690
  // spamming errors every ~30s forever.
2592
- if (!existsSync(this.instanceDir)) {
2691
+ if (!measureSyncWork("daemon.healthCheck", () => existsSync(this.instanceDir))) {
2593
2692
  this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
2594
2693
  this.healthCheckPaused = true;
2595
2694
  this.healthCheckTimer = null;
@@ -2601,16 +2700,26 @@ export class Daemon extends EventEmitter {
2601
2700
  return;
2602
2701
  }
2603
2702
  // The CLI owns the MCP server process, so the daemon can only observe it.
2604
- this.checkMcpServerAlive();
2703
+ measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
2605
2704
  // Human-readable backend label for logs (e.g. "claude", "kiro-cli")
2606
2705
  const cliLabel = this.backend?.binaryName ?? "CLI";
2607
- let paneStatus = await this.tmux.getPaneStatus();
2706
+ const healthTmux = this.tmux;
2707
+ const healthOwner = this.interactionOwner();
2708
+ const healthCurrent = () => this.tmux === healthTmux
2709
+ && sameInteractionOwner(healthOwner, this.interactionOwner())
2710
+ && !this.runtimeMonitorsFrozen && !this.healthCheckPaused && !this.spawning
2711
+ && !this.isPaused && this.pauseWakeState !== "waking";
2712
+ const discardStaleHealth = () => {
2713
+ if (healthCurrent())
2714
+ return false;
2715
+ scheduleNext();
2716
+ return true;
2717
+ };
2718
+ let paneStatus = await healthTmux.getPaneStatus();
2608
2719
  // Auto-pause intentionally exits the pane process. A health tick that
2609
2720
  // began just before pause must not classify that exit as a crash.
2610
- if (this.isPaused || this.pauseWakeState === "waking") {
2611
- scheduleNext();
2721
+ if (discardStaleHealth())
2612
2722
  return;
2613
- }
2614
2723
  if (paneStatus?.alive) {
2615
2724
  this.windowQueryFailureTicks = 0;
2616
2725
  this.stormWindow?.noteWindowAlive(this.name);
@@ -2620,7 +2729,7 @@ export class Daemon extends EventEmitter {
2620
2729
  // 10–100 MiB log runs off the event loop, and this tick must not wait for
2621
2730
  // it (#1161) — scheduleNext() below is not delayed by a rotation.
2622
2731
  if (!this.config.lightweight) {
2623
- void rotateLogIfNeededAsync(join(this.instanceDir, "output.log"));
2732
+ void measureSyncWork("daemon.healthCheck", () => rotateLogIfNeededAsync(join(this.instanceDir, "output.log")));
2624
2733
  }
2625
2734
  scheduleNext();
2626
2735
  return;
@@ -2632,7 +2741,11 @@ export class Daemon extends EventEmitter {
2632
2741
  // and needs no recheck.
2633
2742
  if (paneStatus === null) {
2634
2743
  await new Promise(r => setTimeout(r, 1500));
2635
- paneStatus = await this.tmux.getPaneStatus();
2744
+ if (discardStaleHealth())
2745
+ return;
2746
+ paneStatus = await healthTmux.getPaneStatus();
2747
+ if (discardStaleHealth())
2748
+ return;
2636
2749
  if (paneStatus?.alive) {
2637
2750
  this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
2638
2751
  this.windowQueryFailureTicks = 0;
@@ -2647,12 +2760,17 @@ export class Daemon extends EventEmitter {
2647
2760
  this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
2648
2761
  // Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
2649
2762
  if (paneStatus && exitCode === 0) {
2650
- this.setProcessStatus("stopped");
2651
2763
  // Status 0 is not proof of a clean exit: a codex that hits a quota
2652
2764
  // wall exits 0 too. Capture what it printed before the window goes.
2653
- this.logPaneDeath(cliLabel, exitCode, await this.capturePaneOutput());
2765
+ const output = await this.capturePaneOutput();
2766
+ if (discardStaleHealth())
2767
+ return;
2768
+ this.setProcessStatus("stopped");
2769
+ this.logPaneDeath(cliLabel, exitCode, output);
2654
2770
  this.logger.info("CLI exited normally (code 0) — pausing health check");
2655
- await this.tmux.killWindow();
2771
+ await healthTmux.killWindow();
2772
+ if (discardStaleHealth())
2773
+ return;
2656
2774
  this.healthCheckPaused = true;
2657
2775
  this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.", 0);
2658
2776
  return;
@@ -2665,12 +2783,15 @@ export class Daemon extends EventEmitter {
2665
2783
  let nullReason;
2666
2784
  if (!paneStatus) {
2667
2785
  const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
2786
+ if (discardStaleHealth())
2787
+ return;
2668
2788
  // A server may have already restarted before this daemon's health
2669
2789
  // tick. The new PID is the durable generation boundary: treat it as
2670
2790
  // 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;
2791
+ const serverPid = serverAlive ? await TmuxManager.getServerPid(this.tmuxSessionName) : null;
2792
+ if (discardStaleHealth())
2793
+ return;
2794
+ const generationChanged = serverAlive && this.stormWindow?.observeServerAlive(serverPid) === true;
2674
2795
  if (generationChanged)
2675
2796
  this.emit("tmux_server_crash", this.name);
2676
2797
  if (!serverAlive || generationChanged || this.stormWindow?.needsRecovery(this.name)) {
@@ -2688,13 +2809,17 @@ export class Daemon extends EventEmitter {
2688
2809
  // The fleet breaker owns the delay and extends it on every new
2689
2810
  // server generation. Do not schedule a competing fixed timer.
2690
2811
  await this.stormWindow?.waitForSpawnAllowed();
2812
+ if (discardStaleHealth())
2813
+ return;
2691
2814
  }
2692
2815
  else {
2693
2816
  // null but server alive: window-level disappearance. Probe whether
2694
2817
  // the window truly no longer exists vs a transient query glitch.
2695
2818
  nullReason = "no_window";
2696
2819
  try {
2697
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
2820
+ const windows = await TmuxManager.listWindowsStrict(this.tmuxSessionName, this.controlClient);
2821
+ if (discardStaleHealth())
2822
+ return;
2698
2823
  this.windowQueryFailureTicks = 0;
2699
2824
  const currentWindowId = this.tmux.getWindowId();
2700
2825
  if (windows.some(w => w.id === currentWindowId)) {
@@ -2735,22 +2860,26 @@ export class Daemon extends EventEmitter {
2735
2860
  else {
2736
2861
  this.logger.warn({ exitCode }, `${cliLabel} process exited`);
2737
2862
  }
2738
- this.setProcessStatus("crashed");
2739
2863
  // Capture last output before killing. Best-effort even when the pane is
2740
2864
  // gone (paneStatus null) — gives the crash record something to diagnose
2741
2865
  // from instead of an empty lastOutput.
2742
2866
  let lastOutput;
2743
2867
  try {
2744
- const raw = await this.tmux.capturePaneWithHistory(50);
2868
+ const raw = await healthTmux.capturePaneWithHistory(50);
2745
2869
  // Strip ANSI escape codes for readability
2746
2870
  const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
2747
2871
  lastOutput = cleaned.trimEnd() || undefined;
2748
2872
  }
2749
2873
  catch { /* best effort — pane may already be gone */ }
2874
+ if (discardStaleHealth())
2875
+ return;
2876
+ this.setProcessStatus("crashed");
2750
2877
  this.logPaneDeath(cliLabel, exitCode, lastOutput);
2751
2878
  // Kill the dead window (remain-on-exit keeps it around) before respawn
2752
2879
  if (paneStatus) {
2753
- await this.tmux.killWindow();
2880
+ await healthTmux.killWindow();
2881
+ if (discardStaleHealth())
2882
+ return;
2754
2883
  }
2755
2884
  // Detect claude-code background session conflict — recover without counting as crash
2756
2885
  if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
@@ -3048,6 +3177,9 @@ export class Daemon extends EventEmitter {
3048
3177
  const captureMono = performance.now();
3049
3178
  const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
3050
3179
  || !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
3180
+ let scanStartedAt = null;
3181
+ const endScan = () => { if (scanStartedAt !== null)
3182
+ noteSyncWork("daemon.errorMonitorScan", scanStartedAt); scanStartedAt = null; };
3051
3183
  try {
3052
3184
  const alive = await this.tmux.isWindowAlive();
3053
3185
  if (stale())
@@ -3056,6 +3188,8 @@ export class Daemon extends EventEmitter {
3056
3188
  return;
3057
3189
  const captureAt = Date.now();
3058
3190
  const pane = await this.tmux.capturePane();
3191
+ // The scan of this pane is synchronous until it answers a dialog (an await) or ends: attributed (#1235).
3192
+ scanStartedAt = performance.now();
3059
3193
  if (stale())
3060
3194
  return;
3061
3195
  if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
@@ -3143,6 +3277,7 @@ export class Daemon extends EventEmitter {
3143
3277
  // (not queue) when the pane is busy: this poller runs every 5s, and the
3144
3278
  // dialog will still be on screen next tick.
3145
3279
  let resolved = false;
3280
+ endScan();
3146
3281
  const dismissed = await this.paneWriteLock.tryRun(async () => {
3147
3282
  // Re-read under the lock: the first capture may have gone stale
3148
3283
  // while a delivery was finishing. A stale danger menu must never
@@ -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
@@ -6860,6 +7043,73 @@ export class Daemon extends EventEmitter {
6860
7043
  }
6861
7044
  return true;
6862
7045
  }
7046
+ /**
7047
+ * #1405: a steer is handed to the busy pane only if THIS launch takes one — asked of the backend again at delivery,
7048
+ * not just by the hub when it was sent: a durable steer can outlive the launch it was meant for (a restart onto a
7049
+ * legacy UI or an unverified version), and must then wait for the idle prompt like any message. Backends that do not
7050
+ * answer per launch leave it to the hub's name table.
7051
+ */
7052
+ launchTakesSteer() {
7053
+ return this.backend?.supportsSteer?.() !== false;
7054
+ }
7055
+ /**
7056
+ * #1405: a steer into a CLI whose busy input steers or queues by a mode the user switches (kiro TUI) goes in only
7057
+ * while a fresh capture reads its composer as "steer" — or "idle" once the turn has ended (`allowIdle`, the pre-write
7058
+ * re-check). "queue" (the user's choice), text in the box, or a screen it cannot read: not now — the caller waits for
7059
+ * the idle prompt as for any message. AgEnD never switches the mode. Backends without the reader are not asked.
7060
+ */
7061
+ async steerComposerAdmits(allowIdle) {
7062
+ const read = this.backend?.readSteerComposer;
7063
+ if (!read)
7064
+ return true;
7065
+ if (!this.tmux)
7066
+ return false;
7067
+ let mode;
7068
+ try {
7069
+ mode = read.call(this.backend, await this.tmux.capturePane());
7070
+ }
7071
+ catch {
7072
+ return false;
7073
+ }
7074
+ return mode === "steer" || (allowIdle && mode === "idle");
7075
+ }
7076
+ /**
7077
+ * #1405: one look at a mode-switched composer for a steer's own proof: on the tmux of this write, inside `deadline`
7078
+ * (monotonic) — the capture is raced against the time left and a late answer dropped — and `current` asked after the
7079
+ * await. Null when unreadable, late or no longer this write's: never evidence of an empty or a full box. The pane
7080
+ * comes back with the mode, so what follows judges the same capture.
7081
+ */
7082
+ async steerComposerWithin(tmux, deadline, current) {
7083
+ const read = this.backend?.readSteerComposer;
7084
+ const left = deadline - performance.now();
7085
+ if (!read || left <= 0 || !current())
7086
+ return null;
7087
+ const pane = await withinMs(tmux.capturePane(Math.ceil(left)), left);
7088
+ if (pane === undefined || performance.now() > deadline || !current())
7089
+ return null;
7090
+ const mode = read.call(this.backend, pane);
7091
+ return mode === null ? null : { mode, pane };
7092
+ }
7093
+ /**
7094
+ * #1405: the composer's text is THIS delivery's paste. kiro paints a multi-line paste's first line on the `›` row (live,
7095
+ * 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
7096
+ * is its `[agend-delivery-id:<this attempt's id>]` marker, so a draft, someone else's paste, our paste appended to a
7097
+ * draft, or a marker cut short (wrapped, collapsed, truncated) is never taken for it: not attributable, no Enter.
7098
+ */
7099
+ steerPasteIsOurs(pane, formatted) {
7100
+ const shown = this.backend?.readSteerComposerText?.(pane);
7101
+ if (!shown)
7102
+ return false;
7103
+ const norm = (t) => t.replace(/\s+/g, " ").trim();
7104
+ const first = norm(formatted.split("\n").find(line => line.trim() !== "") ?? "");
7105
+ if (first === "")
7106
+ return false;
7107
+ const row = norm(shown);
7108
+ // The marker line holds nothing but the marker: the row is exactly it.
7109
+ if (/^\[agend-delivery-id:[^\]\s]+\]$/.test(first))
7110
+ return row === first;
7111
+ return row === first || row.startsWith(`${first} `);
7112
+ }
6863
7113
  /**
6864
7114
  * Codex can paint a prompt before the TTY enters raw mode. Both are required.
6865
7115
  *
@@ -7548,6 +7798,11 @@ export class Daemon extends EventEmitter {
7548
7798
  let windowId = initialWindowId;
7549
7799
  // The spawn this write belongs to: acceptance evidence is only read while it is still the current one (#1197).
7550
7800
  const spawnAtWrite = this.spawnGeneration;
7801
+ // #1405: a composer-proven steer is bound to this write's tmux and launch: every read, the Enter and the proof ask
7802
+ // this after each await (a respawn swaps this.tmux; a stop, pause or cancel ends the caller's fence).
7803
+ const tmuxAtWrite = this.tmux;
7804
+ const steerCurrent = () => (!stillCurrent || stillCurrent()) && !this.deliveryWritesStopping
7805
+ && this.tmux === tmuxAtWrite && this.spawnGeneration === spawnAtWrite;
7551
7806
  // Bug A: paste with backoff. Transient failures are usually a stale window id
7552
7807
  // after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
7553
7808
  const maxAttempts = 3;
@@ -7572,6 +7827,20 @@ export class Daemon extends EventEmitter {
7572
7827
  return "handoff-box-unread";
7573
7828
  return this.failDelivery(verdict, status, "paste", "handoff-input-unreadable");
7574
7829
  }
7830
+ // #1405: on that same last capture a mode-switched composer (kiro TUI) must still read "steer" — or "idle", the
7831
+ // turn over. "queue", text in the box or an unreadable row: not written; handled as the unread box above.
7832
+ if (handingOffToNativeQueue && steer && this.backend?.readSteerComposer
7833
+ && pasteBaseline?.steerComposer !== "steer" && pasteBaseline?.steerComposer !== "idle") {
7834
+ if (!verdict.durableBeginCommitted)
7835
+ return "handoff-box-unread";
7836
+ return this.failDelivery(verdict, status, "paste", "steer-composer-not-steering");
7837
+ }
7838
+ // …and the launch must still take a steer (see launchTakesSteer). Not written: before the durable begin the row
7839
+ // is retried, as a steer the gate will then send down the idle path.
7840
+ if (handingOffToNativeQueue && steer && !this.launchTakesSteer()) {
7841
+ verdict.fenced = !verdict.durableBeginCommitted;
7842
+ return false;
7843
+ }
7575
7844
  // Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
7576
7845
  // Commit the submission fence at the last possible point before the
7577
7846
  // first side effect; a crash during those waits remains safely replayable.
@@ -7595,6 +7864,7 @@ export class Daemon extends EventEmitter {
7595
7864
  return false;
7596
7865
  }
7597
7866
  verdict.durableBeginCommitted = true;
7867
+ verdict.submissionMode = attemptEvidence.submissionMode;
7598
7868
  if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
7599
7869
  verdict.transcriptCheckpoint = {
7600
7870
  backend: attemptEvidence.backend,
@@ -7664,10 +7934,50 @@ export class Daemon extends EventEmitter {
7664
7934
  await new Promise(r => setTimeout(r, fallbackMs));
7665
7935
  settle = { settleMs: fallbackMs, observedPostPasteOutput: false, capHit: false, usedFallback: true };
7666
7936
  }
7937
+ // #1405: a steer into a mode-switched composer (kiro TUI) is proven by the box alone — before the Enter it must
7938
+ // positively hold typed text, and after it be the empty placeholder again. The empty placeholder here means the
7939
+ // paste never reached the box; an unreadable box (a failed or late capture, a dialog) proves nothing: no Enter.
7940
+ const steerComposerProof = handingOffToNativeQueue && steer && !!this.backend?.readSteerComposer && tmuxAtWrite !== null;
7941
+ let steerEnterDeadline = Infinity;
7942
+ if (steerComposerProof) {
7943
+ const deadline = performance.now() + STEER_PASTE_VISIBLE_MS;
7944
+ let before = null;
7945
+ for (;;) {
7946
+ before = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
7947
+ if (before !== null || !steerCurrent() || performance.now() >= deadline)
7948
+ break;
7949
+ await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
7950
+ }
7951
+ if (!steerCurrent()) {
7952
+ // Written but not entered, and no longer this write's pane to judge: uncertain, never retried here.
7953
+ verdict.phase = "steer-paste";
7954
+ verdict.proof = "fenced-before-enter";
7955
+ return false;
7956
+ }
7957
+ // The caller's own receipt is inside the budget too: a reading whose continuation ran late is not used.
7958
+ if (performance.now() > deadline)
7959
+ return this.failDelivery(verdict, status, "steer-paste", "box-read-late");
7960
+ if (before?.mode !== "text")
7961
+ return this.failDelivery(verdict, status, "steer-paste", before === null ? "box-unread" : `box-still-${before.mode}`);
7962
+ if (!this.steerPasteIsOurs(before.pane, formatted))
7963
+ return this.failDelivery(verdict, status, "steer-paste", "box-not-this-paste");
7964
+ // …and so is the Enter: the key is sent only while the reading is still within it.
7965
+ steerEnterDeadline = deadline;
7966
+ }
7667
7967
  let enterAt = Date.now();
7668
- if (!(await this.sendDeliveryEnter("initial-submit", undefined, durableAttempt))) {
7968
+ const steerEnterCurrent = () => steerCurrent() && performance.now() <= steerEnterDeadline;
7969
+ if (!(await this.sendDeliveryEnter("initial-submit", steerComposerProof ? steerEnterCurrent : undefined, durableAttempt))) {
7970
+ if (steerComposerProof && !steerCurrent()) {
7971
+ verdict.phase = "submit-enter";
7972
+ verdict.proof = "fenced-before-enter";
7973
+ return false;
7974
+ }
7975
+ if (steerComposerProof && performance.now() > steerEnterDeadline)
7976
+ return this.failDelivery(verdict, status, "submit-enter", "pre-enter-deadline");
7669
7977
  return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
7670
7978
  }
7979
+ // The proof's whole budget, from the Enter: every capture and dialog probe inside it, a late answer dropped.
7980
+ const steerProofDeadline = performance.now() + NATIVE_QUEUE_PASTE_VERIFY_MS + POST_ENTER_PROOF_WINDOW_MS;
7671
7981
  // Kiro's legacy TUI can swallow Enter while it is still processing a large
7672
7982
  // paste — not only during the post-ready redraw (#479): on slower hosts it
7673
7983
  // happens on ordinary deliveries too (v2.1.2 stable, WSL2 + tmux 3.4).
@@ -7681,7 +7991,9 @@ export class Daemon extends EventEmitter {
7681
7991
  // re-baselined to the second Enter so leftover paste-render output between
7682
7992
  // the two cannot be what "confirms" the submission.
7683
7993
  let enterRetry = false;
7684
- if (!rawPaste && this.backend?.requiresDeliveryEnterRetry?.() === true) {
7994
+ // Not after a composer-proven steer: what a bare Enter does in kiro's TUI composer mid-turn is unverified, and the
7995
+ // box itself shows whether the one Enter took the text.
7996
+ if (!rawPaste && !steerComposerProof && this.backend?.requiresDeliveryEnterRetry?.() === true) {
7685
7997
  await new Promise(r => setTimeout(r, 1_000));
7686
7998
  const retryAt = Date.now();
7687
7999
  if (await this.sendDeliveryEnter("queue-less-defensive-retry")) {
@@ -7705,6 +8017,41 @@ export class Daemon extends EventEmitter {
7705
8017
  // can silently swallow it). Idle submissions keep the swallowed-Enter path.
7706
8018
  if (handingOffToNativeQueue) {
7707
8019
  await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
8020
+ if (steerComposerProof) {
8021
+ // #1405: the box is its empty placeholder again — "steer" (taken into the turn), "queue" (the user switched
8022
+ // mode meanwhile: held for the turn's end) or "idle" (the turn over: it started one) — with no dialog and the
8023
+ // same spawn and window. Kiro paints late, so a bounded poll on a monotonic deadline. Still not empty: the
8024
+ // Enter did not take it — uncertain, never re-pasted and never sent another Enter. Every capture and dialog
8025
+ // probe runs inside the budget set at the Enter; the deadline and this write's fence are asked again,
8026
+ // synchronously, right before it is accepted.
8027
+ const deadline = steerProofDeadline;
8028
+ for (;;) {
8029
+ const look = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
8030
+ const after = look?.mode;
8031
+ if (after === "steer" || after === "queue" || after === "idle") {
8032
+ const probe = await withinMs(this.probeBlockingDialog(), deadline - performance.now());
8033
+ if (probe?.state === "clear" && performance.now() <= deadline && steerCurrent() && this.getWindowId() === windowId) {
8034
+ verdict.phase = "steer-accepted";
8035
+ verdict.proof = `steer-composer-${after}`;
8036
+ if (status)
8037
+ this.emit("message_confirmed", status); // ✅ taken from the box
8038
+ return true;
8039
+ }
8040
+ }
8041
+ if (performance.now() >= deadline)
8042
+ break;
8043
+ await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
8044
+ }
8045
+ if (!steerCurrent()) {
8046
+ verdict.phase = "steer-proof";
8047
+ verdict.proof = "fenced";
8048
+ return false;
8049
+ }
8050
+ this.logger.warn("Steer: the composer was not shown empty after the Enter in time — outcome uncertain, not re-sent");
8051
+ verdict.phase = "steer-proof";
8052
+ verdict.proof = "composer-not-emptied";
8053
+ return false;
8054
+ }
7708
8055
  const proof = await this.confirmSubmitted(signature, pasteBaseline);
7709
8056
  if (proof === "submitted") {
7710
8057
  if (status)
@@ -8120,8 +8467,8 @@ export class Daemon extends EventEmitter {
8120
8467
  return this.submittedProof();
8121
8468
  }
8122
8469
  /**
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
8470
+ * The CLI's transcript, from this attempt's checkpoint, holds this delivery's exact marker: a user entry (read), an
8471
+ * entry Claude absorbed mid-turn, or one it queued (taken into its queue). Any of them is the CLI having taken the
8125
8472
  * message, so it outranks the pane — including a box that still shows the paste a moment after Enter (Claude paints
8126
8473
  * the submit late, captured live on 2.1.293), which on its own reads as a strand. Positive only; never a downgrade.
8127
8474
  */
@@ -8133,7 +8480,7 @@ export class Daemon extends EventEmitter {
8133
8480
  return t.provenBy;
8134
8481
  const found = await t.reader.look();
8135
8482
  // 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;
8483
+ return found === "user" || found === "absorbed" || found === "queued" ? found : null;
8137
8484
  }
8138
8485
  /**
8139
8486
  * A write's transcript proof: bounded looks from its checkpoint, fenced to the write — its spawn and launch, a stop,
@@ -8318,6 +8665,7 @@ export class Daemon extends EventEmitter {
8318
8665
  strandedInput: input != null && inputShowsPastedText(input.text, signature.value),
8319
8666
  collapsedPastes: input?.collapsedPastes ?? 0,
8320
8667
  inputReadable: input != null,
8668
+ steerComposer: this.backend?.readSteerComposer?.(pane) ?? null,
8321
8669
  };
8322
8670
  }
8323
8671
  /**
@@ -8381,7 +8729,10 @@ export class Daemon extends EventEmitter {
8381
8729
  * is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
8382
8730
  * (the retries included). Without a guard it is exactly the old unconditional path.
8383
8731
  */
8384
- async submitSystemPaste(text, label, guard) {
8732
+ async submitSystemPaste(text, label, guard, admission) {
8733
+ const current = guard || admission ? () => { admission?.(); return guard?.current() ?? true; } : undefined;
8734
+ if (current && !current())
8735
+ return false;
8385
8736
  if (!this.tmux)
8386
8737
  return false;
8387
8738
  const signature = this.submissionSignature(text);
@@ -8394,32 +8745,41 @@ export class Daemon extends EventEmitter {
8394
8745
  catch {
8395
8746
  return false;
8396
8747
  }
8397
- if (!guard.current() || !guard.accept(pane))
8748
+ if (!current() || !guard.accept(pane))
8398
8749
  return false;
8399
8750
  baseline = this.paneEvidence(pane, signature);
8400
8751
  }
8401
8752
  else {
8402
8753
  baseline = await this.capturePaneEvidence(signature);
8403
8754
  }
8755
+ if (current && !current())
8756
+ return false;
8404
8757
  const pasteGeneration = this.spawnGeneration;
8405
- if (!(await this.tmux.pasteBuffer(text))) {
8758
+ const pasted = current ? await this.tmux.pasteBuffer(text, { guard: current }) : await this.tmux.pasteBuffer(text);
8759
+ if (!pasted) {
8406
8760
  this.logger.warn({ label }, "System paste failed to reach the pane");
8407
8761
  return false;
8408
8762
  }
8763
+ if (current && !current())
8764
+ return false;
8409
8765
  this.rememberPaste(text, pasteGeneration);
8410
8766
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
8411
- if (!(await this.sendDeliveryEnter(label, guard?.current)))
8767
+ if (!(await this.sendDeliveryEnter(label, current)))
8412
8768
  return false;
8413
8769
  let proof = await this.confirmSubmitted(signature, baseline);
8770
+ if (current && !current())
8771
+ return false;
8414
8772
  // A structural box reader sees the paste in the box until the CLI digests the Enter (claude-code repaints a moment
8415
8773
  // later): give that a bounded window before calling it stranded or lost, as the delivery ladder does.
8416
8774
  if (this.backend?.readInputRow) {
8417
8775
  const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
8418
8776
  while (proof !== "submitted" && Date.now() < deadline) {
8419
8777
  await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
8420
- if (guard && !guard.current())
8778
+ if (current && !current())
8421
8779
  return false;
8422
8780
  proof = await this.confirmSubmitted(signature, baseline);
8781
+ if (current && !current())
8782
+ return false;
8423
8783
  }
8424
8784
  }
8425
8785
  if (proof === "unverifiable") {
@@ -8437,7 +8797,7 @@ export class Daemon extends EventEmitter {
8437
8797
  // exists to remove — visible text is also what a strand looks like.
8438
8798
  if (this.systemPasteOptions().retryEnter) {
8439
8799
  await new Promise(r => setTimeout(r, 1_000));
8440
- await this.sendDeliveryEnter(`${label}-defensive-retry`, guard?.current);
8800
+ await this.sendDeliveryEnter(`${label}-defensive-retry`, current);
8441
8801
  }
8442
8802
  return true; // best effort, exactly as before — nothing here is verified
8443
8803
  }
@@ -8446,9 +8806,11 @@ export class Daemon extends EventEmitter {
8446
8806
  // first one did land; when the text is still in the input row it is the
8447
8807
  // submit it never got. Re-pasting would append the text to itself.
8448
8808
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
8449
- if (!(await this.sendDeliveryEnter(`${label}-retry`, guard?.current)))
8809
+ if (!(await this.sendDeliveryEnter(`${label}-retry`, current)))
8450
8810
  return false;
8451
8811
  proof = await this.confirmSubmitted(signature, baseline);
8812
+ if (current && !current())
8813
+ return false;
8452
8814
  }
8453
8815
  if (proof !== "submitted") {
8454
8816
  this.logger.warn({ label, proof }, "System paste may not have been submitted");
@@ -8458,21 +8820,30 @@ export class Daemon extends EventEmitter {
8458
8820
  }
8459
8821
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
8460
8822
  async recoverWindow() {
8823
+ const previousTmux = this.tmux;
8824
+ const recoveryOwner = this.interactionOwner();
8825
+ const recoveryCurrent = () => !this.runtimeMonitorsFrozen
8826
+ && sameInteractionOwner(recoveryOwner, this.interactionOwner());
8461
8827
  const previousWindowId = this.tmux?.getWindowId();
8462
8828
  try {
8463
- const windows = await TmuxManager.listWindows(this.tmuxSessionName);
8829
+ const windows = await TmuxManager.listWindows(this.tmuxSessionName, this.controlClient);
8830
+ if (!recoveryCurrent() || this.tmux !== previousTmux)
8831
+ return undefined;
8464
8832
  const match = windows.find(w => w.name === this.name);
8465
8833
  if (!match)
8466
8834
  return undefined;
8467
- this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
8835
+ this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
8836
+ const recoveredTmux = this.tmux;
8468
8837
  writeFileSync(join(this.instanceDir, "window-id"), match.id);
8469
8838
  // 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
8839
+ // control client re-resolves a dead id — one bounded read — on every
8471
8840
  // reconnect, for the life of the fleet process.
8472
8841
  if (previousWindowId && previousWindowId !== match.id) {
8473
8842
  this.controlClient?.unregisterWindow(previousWindowId);
8474
8843
  }
8475
8844
  await this.controlClient?.registerWindow(match.id);
8845
+ if (!recoveryCurrent() || this.tmux !== recoveredTmux)
8846
+ return undefined;
8476
8847
  this.bindInstanceStateOutputListener(match.id);
8477
8848
  this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
8478
8849
  return match.id;
@@ -8581,6 +8952,31 @@ export class Daemon extends EventEmitter {
8581
8952
  // continuation for an already-answered turn.
8582
8953
  if (replyAttempt?.completionAction)
8583
8954
  clearInFlightTurnMarker(this.instanceDir);
8955
+ // #1377: a confirmed completion action (reply/react/edit_message) ends
8956
+ // the human-facing obligation immediately — the idle edge does not need
8957
+ // to confirm it. Clear any pending confirmation window so a later
8958
+ // cross-instance busy→idle cycle cannot re-arm and fire false recovery.
8959
+ // The guard itself completes on the next steady-idle tick via
8960
+ // maybeConfirmReplyGuardIdle (which checks completionDelivered when
8961
+ // replyGuardIdleConfirm is null) or at the next busy→idle edge.
8962
+ // Cancelled turns are excluded: their completion semantics are handled
8963
+ // by the idle edge's cancelledByUser check.
8964
+ // An older reply that satisfied an earlier obligation but not the latest
8965
+ // one (latestObligation > token.obligation) leaves completionDelivered
8966
+ // false and must not clear the window either.
8967
+ if (replyAttempt?.completionAction) {
8968
+ const snap = this.turnReplyGuard.snapshot();
8969
+ if (snap?.generation === replyAttempt.generation && snap.phase === "awaiting"
8970
+ && snap.completionDelivered && !snap.cancelledByUser) {
8971
+ this.clearReplyGuardConfirm();
8972
+ }
8973
+ }
8974
+ }
8975
+ else if ((error || result == null) && replyAttempt?.reply && TURN_OUTBOUND_TOOLS.has(tool)) {
8976
+ // #1377: the adapter returned a definitive error — record this so the
8977
+ // confirm window can distinguish "in-flight" (unknown) from "known
8978
+ // failed" and report unknown immediately rather than starting recovery.
8979
+ this.turnReplyGuard.settleToolAttempt(replyAttempt, false);
8584
8980
  }
8585
8981
  const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
8586
8982
  if (!sent) {
@@ -8979,7 +9375,8 @@ export class Daemon extends EventEmitter {
8979
9375
  * user input so the agent picks up where the previous session left off.
8980
9376
  * This replaces the old system-prompt injection approach.
8981
9377
  */
8982
- async injectSnapshotMessage() {
9378
+ async injectSnapshotMessage(admission) {
9379
+ admission?.();
8983
9380
  if (this.snapshotConsumed)
8984
9381
  return;
8985
9382
  const snapshot = this.buildSnapshotPrompt();
@@ -8991,11 +9388,16 @@ export class Daemon extends EventEmitter {
8991
9388
  }
8992
9389
  // Small delay to let the CLI fully render its ready prompt
8993
9390
  await new Promise(r => setTimeout(r, 1_000));
9391
+ admission?.();
8994
9392
  try {
8995
9393
  // Messages can arrive during a restart and be queued on pasteLock before the
8996
9394
  // snapshot lands; both write to the pane, so both go through the same lock.
8997
9395
  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"));
9396
+ const injected = await this.paneWriteLock.run(() => {
9397
+ admission?.();
9398
+ return this.submitSystemPaste(restoreNotice, "session-snapshot-restore", undefined, admission);
9399
+ });
9400
+ admission?.();
8999
9401
  if (!injected) {
9000
9402
  // rotation-state.json was deleted when the prompt was built, so there is
9001
9403
  // nothing left to retry from: the restore is gone either way. Say so —
@@ -9242,12 +9644,14 @@ export class Daemon extends EventEmitter {
9242
9644
  // Fresh start or not: the owner is recorded below once this CLI is up, so an
9243
9645
  // id it does not own must be out of the way first, or it would be recorded
9244
9646
  // as this backend's and resumed by the next start.
9647
+ this.startupAdmission?.();
9245
9648
  this.setAsideForeignSession();
9246
9649
  const attemptedResume = !this.skipResume;
9247
9650
  // A resume launch may get a longer budget than a fresh one (kiro: the
9248
9651
  // conversation must come back from the backend before anything paints).
9249
9652
  const resumeBudget = attemptedResume ? this.startupBudgetFor(true) : undefined;
9250
9653
  let alive = await this.trySpawn(false, resumeBudget);
9654
+ this.startupAdmission?.();
9251
9655
  if (!alive && attemptedResume) {
9252
9656
  // Resume failed. Before abandoning the session:
9253
9657
  // 1. If the backend is unreachable (fleet-level memory, or this very pane
@@ -9258,15 +9662,22 @@ export class Daemon extends EventEmitter {
9258
9662
  // 2. Otherwise, for backends that ask for it, retry resume ONCE — the
9259
9663
  // first miss is usually slowness, not a broken session.
9260
9664
  await this.noteStartupPaneForBackendOutage();
9665
+ this.startupAdmission?.();
9261
9666
  await this.failStartupIfBackendUnreachable();
9667
+ this.startupAdmission?.();
9262
9668
  if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
9263
9669
  this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
9264
9670
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9671
+ this.startupAdmission?.();
9265
9672
  await this.tmux.killWindow();
9673
+ this.startupAdmission?.();
9266
9674
  alive = await this.trySpawn(false, resumeBudget);
9675
+ this.startupAdmission?.();
9267
9676
  if (!alive) {
9268
9677
  await this.noteStartupPaneForBackendOutage();
9678
+ this.startupAdmission?.();
9269
9679
  await this.failStartupIfBackendUnreachable();
9680
+ this.startupAdmission?.();
9270
9681
  }
9271
9682
  }
9272
9683
  }
@@ -9288,7 +9699,9 @@ export class Daemon extends EventEmitter {
9288
9699
  // Keep the session and fail this attempt; the fleet retries with
9289
9700
  // backoff, which is also how the backend-outage path behaves.
9290
9701
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9702
+ this.startupAdmission?.();
9291
9703
  await this.tmux.killWindow();
9704
+ this.startupAdmission?.();
9292
9705
  throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
9293
9706
  + "— session kept, will retry");
9294
9707
  }
@@ -9305,11 +9718,16 @@ export class Daemon extends EventEmitter {
9305
9718
  // a session: nothing about a failed fresh launch says the stored
9306
9719
  // conversation is unusable.
9307
9720
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9721
+ this.startupAdmission?.();
9308
9722
  await this.tmux.killWindow();
9723
+ this.startupAdmission?.();
9309
9724
  const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
9725
+ this.startupAdmission?.();
9310
9726
  if (!retryAlive) {
9311
9727
  await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
9728
+ this.startupAdmission?.();
9312
9729
  await this.tmux.killWindow();
9730
+ this.startupAdmission?.();
9313
9731
  throw new Error("CLI failed to start after retry");
9314
9732
  }
9315
9733
  }
@@ -9331,6 +9749,133 @@ export class Daemon extends EventEmitter {
9331
9749
  }
9332
9750
  return resumedSuccessfully;
9333
9751
  }
9752
+ /** The whole agent switch after a resume, on the monotonic clock (#906 §3). */
9753
+ static AGENT_SWITCH_BUDGET_MS = 15_000;
9754
+ static AGENT_SWITCH_POLL_MS = 500;
9755
+ /** Each tmux call of the write (load, paste, Enter, take-back) is bounded by this. */
9756
+ static AGENT_SWITCH_TMUX_OP_MS = 1_000;
9757
+ /**
9758
+ * The paste is started only with this much budget left, checked at the paste itself: the load and the paste, the
9759
+ * settle, the Enter — each bounded by AGENT_SWITCH_TMUX_OP_MS — fit in it.
9760
+ */
9761
+ static AGENT_SWITCH_WRITE_RESERVE_MS = 4_000;
9762
+ /** Between the paste and its Enter, as tmux pastes elsewhere (pasteText). */
9763
+ static AGENT_SWITCH_PASTE_SETTLE_MS = 500;
9764
+ /**
9765
+ * #906 §3: after a resume, make the CLI run as this instance's own agent. Reads the agent off the live layout
9766
+ * (backend.agentSwitch().readActive — the supported bottom layout only, never a name quoted in the conversation);
9767
+ * when it is another one, types the switch command ONCE, under the pane-write lock, and only when ONE final capture
9768
+ * taken there shows the live layout naming another agent AND an idle, input-ready pane (ready, not busy, no
9769
+ * blocking dialog, no input transient) — after the delivery path's own readiness check. It confirms only on a
9770
+ * later capture of the live layout naming ours.
9771
+ *
9772
+ * Fenced like a delivery, from entry on: the spawn generation, launch fence, the pause/wake phase it started in,
9773
+ * the same tmux window, not aborted, writes not stopping — re-checked after every await, and between the paste and
9774
+ * its Enter. The whole step, every await included, ends at one monotonic deadline: a late capture never confirms
9775
+ * and a late admission never writes; a paste whose Enter would land past it is taken back. Not confirmed in time:
9776
+ * the old setup stays, a launch warning says so, the next launch tries again. A stop, pause or respawn ends it
9777
+ * silently, confirming and removing nothing. Never throws into the spawn.
9778
+ */
9779
+ async ensureBackendAgent() {
9780
+ let sw = null;
9781
+ try {
9782
+ sw = this.backend?.agentSwitch?.() ?? null;
9783
+ }
9784
+ catch {
9785
+ sw = null;
9786
+ }
9787
+ const tmux = this.tmux;
9788
+ if (!sw || !tmux || !this.backend)
9789
+ return;
9790
+ const windowId = tmux.getWindowId();
9791
+ const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, phase = this.pauseWakeState;
9792
+ const fenced = () => !this.startupAborted && !this.deliveryWritesStopping && !this.fatalStartupBlocked
9793
+ && (phase === "active" || phase === "waking") && this.pauseWakeState === phase
9794
+ && spawn === this.spawnGeneration && fence === this.launchFenceEpoch && this.tmux === tmux;
9795
+ const deadline = performance.now() + Daemon.AGENT_SWITCH_BUDGET_MS;
9796
+ const live = () => { this.startupAdmission?.(); return fenced() && performance.now() < deadline; };
9797
+ if (!live())
9798
+ return;
9799
+ const TIMEOUT = Symbol("deadline");
9800
+ const within = (work) => {
9801
+ const left = deadline - performance.now();
9802
+ if (left <= 0) {
9803
+ work.catch(() => { });
9804
+ return Promise.resolve(TIMEOUT);
9805
+ }
9806
+ let timer;
9807
+ return Promise.race([work, new Promise(r => { timer = setTimeout(() => r(TIMEOUT), left); })])
9808
+ .finally(() => clearTimeout(timer));
9809
+ };
9810
+ const capture = () => within(tmux.capturePane().catch(() => null));
9811
+ let wroteAt = null;
9812
+ try {
9813
+ while (live()) {
9814
+ const capturedAt = performance.now();
9815
+ const pane = await capture();
9816
+ if (pane === TIMEOUT || !live())
9817
+ break;
9818
+ const active = pane === null ? null : sw.readActive(pane);
9819
+ if (active === sw.agent && (wroteAt === null || capturedAt > wroteAt)) {
9820
+ if (sw.alreadyConfirmed && wroteAt === null)
9821
+ return; // already ours, already on record
9822
+ for (const warning of sw.confirm())
9823
+ this.emit("backend_launch_warning", { name: this.name, message: warning });
9824
+ this.logger.info({ agent: sw.agent, switched: wroteAt !== null }, "The resumed conversation runs as this instance's agent");
9825
+ return;
9826
+ }
9827
+ if (wroteAt === null && active !== null && active !== sw.agent) {
9828
+ const wrote = await within(this.paneWriteLock.run(async () => {
9829
+ // The whole write is one owned transaction under the lock. Every await is bounded; a take-back of a paste
9830
+ // whose Enter would land past the deadline happens here, before the lock is released, so it can never
9831
+ // edit a write admitted after this one.
9832
+ if (!live())
9833
+ return false;
9834
+ const readiness = await within(this.paneReadinessForDelivery(windowId));
9835
+ if (readiness !== "ready" || !live())
9836
+ return false;
9837
+ const final = await capture();
9838
+ if (final === TIMEOUT || final === null || !live())
9839
+ return false;
9840
+ const now = sw.readActive(final);
9841
+ if (now === null || now === sw.agent || this.inputTransientInPane(final) || !this.paneAuthoritativelyIdle(final))
9842
+ return false;
9843
+ // The reserve, at the paste boundary — after the final proof, which may have used some of the budget.
9844
+ if (deadline - performance.now() < Daemon.AGENT_SWITCH_WRITE_RESERVE_MS)
9845
+ return false;
9846
+ const op = Daemon.AGENT_SWITCH_TMUX_OP_MS;
9847
+ // live() is asked before every tmux mutation inside the paste, so a stop, pause, respawn or the deadline
9848
+ // between its load and its paste never reaches the pane.
9849
+ const pasted = await tmux.pasteBuffer(sw.command, { guard: live, timeoutMs: op });
9850
+ if (!pasted)
9851
+ return false;
9852
+ await new Promise(r => setTimeout(r, Daemon.AGENT_SWITCH_PASTE_SETTLE_MS));
9853
+ if (live())
9854
+ return tmux.sendSpecialKey("Enter", op);
9855
+ // Same CLI, but no time left to see it through: take the command back out of its input row (bounded).
9856
+ if (fenced())
9857
+ await tmux.deleteBackward(sw.command.length, op);
9858
+ return false;
9859
+ }));
9860
+ if (wrote === true) {
9861
+ wroteAt = performance.now();
9862
+ this.logger.info({ agent: sw.agent, was: active }, "Switching the resumed conversation to this instance's agent");
9863
+ }
9864
+ }
9865
+ if (!live())
9866
+ break;
9867
+ await within(new Promise(r => setTimeout(r, Math.min(Daemon.AGENT_SWITCH_POLL_MS, Math.max(0, deadline - performance.now())))));
9868
+ }
9869
+ if (!fenced())
9870
+ return; // a stop, pause or respawn: nothing to say, nothing to do
9871
+ this.logger.warn({ agent: sw.agent, typed: wroteAt !== null }, "The resumed conversation did not switch to this instance's agent in time");
9872
+ this.emit("backend_launch_warning", { name: this.name, message: t("kiro.switch_timeout", Daemon.AGENT_SWITCH_BUDGET_MS / 1000) });
9873
+ }
9874
+ catch (err) {
9875
+ this.startupAdmission?.();
9876
+ this.logger.warn({ err }, "The agent switch after resume failed — the shared entries stay; the next launch tries again");
9877
+ }
9878
+ }
9334
9879
  /**
9335
9880
  * Startup budget for this launch: the backend's override (resume-aware), never
9336
9881
  * below a user-configured startup_timeout_ms; undefined = trySpawn's default.
@@ -9472,13 +10017,19 @@ export class Daemon extends EventEmitter {
9472
10017
  * Returns true if CLI is ready, false if it failed or got stuck.
9473
10018
  */
9474
10019
  async trySpawn(reuseWindow = false, startupTimeoutMs) {
9475
- if (!this.spawnGate)
9476
- return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
9477
- return this.spawnGate.run({
10020
+ const ready = !this.spawnGate ? await this.trySpawnInsideGate(reuseWindow, startupTimeoutMs) : await this.spawnGate.run({
9478
10021
  instanceName: this.name,
9479
10022
  workingDirectory: this.config.working_directory,
9480
10023
  reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
9481
10024
  }, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
10025
+ // #906: a resumed kiro conversation comes back as the agent it was saved under. Every launch path that reaches a
10026
+ // ready CLI (start, recovery, wake) switches it to this instance's own agent here, while the spawn still holds
10027
+ // deliveries — and outside the spawn gate, which it does not need.
10028
+ this.startupAdmission?.();
10029
+ if (ready)
10030
+ await this.ensureBackendAgent();
10031
+ this.startupAdmission?.();
10032
+ return ready;
9482
10033
  }
9483
10034
  /**
9484
10035
  * One fresh pane capture judged as ready && !busy && !dialog. Transcript
@@ -9662,7 +10213,7 @@ export class Daemon extends EventEmitter {
9662
10213
  // This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
9663
10214
  // delivery is already in flight or queued. Without the lock the notice and
9664
10215
  // 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"));
10216
+ const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), "instruction-reload-notice"));
9666
10217
  if (!told) {
9667
10218
  // Recording the new instructions here would mark the agent as told about
9668
10219
  // a notice it never received, and every later restart would then skip
@@ -9683,6 +10234,7 @@ export class Daemon extends EventEmitter {
9683
10234
  catch { /* non-fatal */ }
9684
10235
  }
9685
10236
  async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
10237
+ this.startupAdmission?.();
9686
10238
  const launchFence = this.launchFenceEpoch;
9687
10239
  const backendConfig = this.buildBackendConfig();
9688
10240
  // Compare freshly-built instructions against the last value the agent was
@@ -9722,11 +10274,12 @@ export class Daemon extends EventEmitter {
9722
10274
  // A backend that has to ask its CLI something before the launch command exists (OpenCode: does
9723
10275
  // this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
9724
10276
  try {
9725
- await this.backend.prepareLaunch?.();
10277
+ await this.backend.prepareLaunch?.(backendConfig);
9726
10278
  }
9727
10279
  catch { /* unknown capability → the backend's conservative form */ }
9728
10280
  // Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
9729
10281
  // into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
10282
+ this.startupAdmission?.();
9730
10283
  if (launchFence !== this.launchFenceEpoch)
9731
10284
  throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
9732
10285
  this.backend.writeConfig(backendConfig);
@@ -9781,6 +10334,7 @@ export class Daemon extends EventEmitter {
9781
10334
  // The CLI's own shell sets a zero coredump_filter first (#1113): this pane
9782
10335
  // may belong to a tmux server the fleet did not start, which would hand
9783
10336
  // the CLI its own (full) filter instead of the fleet's.
10337
+ this.startupAdmission?.();
9784
10338
  const cmd = coredumpFilterLaunchPrefix() + `${envPrefix} ` + this.backend.buildCommand(launchConfig);
9785
10339
  // Every launched command re-arms the passive-transient check, including a
9786
10340
  // retry inside the same spawn: its load is a new one.
@@ -9803,6 +10357,7 @@ export class Daemon extends EventEmitter {
9803
10357
  if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
9804
10358
  this.emit("tmux_server_crash", this.name);
9805
10359
  }
10360
+ this.startupAdmission?.();
9806
10361
  let windowId;
9807
10362
  if (reuseWindow) {
9808
10363
  this.controlClient?.unregisterWindow(this.tmux.getWindowId());
@@ -9817,6 +10372,7 @@ export class Daemon extends EventEmitter {
9817
10372
  if (retired && retired !== windowId)
9818
10373
  this.controlClient?.unregisterWindow(retired);
9819
10374
  }
10375
+ this.startupAdmission?.();
9820
10376
  writeFileSync(join(this.instanceDir, "window-id"), windowId);
9821
10377
  // Enable remain-on-exit to capture exit codes on crash
9822
10378
  await this.tmux.setRemainOnExit().catch(err => {
@@ -9925,8 +10481,8 @@ export class Daemon extends EventEmitter {
9925
10481
  { pattern: /No, exit|No, quit|Don't trust|I accept|I trust|Yes, continue|Trust folder/i, keys: ["Enter"], description: "Generic confirmation dialog" },
9926
10482
  { pattern: /Resume Session/i, keys: ["Escape"], description: "Resume session picker — start fresh" },
9927
10483
  ];
9928
- const deadline = Date.now() + budgetMs;
9929
- const remaining = () => deadline - Date.now();
10484
+ const deadline = performance.now() + budgetMs;
10485
+ const remaining = () => deadline - performance.now();
9930
10486
  const sleep = async () => { if (remaining() > 0)
9931
10487
  await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
9932
10488
  let cleanReadyPolls = 0;
@@ -9938,13 +10494,26 @@ export class Daemon extends EventEmitter {
9938
10494
  // repainted after Enter, let the following hold-only entry report it
9939
10495
  // instead of sending a second Enter into a possibly changed screen.
9940
10496
  const attemptedSafetyChoices = new Set();
10497
+ // The scan belongs to this launch: its tmux, spawn and launch fence. A respawn, pause or stop that replaces any of
10498
+ // them retires it — a key meant for this launch's dialog must never reach the replacement (#1435 review).
10499
+ const ownerTmux = this.tmux;
10500
+ const ownerSpawn = this.spawnGeneration;
10501
+ const ownerFence = this.launchFenceEpoch;
10502
+ const owned = () => this.tmux === ownerTmux && this.spawnGeneration === ownerSpawn && this.launchFenceEpoch === ownerFence;
9941
10503
  do {
10504
+ this.startupAdmission?.();
9942
10505
  attempts++;
10506
+ if (!owned()) {
10507
+ this.logger.info("Startup dialog scan superseded by a newer launch, pause or stop — retiring without answering");
10508
+ return true;
10509
+ }
9943
10510
  let pane;
9944
10511
  try {
9945
10512
  pane = await this.tmux.capturePane();
10513
+ this.startupAdmission?.();
9946
10514
  }
9947
10515
  catch (err) {
10516
+ this.startupAdmission?.();
9948
10517
  // Transient tmux trouble is not evidence about the CLI. Returning false
9949
10518
  // here would clear the session; retry within the budget instead.
9950
10519
  captureFailures++;
@@ -10022,21 +10591,34 @@ export class Daemon extends EventEmitter {
10022
10591
  // delivery on `spawning`. Take the pane lock for the key sequence so a
10023
10592
  // queued message cannot be pasted into a half-dismissed trust dialog.
10024
10593
  const sent = await this.paneWriteLock.run(async () => {
10594
+ this.startupAdmission?.();
10025
10595
  // The capture above may have gone stale while waiting for the
10026
10596
  // write lock. Trust/other safety prompts must still be the
10027
10597
  // CURRENT menu, with the same safe cursor, at the instant of
10028
- // the key send. A changed pane falls through to the next scan.
10598
+ // the key send. A changed pane falls through to the next scan;
10599
+ // a pane that is no longer this launch's is not read or keyed.
10600
+ if (!owned())
10601
+ return false;
10029
10602
  if (dialog.inputBlocked) {
10030
10603
  const currentPane = await this.tmux.capturePane();
10604
+ this.startupAdmission?.();
10605
+ // The capture is an await too: a replacement launched during it must find no answer recorded as its own.
10606
+ if (!owned())
10607
+ return false;
10031
10608
  if (!Daemon.dialogMatches(dialog, currentPane))
10032
10609
  return false;
10033
10610
  }
10034
10611
  if (dialog.autoResolutionKey) {
10035
10612
  attemptedSafetyChoices.add(dialog.autoResolutionKey);
10036
- this.autoResolvedDialogGeneration = this.spawnGeneration;
10613
+ this.autoResolvedDialogGeneration = ownerSpawn;
10037
10614
  this.autoResolvedDialogKey = dialog.autoResolutionKey;
10038
10615
  }
10039
10616
  for (const key of dialog.keys) {
10617
+ this.startupAdmission?.();
10618
+ // Before every key, synchronously: the lock wait, the capture and the gap after the previous key can
10619
+ // each have seen a respawn, pause or stop.
10620
+ if (!owned())
10621
+ return false;
10040
10622
  if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
10041
10623
  if (!await this.tmux.sendSpecialKey(key))
10042
10624
  return false;
@@ -10118,6 +10700,7 @@ export class Daemon extends EventEmitter {
10118
10700
  return false;
10119
10701
  }
10120
10702
  catch (err) {
10703
+ this.startupAdmission?.();
10121
10704
  // Key sends / isWindowAlive failing: same rule — log, retry within the budget.
10122
10705
  this.logger.warn({ err }, "startup dialog scan step failed — retrying");
10123
10706
  if ((await this.paneLiveness()) === "dead")
@@ -10135,6 +10718,7 @@ export class Daemon extends EventEmitter {
10135
10718
  else {
10136
10719
  this.logger.warn({ attempts, budgetMs, captureFailures }, "Startup scan exhausted without a ready prompt or a known dialog — assuming ready (unknown CLI screen)");
10137
10720
  }
10721
+ this.startupAdmission?.();
10138
10722
  return true;
10139
10723
  }
10140
10724
  /**