@cluesmith/codev 3.3.2 → 3.3.4

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 (257) hide show
  1. package/dashboard-dist/assets/{index-D3XaKpZW.js → index-DZf6pNPE.js} +41 -41
  2. package/dashboard-dist/assets/index-DZf6pNPE.js.map +1 -0
  3. package/dashboard-dist/assets/index-DnYLy5C_.css +32 -0
  4. package/dashboard-dist/index.html +2 -2
  5. package/dist/agent-farm/cli.d.ts.map +1 -1
  6. package/dist/agent-farm/cli.js +48 -0
  7. package/dist/agent-farm/cli.js.map +1 -1
  8. package/dist/agent-farm/commands/ide.d.ts +20 -0
  9. package/dist/agent-farm/commands/ide.d.ts.map +1 -0
  10. package/dist/agent-farm/commands/ide.js +138 -0
  11. package/dist/agent-farm/commands/ide.js.map +1 -0
  12. package/dist/agent-farm/commands/inbox.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/inbox.js +79 -10
  14. package/dist/agent-farm/commands/inbox.js.map +1 -1
  15. package/dist/agent-farm/commands/interrupt.d.ts +4 -1
  16. package/dist/agent-farm/commands/interrupt.d.ts.map +1 -1
  17. package/dist/agent-farm/commands/interrupt.js +6 -3
  18. package/dist/agent-farm/commands/interrupt.js.map +1 -1
  19. package/dist/agent-farm/commands/reset.d.ts +4 -1
  20. package/dist/agent-farm/commands/reset.d.ts.map +1 -1
  21. package/dist/agent-farm/commands/reset.js +6 -3
  22. package/dist/agent-farm/commands/reset.js.map +1 -1
  23. package/dist/agent-farm/commands/send.d.ts +28 -0
  24. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  25. package/dist/agent-farm/commands/send.js +79 -6
  26. package/dist/agent-farm/commands/send.js.map +1 -1
  27. package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -1
  28. package/dist/agent-farm/commands/spawn-worktree.js +16 -7
  29. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
  30. package/dist/agent-farm/commands/tower.d.ts +21 -9
  31. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/tower.js +90 -30
  33. package/dist/agent-farm/commands/tower.js.map +1 -1
  34. package/dist/agent-farm/db/index.d.ts +3 -1
  35. package/dist/agent-farm/db/index.d.ts.map +1 -1
  36. package/dist/agent-farm/db/index.js +8 -443
  37. package/dist/agent-farm/db/index.js.map +1 -1
  38. package/dist/agent-farm/db/mailbox.d.ts +47 -7
  39. package/dist/agent-farm/db/mailbox.d.ts.map +1 -1
  40. package/dist/agent-farm/db/mailbox.js +56 -12
  41. package/dist/agent-farm/db/mailbox.js.map +1 -1
  42. package/dist/agent-farm/db/migrations.d.ts +57 -0
  43. package/dist/agent-farm/db/migrations.d.ts.map +1 -0
  44. package/dist/agent-farm/db/migrations.js +530 -0
  45. package/dist/agent-farm/db/migrations.js.map +1 -0
  46. package/dist/agent-farm/db/schema.d.ts +1 -1
  47. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  48. package/dist/agent-farm/db/schema.js +1 -0
  49. package/dist/agent-farm/db/schema.js.map +1 -1
  50. package/dist/agent-farm/db/tower-owner.d.ts +168 -0
  51. package/dist/agent-farm/db/tower-owner.d.ts.map +1 -0
  52. package/dist/agent-farm/db/tower-owner.js +369 -0
  53. package/dist/agent-farm/db/tower-owner.js.map +1 -0
  54. package/dist/agent-farm/db/types.d.ts +47 -0
  55. package/dist/agent-farm/db/types.d.ts.map +1 -1
  56. package/dist/agent-farm/db/types.js.map +1 -1
  57. package/dist/agent-farm/lib/ide-record.d.ts +65 -0
  58. package/dist/agent-farm/lib/ide-record.d.ts.map +1 -0
  59. package/dist/agent-farm/lib/ide-record.js +118 -0
  60. package/dist/agent-farm/lib/ide-record.js.map +1 -0
  61. package/dist/agent-farm/lib/tunnel-client.d.ts +22 -0
  62. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  63. package/dist/agent-farm/lib/tunnel-client.js +96 -19
  64. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  65. package/dist/agent-farm/servers/cron-delivery.d.ts +7 -1
  66. package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -1
  67. package/dist/agent-farm/servers/cron-delivery.js +2 -1
  68. package/dist/agent-farm/servers/cron-delivery.js.map +1 -1
  69. package/dist/agent-farm/servers/gate-profiles.d.ts +44 -2
  70. package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -1
  71. package/dist/agent-farm/servers/gate-profiles.js +79 -6
  72. package/dist/agent-farm/servers/gate-profiles.js.map +1 -1
  73. package/dist/agent-farm/servers/ide-forward.d.ts +62 -0
  74. package/dist/agent-farm/servers/ide-forward.d.ts.map +1 -0
  75. package/dist/agent-farm/servers/ide-forward.js +274 -0
  76. package/dist/agent-farm/servers/ide-forward.js.map +1 -0
  77. package/dist/agent-farm/servers/ide-server.d.ts +68 -0
  78. package/dist/agent-farm/servers/ide-server.d.ts.map +1 -0
  79. package/dist/agent-farm/servers/ide-server.js +281 -0
  80. package/dist/agent-farm/servers/ide-server.js.map +1 -0
  81. package/dist/agent-farm/servers/mailbox-delivery.d.ts +296 -12
  82. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
  83. package/dist/agent-farm/servers/mailbox-delivery.js +566 -69
  84. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
  85. package/dist/agent-farm/servers/mailbox-wiring.d.ts +66 -19
  86. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
  87. package/dist/agent-farm/servers/mailbox-wiring.js +204 -37
  88. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
  89. package/dist/agent-farm/servers/message-write.d.ts +138 -11
  90. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  91. package/dist/agent-farm/servers/message-write.js +144 -35
  92. package/dist/agent-farm/servers/message-write.js.map +1 -1
  93. package/dist/agent-farm/servers/overview.d.ts +28 -12
  94. package/dist/agent-farm/servers/overview.d.ts.map +1 -1
  95. package/dist/agent-farm/servers/overview.js +130 -79
  96. package/dist/agent-farm/servers/overview.js.map +1 -1
  97. package/dist/agent-farm/servers/render-gate.d.ts +77 -1
  98. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
  99. package/dist/agent-farm/servers/render-gate.js +174 -25
  100. package/dist/agent-farm/servers/render-gate.js.map +1 -1
  101. package/dist/agent-farm/servers/session-submit.d.ts +7 -4
  102. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
  103. package/dist/agent-farm/servers/session-submit.js +7 -4
  104. package/dist/agent-farm/servers/session-submit.js.map +1 -1
  105. package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
  106. package/dist/agent-farm/servers/tower-cron.js +7 -3
  107. package/dist/agent-farm/servers/tower-cron.js.map +1 -1
  108. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
  109. package/dist/agent-farm/servers/tower-instances.js +17 -10
  110. package/dist/agent-farm/servers/tower-instances.js.map +1 -1
  111. package/dist/agent-farm/servers/tower-routes.d.ts +32 -0
  112. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  113. package/dist/agent-farm/servers/tower-routes.js +186 -26
  114. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  115. package/dist/agent-farm/servers/tower-server.js +79 -9
  116. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  117. package/dist/agent-farm/servers/tower-terminals.d.ts +21 -0
  118. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  119. package/dist/agent-farm/servers/tower-terminals.js +106 -13
  120. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  121. package/dist/agent-farm/servers/tower-utils.d.ts +35 -0
  122. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
  123. package/dist/agent-farm/servers/tower-utils.js +39 -0
  124. package/dist/agent-farm/servers/tower-utils.js.map +1 -1
  125. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  126. package/dist/agent-farm/servers/tower-websocket.js +26 -2
  127. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  128. package/dist/agent-farm/utils/message-format.d.ts +78 -2
  129. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  130. package/dist/agent-farm/utils/message-format.js +107 -11
  131. package/dist/agent-farm/utils/message-format.js.map +1 -1
  132. package/dist/agent-farm/utils/port.d.ts +22 -0
  133. package/dist/agent-farm/utils/port.d.ts.map +1 -0
  134. package/dist/agent-farm/utils/port.js +35 -0
  135. package/dist/agent-farm/utils/port.js.map +1 -0
  136. package/dist/agent-farm/utils/server-utils.d.ts +26 -0
  137. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  138. package/dist/agent-farm/utils/server-utils.js +36 -0
  139. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  140. package/dist/cli.d.ts.map +1 -1
  141. package/dist/cli.js +3 -1
  142. package/dist/cli.js.map +1 -1
  143. package/dist/commands/consult/index.d.ts.map +1 -1
  144. package/dist/commands/consult/index.js +61 -1
  145. package/dist/commands/consult/index.js.map +1 -1
  146. package/dist/commands/consult/tree-tripwire.d.ts +177 -0
  147. package/dist/commands/consult/tree-tripwire.d.ts.map +1 -0
  148. package/dist/commands/consult/tree-tripwire.js +356 -0
  149. package/dist/commands/consult/tree-tripwire.js.map +1 -0
  150. package/dist/commands/doctor.d.ts +69 -0
  151. package/dist/commands/doctor.d.ts.map +1 -1
  152. package/dist/commands/doctor.js +165 -2
  153. package/dist/commands/doctor.js.map +1 -1
  154. package/dist/commands/generate-image.d.ts +25 -1
  155. package/dist/commands/generate-image.d.ts.map +1 -1
  156. package/dist/commands/generate-image.js +410 -2
  157. package/dist/commands/generate-image.js.map +1 -1
  158. package/dist/commands/porch/prompts.d.ts.map +1 -1
  159. package/dist/commands/porch/prompts.js +18 -2
  160. package/dist/commands/porch/prompts.js.map +1 -1
  161. package/dist/lib/agent-env.d.ts +86 -0
  162. package/dist/lib/agent-env.d.ts.map +1 -0
  163. package/dist/lib/agent-env.js +126 -0
  164. package/dist/lib/agent-env.js.map +1 -0
  165. package/dist/lib/forge-contracts.d.ts +76 -5
  166. package/dist/lib/forge-contracts.d.ts.map +1 -1
  167. package/dist/lib/forge-contracts.js.map +1 -1
  168. package/dist/lib/forge-rate-limit.d.ts +45 -0
  169. package/dist/lib/forge-rate-limit.d.ts.map +1 -0
  170. package/dist/lib/forge-rate-limit.js +89 -0
  171. package/dist/lib/forge-rate-limit.js.map +1 -0
  172. package/dist/lib/forge.d.ts +29 -1
  173. package/dist/lib/forge.d.ts.map +1 -1
  174. package/dist/lib/forge.js +96 -21
  175. package/dist/lib/forge.js.map +1 -1
  176. package/dist/lib/github.d.ts +13 -1
  177. package/dist/lib/github.d.ts.map +1 -1
  178. package/dist/lib/github.js +58 -15
  179. package/dist/lib/github.js.map +1 -1
  180. package/dist/lib/test-env.d.ts +17 -0
  181. package/dist/lib/test-env.d.ts.map +1 -1
  182. package/dist/lib/test-env.js +20 -0
  183. package/dist/lib/test-env.js.map +1 -1
  184. package/dist/terminal/pty-manager.d.ts +10 -1
  185. package/dist/terminal/pty-manager.d.ts.map +1 -1
  186. package/dist/terminal/pty-manager.js +38 -9
  187. package/dist/terminal/pty-manager.js.map +1 -1
  188. package/dist/terminal/pty-session.d.ts +174 -8
  189. package/dist/terminal/pty-session.d.ts.map +1 -1
  190. package/dist/terminal/pty-session.js +299 -25
  191. package/dist/terminal/pty-session.js.map +1 -1
  192. package/dist/terminal/session-manager.d.ts +23 -2
  193. package/dist/terminal/session-manager.d.ts.map +1 -1
  194. package/dist/terminal/session-manager.js +115 -36
  195. package/dist/terminal/session-manager.js.map +1 -1
  196. package/dist/terminal/session-screen.d.ts +34 -5
  197. package/dist/terminal/session-screen.d.ts.map +1 -1
  198. package/dist/terminal/session-screen.js +52 -6
  199. package/dist/terminal/session-screen.js.map +1 -1
  200. package/dist/terminal/shellper-client.d.ts +73 -0
  201. package/dist/terminal/shellper-client.d.ts.map +1 -1
  202. package/dist/terminal/shellper-client.js +151 -0
  203. package/dist/terminal/shellper-client.js.map +1 -1
  204. package/dist/terminal/shellper-main.js +20 -3
  205. package/dist/terminal/shellper-main.js.map +1 -1
  206. package/dist/terminal/shellper-process.d.ts +2 -0
  207. package/dist/terminal/shellper-process.d.ts.map +1 -1
  208. package/dist/terminal/shellper-process.js +17 -1
  209. package/dist/terminal/shellper-process.js.map +1 -1
  210. package/dist/terminal/shellper-protocol.d.ts +28 -0
  211. package/dist/terminal/shellper-protocol.d.ts.map +1 -1
  212. package/dist/terminal/shellper-protocol.js.map +1 -1
  213. package/dist/terminal/terminal-replies.d.ts +102 -0
  214. package/dist/terminal/terminal-replies.d.ts.map +1 -0
  215. package/dist/terminal/terminal-replies.js +181 -0
  216. package/dist/terminal/terminal-replies.js.map +1 -0
  217. package/package.json +4 -4
  218. package/scripts/forge/gitea/_lib.sh +214 -0
  219. package/scripts/forge/gitea/issue-comment.sh +8 -1
  220. package/scripts/forge/gitea/issue-view.sh +95 -4
  221. package/scripts/forge/gitea/pr-create.sh +155 -0
  222. package/scripts/forge/gitea/pr-exists.sh +32 -4
  223. package/scripts/forge/gitea/pr-list.sh +40 -31
  224. package/scripts/forge/gitea/pr-view.sh +65 -4
  225. package/scripts/forge/gitea/recently-merged.sh +91 -21
  226. package/scripts/forge/gitea/user-identity.sh +30 -1
  227. package/scripts/forge/github/issue-view.sh +5 -2
  228. package/scripts/forge/github/pr-create.sh +47 -0
  229. package/scripts/forge/github/pr-list.sh +6 -2
  230. package/scripts/forge/github/pr-search.sh +6 -2
  231. package/scripts/forge/gitlab/issue-search.sh +1 -0
  232. package/scripts/forge/gitlab/issue-view.sh +24 -4
  233. package/scripts/forge/gitlab/pr-create.sh +53 -0
  234. package/scripts/forge/gitlab/pr-search.sh +37 -2
  235. package/scripts/forge/linear/auth-status.sh +1 -0
  236. package/scripts/forge/linear/issue-comment.sh +1 -0
  237. package/scripts/forge/linear/issue-list.sh +1 -0
  238. package/scripts/forge/linear/issue-search.sh +1 -0
  239. package/scripts/forge/linear/issue-view.sh +14 -2
  240. package/scripts/forge/linear/recently-closed.sh +1 -0
  241. package/scripts/forge/linear/user-identity.sh +1 -0
  242. package/skeleton/.claude/skills/generate-image/SKILL.md +39 -1
  243. package/skeleton/.codex/skills/generate-image/SKILL.md +39 -1
  244. package/skeleton/protocols/air/prompts/pr.md +6 -1
  245. package/skeleton/protocols/aspir/prompts/review.md +6 -1
  246. package/skeleton/protocols/bugfix/prompts/pr.md +6 -1
  247. package/skeleton/protocols/bugfix/protocol.md +2 -1
  248. package/skeleton/protocols/maintain/prompts/review.md +6 -1
  249. package/skeleton/protocols/pir/prompts/review.md +10 -7
  250. package/skeleton/protocols/spir/prompts/review.md +6 -1
  251. package/skeleton/resources/commands/agent-farm.md +28 -3
  252. package/skeleton/roles/consultant.md +32 -0
  253. package/skeleton/templates/AGENTS.md +1 -1
  254. package/skeleton/templates/CLAUDE.md +1 -1
  255. package/templates/tower.html +16 -1
  256. package/dashboard-dist/assets/index-BZRTEMtH.css +0 -32
  257. package/dashboard-dist/assets/index-D3XaKpZW.js.map +0 -1
