@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.3

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 (317) hide show
  1. package/README.md +6 -4
  2. package/README.zh-TW.md +6 -4
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +4 -1
  6. package/dist/agent-endpoint.js +82 -14
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-token-header.d.ts +13 -0
  9. package/dist/agent-token-header.js +16 -0
  10. package/dist/agent-token-header.js.map +1 -0
  11. package/dist/atomic-write.d.ts +21 -0
  12. package/dist/atomic-write.js +65 -0
  13. package/dist/atomic-write.js.map +1 -0
  14. package/dist/auth-api.js +2 -0
  15. package/dist/auth-api.js.map +1 -1
  16. package/dist/backend/antigravity.js +4 -3
  17. package/dist/backend/antigravity.js.map +1 -1
  18. package/dist/backend/codex.d.ts +7 -0
  19. package/dist/backend/codex.js +7 -0
  20. package/dist/backend/codex.js.map +1 -1
  21. package/dist/backend/grok.js +1 -1
  22. package/dist/backend/grok.js.map +1 -1
  23. package/dist/backend/kiro.d.ts +7 -0
  24. package/dist/backend/kiro.js +82 -33
  25. package/dist/backend/kiro.js.map +1 -1
  26. package/dist/backend/muse.d.ts +7 -0
  27. package/dist/backend/muse.js +8 -1
  28. package/dist/backend/muse.js.map +1 -1
  29. package/dist/backend/types.d.ts +12 -0
  30. package/dist/backend/types.js.map +1 -1
  31. package/dist/binary-probe.d.ts +80 -0
  32. package/dist/binary-probe.js +182 -0
  33. package/dist/binary-probe.js.map +1 -0
  34. package/dist/cache-analysis.d.ts +92 -0
  35. package/dist/cache-analysis.js +147 -0
  36. package/dist/cache-analysis.js.map +1 -0
  37. package/dist/cache-ledger.d.ts +108 -0
  38. package/dist/cache-ledger.js +230 -0
  39. package/dist/cache-ledger.js.map +1 -0
  40. package/dist/cache-prices.d.ts +32 -0
  41. package/dist/cache-prices.js +73 -0
  42. package/dist/cache-prices.js.map +1 -0
  43. package/dist/cache-scan.d.ts +39 -0
  44. package/dist/cache-scan.js +115 -0
  45. package/dist/cache-scan.js.map +1 -0
  46. package/dist/cache-service.d.ts +131 -0
  47. package/dist/cache-service.js +452 -0
  48. package/dist/cache-service.js.map +1 -0
  49. package/dist/channel/adapters/discord.d.ts +5 -1
  50. package/dist/channel/adapters/discord.js +56 -11
  51. package/dist/channel/adapters/discord.js.map +1 -1
  52. package/dist/channel/adapters/telegram.d.ts +38 -1
  53. package/dist/channel/adapters/telegram.js +82 -0
  54. package/dist/channel/adapters/telegram.js.map +1 -1
  55. package/dist/channel/mcp-tools.js +1 -1
  56. package/dist/channel/mcp-tools.js.map +1 -1
  57. package/dist/channel/tool-router.d.ts +8 -1
  58. package/dist/channel/tool-router.js +4 -2
  59. package/dist/channel/tool-router.js.map +1 -1
  60. package/dist/channel/types.d.ts +21 -0
  61. package/dist/cli-entry.d.ts +19 -0
  62. package/dist/cli-entry.js +36 -0
  63. package/dist/cli-entry.js.map +1 -0
  64. package/dist/cli.js +376 -54
  65. package/dist/cli.js.map +1 -1
  66. package/dist/connection-secrets.d.ts +5 -0
  67. package/dist/connection-secrets.js.map +1 -1
  68. package/dist/cpu-profile.js +50 -0
  69. package/dist/cpu-profile.js.map +1 -1
  70. package/dist/daemon.d.ts +18 -0
  71. package/dist/daemon.js +256 -63
  72. package/dist/daemon.js.map +1 -1
  73. package/dist/delivery-outbox.d.ts +1 -0
  74. package/dist/delivery-outbox.js +12 -0
  75. package/dist/delivery-outbox.js.map +1 -1
  76. package/dist/discord-permissions.d.ts +33 -0
  77. package/dist/discord-permissions.js +42 -0
  78. package/dist/discord-permissions.js.map +1 -0
  79. package/dist/env-file.d.ts +11 -0
  80. package/dist/env-file.js +27 -0
  81. package/dist/env-file.js.map +1 -0
  82. package/dist/event-log.d.ts +4 -1
  83. package/dist/event-log.js +6 -3
  84. package/dist/event-log.js.map +1 -1
  85. package/dist/fleet-context.d.ts +5 -0
  86. package/dist/fleet-context.js.map +1 -1
  87. package/dist/fleet-control-audit.d.ts +5 -0
  88. package/dist/fleet-control-audit.js +7 -0
  89. package/dist/fleet-control-audit.js.map +1 -1
  90. package/dist/fleet-level-config.d.ts +8 -1
  91. package/dist/fleet-level-config.js +53 -0
  92. package/dist/fleet-level-config.js.map +1 -1
  93. package/dist/fleet-manager.d.ts +122 -7
  94. package/dist/fleet-manager.js +871 -152
  95. package/dist/fleet-manager.js.map +1 -1
  96. package/dist/fleet-stop-budget.d.ts +22 -0
  97. package/dist/fleet-stop-budget.js +95 -0
  98. package/dist/fleet-stop-budget.js.map +1 -0
  99. package/dist/full-restart.js +2 -3
  100. package/dist/full-restart.js.map +1 -1
  101. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  102. package/dist/install-lock.d.ts +49 -0
  103. package/dist/install-lock.js +200 -0
  104. package/dist/install-lock.js.map +1 -0
  105. package/dist/instance-config-impact.js +3 -2
  106. package/dist/instance-config-impact.js.map +1 -1
  107. package/dist/kiro-transcript-lane.d.ts +8 -4
  108. package/dist/kiro-transcript-lane.js +66 -44
  109. package/dist/kiro-transcript-lane.js.map +1 -1
  110. package/dist/locale.js +96 -0
  111. package/dist/locale.js.map +1 -1
  112. package/dist/next-steps.d.ts +5 -0
  113. package/dist/next-steps.js +26 -0
  114. package/dist/next-steps.js.map +1 -0
  115. package/dist/outbound-handlers.js +8 -0
  116. package/dist/outbound-handlers.js.map +1 -1
  117. package/dist/outbound-schemas.d.ts +4 -0
  118. package/dist/outbound-schemas.js +7 -2
  119. package/dist/outbound-schemas.js.map +1 -1
  120. package/dist/outbox-open-error.d.ts +7 -0
  121. package/dist/outbox-open-error.js +15 -0
  122. package/dist/outbox-open-error.js.map +1 -0
  123. package/dist/package-preimage.d.ts +26 -0
  124. package/dist/package-preimage.js +96 -0
  125. package/dist/package-preimage.js.map +1 -0
  126. package/dist/pane-context-cache.d.ts +25 -0
  127. package/dist/pane-context-cache.js +73 -0
  128. package/dist/pane-context-cache.js.map +1 -0
  129. package/dist/paths.d.ts +0 -7
  130. package/dist/paths.js +8 -2
  131. package/dist/paths.js.map +1 -1
  132. package/dist/provider-probe.d.ts +15 -0
  133. package/dist/provider-probe.js +20 -0
  134. package/dist/provider-probe.js.map +1 -1
  135. package/dist/public-link-progress.d.ts +73 -0
  136. package/dist/public-link-progress.js +190 -0
  137. package/dist/public-link-progress.js.map +1 -0
  138. package/dist/public-web-gateway.js +2 -2
  139. package/dist/public-web-gateway.js.map +1 -1
  140. package/dist/public-web-link.d.ts +6 -1
  141. package/dist/public-web-link.js +87 -15
  142. package/dist/public-web-link.js.map +1 -1
  143. package/dist/quickstart-api.d.ts +93 -20
  144. package/dist/quickstart-api.js +194 -62
  145. package/dist/quickstart-api.js.map +1 -1
  146. package/dist/quickstart.d.ts +15 -0
  147. package/dist/quickstart.js +110 -33
  148. package/dist/quickstart.js.map +1 -1
  149. package/dist/reply-buttons-controller.d.ts +98 -0
  150. package/dist/reply-buttons-controller.js +174 -0
  151. package/dist/reply-buttons-controller.js.map +1 -0
  152. package/dist/reply-buttons.d.ts +113 -0
  153. package/dist/reply-buttons.js +208 -0
  154. package/dist/reply-buttons.js.map +1 -0
  155. package/dist/reply-dedup.js +3 -1
  156. package/dist/reply-dedup.js.map +1 -1
  157. package/dist/restart-guard.d.ts +53 -0
  158. package/dist/restart-guard.js +114 -0
  159. package/dist/restart-guard.js.map +1 -0
  160. package/dist/scheduler/db.js +6 -2
  161. package/dist/scheduler/db.js.map +1 -1
  162. package/dist/service-activation.d.ts +62 -7
  163. package/dist/service-activation.js +237 -55
  164. package/dist/service-activation.js.map +1 -1
  165. package/dist/service-installer.d.ts +56 -1
  166. package/dist/service-installer.js +152 -12
  167. package/dist/service-installer.js.map +1 -1
  168. package/dist/service-plan.d.ts +65 -0
  169. package/dist/service-plan.js +108 -0
  170. package/dist/service-plan.js.map +1 -0
  171. package/dist/settings-api.js +92 -10
  172. package/dist/settings-api.js.map +1 -1
  173. package/dist/settings-authority.d.ts +11 -0
  174. package/dist/settings-authority.js +128 -0
  175. package/dist/settings-authority.js.map +1 -0
  176. package/dist/settings-change.d.ts +5 -0
  177. package/dist/settings-change.js +37 -8
  178. package/dist/settings-change.js.map +1 -1
  179. package/dist/settings-confirmation.d.ts +3 -0
  180. package/dist/settings-confirmation.js +7 -1
  181. package/dist/settings-confirmation.js.map +1 -1
  182. package/dist/settings-effect.js +19 -7
  183. package/dist/settings-effect.js.map +1 -1
  184. package/dist/settings-http-confirmation.js +1 -1
  185. package/dist/settings-http-confirmation.js.map +1 -1
  186. package/dist/settings-redaction.d.ts +18 -0
  187. package/dist/settings-redaction.js +58 -0
  188. package/dist/settings-redaction.js.map +1 -0
  189. package/dist/setup-form.d.ts +9 -1
  190. package/dist/setup-form.js +25 -1
  191. package/dist/setup-form.js.map +1 -1
  192. package/dist/setup-host.js +15 -6
  193. package/dist/setup-host.js.map +1 -1
  194. package/dist/setup-wizard.js +27 -19
  195. package/dist/setup-wizard.js.map +1 -1
  196. package/dist/sqlite-open-errors.d.ts +12 -0
  197. package/dist/sqlite-open-errors.js +13 -0
  198. package/dist/sqlite-open-errors.js.map +1 -0
  199. package/dist/systemd-runtime.d.ts +15 -0
  200. package/dist/systemd-runtime.js +41 -0
  201. package/dist/systemd-runtime.js.map +1 -0
  202. package/dist/tmux-control.d.ts +44 -0
  203. package/dist/tmux-control.js +170 -21
  204. package/dist/tmux-control.js.map +1 -1
  205. package/dist/token-env-name.d.ts +37 -0
  206. package/dist/token-env-name.js +93 -0
  207. package/dist/token-env-name.js.map +1 -0
  208. package/dist/tool-permissions.js +5 -3
  209. package/dist/tool-permissions.js.map +1 -1
  210. package/dist/topic-commands.d.ts +7 -16
  211. package/dist/topic-commands.js +56 -85
  212. package/dist/topic-commands.js.map +1 -1
  213. package/dist/transcript-monitor.js +3 -0
  214. package/dist/transcript-monitor.js.map +1 -1
  215. package/dist/transcript-sources.d.ts +68 -0
  216. package/dist/transcript-sources.js +336 -29
  217. package/dist/transcript-sources.js.map +1 -1
  218. package/dist/transcript-turns.d.ts +39 -0
  219. package/dist/transcript-turns.js +58 -0
  220. package/dist/transcript-turns.js.map +1 -0
  221. package/dist/tunnel/cloudflared-install.d.ts +18 -0
  222. package/dist/tunnel/cloudflared-install.js +15 -1
  223. package/dist/tunnel/cloudflared-install.js.map +1 -1
  224. package/dist/turn-reply-guard.d.ts +3 -0
  225. package/dist/turn-reply-guard.js.map +1 -1
  226. package/dist/ui/chat-commands.js +43 -0
  227. package/dist/ui/chat-render.js +34 -8
  228. package/dist/ui/chat-store.js +51 -2
  229. package/dist/ui/chat-strings.js +30 -2
  230. package/dist/ui/chat-thread.js +141 -47
  231. package/dist/ui/first-run.js +64 -0
  232. package/dist/ui/fleet-cache.js +168 -0
  233. package/dist/ui/html-attachment.js +26 -0
  234. package/dist/ui/panel-chat.js +117 -23
  235. package/dist/ui/panel-fleet.js +31 -17
  236. package/dist/ui/panel-settings.js +29 -15
  237. package/dist/ui/preview-panel.js +237 -0
  238. package/dist/ui/preview.js +7 -3
  239. package/dist/ui/settings-apply.js +3 -1
  240. package/dist/ui/settings-dialogs.js +95 -25
  241. package/dist/ui/settings-discord.js +130 -0
  242. package/dist/ui/settings-model.js +21 -0
  243. package/dist/ui/settings-strings.js +50 -8
  244. package/dist/ui/settings-token.js +54 -0
  245. package/dist/ui/settings-wizard.js +63 -29
  246. package/dist/ui/shared/app-route.js +1 -1
  247. package/dist/ui/shared/app-shell.js +25 -37
  248. package/dist/ui/shared/app-store.js +5 -3
  249. package/dist/ui/shared/app-stream.js +2 -1
  250. package/dist/ui/shared/app.css +181 -5
  251. package/dist/ui/shared/app.js +2 -1
  252. package/dist/ui/shared/instance-nav.js +310 -0
  253. package/dist/ui/shared/panel-view.js +12 -190
  254. package/dist/ui/shared/signin.js +13 -1
  255. package/dist/ui/shared/ui-icons.js +2 -0
  256. package/dist/ui/shared/view-roster-store.js +5 -0
  257. package/dist/ui/shared/view-strings.js +12 -0
  258. package/dist/ui/signin.html +2 -1
  259. package/dist/update-dispatch.d.ts +28 -0
  260. package/dist/update-dispatch.js +73 -0
  261. package/dist/update-dispatch.js.map +1 -0
  262. package/dist/update-install.d.ts +78 -8
  263. package/dist/update-install.js +158 -30
  264. package/dist/update-install.js.map +1 -1
  265. package/dist/update-launch.d.ts +22 -0
  266. package/dist/update-launch.js +106 -0
  267. package/dist/update-launch.js.map +1 -0
  268. package/dist/update-marker.js +3 -8
  269. package/dist/update-marker.js.map +1 -1
  270. package/dist/usage/format-rich.js +7 -1
  271. package/dist/usage/format-rich.js.map +1 -1
  272. package/dist/usage/i18n-keys.d.ts +1 -1
  273. package/dist/usage/i18n-keys.js +1 -1
  274. package/dist/usage/i18n-keys.js.map +1 -1
  275. package/dist/usage/providers.d.ts +4 -0
  276. package/dist/usage/providers.js +2 -1
  277. package/dist/usage/providers.js.map +1 -1
  278. package/dist/usage/usage-api.js +48 -2
  279. package/dist/usage/usage-api.js.map +1 -1
  280. package/dist/view-api.d.ts +40 -0
  281. package/dist/view-api.js +28 -10
  282. package/dist/view-api.js.map +1 -1
  283. package/dist/web-api.d.ts +22 -0
  284. package/dist/web-api.js +115 -16
  285. package/dist/web-api.js.map +1 -1
  286. package/dist/web-auth.d.ts +8 -0
  287. package/dist/web-auth.js +34 -8
  288. package/dist/web-auth.js.map +1 -1
  289. package/dist/web-chat-history.d.ts +21 -0
  290. package/dist/web-chat-history.js +44 -0
  291. package/dist/web-chat-history.js.map +1 -1
  292. package/dist/web-commands.d.ts +77 -0
  293. package/dist/web-commands.js +81 -0
  294. package/dist/web-commands.js.map +1 -0
  295. package/dist/web-host-guard.d.ts +18 -2
  296. package/dist/web-host-guard.js +30 -5
  297. package/dist/web-host-guard.js.map +1 -1
  298. package/dist/web-session.d.ts +7 -1
  299. package/dist/web-session.js +23 -2
  300. package/dist/web-session.js.map +1 -1
  301. package/dist/web-shell-routes.d.ts +1 -1
  302. package/dist/web-shell-routes.js +1 -1
  303. package/dist/web-shell-routes.js.map +1 -1
  304. package/launcher/agend +110 -0
  305. package/launcher/agend-agent +110 -0
  306. package/launcher/agend-agent.cjs +4 -0
  307. package/launcher/agend.cjs +4 -0
  308. package/launcher/install-admission.cjs +105 -0
  309. package/launcher/launch.cjs +64 -0
  310. package/launcher/postinstall.cjs +118 -0
  311. package/launcher/run-in-process.cjs +13 -0
  312. package/launcher/runtime-platform.cjs +86 -0
  313. package/launcher/runtime-select.cjs +217 -0
  314. package/package.json +12 -4
  315. package/scripts/preinstall-guard.cjs +40 -21
  316. package/templates/launchd.plist.ejs +2 -2
  317. package/templates/systemd.service.ejs +4 -4