@@ -33,8 +33,10 @@
33
33
  * {@link DeliveryPorts}, so the orchestration is unit-testable without a live Tower.
34
34
  */
35
35
  import path from 'node:path';
36
- import { findHeldForAgent, getById, listHeld, markDelivered, setHeldReason, pruneTerminal, findEscalatable, markEscalated, findStarvingAgents, } from '../db/mailbox.js';
36
+ import { findHeldForAgent, getById, listHeld, markDelivered, setHeldVerdict, pruneTerminal, findEscalatable, markEscalated, markEscalatedDelivered, findStarvingAgents, } from '../db/mailbox.js';
37
+ import { writeStrategyForApp } from './message-write.js';
37
38
  import { KeyedSerializer } from './write-queue.js';
39
+ import { formatVerdict, isUnverifiableVerdict } from '@cluesmith/codev-sdk/hold-verdict';
38
40
  /**
39
41
  * A gate outcome the render gate CANNOT bound to a decision — an unrecognized app
40
42
  * (`no-profile`) or a recognized app whose composer region can't be found
@@ -44,9 +46,18 @@ import { KeyedSerializer } from './write-queue.js';
44
46
  * `busy`/`user-text` streak is deliberately excluded (a human legitimately at the line).
45
47
  * Shared by `recordStreak` and the cooldown branch of {@link MailboxDrainer.tick} so a
46
48
  * skipped tick and a real pass agree on what counts as classifier-stuck (CMAP round 3).
49
+ *
50
+ * This is the POLICY-side name for the same question `isUnverifiableVerdict` answers for the
51
+ * presentation surfaces, so it delegates rather than restating the rule (maintainer review,
52
+ * PR #1604). Two copies of "which verdicts never clear on their own" is one edit away from an
53
+ * escalation policy and an operator-facing remedy disagreeing about the same row. The wrapper
54
+ * is kept rather than collapsed to a single function because the two callers want different
55
+ * types: this one is typed on the DB/gate unions and reads naturally beside the escalation
56
+ * policy it serves, while the shared predicate takes the plain strings the CLI, the dashboard
57
+ * and the VS Code toast actually hold.
47
58
  */
48
59
  function isClassifierStuck(reason, detail) {
49
- return reason === 'no-profile' || detail === 'no-region-end' || detail === 'no-composer-marker';
60
+ return isUnverifiableVerdict(reason, detail);
50
61
  }
51
62
  /**
52
63
  * How long the receiving session's screen must have been QUIET before a gated delivery may
@@ -74,6 +85,108 @@ export const SETTLE_BEFORE_WRITE_MS = 250;
74
85
  function settled(ports, session) {
75
86
  return ports.now() - session.lastDataAt >= SETTLE_BEFORE_WRITE_MS;
76
87
  }
88
+ const composerSamples = new WeakMap();
89
+ /**
90
+ * The longest gap between two observations that can still be treated as ONE continuous run of
91
+ * the composer holding still (Issue #1664).
92
+ *
93
+ * Two equal fingerprints far apart in time prove nothing on their own: between them the
94
+ * composer could have changed and changed back, which is exactly #1521's turn-end redraw. An
95
+ * agent with no held mail is not sampled at all, so without this bound a sample taken minutes
96
+ * ago would authorise an immediate write onto a composer that had just been repainted —
97
+ * observed in the #1664 acceptance harness, where consecutive trials reused a stale sample and
98
+ * delivered on their very first pass.
99
+ *
100
+ * Set above the drainer's 1.5 s backstop so an ordinary chain of passes always counts as
101
+ * continuous, and the delivery of a held row never depends on a faster trigger existing. In
102
+ * practice the gap is far smaller: a `composer-redraw` hold carries a `retryAfterMs`, so the
103
+ * next observation lands about a settle later.
104
+ */
105
+ export const MAX_COMPOSER_SAMPLE_GAP_MS = 2_000;
106
+ /**
107
+ * Record the composer region as it looks now, and answer whether it has been UNCHANGED for at
108
+ * least {@link SETTLE_BEFORE_WRITE_MS} (Issue #1664).
109
+ *
110
+ * This is the composer-scoped replacement for {@link settled}. The render gate proves the
111
+ * composer is EMPTY; this proves it has stopped being PAINTED, which is the property #1521
112
+ * actually needed and #1573 approximated with whole-screen output quiescence. The
113
+ * approximation is what this issue exists to remove: measured on a real streaming claude PTY,
114
+ * the screen repainted a median of 8 times a second — so the whole-screen quiet never arrived —
115
+ * while the composer region sat byte-identical for minutes at a time. Mail waited a mean of
116
+ * 54 s, and up to 561 s, for a turn to end, and the recipient was safe to write to the whole
117
+ * time: Claude Code and codex accept input mid-turn and queue it for the next one.
118
+ *
119
+ * A `null` fingerprint (no locatable composer) is never stable — the classifier will have held
120
+ * on the same screen anyway, and an indeterminate region must not start a stability clock.
121
+ *
122
+ * Called on every delivery pass, INCLUDING the ones that go on to hold, so the clock advances
123
+ * across passes rather than restarting each time.
124
+ */
125
+ function noteComposer(session, fingerprint, now) {
126
+ if (fingerprint === null) {
127
+ composerSamples.delete(session);
128
+ return false;
129
+ }
130
+ const prev = composerSamples.get(session);
131
+ // A different region, or a gap too long to call one continuous observation, restarts the
132
+ // clock: both mean we cannot say what the composer did in between.
133
+ if (!prev || prev.fingerprint !== fingerprint || now - prev.at > MAX_COMPOSER_SAMPLE_GAP_MS) {
134
+ composerSamples.set(session, { fingerprint, since: now, at: now });
135
+ return false;
136
+ }
137
+ prev.at = now;
138
+ return now - prev.since >= SETTLE_BEFORE_WRITE_MS;
139
+ }
140
+ /**
141
+ * How long until {@link noteComposer} would report the current sample stable, in ms — what the
142
+ * drainer arms its one-shot re-drain for (Issue #1664), so a message for a working agent lands
143
+ * about a settle after the composer stops moving rather than on the next backstop tick.
144
+ * Clamped at 0, and {@link SETTLE_BEFORE_WRITE_MS} when there is no sample to measure from.
145
+ */
146
+ function msUntilComposerSettled(session, now) {
147
+ const prev = composerSamples.get(session);
148
+ if (!prev)
149
+ return SETTLE_BEFORE_WRITE_MS;
150
+ const remaining = SETTLE_BEFORE_WRITE_MS - (now - prev.since);
151
+ return Number.isFinite(remaining) && remaining > 0 ? remaining : 0;
152
+ }
153
+ /**
154
+ * How long the receiving session must have been free of HUMAN INPUT before a gated delivery
155
+ * may write onto it (Issue #1473) — the input-side counterpart of {@link SETTLE_BEFORE_WRITE_MS}.
156
+ *
157
+ * The change token catches input that arrives after the gate sampled it. It cannot catch input
158
+ * that arrived just BEFORE the sample and has not been echoed yet: nothing has moved, both
159
+ * samples agree — correctly — and the classifier reads a genuinely empty composer while the
160
+ * character is still travelling to the TUI. Only elapsed time can see that.
161
+ *
162
+ * One notch above the output settle because the round trip it covers is strictly the longer
163
+ * one: an echo has to reach the TUI, be processed, be painted, and come back as output, whereas
164
+ * the output settle only waits for painting to stop.
165
+ *
166
+ * **This BOUNDS the echo-lag window; it does not close it.** Input older than this interval
167
+ * whose echo is still delayed, and input in flight from the browser at sample time, both
168
+ * survive by construction. A larger constant buys coverage at the price of latency on every
169
+ * delivery, which is why the next tightening (if measurement wants one) is a BOUNDED
170
+ * `lastInputAt > lastDataAt` hold rather than a bigger number here.
171
+ */
172
+ export const INPUT_SETTLE_BEFORE_WRITE_MS = 300;
173
+ /**
174
+ * Has the session been free of human input long enough to write onto (Issue #1473)?
175
+ *
176
+ * Positive `>=` for the same reason {@link settled} is: a session carrying no usable timestamp
177
+ * yields NaN, every NaN comparison is false, and an unknown input age must read as NOT settled.
178
+ */
179
+ function inputSettled(ports, session) {
180
+ return ports.now() - session.lastInputAt >= INPUT_SETTLE_BEFORE_WRITE_MS;
181
+ }
182
+ /**
183
+ * How long until {@link inputSettled} would pass, in ms — what the drainer arms its one-shot
184
+ * re-drain for (Issue #1473). Clamped at 0 so a clock skew can never schedule into the past.
185
+ */
186
+ function msUntilInputSettled(ports, session) {
187
+ const remaining = INPUT_SETTLE_BEFORE_WRITE_MS - (ports.now() - session.lastInputAt);
188
+ return Number.isFinite(remaining) && remaining > 0 ? remaining : 0;
189
+ }
77
190
  /**
78
191
  * Minimum length of a normalized {@link echoNeedle} for it to be worth matching. Below this a
79
192
  * needle is not distinctive enough to prove anything — a two-character raw message would match
@@ -99,6 +212,17 @@ const MIN_ECHO_NEEDLE_LENGTH = 12;
99
212
  export function normalizeForEcho(text) {
100
213
  return text.replace(/[^A-Za-z0-9]/g, '');
101
214
  }
215
+ /**
216
+ * The paste placards a TUI renders in place of a long bracketed paste (Issue #1567), in
217
+ * {@link normalizeForEcho} form: claude shows `[Pasted text #N +M lines]`, codex
218
+ * `[Pasted Content N chars]` (both measured, `codev/evidence/1567-head-loss/`). A long frame
219
+ * now travels as a bracketed paste, so until it is submitted the composer shows one of these
220
+ * instead of the header — and a `--no-enter` send never submits. The echo watch therefore
221
+ * counts these alongside the header needle; a NEW occurrence of either is evidence the write
222
+ * landed. Counted, not merely matched, for the same reason as the header: a previous paste's
223
+ * placard may already be on screen.
224
+ */
225
+ export const PASTE_PLACARD_NEEDLES = ['Pastedtext', 'PastedContent'];
102
226
  /**
103
227
  * The needle {@link DeliveryPorts.watchEcho} opens on: the formatted message's FIRST line,
104
228
  * normalized. Returns `''` when it is too short to be distinctive (see
@@ -131,16 +255,31 @@ export function agentKey(workspacePath, toAgent) {
131
255
  * (Spec 1313 render-gate round 2 — the fix for the non-monotone `currentSeq:partialBytes`
132
256
  * pair, which aliased once #1205's partial trim made `partialBytes` decrease), and the
133
257
  * geometry catches a resize that reflows the screen without new output. So two samples that
134
- * match mean the classified screen is byte-for-byte unchanged. Two consumers rely on that:
135
- * 1. gate→write TOCTOU re-validation — sampled before the async classify and re-checked
136
- * after, so a keystroke landing during the classify holds instead of writing onto the
137
- * new draft;
138
- * 2. the drainer's verdict memo ({@link CachedVerdict}) — a cached verdict is reused only
139
- * while this token is unchanged, so a static screen is classified once instead of
140
- * re-checked every 1.5 s backstop tick.
258
+ * match mean the classified screen is byte-for-byte unchanged.
259
+ *
260
+ * ONE consumer relies on that now — the drainer's verdict memo ({@link CachedVerdict}): a cached
261
+ * verdict is reused only while this token is unchanged, so a static screen is classified once
262
+ * instead of re-checked every 1.5 s backstop tick. Issue #1664 retired the second consumer, the
263
+ * gate→write TOCTOU re-validation, because its OUTPUT half held on every repaint of a working
264
+ * agent — the defect that issue is named for. That re-validation now compares the composer-region
265
+ * fingerprint (output side) and `inputSeq` directly (input side); see `noteComposer` and the
266
+ * in-lock precheck.
267
+ *
268
+ * `inputSeq` stays in this token because the MEMO earns it on its own (Issue #1473). A
269
+ * `CachedVerdict` survives across backstop ticks, so the gap between the cached classify and its
270
+ * reuse is bounded by no settle at all. Without an input term a CLEAN verdict would be reusable
271
+ * ACROSS a keystroke — PTY input never advances the ring, so the output token is genuinely
272
+ * unchanged.
273
+ *
274
+ * (The delivery's OWN paced write is excluded by construction — it writes with the `'delivery'`
275
+ * origin, which moves no input signal — so folding this in cannot make a delivery block itself.)
276
+ *
277
+ * Monotonicity, precisely: the TOKEN is not globally monotone (geometry and app can change back
278
+ * and forth), but both COUNTERS in it are, and that plus {@link CachedVerdict}'s session-identity
279
+ * guard is what makes non-aliasing hold.
141
280
  */