package/dist/daemon.js CHANGED
@@ -11,6 +11,7 @@ import { clearPausedMarker, writePausedMarker } from "./pause-marker.js";
11
11
  import { TmuxManager, resolveTmuxLogicalSize } from "./tmux-manager.js";
12
12
  import { TranscriptMonitor } from "./transcript-monitor.js";
13
13
  import { createTranscriptSource } from "./transcript-sources.js";
14
+ import { TranscriptTurnLedger } from "./transcript-turns.js";
14
15
  import { credentialProfileStoreHome, resolveCredentialProfile } from "./backend/credential-profile.js";
15
16
  import { resolveToolSet } from "./tool-permissions.js";
16
17
  import { getAgendHome } from "./paths.js";
@@ -25,6 +26,7 @@ import { MessageBus } from "./channel/message-bus.js";
25
26
  import { shellQuote, UnsupportedCliError } from "./backend/types.js";
26
27
  import { getTmuxSession } from "./config.js";
27
28
  import { routeToolCall } from "./channel/tool-router.js";
29
+ import { parseReplyButtons, replyButtonsFallbackText } from "./reply-buttons.js";
28
30
  import { HangDetector } from "./hang-detector.js";
29
31
  import { writeSecretFile } from "./secret-file.js";
30
32
  import { PaneWriteLock } from "./pane-write-lock.js";