142
281
  function ringToken(session, profile) {
143
- return `${session.bytesWritten}:${session.info.cols}x${session.info.rows}:${profile.app}`;
282
+ return `${session.bytesWritten}:${session.inputSeq}:${session.info.cols}x${session.info.rows}:${profile.app}`;
144
283
  }
145
284
  /** Reconstruct the delivered-message broadcast frame from a persisted row. */
146
285
  export function broadcastForRow(row, now) {
@@ -176,12 +315,25 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
176
315
  const held = findHeldForAgent(db, workspacePath, toAgent, ports.now());
177
316
  if (held.length === 0)
178
317
  return { delivered: [], reason: null };
179
- const hold = (reason) => {
318
+ // Every NON-gate hold, plus the post-classify re-holds (token moved / not settled). `detail`
319
+ // defaults to null (Issue #1482): a detail describes a gate verdict, and holding for
320
+ // `no-live-pty` — or re-holding a screen that moved under us — is not one. Leaving the
321
+ // previous detail in place would let `afx inbox` keep asserting "a human is at the composer"
322
+ // about a session whose PTY has since died.
323
+ //
324
+ // The one caller that passes a detail is the input hold (Issue #1473). It needs one because
325
+ // `hold('busy')` with a null detail is invisible to #1482's whole diagnostic axis AND to
326
+ // `isClassifierStuck`, which excludes plain `busy` — so an input hold that RECURRED (an app
327
+ // polling geometry every repaint, say, whose reply this module failed to recognise) would
328
+ // hold indefinitely with nothing to escalate it and nothing on any surface saying why. With
329
+ // the detail, `afx inbox` and the send response read `busy:recent-input` through the existing
330
+ // shared formatter, no formatter change required.
331
+ const hold = (reason, detail = null) => {
180
332
  for (const row of held) {
181
- if (row.reason !== reason)
182
- setHeldReason(db, row.id, reason, ports.now());
333
+ if (row.reason !== reason || row.detail !== detail)
334
+ setHeldVerdict(db, row.id, reason, detail, ports.now());
183
335
  }
184
- return { delivered: [], reason };
336
+ return { delivered: [], reason, ...(detail ? { detail } : {}) };
185
337
  };
186
338
  const session = ports.getSessionForAgent(workspacePath, toAgent);
187
339
  if (!session)
@@ -189,17 +341,26 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
189
341
  const profile = ports.resolveProfile(session);
190
342
  if (!profile)
191
343
  return hold('no-profile');
192
- // Sample the ring's change-token BEFORE the (possibly memoized) classify, so we can
193
- // re-validate afterward that the screen didn't move under us (below).
344
+ /** {@link hold} for the input-settle case, carrying the drainer's one-shot re-drain delay. */
345
+ const holdOnInput = () => ({
346
+ ...hold('busy', 'recent-input'),
347
+ retryAfterMs: msUntilInputSettled(ports, session),
348
+ });
349
+ // Sample the ring's change-token BEFORE the (possibly memoized) classify — the memo's reuse
350
+ // key (Issue #1664 retired its second job, the post-classify screen re-validation; the
351
+ // composer fingerprint below does that now, scoped to the rows that matter).
194
352
  const tokenBefore = ringToken(session, profile);
353
+ // …and the input counter on its own: it is what catches a human keystroke landing in the
354
+ // gate→write window, and it is the only signal that can, since PTY input advances nothing on
355
+ // the screen the classifier read (Issue #1473).
356
+ const inputSeqBefore = session.inputSeq;
195
357
  // Verdict memo (Spec 1313 render-gate follow-up). The 1.5 s backstop re-checks every held
196
358
  // agent's screen each tick; for a STATIC screen that classify is pure waste. Reuse the
197
359
  // cached verdict while BOTH the live session instance AND the token are unchanged — the token
198
360
  // advances on ANY new output, so a match means the screen is byte-for-byte what we already
199
361
  // classified, and the session guard closes the PTY-respawn aliasing route (a replacement
200
362
  // session is a DIFFERENT object); the monotone token closes the old `RingBuffer.clear()` route
201
- // (see {@link CachedVerdict}). A memo hit does NO await, so the post-classify re-validation
202
- // below (`ringToken(...) !== tokenBefore`) passes trivially: no keystroke can land in a
363
+ // (see {@link CachedVerdict}). A memo hit does NO await, so no keystroke can land in a
203
364
  // classify window that never opened. The memo is owned + bounded by the drainer's backstop
204
365
  // {@link MailboxDrainer.tick} (pruned to the held-agent set each tick); every OTHER caller —
205
366
  // the request/cron paths and the fast scheduleDrain trigger — passes none and classifies
@@ -218,27 +379,72 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
218
379
  // Carry the gate detail so a sustained classifier-stuck streak (a drifted profile
219
380
  // or an unrenderable frame) escalates to liveness telemetry instead of holding silently.
220
381
  const reason = verdict.reason ?? 'busy';
382
+ // `empty` is the CLEAN detail and cannot reach here (a clean verdict does not take this
383
+ // branch); narrowing it away keeps the persisted set to the three not-clean values the
384
+ // column's type admits.
385
+ const detail = verdict.detail === 'empty' ? null : verdict.detail;
386
+ // Discard the composer STABILITY sample (Issue #1664, CMAP round 1 — claude). A not-clean
387
+ // verdict is positive EVIDENCE that the composer moved or cannot be read: a draft appeared,
388
+ // a dialog opened, a frame arrived mid-repaint. Keeping the previous sample would let a
389
+ // later clean pass with a coincidentally identical fingerprint claim the region "held still"
390
+ // across the very interval we watched it move — writing onto a composer we know changed.
391
+ // Evidence against stability must reset the clock, not be skipped over.
392
+ composerSamples.delete(session);
221
393
  for (const row of held) {
222
- if (row.reason !== reason)
223
- setHeldReason(db, row.id, reason, ports.now());
394
+ if (row.reason !== reason || row.detail !== detail) {
395
+ setHeldVerdict(db, row.id, reason, detail, ports.now());
396
+ }
224
397
  }
225
398
  return { delivered: [], reason, detail: verdict.detail };
226
399
  }
227
- // Re-validate the SCREEN before writing (Spec 1313 render-gate diff review). The classify
228
- // above may have awaited (the mirror flushes its parser, and xterm yields between parse
229
- // slices); if the screen advanced since we sampled `tokenBefore`, a draft may have started
230
- // under us and the clean verdict is now stale. Writing then would fuse the message into that
231
- // draft — the exact false-clean the gate prevents. Hold instead; it delivers on the next
232
- // clean tick. (On a memo hit no await occurred, so the token is unchanged and this passes
233
- // trivially.)
234
- if (ringToken(session, profile) !== tokenBefore)
235
- return hold('busy');
236
- // Settle-before-write (Issue #1573). A clean verdict says the composer is EMPTY, not that it
237
- // has finished being drawn. Require a quiet interval since the session's last output byte
238
- // before putting anything on the line; see {@link SETTLE_BEFORE_WRITE_MS}. Re-checked inside
239
- // the per-terminal lock below, because that is where the last byte before ours can land.
240
- if (!settled(ports, session))
241
- return hold('busy');
400
+ // Settle-before-write (Issue #1573, rescoped by Issue #1664). A clean verdict says the
401
+ // composer is EMPTY, not that it has finished being DRAWN, and writing into a composer
402
+ // mid-repaint is what ate the leading bytes in #1521. The proof used to be whole-screen
403
+ // output quiescence — which a working agent never offers, because it is repainting a spinner
404
+ // and a streaming transcript several times a second — so mail queued behind entire turns for
405
+ // a hazard that lives only in the composer. Ask the composer instead: deliver when its region
406
+ // has been byte-identical across two samples a settle apart, OR when the whole screen is
407
+ // quiet (the original proof, kept because it is strictly stronger and lets an IDLE recipient
408
+ // deliver on the very first pass, with no second sample to wait for).
409
+ //
410
+ // Sampled here rather than inside `classify` so it is the FRESHEST reading of the composer:
411
+ // the classify may have awaited (the mirror flushes its parser, and xterm yields between
412
+ // parse slices), and this fingerprint is taken after that await returns. So it doubles as the
413
+ // output half of the gate→write TOCTOU: a draft that appeared during the classify moves the
414
+ // region, which cannot then be stable, and we hold rather than fusing the message into it.
415
+ const fingerprint = ports.composerFingerprint(session, profile);
416
+ // One clock reading for the whole decision: `noteComposer` records against it and
417
+ // `msUntilComposerSettled` measures from it, so the recorded sample and the retry delay can
418
+ // never describe two different instants.
419
+ const sampledAt = ports.now();
420
+ const composerStable = noteComposer(session, fingerprint, sampledAt);
421
+ // …and composer stability is only a licence to write for an app MEASURED to accept input
422
+ // mid-turn and queue it (`GateProfile.queuesInputMidTurn`). For any other app the original
423
+ // whole-screen settle stands: an app that DROPS input arriving mid-turn must only ever be
424
+ // written to while it is quiet, and the cost of being wrong there is a silently lost message,
425
+ // not a slow one. The capability is per-app measurement, never inheritance.
426
+ const mayWriteWhileTalking = profile.queuesInputMidTurn === true && composerStable;
427
+ if (fingerprint === null || (!mayWriteWhileTalking && !settled(ports, session))) {
428
+ return {
429
+ ...hold('busy', 'composer-redraw'),
430
+ retryAfterMs: msUntilComposerSettled(session, sampledAt),
431
+ };
432
+ }
433
+ // Re-validate the INPUT half of the change token (Issue #1473). A human keystroke landing
434
+ // during the classify moves nothing on the screen the classifier read — PTY input does not
435
+ // advance the ring, and the character may not be echoed yet — so only this counter sees it.
436
+ // The output half is gone (Issue #1664): it re-held on every repaint above, which is the
437
+ // whole defect, and the composer fingerprint sampled above is a strictly better answer to
438
+ // the question it was asking.
439
+ if (session.inputSeq !== inputSeqBefore)
440
+ return hold('busy', 'recent-input');
441
+ // Input-settle (Issue #1473). The token above sees input that arrived AFTER we sampled it;
442
+ // this sees input that arrived just BEFORE and has not been echoed yet — nothing moved, both
443
+ // samples agree, and the classifier read a genuinely empty composer while the character was
444
+ // still in flight. Only elapsed time can catch that. Re-checked in the lock below for the
445
+ // same reason the output settle is.
446
+ if (!inputSettled(ports, session))
447
+ return holdOnInput();
242
448
  // Clean, verified-empty prompt → deliver the oldest held message. Await the
243
449
  // write's paced completion so a serialized follow-up delivery never begins
244
450
  // until this message's text + Enter is fully on the wire.
@@ -272,18 +478,81 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
272
478
  // `--interrupt`/`--escape` had just cleared. Cheap enough to repeat — a synchronous ring
273
479
  // read and one indexed better-sqlite3 lookup.
274
480
  //
275
- // The residual it CANNOT close: `ringToken` counts OUTPUT bytes, so input written by a
276
- // path that does not take this lock (the raw `/api/terminals/:id/write` passthrough, or a
277
- // human's keystrokes over the WebSocket) can sit un-echoed on the line and read as
278
- // unchanged. Serialization — not this precheck — is what makes the lock-taking writers
279
- // safe; the echo-lag residual for the rest is #1473's territory.
481
+ // Issue #1473 narrowed the residual this block used to describe. `inputSeq` moves for input
482
+ // from a writer that does NOT take this lock — the raw `/api/terminals/:id/write` passthrough,
483
+ // a human's keystrokes over the WebSocket, the delayed `^C` — even while it sits un-echoed on
484
+ // the line, and the `inputSettled` check below covers input that landed before we sampled.
485
+ // Issue #1664 replaced the OUTPUT half of that re-check (a whole-screen `bytesWritten`
486
+ // comparison plus the settle) with the composer-region fingerprint, so a spinner frame landing
487
+ // during the lock wait no longer aborts a delivery the recipient was never unsafe to receive.
488
+ // What SURVIVES, and must not be claimed closed:
489
+ //
490
+ // • input older than the input-settle interval whose echo is still delayed, and input in
491
+ // flight from the browser at sample time — the settle bounds this window, it does not
492
+ // close it;
493
+ // • an `afx attach` client entirely: it speaks to the shellper socket directly and never
494
+ // touches `PtySession`, so neither its input nor its terminal's replies are observed here;
495
+ // • a reply shape `stripTerminalReplies` does not recognise, which counts as input — a
496
+ // spurious hold, now visible as `busy:recent-input` rather than silent;
497
+ // • a race DURING the paced write, which is reported (`racedByInput`) rather than prevented,
498
+ // because by then the bytes are already out;
499
+ // • (Issue #1664) a composer that redraws and returns to a byte-identical rendering entirely
500
+ // inside this lock wait — the whole-screen settle this replaced did cover it, at the price
501
+ // of never delivering to a working agent at all; `MAX_COMPOSER_SAMPLE_GAP_MS` bounds it,
502
+ // and `SessionScreen.peek()` refuses to answer at all while any fed byte is unparsed, so
503
+ // a redraw cannot hide in the parse queue;
504
+ // • (Issue #1664, NEW with this change) a turn ending DURING the paced write. The write
505
+ // spans ~100 ms across setTimeout gaps and is fingerprint-checked once, at its first byte.
506
+ // Under the old settle this could not arise — a delivery only ever wrote to an agent that
507
+ // had been silent for 250 ms — whereas a delivery to a WORKING agent can now be overtaken
508
+ // by that agent's own turn-end repaint.
509
+ //
510
+ // NOT fully detected, and the detection it does have must not be overstated: `watchEcho`
511
+ // matches the message's HEADER (or a paste placard) and nothing else, so it catches a
512
+ // redraw that eats the LEADING bytes (#1521's shape → `verified: false`) and is blind to
513
+ // one that truncates the TAIL — the header rendered, so the delivery reports confirmed
514
+ // while the body is short. That gap is not new to this issue (the watch has always been
515
+ // header-only) but this issue widens the window in which it can be hit, so it is worth
516
+ // naming rather than leaving inside a general "detected, not prevented". Tracked as a
517
+ // follow-up under #1578; a tail oracle would need the write edge to know what it sent and
518
+ // the watch to look for the END of it.
519
+ //
520
+ // Measured across 100 delivering trials against a real claude — the #1664 streaming,
521
+ // turn-end and re-run acceptance runs — head-loss was 0/100. Those trials DO check the
522
+ // tail: each body carries a unique trailing token and the harness asserts it rendered, so
523
+ // the empirical bound covers the case the production watch cannot see.
280
524
  const precheck = () => {
281
525
  if (!session.writable)
282
526
  return { kind: 'hold', reason: 'no-live-pty' };
283
- if (ringToken(session, profile) !== tokenBefore)
284
- return { kind: 'hold', reason: 'busy' };
285
- if (!settled(ports, session))
527
+ if (session.inputSeq !== inputSeqBefore) {
528
+ return { kind: 'hold', reason: 'busy', detail: 'recent-input' };
529
+ }
530
+ // For an app NOT measured to queue input mid-turn, the whole-screen settle is still the
531
+ // rule, and it is re-checked here exactly as it was before Issue #1664 — that app's delivery
532
+ // path is unchanged end to end.
533
+ if (profile.queuesInputMidTurn !== true && !settled(ports, session)) {
286
534
  return { kind: 'hold', reason: 'busy' };
535
+ }
536
+ // Issue #1664: the composer region, not the whole screen. An `--interrupt`/`--escape` that
537
+ // cleared the line during our lock wait, or a turn-end box redraw, moves this; the spinner
538
+ // and the streaming transcript above it do not. A `null` fingerprint (no locatable
539
+ // composer) can never equal the sample, so it holds — the fail-safe direction.
540
+ if (ports.composerFingerprint(session, profile) !== fingerprint) {
541
+ return {
542
+ kind: 'hold',
543
+ reason: 'busy',
544
+ detail: 'composer-redraw',
545
+ retryAfterMs: SETTLE_BEFORE_WRITE_MS,
546
+ };
547
+ }
548
+ if (!inputSettled(ports, session)) {
549
+ return {
550
+ kind: 'hold',
551
+ reason: 'busy',
552
+ detail: 'recent-input',
553
+ retryAfterMs: msUntilInputSettled(ports, session),
554
+ };
555
+ }
287
556
  const stillHeld = getById(db, row.id);
288
557
  if (!stillHeld || stillHeld.status !== 'held')
289
558
  return { kind: 'row-resolved' };
@@ -299,7 +568,7 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
299
568
  // delivery); the try either assigns the real result or throws past this point.
300
569
  let result = { status: 'aborted', abort: { kind: 'hold', reason: 'busy' } };
301
570
  try {
302
- result = await ports.writeMessage(session, current.formatted_message, current.no_enter === 1, precheck);
571
+ result = await ports.writeMessage(session, current.formatted_message, current.no_enter === 1, precheck, writeStrategyForApp(profile.app));
303
572
  }
304
573
  finally {
305
574
  // Invalidate the memo on EVERY write outcome — a clean `true`, a dropped-write `false`, OR a
@@ -313,11 +582,25 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
313
582
  // putting SOME bytes on the wire, e.g. the text landed but the Enter dropped → we hold below;
314
583
  // (c) writeMessage REJECTS after partial bytes — its port contract (`boolean | Promise<boolean>`)
315
584
  // permits a binding to throw, and a bare throw would skip a delete placed after the await. In
316
- // every case a leftover CLEAN would let a follow-up held message memo-hit the SAME token (PTY
317
- // INPUT does not advance the ring — only OUTPUT does) and write onto the not-yet-echoed line, so
318
- // the memo must die here. The deeper input-echo-lag window — a fresh classify racing the echo — is
319
- // the pre-existing gate→write INPUT race in the review's Technical Debt.
585
+ // every case a leftover CLEAN would let a follow-up held message memo-hit the SAME token and
586
+ // write onto the not-yet-echoed line, so the memo must die here. (A DELIVERY's own bytes still
587
+ // advance no signal by design — it writes with the `'delivery'` origin — so this delete, not
588
+ // the token, is what covers our own write.) Issue #1473 closed the sibling case, a HUMAN
589
+ // keystroke: `ringToken` now carries the session's input counter, so a memoized CLEAN verdict
590
+ // can no longer be reused across one. What survives is the echo-lag window the input-settle
591
+ // interval BOUNDS rather than closes — input older than the settle whose echo is still
592
+ // delayed, and input in flight from the client at sample time.
320
593
  memo?.delete(cacheKey);
594
+ // …and the composer STABILITY sample, for the same reason and in the same breath (Issue
595
+ // #1664, CMAP round 1 — codex). This delivery's own bytes are a composer change we KNOW
596
+ // happened: the body lands on the line, the Enter submits it, and the TUI paints a fresh
597
+ // prompt. Left in place, that pre-write observation is proof of stability for a composer
598
+ // that has since been rewritten twice — so a second delivery arriving inside
599
+ // `MAX_COMPOSER_SAMPLE_GAP_MS`, finding the same empty-composer fingerprint, would be
600
+ // authorised to write IMMEDIATELY into the redraw following our own submit. That is #1521's
601
+ // window, reopened by our own hand. The gap bound cannot help here (the reuse is well
602
+ // inside it), and unlike a third-party write this one needs no inference to detect.
603
+ composerSamples.delete(session);
321
604
  }
322
605
  // Anything short of a complete submit holds the row — a delivery is marked delivered only when
323
606
  // every byte, Enter included, reached the terminal.
@@ -357,27 +640,39 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
357
640
  ports.onHeldStateChange(); // the held set changed under us → refresh the indicator
358
641
  return { delivered: [], reason: null };
359
642
  }
360
- return hold(result.abort.reason);
643
+ // Carry the in-lock refusal's detail and re-drain delay through (Issue #1473): an input
644
+ // hold decided inside the lock is the same hold as one decided before it, and must reach
645
+ // the operator surfaces and the drainer's retry timer identically.
646
+ const outcome = hold(result.abort.reason, result.abort.detail ?? null);
647
+ return result.abort.retryAfterMs === undefined
648
+ ? outcome
649
+ : { ...outcome, retryAfterMs: result.abort.retryAfterMs };
361
650
  }