@@ -69,6 +71,12 @@ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
69
71
  * backstop is delayed by at most this window, never disabled.
70
72
  */
71
73
  const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
74
+ /**
75
+ * #1510: how long an idle pane may be held because the transcript still shows the turn running. A turn whose end is
76
+ * never written (the CLI killed, the rollout replaced) would otherwise be re-checked for ever; past this it stands
77
+ * down like a turn the transcript cannot place — never a recovery.
78
+ */
79
+ const TRANSCRIPT_RUNNING_HOLD_MAX_MS = 30 * 60_000;
72
80
  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.";
73
81
  /** Point a resumed CLI at its one backend-native instruction source. */
74
82
  /** The outbox evidence for a delivery the CLI's transcript shows taken (#758; #1201 absorbed). */
@@ -644,6 +652,8 @@ const CONFIRM_BUSY_POLL_MS = 200;
644
652
  * hold the pane write lock along with it.
645
653
  */
646
654
  const CONFIRM_BUSY_MAX_WAIT_MS = 10_000;
655
+ /** How much longer a confirmation waits while the control client is blind (a drained read; its limit is 10 s). */
656
+ const CONFIRM_BUSY_BLIND_EXTRA_MS = 12_000;
647
657
  /**
648
658
  * How long a delivery waits for an in-flight spawn. Comfortably past the default
649
659
  * 25s startup timeout plus dialog dismissal; past it we fall back to the old
@@ -1168,6 +1178,8 @@ export class Daemon extends EventEmitter {
1168
1178
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
1169
1179
  autoResolvedDialogKey = null;
1170
1180
  autoResolvedDialogGeneration = 0;
1181
+ /** Per-launch safety phases survive startup handoff, redraws and failed/uncertain key ACKs. */
1182
+ launchDialogClaims = null;
1171
1183
  /**
1172
1184
  * (#1103) Proof that THIS spawn's Codex went onto Luna Reserve: AgEnD saw (and
1173
1185
  * answered) the usage-limit menu that codex shows when it switches itself to
@@ -1223,6 +1235,10 @@ export class Daemon extends EventEmitter {
1223
1235
  // It also supplies the older dead-MCP/malformed-call recovery paths, so those
1224
1236
  // paths cannot disagree about whether the turn already spoke to the channel.
1225
1237
  turnReplyGuard = new TurnReplyGuard();
1238
+ /** #1510: turn boundaries from the CLI's transcript, for a backend whose guard reads its turn end there. */
1239
+ transcriptTurns = new TranscriptTurnLedger();
1240
+ /** #1510: since when (monotonic) this generation has been held as running in the transcript. */
1241
+ transcriptRunningSince = null;
1226
1242
  /** Prevent a visible stale XML fragment from being recovered on later turns. */
1227
1243
  lastMalformedToolCallSignature;
1228
1244
  proxyReplySeq = 0;
@@ -2176,7 +2192,7 @@ export class Daemon extends EventEmitter {
2176
2192
  // (statusline transcript); codex/kiro/opencode read their CLI's own
2177
2193
  // conversation store via a pluggable source. Backends with no known
2178
2194
  // source stay inert exactly as before.
2179
- this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger, createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory, this.credentialProfileStore()));
2195
+ this.transcriptMonitor = this.buildTranscriptMonitor(createTranscriptSource(this.config.backend ?? "claude-code", this.config.working_directory, this.credentialProfileStore()));
2180
2196
  const transcriptMonitor = this.transcriptMonitor;