362
- // The write completed — every byte, Enter included, was accepted by the session. That is
363
- // still only proof the bytes were QUEUED (Issue #1573): `session.write` reports true whenever
364
- // the socket object is connected, so a composer that ate the leading bytes (#1564) lands here
365
- // looking identical to a clean delivery. Ask the screen before claiming success.
651
+ // THE POINT OF NO RETURN (Issue #1584). Past this line the write COMPLETED — every byte,
652
+ // Enter included, was accepted by the session — so the row is at-least-once delivered and
653
+ // must NEVER be written again. Nothing below may `hold(...)`: a hold puts the row back in
654
+ // the drainer's held set, and the next clean-prompt pass re-writes the WHOLE message with
655
+ // no attempt cap anywhere in this module. That is exactly what #1583 saw in the field —
656
+ // one `afx send --file` re-injected dozens of times, byte-identical, silently (a `busy`
657
+ // hold is excluded from {@link isClassifierStuck}, so no streak ever escalates it).
366
658
  //
367
- // A `false` here is "could not confirm", so the row is HELD, not dropped: the existing
368
- // redelivery machinery retries it on a later clean gate, and the escalation path already makes
369
- // a row that never confirms visible. That inverts the failure this issue exists to remove —
370
- // the worst case becomes a duplicate delivery the agent can see, instead of a silent loss the
371
- // sender was told was a success.
372
- if (echo && !(await echo.verify())) {
373
- ports.log(`[mailbox] write to ${toAgent} @ ${path.basename(workspacePath)} completed but its header ` +
374
- `never appeared on the terminal — holding ${row.id.slice(0, 8)}… for redelivery rather ` +
375
- `than reporting it delivered`);
376
- return hold('busy');
377
- }
378
- // markDelivered is guarded (held→delivered only). If it did NOT transition, the row
379
- // was dismissed/superseded during the paced write — accept that terminal state and
380
- // do not broadcast a delivery for it.
659
+ // Only PRE-write prechecks may hold, and every hold above this point is one: the branches
660
+ // between the write and here wrote NOTHING (`contended`, `aborted`) or produced a write that
661
+ // cannot be trusted to have landed (`dropped` — bytes lost mid-pace to a dead socket;
662
+ // `preempted` — an unserialized operator submission may have cleared or truncated the
663
+ // composer under them). Only `written` reaches this line, and only `written` is the
664
+ // at-least-once guarantee that forbids a re-write.
665
+ //
666
+ // COMMIT THE DELIVERY FIRST, before any further await or fallible call (CMAP round 1 — Codex).
667
+ // The point of no return is only real if it is DURABLE: while the row still reads `held` in
668
+ // the database it is re-writable by the next drainer tick, so leaving it held across ~1.2 s of
669
+ // verification would reopen the loop for any interruption of that window — a Tower crash or
670
+ // restart, or a `verify()` that rejects instead of answering. `markDelivered` is synchronous
671
+ // (better-sqlite3), so ordering it here leaves no window at all.
672
+ //
673
+ // markDelivered is guarded (held→delivered only). If it did NOT transition, the row was
674
+ // dismissed/superseded during the paced write — accept that terminal state and do not
675
+ // broadcast a delivery for it.
381
676
  if (!markDelivered(db, row.id, ports.now())) {
382
677
  ports.onHeldStateChange();
383
678
  return { delivered: [], reason: null };
@@ -385,7 +680,72 @@ export async function deliverAgentMail(ports, db, workspacePath, toAgent, memo)
385
680
  ports.broadcast(broadcastForRow(current, ports.now()));
386
681
  ports.onHeldStateChange(); // a held row left the set → refresh the indicator count
387
682
  ports.log(`[mailbox] delivered ${row.id} → ${toAgent} @ ${path.basename(workspacePath)}`);
388
- return { delivered: [row.id], reason: null };
683
+ // Echo verification (Issue #1573) still runs — it is the only end-to-end evidence the bytes
684
+ // reached the terminal — but it is now pure REPORTING, downstream of a delivery that is
685
+ // already committed. `false` means "could not confirm", never "definitely lost".
686
+ let verified;
687
+ if (echo) {
688
+ // One bounded RE-verify window before giving up. `verify()` polls to its own deadline and
689
+ // then answers false; a second call opens a fresh window against the SAME pre-write sample,
690
+ // so it still requires evidence THIS write produced. It accommodates a slow renderer
691
+ // without writing a single byte, and the total stays inside the sender's patience (~1.5 s)
692
+ // because the request path awaits this.
693
+ //
694
+ // A REJECTION is unconfirmed, not an error to propagate: the bytes are out and the row is
695
+ // committed, so throwing here would only deny the sender the `verified` answer and log a
696
+ // spurious delivery failure.
697
+ try {
698
+ verified = (await echo.verify()) || (await echo.verify());
699
+ }
700
+ catch (err) {
701
+ verified = false;
702
+ ports.log(`[mailbox] echo verification errored for ${row.id.slice(0, 8)}…: ${String(err)}`, 'WARN');
703
+ }
704
+ }
705
+ // The escalation decision sits OUTSIDE `if (echo)` (Issue #1473). A during-write input race
706
+ // is a reason to flag a delivery whether or not it had a needle worth matching — and in its
707
+ // most common shape (a human's Enter submitting our half-written body) the header DID land,
708
+ // so `verified` is `true` and the old `if (!verified)` inside the echo block never fired.
709
+ // Computed once, so a delivery that is both raced AND unechoed escalates exactly once.
710
+ const racedByInput = result.racedByInput === true;
711
+ const unverified = racedByInput || verified === false;
712
+ if (unverified) {
713
+ // `'input-raced'` takes precedence: it names the more actionable remedy, and a missing echo
714
+ // is frequently just the downstream symptom of the race.
715
+ const cause = racedByInput ? 'input-raced' : 'no-echo';
716
+ // The row is already `delivered`, so this is the delivered-only counterpart of the
717
+ // drainer's held-only `markEscalated`.
718
+ markEscalatedDelivered(db, row.id, ports.now());
719
+ // BRANCH the text rather than appending to it. The no-echo wording asserts the header never
720
+ // appeared, which is false for the raced case; and its `needle N chars` clause would run
721
+ // with no needle at all here and print "needle 0 chars".
722
+ const what = racedByInput
723
+ ? `the write completed, but the terminal received input while it was in flight, so the ` +
724
+ `body may have been truncated or submitted early`
725
+ : `the write completed but neither its header nor a paste placard appeared on the ` +
726
+ `terminal (needle ${needle ? needle.length : 0} chars)`;
727
+ ports.log(`[mailbox] delivered-unverified ${row.id.slice(0, 8)}… → ${toAgent} @ ` +
728
+ `${path.basename(workspacePath)} (terminal ${session.id}, ${cause}): ${what}. Recorded as ` +
729
+ `delivered and flagged — NOT re-written (Issue #1584).`, 'WARN');
730
+ // Raise it where a human will see it. The sender's response covers an interactive
731
+ // `afx send`, but a cron or backstop delivery has no sender waiting on a response, and a
732
+ // DELIVERED row is invisible to every held-scoped surface (`afx inbox`, the held-count
733
+ // indicator) — without this its only trace is a log line (CMAP round 1 — Codex).
734
+ ports.onUnverifiedDelivery?.({
735
+ workspacePath,
736
+ toAgent,
737
+ mailboxId: row.id,
738
+ terminalId: session.id,
739
+ cause,
740
+ });
741
+ return {
742
+ delivered: [row.id],
743
+ reason: null,
744
+ unverifiedCause: cause,
745
+ ...(verified === undefined ? {} : { verified }),
746
+ };
747
+ }
748
+ return { delivered: [row.id], reason: null, ...(verified === undefined ? {} : { verified }) };
389
749
  }
390
750
  /**
391
751
  * Shared per-agent delivery serializer (Spec 1313, Phase 4). Every live caller —
@@ -428,6 +788,26 @@ const LIVENESS_STREAK_THRESHOLD = 10;
428
788
  // spam owners. Derived from escalationMs (default 60s → 180s), so it scales with the
429
789
  // configured `mailbox.escalationSeconds` without a separate config knob.
430
790
  const DEFAULT_OWNER_NOTICE_MULTIPLE = 3;
791
+ // Issue #1473: slack added to a pass's `retryAfterMs` when arming the one-shot input re-drain,
792
+ // so the retry lands just PAST the settle boundary rather than exactly on it (where a timer
793
+ // firing a millisecond early would re-hold and cost another full cycle).
794
+ const INPUT_RETRY_MARGIN_MS = 25;
795
+ // Issue #1473: how long an agent must hold on recent input, UNBROKEN, before the drainer logs a
796
+ // diagnostic. A human types in BURSTS, so a run this long without a single gap is a machine, not
797
+ // a person — most likely a reply shape the filter does not recognise, arriving on every repaint.
798
+ // Deliberately a log line and not an escalation: the hold class it describes is the same "a human
799
+ // is at the line" class as `user-text`, and wiring it to the escalation path would false-alarm on
800
+ // every ordinary typist. This is the trace that makes the otherwise-silent case findable later.
801
+ //
802
+ // ELAPSED TIME, not a pass count. It was a count of 60, documented as "~90s at the 300ms re-drain
803
+ // cadence" — but the arithmetic was wrong (60 x ~325ms is ~20s) and, worse, a count silently
804
+ // re-scales whenever the drain cadence changes: the backstop, the quiescence trigger and the
805
+ // submit trigger all drive passes too, so the real interval is bounded above by the re-drain and
806
+ // has no fixed floor. Twenty seconds of unbroken cursor-key or mouse activity is something a
807
+ // person genuinely does — the manual verification for this very issue ran 15-20s of continuous
808
+ // arrow presses per repetition — so the old threshold would have libelled a human as a machine.
809
+ // A wall-clock bound says what the comment always meant and cannot drift with the cadence.
810
+ const CONSECUTIVE_INPUT_HOLD_WARN_MS = 90_000;
431
811
  /**
432
812
  * The poll backstop that replaces `SendBuffer`'s flush timer. On each tick it walks
433
813
  * every agent with held mail and runs {@link deliverAgentMail}, so a message held
@@ -464,6 +844,14 @@ export class MailboxDrainer {
464
844
  // with the persistent bounded mirror, a classify is O(viewport) regardless of history, so there
465
845
  // is no expensive whole-ring render left to throttle — every tick just re-classifies cheaply.)
466
846
  verdictMemo = new Map();
847
+ // Issue #1473: the one-shot input re-drain. At most one pending timer per agent (a later
848
+ // hold coalesces onto the timer already armed), generation-guarded exactly like
849
+ // `scheduleDrain`, and cleared in `stop()` alongside the backstop timer.
850
+ inputRetryTimers = new Map();
851
+ // Issue #1473: each agent's unbroken run of input-holds — the diagnostic behind
852
+ // {@link CONSECUTIVE_INPUT_HOLD_WARN_MS}. Reset by any outcome that is not an input hold,
853
+ // delivery included; that reset is what makes the run UNBROKEN rather than cumulative.
854
+ consecutiveInputHolds = new Map();
467
855
  // Lifecycle generation (CMAP round 2 — Codex/Claude): the drainer instance is REUSED across
468
856
  // stop()/start() (mailbox-wiring `ensureDrainer`), and the tests do start/stop/start. Bumped on
469
857
  // stop() so an in-flight tick/scheduleDrain that resumes after a restart bails before mutating
@@ -509,12 +897,111 @@ export class MailboxDrainer {
509
897
  this.notCleanStreak.clear();
510
898
  this.scheduledDrains.clear();
511
899
  this.notifiedAgents.clear();
900
+ // Issue #1473: the input re-drain timers are real pending timers, not just map entries, so
901
+ // they must be CLEARED here and not merely dropped — the generation guard inside each one
902
+ // would make a survivor harmless, but leaving it pending keeps the event loop alive.
903
+ for (const timer of this.inputRetryTimers.values())
904
+ clearTimeout(timer);
905
+ this.inputRetryTimers.clear();
906
+ this.consecutiveInputHolds.clear();
512
907
  this.generation++;
513
908
  }
514
909
  /** Per-agent consecutive not-clean count (liveness telemetry; Phase 7 reads this). */