2181
2197
  const transcriptFence = this.launchFenceEpoch;
2182
2198
  // Baseline in the isolate before this daemon accepts new work. A
@@ -3166,8 +3182,9 @@ export class Daemon extends EventEmitter {
3166
3182
  const readyPattern = this.backend.getReadyPattern();
3167
3183
  const busyPattern = this.backend.getBusyPattern?.() ?? null;
3168
3184
  this.errorMonitorTimer = setInterval(async () => {
3169
- if (!this.tmux || this.spawning)
3185
+ if (!this.tmux || this.spawning || this.deliveryWritesStopping)
3170
3186
  return;
3187
+ const pollTmux = this.tmux;
3171
3188
  // This poll belongs to the spawn and the monitors it started under. Every await below can outlive them (a stop,
3172
3189
  // a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
3173
3190
  const pollSpawn = this.spawnGeneration;
@@ -3176,18 +3193,19 @@ export class Daemon extends EventEmitter {
3176
3193
  const captureOrder = ++this.interactionCaptureSerial;
3177
3194
  const captureMono = performance.now();
3178
3195
  const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
3179
- || !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
3196
+ || !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning
3197
+ || this.deliveryWritesStopping || this.tmux !== pollTmux;
3180
3198
  let scanStartedAt = null;
3181
3199
  const endScan = () => { if (scanStartedAt !== null)
3182
3200
  noteSyncWork("daemon.errorMonitorScan", scanStartedAt); scanStartedAt = null; };
3183
3201
  try {
3184
- const alive = await this.tmux.isWindowAlive();
3202
+ const alive = await pollTmux.isWindowAlive();
3185
3203
  if (stale())
3186
3204
  return;
3187
3205
  if (!alive)
3188
3206
  return;
3189
3207
  const captureAt = Date.now();
3190
- const pane = await this.tmux.capturePane();
3208
+ const pane = await pollTmux.capturePane();
3191
3209
  // The scan of this pane is synchronous until it answers a dialog (an await) or ends: attributed (#1235).
3192
3210
  scanStartedAt = performance.now();
3193
3211
  if (stale())
@@ -3264,9 +3282,7 @@ export class Daemon extends EventEmitter {
3264
3282
  continue;
3265
3283
  }
3266
3284
  const autoKey = dialog.autoResolutionKey;
3267
- if (dialog.verifyAfterKeys && autoKey
3268
- && this.autoResolvedDialogGeneration === this.spawnGeneration
3269
- && this.autoResolvedDialogKey === autoKey) {
3285
+ if (dialog.verifyAfterKeys && autoKey && this.hasAutoDialogClaim(dialog)) {
3270
3286
  // A previous poll sent the safety choice but the CLI has not
3271
3287
  // repainted yet. Never send another Enter into the same screen.
3272
3288
  continue;
@@ -3285,7 +3301,7 @@ export class Daemon extends EventEmitter {
3285
3301
  const currentAt = Date.now();
3286
3302
  const currentMono = performance.now();
3287
3303
  const currentOrder = ++this.interactionCaptureSerial;
3288
- const currentPane = await this.tmux.capturePane();
3304
+ const currentPane = await pollTmux.capturePane();
3289
3305
  // Every await below can outlive the spawn / monitors this poll started under: after each one, before
3290
3306
  // any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
3291
3307
  if (stale())
@@ -3309,28 +3325,26 @@ export class Daemon extends EventEmitter {
3309
3325
  // not evidence that the old request was answered, and the one-minute fallback must keep running.
3310
3326
  if (this.dialogAnswers?.key === Daemon.answerKey(dialog) && this.dialogAnswers.screen !== before)
3311
3327
  this.endDialogEpisode(dialog.requestIdentity !== undefined);
3312
- if (dialog.verifyAfterKeys && autoKey
3313
- && this.autoResolvedDialogGeneration === this.spawnGeneration
3314
- && this.autoResolvedDialogKey === autoKey)
3328
+ if (dialog.verifyAfterKeys && autoKey && this.hasAutoDialogClaim(dialog))
3315
3329
  return;
3316
- if (dialog.verifyAfterKeys && autoKey) {
3317
- this.autoResolvedDialogGeneration = this.spawnGeneration;
3318
- this.autoResolvedDialogKey = autoKey;
3319
- }
3330
+ if (dialog.verifyAfterKeys && autoKey)
3331
+ this.claimAutoDialog(dialog);
3320
3332
  this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
3321
3333
  const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
3322
3334
  for (const key of dialog.keys) {
3335
+ if (stale())
3336
+ return;
3323
3337
  let sent = false;
3324
3338
  if (SPECIAL_KEYS.has(key)) {
3325
- sent = await this.tmux.sendSpecialKey(key);
3339
+ sent = await pollTmux.sendSpecialKey(key);
3326
3340
  }
3327
3341
  else {
3328
- sent = await this.tmux.pasteText(key, this.systemPasteOptions());
3342
+ sent = await pollTmux.pasteText(key, this.systemPasteOptions());
3329
3343
  }
3330
3344
  if (stale())
3331
3345
  return;
3332
3346
  if (!sent) {
3333
- if (dialog.verifyAfterKeys && autoKey) {
3347
+ if (dialog.verifyAfterKeys && autoKey && !dialog.oncePerLaunch) {
3334
3348
  this.autoResolvedDialogGeneration = 0;
3335
3349
  this.autoResolvedDialogKey = null;
3336
3350
  }
@@ -3345,7 +3359,7 @@ export class Daemon extends EventEmitter {
3345
3359
  const afterKeysAt = Date.now();
3346
3360
  const afterKeysMono = performance.now();
3347
3361
  const afterKeysOrder = ++this.interactionCaptureSerial;
3348
- const afterKeysPane = await this.tmux.capturePane();
3362
+ const afterKeysPane = await pollTmux.capturePane();
3349
3363
  if (stale())
3350
3364
  return;
3351
3365
  const dialogStillActive = dialog.inputBlocked
@@ -3375,7 +3389,7 @@ export class Daemon extends EventEmitter {
3375
3389
  // backend-named identity any difference on the screen (a ticking timer) proves nothing either way: nothing
3376
3390
  // is reported and the parked clock is left alone. Two requests that look exactly alike cannot be told apart.
3377
3391
  await new Promise(r => setTimeout(r, DIALOG_ANSWER_SETTLE_MS));
3378
- const afterPane = await this.tmux.capturePane();
3392
+ const afterPane = await pollTmux.capturePane();
3379
3393
  if (stale())
3380
3394
  return; // (nothing was touched since the last check: only a read)
3381
3395
  const stillThere = Daemon.dialogMatches(dialog, afterPane);
@@ -4113,6 +4127,23 @@ export class Daemon extends EventEmitter {
4113
4127
  this.logger.debug({ err: e }, "Failed to remove PID file");
4114
4128
  }
4115
4129
  }
4130
+ /** Context refresh uses the already-bound manager; no name-based child or backend probe. */
4131
+ getPaneContextSource() {
4132
+ const tmux = this.tmux;
4133
+ const control = this.controlClient;
4134
+ if (!tmux || !control || this.deliveryWritesStopping)
4135
+ return null;
4136
+ const owner = this.interactionOwner();
4137
+ const epoch = this.deliveryEpoch;
4138
+ const isCurrent = () => {
4139
+ const now = this.interactionOwner();
4140
+ return !this.deliveryWritesStopping && this.controlClient === control && this.tmux === tmux && this.deliveryEpoch === epoch
4141
+ && now.bootId === owner.bootId && now.spawnGeneration === owner.spawnGeneration
4142
+ && now.launchAttempt === owner.launchAttempt && now.launchFenceEpoch === owner.launchFenceEpoch;
4143
+ };
4144
+ return { owner: tmux, generation: JSON.stringify([owner.bootId, owner.spawnGeneration, owner.launchAttempt, owner.launchFenceEpoch, epoch]),
4145
+ isCurrent, capture: () => isCurrent() ? tmux.capturePaneWithHistory(60, 2_000) : Promise.reject(new Error("Pane context owner retired")) };
4146
+ }
4116
4147
  getHangDetector() {
4117
4148
  return this.hangDetector;
4118
4149
  }
@@ -4651,11 +4682,14 @@ export class Daemon extends EventEmitter {
4651
4682
  // chat_id) does not update lastChatId, so its proxy reply would land in
4652
4683
  // whatever USER topic spoke to this instance last — the wrong audience for
4653
4684
  // a task result, and a stale one (sol's review of #515).
4654
- if (meta.from_instance || !meta.chat_id)
4685
+ // A web message on a fleet with no chat platform has no chat_id (its reply goes to the web chat through the fleet's
4686
+ // web-only sink), but it is a person waiting for an answer all the same: the guard arms for it (alpha.2).
4687
+ if (meta.from_instance || (!meta.chat_id && meta.source !== "web"))
4655
4688
  return;
4656
4689
  // The last non-empty line of what we pasted: everything on screen after it
4657
4690
  // is the agent's own output.
4658
4691
  const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
4692
+ const deliveredAt = Date.now();
4659
4693
  // #1241 P2-1: carry pre-arm work evidence over the arm, bounded by this
4660
4694
  // delivery's ingress — output the paste produced before it confirmed.
4661
4695
  this.turnReplyGuard.arm({
@@ -4665,6 +4699,8 @@ export class Daemon extends EventEmitter {
4665
4699
  messageId: meta.message_id || undefined,
4666
4700
  correlationId: meta.correlation_id || undefined,
4667
4701
  inboundMarker,
4702
+ deliveredText,
4703
+ deliveredAt,
4668
4704
  }, this.pendingWork.lastInboundTimestamp());
4669
4705
  // #1209: persist the in-flight turn with a seam checkpoint. A marker
4670
4706
  // that survives a restart is an interrupted turn by construction —
@@ -4953,6 +4989,34 @@ export class Daemon extends EventEmitter {
4953
4989
  this.clearReplyGuardConfirm();
4954
4990
  return;
4955
4991
  }
4992
+ // #1510: a backend whose turn end is read from its own transcript (codex) must show it there too: the turn that
4993
+ // took this delivery ended and nothing started since. Anything less never starts a recovery.
4994
+ if (this.backend?.turnEndFromTranscript === true) {
4995
+ let verdict = this.transcriptTurns.verdict(turn.target.deliveredText, turn.target.deliveredAt ?? 0);
4996
+ if (verdict === "running") {
4997
+ if (this.transcriptRunningSince?.generation !== turn.generation) {
4998
+ this.transcriptRunningSince = { generation: turn.generation, at: performance.now() };
4999
+ }
5000
+ if (performance.now() - this.transcriptRunningSince.at >= TRANSCRIPT_RUNNING_HOLD_MAX_MS)
5001
+ verdict = "unknown";
5002
+ }
5003
+ if (verdict !== "running")
5004
+ this.transcriptRunningSince = null;
5005
+ if (verdict === "running") {
5006
+ // Idle on screen, still working in the transcript: look again after another window.
5007
+ this.armReplyGuardConfirm(turn.generation);
5008
+ this.logger.info({ correlationId: turn.target.correlationId, generation: turn.generation, reason: "transcript_turn_running" }, "Reply guard holding — the transcript shows the turn still running");
5009
+ return;
5010
+ }
5011
+ if (verdict !== "ended") {
5012
+ this.clearReplyGuardConfirm();
5013
+ this.turnReplyGuard.complete(turn.generation);
5014
+ this.logger.info({ correlationId: turn.target.correlationId, generation: turn.generation, reason: `transcript_${verdict}` }, verdict === "unknown" ? "Reply guard stood down — this delivery's turn is not in the transcript followed (fail-safe)"
5015
+ : verdict === "aborted" ? "Reply guard stood down — the turn was interrupted"
5016
+ : "Reply guard stood down — the turn ended on a provider error (its own notice covers it)");
5017
+ return;
5018
+ }
5019
+ }
4956
5020
  this.clearReplyGuardConfirm();
4957
5021
  if (turn.replyAttempted) {
4958
5022
  // A reply was attempted but its deliver result is unknown (in-flight
@@ -4970,6 +5034,16 @@ export class Daemon extends EventEmitter {
4970
5034
  || this.config.backend === "claude-code"
4971
5035
  || this.backend?.binaryName === "claude";
4972
5036
  }
5037
+ /**
5038
+ * This launch's transcript monitor. #1510: its turn boundaries feed the reply guard's ledger, which starts empty
5039
+ * with it — a new monitor re-baselines at the transcript's end, so nothing the old one read can be attributed.
5040
+ */
5041
+ buildTranscriptMonitor(source) {
5042
+ const monitor = new TranscriptMonitor(this.instanceDir, this.logger, source);
5043
+ this.transcriptTurns.reset();
5044
+ monitor.on("turns", (turns) => this.transcriptTurns.observe(turns));
5045
+ return monitor;
5046
+ }
4973
5047
  replyCompletionGuardEnabled() {
4974
5048
  return this.config.reply_completion_guard !== false
4975
5049
  && this.backend?.replyCompletionGuard === true;
@@ -6727,6 +6801,32 @@ export class Daemon extends EventEmitter {
6727
6801
  }
6728
6802
  return active;
6729
6803
  }
6804
+ hasAutoDialogClaim(dialog) {
6805
+ const key = dialog.autoResolutionKey;
6806
+ if (!key)
6807
+ return false;
6808
+ if (dialog.oncePerLaunch) {
6809
+ const claims = this.launchDialogClaims;
6810
+ return claims !== null && claims.spawn === this.spawnGeneration && claims.attempt === this.launchAttempt
6811
+ && claims.keys.has(key);
6812
+ }
6813
+ return this.autoResolvedDialogGeneration === this.spawnGeneration && this.autoResolvedDialogKey === key;
6814
+ }
6815
+ claimAutoDialog(dialog) {
6816
+ const key = dialog.autoResolutionKey;
6817
+ if (!key)
6818
+ return;
6819
+ if (dialog.oncePerLaunch) {
6820
+ if (!this.launchDialogClaims || this.launchDialogClaims.spawn !== this.spawnGeneration
6821
+ || this.launchDialogClaims.attempt !== this.launchAttempt) {
6822
+ this.launchDialogClaims = { spawn: this.spawnGeneration, attempt: this.launchAttempt, keys: new Set() };
6823
+ }
6824
+ this.launchDialogClaims.keys.add(key);
6825
+ return;
6826
+ }
6827
+ this.autoResolvedDialogGeneration = this.spawnGeneration;
6828
+ this.autoResolvedDialogKey = key;
6829
+ }
6730
6830
  /** Whether a runtime prompt currently owns the pane's stdin. */
6731
6831
  isInputBlocked() {
6732
6832
  return this.inputBlockedDialogKey !== null;
@@ -6788,8 +6888,7 @@ export class Daemon extends EventEmitter {
6788
6888
  // this spawn is not what the screen is waiting on any more — the entry
6789
6889
  // after it (typically a hold) describes it.
6790
6890
  if (dialog.verifyAfterKeys && dialog.autoResolutionKey
6791
- && this.autoResolvedDialogGeneration === this.spawnGeneration
6792
- && this.autoResolvedDialogKey === dialog.autoResolutionKey
6891
+ && this.hasAutoDialogClaim(dialog)
6793
6892
  && dialogs.some(other => other !== dialog && Daemon.dialogMatches(other, pane)))
6794
6893
  continue;
6795
6894
  this.trackDialogParked(dialog);
@@ -7766,6 +7865,10 @@ export class Daemon extends EventEmitter {
7766
7865
  async sendDeliveryEnter(phase, stillCurrent, durableAttempt) {
7767
7866
  if (!(await this.waitForInputTransientToClear(phase)))
7768
7867
  return false;
7868
+ return this.sendDeliveryEnterAfterAvailability(phase, stillCurrent, durableAttempt);
7869
+ }
7870
+ /** Write half only: callers must finish the availability wait before any final recovery ownership proof. */
7871
+ async sendDeliveryEnterAfterAvailability(phase, stillCurrent, durableAttempt) {
7769
7872
  // A recovery Enter may have waited for a transient while cancel or spawn
7770
7873
  // replaced the delivery. Check at the last point before the tmux write.
7771
7874
  if (this.deliveryWritesStopping || (stillCurrent && !stillCurrent()))
@@ -8246,6 +8349,10 @@ export class Daemon extends EventEmitter {
8246
8349
  // is disqualifying and no amount of output may override it. Where it
8247
8350
  // cannot (a backend with no prompt pattern), the output signal remains
8248
8351
  // exactly as before — this must not regress backends we cannot read.
8352
+ // The attempt's owner (#1514 review): a confirmation fenced because the window was replaced must not be revived
8353
+ // by the late proof below on the replacement's pane.
8354
+ const ownerTmux = this.tmux, ownerGeneration = this.spawnGeneration;
8355
+ const owned = () => this.tmux === ownerTmux && this.spawnGeneration === ownerGeneration;
8249
8356
  const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry", !rawPaste);
8250
8357
  if (becameBusy) {
8251
8358
  if (status)
@@ -8257,15 +8364,29 @@ export class Daemon extends EventEmitter {
8257
8364
  if (!rawPaste && !steer && !this.canProveSubmission())
8258
8365
  verdict.proof = "output-edge";
8259
8366
  }
8367
+ else if (!owned()) {
8368
+ // The window this delivery was pasted into was replaced while it was being confirmed: whether its CLI took
8369
+ // the message is unknown, and the new window's pane says nothing about it. Uncertain, never ✅ or ❌.
8370
+ this.logger.warn({ phase: "post-submit-proof" }, "Window replaced during submission proof — delivery outcome uncertain");
8371
+ verdict.phase = "post-submit-proof";
8372
+ verdict.proof = "window-replaced";
8373
+ return false;
8374
+ }
8260
8375
  else {
8261
8376
  const proof = this.structuredInputEvidence()
8262
- ? await this.lateCodexSubmissionProof(signature, pasteBaseline)
8263
- : await this.confirmSubmitted(signature, pasteBaseline);
8264
- if (proof === "submitted") {
8377
+ ? await this.lateCodexSubmissionProof(signature, pasteBaseline, false, owned)
8378
+ : await this.confirmSubmitted(signature, pasteBaseline, owned);
8379
+ if (proof === "submitted" && owned()) {
8265
8380
  if (status)
8266
8381
  this.emit("message_confirmed", status);
8267
8382
  return true;
8268
8383
  }
8384
+ if (!owned()) {
8385
+ this.logger.warn({ phase: "post-submit-proof" }, "Window replaced during submission proof — delivery outcome uncertain");
8386
+ verdict.phase = "post-submit-proof";
8387
+ verdict.proof = "window-replaced";
8388
+ return false;
8389
+ }
8269
8390
  if (this.structuredInputEvidence() && proof !== "stranded") {
8270
8391
  // This is the observed #910 race: the CLI processed the message
8271
8392
  // although its echo had not appeared within the proof window. A
@@ -8368,16 +8489,25 @@ export class Daemon extends EventEmitter {
8368
8489
  * still confirmed on output alone).
8369
8490
  */
8370
8491
  async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase, allowRecoveryEnter = true) {
8492
+ // The window this confirmation is about (#1490). The waits below can span a recoverWindow, which replaces
8493
+ // this.tmux with a new TmuxManager, so its identity (with the spawn generation) is the fence. A recovery Enter or
8494
+ // a proof taken after that would be about another window: the fence goes down to the write
8495
+ // (sendDeliveryEnterAfterAvailability asks it last, right before the key) and into every proof, which asks it
8496
+ // before its capture and again before judging what it read (#1514 review) — so a replacement during a capture
8497
+ // can neither confirm this delivery nor run a proof's side effects.
8498
+ const generation = this.spawnGeneration, tmux = this.tmux;
8499
+ const current = () => this.spawnGeneration === generation && this.tmux === tmux;
8500
+ const proofNow = () => this.confirmSubmitted(signature, baseline, current);
8371
8501
  if (!this.canProveSubmission()) {
8372
- let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
8502
+ let busy = (await this.confirmBusyAfterEnter(windowId, enterAt)) && current();
8373
8503
  if (!busy) {
8374
- if (!allowRecoveryEnter)
8504
+ if (!allowRecoveryEnter || !current())
8375
8505
  return false;
8376
8506
  this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
8377
8507
  const retryAt = Date.now();
8378
- if (!(await this.sendDeliveryEnter(retryPhase)))
8508
+ if (!(await this.sendDeliveryEnter(retryPhase, current)))
8379
8509
  return false;
8380
- busy = await this.confirmBusyAfterEnter(windowId, retryAt);
8510
+ busy = (await this.confirmBusyAfterEnter(windowId, retryAt)) && current();
8381
8511
  }
8382
8512
  return busy;
8383
8513
  }
@@ -8393,7 +8523,9 @@ export class Daemon extends EventEmitter {
8393
8523
  // proves a swallowed Enter only once it outlasts the window; an early one is polled through. Codex keeps its rule.
8394
8524
  const strandMustPersist = !!this.backend?.readInputRow;
8395
8525
  for (;;) {
8396
- proof = await this.confirmSubmitted(signature, baseline);
8526
+ proof = await proofNow();
8527
+ if (!current())
8528
+ return false;
8397
8529
  if (proof === "submitted")
8398
8530
  return true;
8399
8531
  if ((proof === "stranded" && !strandMustPersist) || Date.now() >= firstDeadline)
@@ -8402,18 +8534,22 @@ export class Daemon extends EventEmitter {
8402
8534
  }
8403
8535
  if (proof !== "stranded" || !allowRecoveryEnter)
8404
8536
  return false;
8405
- if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
8537
+ if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)) || !current())
8538
+ return false;
8539
+ proof = await proofNow();
8540
+ if (!current())
8406
8541
  return false;
8407
- proof = await this.confirmSubmitted(signature, baseline);
8408
8542
  if (proof === "submitted")
8409
8543
  return true;
8410
8544
  if (proof !== "stranded")
8411
8545
  return false;
8412
- if (!(await this.sendDeliveryEnter(retryPhase)))
8546
+ if (!(await this.sendDeliveryEnter(retryPhase, current)))
8413
8547
  return false;
8414
8548
  const retryDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
8415
8549
  for (;;) {
8416
- proof = await this.confirmSubmitted(signature, baseline);
8550
+ proof = await proofNow();
8551
+ if (!current())
8552
+ return false;
8417
8553
  if (proof === "submitted")
8418
8554
  return true;
8419
8555
  if (Date.now() >= retryDeadline)
@@ -8421,8 +8557,10 @@ export class Daemon extends EventEmitter {
8421
8557
  await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
8422
8558
  }
8423
8559
  }
8424
- if (await this.confirmSubmitted(signature, baseline) === "submitted")
8560
+ if (await proofNow() === "submitted" && current())
8425
8561
  return true;
8562
+ if (!current())
8563
+ return false;
8426
8564
  // Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
8427
8565
  // how we got here. Then poll briefly rather than judging on one capture:
8428
8566
  // the key takes a moment to be digested, and a single early look would
@@ -8432,25 +8570,32 @@ export class Daemon extends EventEmitter {
8432
8570
  this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
8433
8571
  if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
8434
8572
  return false;
8435
- if (!(await this.sendDeliveryEnter(retryPhase)))
8573
+ if (!(await this.sendDeliveryEnter(retryPhase, current)))
8436
8574
  return false;
8437
8575
  const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
8438
8576
  for (;;) {
8439
- if (await this.confirmSubmitted(signature, baseline) === "submitted")
8577
+ const proof = await proofNow();
8578
+ if (!current())
8579
+ return false;
8580
+ if (proof === "submitted")
8440
8581
  return true;
8441
8582
  if (Date.now() >= deadline)
8442
8583
  return false;
8443
8584
  await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
8444
8585
  }
8445
8586
  }
8446
- async confirmSubmitted(signature, baseline) {
8447
- if (!this.tmux)
8587
+ async confirmSubmitted(signature, baseline, owned) {
8588
+ // `owned`: the attempt's window fence (#1514 review), asked before the capture and again before judging it —
8589
+ // judging can retire the spawn's input guard, so a capture of a replaced window must not even be judged.
8590
+ if (!this.tmux || (owned && !owned()))
8448
8591
  return "unproven";
8449
8592
  let pane = null;
8450
8593
  try {
8451
8594
  pane = await this.tmux.capturePane();
8452
8595
  }
8453
8596
  catch { /* the transcript may still say */ }
8597
+ if (owned && !owned())
8598
+ return "unproven";
8454
8599
  const onPane = pane === null ? "unproven" : this.judgeSubmission(pane, signature, baseline);
8455
8600
  if (onPane === "submitted")
8456
8601
  return onPane;
@@ -8458,7 +8603,7 @@ export class Daemon extends EventEmitter {
8458
8603
  const found = await this.transcriptLook(signature);
8459
8604
  // One synchronous stretch from here: the fence is asked after the last await, and only then is the hit kept and the
8460
8605
  // spawn's guard retired — a stop or respawn that lands between the look and this line leaves both untouched.
8461
- if (!found || !t?.current())
8606
+ if (!found || !t?.current() || (owned && !owned()))
8462
8607
  return onPane;
8463
8608
  if (!t.provenBy) {
8464
8609
  t.provenBy = found;
@@ -8547,12 +8692,12 @@ export class Daemon extends EventEmitter {
8547
8692
  // read as ours, we would press Enter to "recover" it, and the turn IT
8548
8693
  // starts would confirm a message that never reached the pane.
8549
8694
  //
8550
- // For a structural box reader (claude-code), "did not already show it" needs a baseline whose box was READ: an
8551
- // unreadable one (a dialog, a redraw the reader refused) says nothing about what the box held, and counting it as
8552
- // empty would claim an older message as ours and press Enter on it (#1353 review). The prompt-row backends (codex,
8553
- // kiro) keep their existing attribution.
8695
+ // Structured evidence (Claude's box and Codex's current input/footer pair) requires both snapshots to be READ.
8696
+ // An unreadable baseline says nothing about what the box held; treating it as empty would claim an older draft
8697
+ // as ours and press Enter on it (#1353/#1359). Codex's native warning viewer can ignore the paste and first Enter,
8698
+ // then reveal an older draft when the user dismisses it. Row-only backends retain their existing attribution.
8554
8699
  const attributable = baseline != null
8555
- && (!this.backend?.readInputRow || (baseline.inputReadable && after.inputReadable));
8700
+ && (!this.structuredInputEvidence() || (baseline.inputReadable && after.inputReadable));
8556
8701
  if (after.strandedInput && (signature.unique || (attributable && baseline.strandedInput === false)))
8557
8702
  return "stranded";
8558
8703
  // A paste the CLI shows collapsed carries no signature at all. One the box did not hold before we pasted is
@@ -8593,11 +8738,13 @@ export class Daemon extends EventEmitter {
8593
8738
  return "unverifiable";
8594
8739
  }
8595
8740
  /** Only positive echo/queue evidence may turn an ambiguous Codex write into ✅. */
8596
- async lateCodexSubmissionProof(signature, baseline, waitThroughStranded = false) {
8741
+ async lateCodexSubmissionProof(signature, baseline, waitThroughStranded = false, owned) {
8597
8742
  const deadline = Date.now() + CODEX_LATE_PROOF_MS;
8598
8743
  let proof = "unproven";
8599
8744
  for (;;) {
8600
- proof = await this.confirmSubmitted(signature, baseline);
8745
+ if (owned && !owned())
8746
+ return "unproven";
8747
+ proof = await this.confirmSubmitted(signature, baseline, owned);
8601
8748
  // Ordinary idle-path proof may return a strand immediately. After a
8602
8749
  // native-queue recovery Enter, though, it can be the previous frame
8603
8750
  // still on screen; only a strand that survives the bounded repaint
@@ -8609,6 +8756,8 @@ export class Daemon extends EventEmitter {
8609
8756
  // Scrollback can hold the echo the viewport lost — but it is judged exactly like the viewport: a box that
8610
8757
  // cannot be read, or that still holds our text, is never upgraded by an echo further up.
8611
8758
  const history = await this.tmux.capturePaneWithHistory(300);
8759
+ if (owned && !owned())
8760
+ return "unproven";
8612
8761
  if (this.judgeSubmission(history, signature, baseline) === "submitted")
8613
8762
  return this.submittedProof();
8614
8763
  }
@@ -8802,12 +8951,36 @@ export class Daemon extends EventEmitter {
8802
8951
  return true; // best effort, exactly as before — nothing here is verified
8803
8952
  }
8804
8953
  if (proof !== "submitted") {
8805
- // A bare Enter is a no-op at an empty prompt, so this is safe even if the
8806
- // first one did land; when the text is still in the input row it is the
8807
- // submit it never got. Re-pasting would append the text to itself.
8808
8954
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
8809
- if (!(await this.sendDeliveryEnter(`${label}-retry`, current)))
8955
+ if (current && !current())
8810
8956
  return false;
8957
+ const retryPhase = `${label}-retry`;
8958
+ if (this.structuredInputEvidence()) {
8959
+ // Both the retry delay and the Enter availability wait may outlive the
8960
+ // paste. Complete those waits BEFORE the last ownership/positive proof.
8961
+ if (!(await this.waitForInputTransientToClear(retryPhase)))
8962
+ return false;
8963
+ if (this.deliveryWritesStopping || (current && !current()))
8964
+ return false;
8965
+ proof = await this.confirmSubmitted(signature, baseline);
8966
+ if (this.deliveryWritesStopping || (current && !current()))
8967
+ return false;
8968
+ if (proof === "submitted")
8969
+ return true;
8970
+ if (proof !== "stranded") {
8971
+ this.logger.warn({ label, proof }, "System paste recovery input could not be attributed");
8972
+ return false;
8973
+ }
8974
+ // No further availability wait may invalidate this proof. The write
8975
+ // half retains the same stopping/admission fence immediately before IPC.
8976
+ if (!(await this.sendDeliveryEnterAfterAvailability(retryPhase, current)))
8977
+ return false;
8978
+ }
8979
+ else {
8980
+ // Row-only backends retain their ordinary availability wait and retry.
8981
+ if (!(await this.sendDeliveryEnter(retryPhase, current)))
8982
+ return false;
8983
+ }
8811
8984
  proof = await this.confirmSubmitted(signature, baseline);
8812
8985
  if (current && !current())
8813
8986
  return false;
@@ -8871,12 +9044,18 @@ export class Daemon extends EventEmitter {
8871
9044
  async confirmBusyAfterEnter(windowId, since) {
8872
9045
  const client = this.controlClient;
8873
9046
  const hardDeadline = Date.now() + CONFIRM_BUSY_MAX_WAIT_MS;
9047
+ // A drained control read hides notifications (#1517 review): while the client is blind a quiet pane is not
9048
+ // evidence of a swallowed Enter, so blind polls do not count toward "no reaction". Bounded: past the drain limit
9049
+ // the client retires and resets, which restarts this check from when it could see again.
9050
+ const blindDeadline = hardDeadline + CONFIRM_BUSY_BLIND_EXTRA_MS;
8874
9051
  let observedFrom = since;
8875
9052
  let polls = 0;
8876
9053
  while (polls < CONFIRM_BUSY_POLLS) {
8877
9054
  await new Promise(r => setTimeout(r, CONFIRM_BUSY_POLL_MS));
8878
9055
  if (client.hasOutputSince(windowId, observedFrom))
8879
9056
  return true;
9057
+ if (client.isObservationBlind?.() && Date.now() < blindDeadline)
9058
+ continue;
8880
9059
  const resetAt = client.getObservationResetAt();
8881
9060
  if (resetAt > observedFrom && Date.now() < hardDeadline) {
8882
9061
  this.logger.debug({ windowId, resetAt }, "Control mode reconnected mid-confirmation — restarting the Enter check from when we could see again");
@@ -9194,7 +9373,20 @@ export class Daemon extends EventEmitter {
9194
9373
  respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
9195
9374
  return;
9196
9375
  }
9197
- if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
9376
+ // #1266: without the fleet there is no button store: buttons are checked, then offered as text.
9377
+ let routed = args;
9378
+ if (tool === "reply") {
9379
+ const buttons = parseReplyButtons(args.buttons, args);
9380
+ if (buttons && "error" in buttons) {
9381
+ respond(null, `reply: ${buttons.error}`);
9382
+ return;
9383
+ }
9384
+ if (buttons) {
9385
+ routed = { ...args, text: `${String(args.text)}\n\n${replyButtonsFallbackText(buttons.buttons)}` };
9386
+ delete routed.buttons;
9387
+ }
9388
+ }
9389
+ if (!routeToolCall(adapter, tool, routed, this.lastThreadId, respond)) {
9198
9390
  respond(null, `Unknown tool: ${tool}`);
9199
9391
  }
9200
9392
  }
@@ -9353,7 +9545,8 @@ export class Daemon extends EventEmitter {
9353
9545
  instanceName: this.name,
9354
9546
  mcpServers: isCliMode ? {} : {
9355
9547
  "agend": {
9356
- command: "node",
9548
+ // The Node this daemon runs on (#1450 C5): the CLIs start the MCP server with it, not a `node` from PATH.
9549
+ command: process.execPath,
9357
9550
  args: [serverJs],
9358
9551
  env: mcpEnv,
9359
9552
  },
@@ -10290,15 +10483,16 @@ export class Daemon extends EventEmitter {
10290
10483
  // this file from <instanceDir>/agent.token (mode 0o600) and sends its
10291
10484
  // value in the X-Agend-Instance-Token header; the daemon-side /agent
10292
10485
  // endpoint verifies it matches the on-disk value for the claimed
10293
- // instance. This prevents other local processes (even those holding
10294
- // the global web token) from impersonating instances.
10486
+ // instance. A global web token cannot substitute for this credential, but
10487
+ // same-uid shell processes can read sibling tokens. Mode 0600 separates
10488
+ // OS users; it does not isolate mutually untrusted agents on this account.
10295
10489
  const agentTokenPath = join(this.instanceDir, "agent.token");
10296
10490
  const agentToken = randomBytes(32).toString("hex");
10297
10491
  const tokenWrite = writeSecretFile(agentTokenPath, agentToken);
10298
10492
  if (!tokenWrite.ok) {
10299
10493
  // Do not fail the spawn over it — an instance that cannot start is worse
10300
10494
  // than one whose token is readable. But say so, loudly: the whole point of
10301
- // this token is that other local processes cannot use it.
10495
+ // this permission check separates other OS users, not same-uid processes.
10302
10496
  this.logger.error({ path: agentTokenPath, mode: tokenWrite.mode?.toString(8), reason: tokenWrite.reason }, "Agent token file is not owner-only — other local users can impersonate this instance");
10303
10497
  }
10304
10498
  // AGEND_HOME points the child's agent-cli at the same data dir the daemon
@@ -10499,7 +10693,8 @@ export class Daemon extends EventEmitter {
10499
10693
  const ownerTmux = this.tmux;
10500
10694
  const ownerSpawn = this.spawnGeneration;
10501
10695
  const ownerFence = this.launchFenceEpoch;
10502
- const owned = () => this.tmux === ownerTmux && this.spawnGeneration === ownerSpawn && this.launchFenceEpoch === ownerFence;
10696
+ const owned = () => !this.deliveryWritesStopping && this.tmux === ownerTmux
10697
+ && this.spawnGeneration === ownerSpawn && this.launchFenceEpoch === ownerFence;
10503
10698
  do {
10504
10699
  this.startupAdmission?.();
10505
10700
  attempts++;
@@ -10545,8 +10740,7 @@ export class Daemon extends EventEmitter {
10545
10740
  if (Daemon.dialogMatches(dialog, pane)) {
10546
10741
  if (dialog.autoResolutionKey
10547
10742
  && (attemptedSafetyChoices.has(dialog.autoResolutionKey)
10548
- || (this.autoResolvedDialogGeneration === this.spawnGeneration
10549
- && this.autoResolvedDialogKey === dialog.autoResolutionKey)))
10743
+ || this.hasAutoDialogClaim(dialog)))
10550
10744
  continue;
10551
10745
  lastDialog = dialog;
10552
10746
  this.noteCodexReserveDialog(dialog, pane);
@@ -10610,8 +10804,7 @@ export class Daemon extends EventEmitter {
10610
10804
  }
10611
10805
  if (dialog.autoResolutionKey) {
10612
10806
  attemptedSafetyChoices.add(dialog.autoResolutionKey);
10613
- this.autoResolvedDialogGeneration = ownerSpawn;
10614
- this.autoResolvedDialogKey = dialog.autoResolutionKey;
10807
+ this.claimAutoDialog(dialog);
10615
10808
  }
10616
10809
  for (const key of dialog.keys) {
10617
10810
  this.startupAdmission?.();