515
910
  get streaks() {
516
911
  return this.notCleanStreak;
517
912
  }
913
+ /** Agents with a pending input re-drain timer (Issue #1473; test/observability). */
914
+ get pendingInputRetries() {
915
+ return [...this.inputRetryTimers.keys()];
916
+ }
917
+ /** Per-agent consecutive input-hold count (Issue #1473; test/observability). */
918
+ get inputHoldStreaks() {
919
+ return new Map([...this.consecutiveInputHolds].map(([k, v]) => [k, v.count]));
920
+ }
921
+ /**
922
+ * Arm the input re-drain for a pass this drainer did not itself run (Issue #1473).
923
+ *
924
+ * The `afx send` REQUEST path calls `deliverAgentMailSerialized` directly rather than going
925
+ * through the drainer, so its outcome — `retryAfterMs` included — never reached
926
+ * {@link armInputRetry}. The visible consequence was that a send arriving while the terminal
927
+ * had just been typed at fell through to the quiescence trigger or the backstop, which is
928
+ * precisely the latency the re-drain exists to remove, in the one case an operator is sitting
929
+ * there watching. Measured, not theorised: the dev-approval evidence run timed that pass at
930
+ * the quiescence debounce rather than one settle.
931
+ *
932
+ * A no-op before the drainer is started, and idempotent per agent — the timer coalesces, so a
933
+ * request-path arm and a backstop arm for the same agent cannot stack.
934
+ */
935
+ noteOutcome(workspacePath, toAgent, outcome) {
936
+ if (!this.ports || !this.db)
937
+ return;
938
+ this.armInputRetry(agentKey(workspacePath, toAgent), workspacePath, toAgent, outcome, this.generation);
939
+ }
940
+ /**
941
+ * Arm the one-shot input re-drain and track the consecutive-input-hold diagnostic
942
+ * (Issue #1473). Called from both pass sites, exactly where {@link recordStreak} is, so the
943
+ * backstop tick and the fast trigger behave identically.
944
+ *
945
+ * The retry exists because the `'submit'` fast trigger is now provably ALWAYS held: it fires
946
+ * synchronously from `stopComposing`, and the drain it schedules runs in a microtask, so at
947
+ * that pass the input timestamp is `now` by construction. Without a retry, the single most
948
+ * common "the line just cleared, deliver now" path would silently degrade to the 1.5 s
949
+ * backstop — as would any navigation key that provokes no output of its own.
950
+ *
951
+ * COALESCED: while a timer is pending for an agent, later input holds do not stack another.
952
+ * GENERATION-GUARDED like {@link scheduleDrain}, so a timer that survives a stop()/start()
953
+ * bails instead of driving the new generation's state.
954
+ */
955
+ armInputRetry(key, workspacePath, toAgent, outcome, gen) {
956
+ if (outcome.retryAfterMs === undefined) {
957
+ this.consecutiveInputHolds.delete(key);
958
+ return;
959
+ }
960
+ // The timer serves BOTH self-clearing holds (Issue #1664 added `composer-redraw`), but the
961
+ // streak diagnostic below is about a human at the keyboard specifically — its warning text
962
+ // says so — and a repainting composer is not that. Arm the retry, break the input streak.
963
+ if (outcome.detail !== 'recent-input') {
964
+ this.consecutiveInputHolds.delete(key);
965
+ this.armRetryTimer(key, workspacePath, toAgent, outcome.retryAfterMs, gen);
966
+ return;
967
+ }
968
+ const now = this.ports?.now() ?? Date.now();
969
+ // `since` is the START of the unbroken run, so the warning measures wall-clock, not passes.
970
+ // `warned` makes it report once at the crossing rather than on every pass past it — the
971
+ // streak can run for hours and the operator needs one line, not a stream.
972
+ const streak = this.consecutiveInputHolds.get(key) ?? { count: 0, since: now, warned: false };
973
+ streak.count += 1;
974
+ this.consecutiveInputHolds.set(key, streak);
975
+ const heldForMs = now - streak.since;
976
+ if (!streak.warned && heldForMs >= CONSECUTIVE_INPUT_HOLD_WARN_MS) {
977
+ streak.warned = true;
978
+ this.ports?.log(`[mailbox] ${toAgent} @ ${path.basename(workspacePath)} has held on recent terminal input ` +
979
+ `continuously for ${Math.round(heldForMs / 1000)}s (${streak.count} checks). A human types ` +
980
+ `in bursts, so an unbroken run this long usually means something is emitting input-shaped ` +
981
+ `bytes continuously — most likely a terminal reply the gate's filter does not recognise ` +
982
+ `(Issue #1473). Mail for this agent is NOT being delivered while this persists.`, 'WARN');
983
+ }
984
+ this.armRetryTimer(key, workspacePath, toAgent, outcome.retryAfterMs, gen);
985
+ }
986
+ /**
987
+ * The one-shot re-drain timer shared by both self-clearing holds (Issue #1473's input settle
988
+ * and Issue #1664's composer redraw). COALESCED — while a timer is pending for an agent, a
989
+ * later hold does not stack another — and GENERATION-GUARDED, so a timer that survives a
990
+ * stop()/start() bails instead of driving the new generation's state.
991
+ */
992
+ armRetryTimer(key, workspacePath, toAgent, retryAfterMs, gen) {
993
+ if (this.inputRetryTimers.has(key))
994
+ return; // a retry is already pending for this agent
995
+ const timer = setTimeout(() => {
996
+ this.inputRetryTimers.delete(key);
997
+ if (this.generation !== gen)
998
+ return; // stop() ran while we waited → old ports/db
999
+ void this.scheduleDrain(workspacePath, toAgent);
1000
+ }, retryAfterMs + INPUT_RETRY_MARGIN_MS);
1001
+ if (typeof timer.unref === 'function')
1002
+ timer.unref();
1003
+ this.inputRetryTimers.set(key, timer);
1004
+ }
518
1005
  /**
519
1006
  * Agent keys that currently hold a cached gate verdict (render-gate memo).
520
1007
  * Observability/test only: {@link tick} prunes this to the current held-agent set, so
@@ -565,6 +1052,7 @@ export class MailboxDrainer {
565
1052
  return; // stop() landed during the await → do NOT mutate the
566
1053
  // NEW generation's freshly-cleared streak map
567
1054
  this.recordStreak(key, outcome);
1055
+ this.armInputRetry(key, workspacePath, toAgent, outcome, gen);
568
1056
  }
569
1057
  catch (err) {
570
1058
  ports.log(`[mailbox] backstop delivery failed for ${toAgent}: ${String(err)}`);
@@ -612,9 +1100,10 @@ export class MailboxDrainer {
612
1100
  mailboxId: row.id,
613
1101
  ageMs,
614
1102
  reason: row.reason,
1103
+ detail: row.detail,
615
1104
  });
616
1105
  ports.log(`[mailbox] ESCALATED ${row.id.slice(0, 8)}… → ${row.to_agent} @ ${path.basename(row.workspace_path)} ` +
617
- `(held ${Math.round(ageMs / 1000)}s, reason ${row.reason ?? 'held'}) — visibility only, not delivered`);
1106
+ `(held ${Math.round(ageMs / 1000)}s, reason ${formatVerdict(row.reason, row.detail)}) — visibility only, not delivered`);
618
1107
  }
619
1108
  // A row's escalated flag flipped → the overview-derived `mailboxEscalated` attention
620
1109
  // bit changed. Fire the held-state-change event too (in addition to the per-row
@@ -664,8 +1153,15 @@ export class MailboxDrainer {
664
1153
  workspacePath: agent.workspacePath,
665
1154
  toAgent: agent.toAgent,
666
1155
  reason: agent.reason,
1156
+ // Issue #1482: the gate detail decides which notice the owner gets, and the streak
1157
+ // says how many consecutive checks confirmed it. Both are already tracked — the
1158
+ // detail on the row (aggregated by findStarvingAgents), the streak in the drainer's
1159
+ // own map — so this costs no new state, just carrying what we have to where the
1160
+ // human reads it.
1161
+ detail: agent.detail,
667
1162
  ageMs: now - agent.stuckSince,
668
1163
  heldCount: agent.count,
1164
+ streak: this.notCleanStreak.get(key) ?? 0,
669
1165
  });
670
1166
  if (enqueued)
671
1167
  this.notifiedAgents.add(key);
@@ -761,6 +1257,7 @@ export class MailboxDrainer {
761
1257
  return; // stop() landed during the await → do NOT mutate the
762
1258
  // NEW generation's freshly-cleared streak map
763
1259
  this.recordStreak(key, outcome);
1260
+ this.armInputRetry(key, workspacePath, toAgent, outcome, gen);
764
1261
  }
765
1262
  catch (err) {
766
1263
  ports.log(`[mailbox] scheduled drain failed for ${toAgent}: ${String(err)}`);