@cluesmith/codev 3.2.4 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. package/dashboard-dist/assets/index-BZRTEMtH.css +32 -0
  2. package/dashboard-dist/assets/{index-BZ1x7x1X.js → index-JCbkpAcV.js} +33 -33
  3. package/dashboard-dist/assets/index-JCbkpAcV.js.map +1 -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 +142 -0
  7. package/dist/agent-farm/cli.js.map +1 -1
  8. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/cleanup.js +16 -0
  10. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  11. package/dist/agent-farm/commands/inbox.d.ts +42 -0
  12. package/dist/agent-farm/commands/inbox.d.ts.map +1 -0
  13. package/dist/agent-farm/commands/inbox.js +129 -0
  14. package/dist/agent-farm/commands/inbox.js.map +1 -0
  15. package/dist/agent-farm/commands/interrupt.d.ts +20 -0
  16. package/dist/agent-farm/commands/interrupt.d.ts.map +1 -0
  17. package/dist/agent-farm/commands/interrupt.js +65 -0
  18. package/dist/agent-farm/commands/interrupt.js.map +1 -0
  19. package/dist/agent-farm/commands/reset/constants.d.ts +52 -0
  20. package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -0
  21. package/dist/agent-farm/commands/reset/constants.js +52 -0
  22. package/dist/agent-farm/commands/reset/constants.js.map +1 -0
  23. package/dist/agent-farm/commands/reset/context.d.ts +230 -0
  24. package/dist/agent-farm/commands/reset/context.d.ts.map +1 -0
  25. package/dist/agent-farm/commands/reset/context.js +428 -0
  26. package/dist/agent-farm/commands/reset/context.js.map +1 -0
  27. package/dist/agent-farm/commands/reset/index.d.ts +192 -0
  28. package/dist/agent-farm/commands/reset/index.d.ts.map +1 -0
  29. package/dist/agent-farm/commands/reset/index.js +488 -0
  30. package/dist/agent-farm/commands/reset/index.js.map +1 -0
  31. package/dist/agent-farm/commands/reset/receipt.d.ts +97 -0
  32. package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -0
  33. package/dist/agent-farm/commands/reset/receipt.js +149 -0
  34. package/dist/agent-farm/commands/reset/receipt.js.map +1 -0
  35. package/dist/agent-farm/commands/reset/reorient.d.ts +117 -0
  36. package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -0
  37. package/dist/agent-farm/commands/reset/reorient.js +313 -0
  38. package/dist/agent-farm/commands/reset/reorient.js.map +1 -0
  39. package/dist/agent-farm/commands/reset.d.ts +19 -0
  40. package/dist/agent-farm/commands/reset.d.ts.map +1 -0
  41. package/dist/agent-farm/commands/reset.js +292 -0
  42. package/dist/agent-farm/commands/reset.js.map +1 -0
  43. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  44. package/dist/agent-farm/commands/send.js +49 -8
  45. package/dist/agent-farm/commands/send.js.map +1 -1
  46. package/dist/agent-farm/commands/spawn-worktree.d.ts +105 -6
  47. package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -1
  48. package/dist/agent-farm/commands/spawn-worktree.js +289 -66
  49. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
  50. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  51. package/dist/agent-farm/commands/spawn.js +13 -1
  52. package/dist/agent-farm/commands/spawn.js.map +1 -1
  53. package/dist/agent-farm/commands/status.d.ts.map +1 -1
  54. package/dist/agent-farm/commands/status.js +80 -8
  55. package/dist/agent-farm/commands/status.js.map +1 -1
  56. package/dist/agent-farm/db/index.d.ts +1 -1
  57. package/dist/agent-farm/db/index.d.ts.map +1 -1
  58. package/dist/agent-farm/db/index.js +80 -1
  59. package/dist/agent-farm/db/index.js.map +1 -1
  60. package/dist/agent-farm/db/mailbox.d.ts +222 -0
  61. package/dist/agent-farm/db/mailbox.d.ts.map +1 -0
  62. package/dist/agent-farm/db/mailbox.js +304 -0
  63. package/dist/agent-farm/db/mailbox.js.map +1 -0
  64. package/dist/agent-farm/db/schema.d.ts +1 -1
  65. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  66. package/dist/agent-farm/db/schema.js +35 -0
  67. package/dist/agent-farm/db/schema.js.map +1 -1
  68. package/dist/agent-farm/db/types.d.ts +45 -0
  69. package/dist/agent-farm/db/types.d.ts.map +1 -1
  70. package/dist/agent-farm/db/types.js.map +1 -1
  71. package/dist/agent-farm/lib/builder-lookup.d.ts +1 -1
  72. package/dist/agent-farm/lib/builder-lookup.js +2 -2
  73. package/dist/agent-farm/lib/builder-lookup.js.map +1 -1
  74. package/dist/agent-farm/lib/reconnect-backoff.d.ts +54 -0
  75. package/dist/agent-farm/lib/reconnect-backoff.d.ts.map +1 -0
  76. package/dist/agent-farm/lib/reconnect-backoff.js +47 -0
  77. package/dist/agent-farm/lib/reconnect-backoff.js.map +1 -0
  78. package/dist/agent-farm/lib/tower-client.d.ts +16 -6
  79. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  80. package/dist/agent-farm/lib/tower-client.js +37 -6
  81. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  82. package/dist/agent-farm/lib/tunnel-client.d.ts +69 -1
  83. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  84. package/dist/agent-farm/lib/tunnel-client.js +218 -27
  85. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  86. package/dist/agent-farm/servers/cron-delivery.d.ts +72 -0
  87. package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -0
  88. package/dist/agent-farm/servers/cron-delivery.js +80 -0
  89. package/dist/agent-farm/servers/cron-delivery.js.map +1 -0
  90. package/dist/agent-farm/servers/delayed-send.d.ts +85 -0
  91. package/dist/agent-farm/servers/delayed-send.d.ts.map +1 -0
  92. package/dist/agent-farm/servers/delayed-send.js +165 -0
  93. package/dist/agent-farm/servers/delayed-send.js.map +1 -0
  94. package/dist/agent-farm/servers/gate-profiles.d.ts +67 -0
  95. package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -0
  96. package/dist/agent-farm/servers/gate-profiles.js +113 -0
  97. package/dist/agent-farm/servers/gate-profiles.js.map +1 -0
  98. package/dist/agent-farm/servers/mailbox-delivery.d.ts +370 -0
  99. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -0
  100. package/dist/agent-farm/servers/mailbox-delivery.js +617 -0
  101. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -0
  102. package/dist/agent-farm/servers/mailbox-wiring.d.ts +125 -0
  103. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -0
  104. package/dist/agent-farm/servers/mailbox-wiring.js +425 -0
  105. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -0
  106. package/dist/agent-farm/servers/message-write.d.ts +53 -1
  107. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  108. package/dist/agent-farm/servers/message-write.js +63 -0
  109. package/dist/agent-farm/servers/message-write.js.map +1 -1
  110. package/dist/agent-farm/servers/overview.d.ts.map +1 -1
  111. package/dist/agent-farm/servers/overview.js +23 -4
  112. package/dist/agent-farm/servers/overview.js.map +1 -1
  113. package/dist/agent-farm/servers/render-gate.d.ts +141 -0
  114. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -0
  115. package/dist/agent-farm/servers/render-gate.js +251 -0
  116. package/dist/agent-farm/servers/render-gate.js.map +1 -0
  117. package/dist/agent-farm/servers/session-submit.d.ts +90 -0
  118. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -0
  119. package/dist/agent-farm/servers/session-submit.js +127 -0
  120. package/dist/agent-farm/servers/session-submit.js.map +1 -0
  121. package/dist/agent-farm/servers/tower-cron.d.ts +10 -7
  122. package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
  123. package/dist/agent-farm/servers/tower-cron.js +58 -53
  124. package/dist/agent-farm/servers/tower-cron.js.map +1 -1
  125. package/dist/agent-farm/servers/tower-instances.d.ts +14 -2
  126. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
  127. package/dist/agent-farm/servers/tower-instances.js +78 -16
  128. package/dist/agent-farm/servers/tower-instances.js.map +1 -1
  129. package/dist/agent-farm/servers/tower-messages.d.ts +38 -1
  130. package/dist/agent-farm/servers/tower-messages.d.ts.map +1 -1
  131. package/dist/agent-farm/servers/tower-messages.js +106 -1
  132. package/dist/agent-farm/servers/tower-messages.js.map +1 -1
  133. package/dist/agent-farm/servers/tower-routes.d.ts +16 -4
  134. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  135. package/dist/agent-farm/servers/tower-routes.js +670 -105
  136. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  137. package/dist/agent-farm/servers/tower-server.js +163 -31
  138. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  139. package/dist/agent-farm/servers/tower-terminals.d.ts +17 -2
  140. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  141. package/dist/agent-farm/servers/tower-terminals.js +104 -122
  142. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  143. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
  144. package/dist/agent-farm/servers/tower-tunnel.js +63 -4
  145. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
  146. package/dist/agent-farm/servers/tower-types.d.ts +2 -1
  147. package/dist/agent-farm/servers/tower-types.d.ts.map +1 -1
  148. package/dist/agent-farm/servers/tower-utils.d.ts +64 -1
  149. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
  150. package/dist/agent-farm/servers/tower-utils.js +168 -1
  151. package/dist/agent-farm/servers/tower-utils.js.map +1 -1
  152. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  153. package/dist/agent-farm/servers/tower-websocket.js +6 -21
  154. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  155. package/dist/agent-farm/servers/write-queue.d.ts +35 -0
  156. package/dist/agent-farm/servers/write-queue.d.ts.map +1 -0
  157. package/dist/agent-farm/servers/write-queue.js +56 -0
  158. package/dist/agent-farm/servers/write-queue.js.map +1 -0
  159. package/dist/agent-farm/types.d.ts +39 -0
  160. package/dist/agent-farm/types.d.ts.map +1 -1
  161. package/dist/agent-farm/utils/agent-names.d.ts +2 -2
  162. package/dist/agent-farm/utils/agent-names.d.ts.map +1 -1
  163. package/dist/agent-farm/utils/agent-names.js +3 -3
  164. package/dist/agent-farm/utils/agent-names.js.map +1 -1
  165. package/dist/agent-farm/utils/architect-name.d.ts +2 -2
  166. package/dist/agent-farm/utils/architect-name.d.ts.map +1 -1
  167. package/dist/agent-farm/utils/architect-name.js +3 -3
  168. package/dist/agent-farm/utils/architect-name.js.map +1 -1
  169. package/dist/agent-farm/utils/config.d.ts +13 -0
  170. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  171. package/dist/agent-farm/utils/config.js +33 -1
  172. package/dist/agent-farm/utils/config.js.map +1 -1
  173. package/dist/agent-farm/utils/harness.d.ts +103 -7
  174. package/dist/agent-farm/utils/harness.d.ts.map +1 -1
  175. package/dist/agent-farm/utils/harness.js +126 -19
  176. package/dist/agent-farm/utils/harness.js.map +1 -1
  177. package/dist/cli.d.ts.map +1 -1
  178. package/dist/cli.js +6 -32
  179. package/dist/cli.js.map +1 -1
  180. package/dist/commands/consult/cli-options.d.ts +34 -0
  181. package/dist/commands/consult/cli-options.d.ts.map +1 -0
  182. package/dist/commands/consult/cli-options.js +67 -0
  183. package/dist/commands/consult/cli-options.js.map +1 -0
  184. package/dist/commands/consult/index.d.ts +56 -2
  185. package/dist/commands/consult/index.d.ts.map +1 -1
  186. package/dist/commands/consult/index.js +272 -28
  187. package/dist/commands/consult/index.js.map +1 -1
  188. package/dist/commands/consult/metrics.d.ts +42 -0
  189. package/dist/commands/consult/metrics.d.ts.map +1 -1
  190. package/dist/commands/consult/metrics.js +129 -9
  191. package/dist/commands/consult/metrics.js.map +1 -1
  192. package/dist/commands/doctor.d.ts.map +1 -1
  193. package/dist/commands/doctor.js +71 -19
  194. package/dist/commands/doctor.js.map +1 -1
  195. package/dist/commands/porch/checks.d.ts +23 -0
  196. package/dist/commands/porch/checks.d.ts.map +1 -1
  197. package/dist/commands/porch/checks.js +60 -0
  198. package/dist/commands/porch/checks.js.map +1 -1
  199. package/dist/commands/porch/config.d.ts +20 -0
  200. package/dist/commands/porch/config.d.ts.map +1 -1
  201. package/dist/commands/porch/config.js +20 -0
  202. package/dist/commands/porch/config.js.map +1 -1
  203. package/dist/commands/porch/index.js +21 -28
  204. package/dist/commands/porch/index.js.map +1 -1
  205. package/dist/commands/porch/next.d.ts.map +1 -1
  206. package/dist/commands/porch/next.js +2 -41
  207. package/dist/commands/porch/next.js.map +1 -1
  208. package/dist/lib/config.d.ts +69 -3
  209. package/dist/lib/config.d.ts.map +1 -1
  210. package/dist/lib/config.js +67 -3
  211. package/dist/lib/config.js.map +1 -1
  212. package/dist/lib/consult-lanes.d.ts +149 -0
  213. package/dist/lib/consult-lanes.d.ts.map +1 -0
  214. package/dist/lib/consult-lanes.js +360 -0
  215. package/dist/lib/consult-lanes.js.map +1 -0
  216. package/dist/lib/github.js +1 -1
  217. package/dist/lib/github.js.map +1 -1
  218. package/dist/lib/prompt-behavior-metrics.d.ts +67 -0
  219. package/dist/lib/prompt-behavior-metrics.d.ts.map +1 -0
  220. package/dist/lib/prompt-behavior-metrics.js +290 -0
  221. package/dist/lib/prompt-behavior-metrics.js.map +1 -0
  222. package/dist/lib/skeleton.d.ts +22 -1
  223. package/dist/lib/skeleton.d.ts.map +1 -1
  224. package/dist/lib/skeleton.js +120 -0
  225. package/dist/lib/skeleton.js.map +1 -1
  226. package/dist/lib/test-env.d.ts +74 -0
  227. package/dist/lib/test-env.d.ts.map +1 -0
  228. package/dist/lib/test-env.js +92 -0
  229. package/dist/lib/test-env.js.map +1 -0
  230. package/dist/terminal/pty-manager.d.ts +2 -0
  231. package/dist/terminal/pty-manager.d.ts.map +1 -1
  232. package/dist/terminal/pty-manager.js +16 -6
  233. package/dist/terminal/pty-manager.js.map +1 -1
  234. package/dist/terminal/pty-session.d.ts +131 -0
  235. package/dist/terminal/pty-session.d.ts.map +1 -1
  236. package/dist/terminal/pty-session.js +232 -39
  237. package/dist/terminal/pty-session.js.map +1 -1
  238. package/dist/terminal/ring-buffer.d.ts +39 -6
  239. package/dist/terminal/ring-buffer.d.ts.map +1 -1
  240. package/dist/terminal/ring-buffer.js +74 -6
  241. package/dist/terminal/ring-buffer.js.map +1 -1
  242. package/dist/terminal/session-manager.d.ts +53 -0
  243. package/dist/terminal/session-manager.d.ts.map +1 -1
  244. package/dist/terminal/session-manager.js +94 -9
  245. package/dist/terminal/session-manager.js.map +1 -1
  246. package/dist/terminal/session-screen.d.ts +91 -0
  247. package/dist/terminal/session-screen.d.ts.map +1 -0
  248. package/dist/terminal/session-screen.js +135 -0
  249. package/dist/terminal/session-screen.js.map +1 -0
  250. package/dist/terminal/shellper-main.js +2 -1
  251. package/dist/terminal/shellper-main.js.map +1 -1
  252. package/dist/terminal/shellper-process.d.ts +1 -1
  253. package/dist/terminal/shellper-process.d.ts.map +1 -1
  254. package/dist/terminal/shellper-process.js +17 -8
  255. package/dist/terminal/shellper-process.js.map +1 -1
  256. package/dist/terminal/shellper-replay-buffer.d.ts +69 -7
  257. package/dist/terminal/shellper-replay-buffer.d.ts.map +1 -1
  258. package/dist/terminal/shellper-replay-buffer.js +167 -31
  259. package/dist/terminal/shellper-replay-buffer.js.map +1 -1
  260. package/package.json +8 -6
  261. package/skeleton/.claude/skills/afx/SKILL.md +14 -1
  262. package/skeleton/.claude/skills/arch-init/SKILL.md +22 -9
  263. package/skeleton/.claude/skills/arch-save/SKILL.md +183 -0
  264. package/skeleton/.claude/skills/codev/SKILL.md +31 -0
  265. package/skeleton/.claude/skills/runnable-worktrees/SKILL.md +119 -0
  266. package/skeleton/.codex/skills/afx/SKILL.md +14 -1
  267. package/skeleton/.codex/skills/arch-init/SKILL.md +22 -9
  268. package/skeleton/.codex/skills/arch-save/SKILL.md +183 -0
  269. package/skeleton/.codex/skills/codev/SKILL.md +31 -0
  270. package/skeleton/.codex/skills/runnable-worktrees/SKILL.md +119 -0
  271. package/skeleton/protocols/air/builder-prompt.md +28 -47
  272. package/skeleton/protocols/air/consult-types/impl-review.md +17 -34
  273. package/skeleton/protocols/air/consult-types/pr-review.md +14 -37
  274. package/skeleton/protocols/air/prompts/implement.md +20 -73
  275. package/skeleton/protocols/air/prompts/pr.md +22 -54
  276. package/skeleton/protocols/air/protocol.md +40 -71
  277. package/skeleton/protocols/aspir/builder-prompt.md +29 -66
  278. package/skeleton/protocols/aspir/consult-types/impl-review.md +17 -44
  279. package/skeleton/protocols/aspir/consult-types/phase-review.md +17 -44
  280. package/skeleton/protocols/aspir/consult-types/plan-review.md +14 -38
  281. package/skeleton/protocols/aspir/consult-types/pr-review.md +13 -39
  282. package/skeleton/protocols/aspir/consult-types/spec-review.md +13 -33
  283. package/skeleton/protocols/aspir/prompts/implement.md +27 -196
  284. package/skeleton/protocols/aspir/prompts/plan.md +21 -85
  285. package/skeleton/protocols/aspir/prompts/review.md +29 -228
  286. package/skeleton/protocols/aspir/prompts/specify.md +25 -111
  287. package/skeleton/protocols/aspir/protocol.json +10 -0
  288. package/skeleton/protocols/aspir/protocol.md +36 -82
  289. package/skeleton/protocols/bugfix/builder-prompt.md +23 -45
  290. package/skeleton/protocols/bugfix/consult-types/impl-review.md +21 -45
  291. package/skeleton/protocols/bugfix/consult-types/pr-review.md +18 -52
  292. package/skeleton/protocols/bugfix/prompts/fix.md +16 -61
  293. package/skeleton/protocols/bugfix/prompts/investigate.md +18 -62
  294. package/skeleton/protocols/bugfix/prompts/pr.md +22 -57
  295. package/skeleton/protocols/bugfix/protocol.md +44 -50
  296. package/skeleton/protocols/experiment/builder-prompt.md +25 -58
  297. package/skeleton/protocols/experiment/protocol.md +22 -188
  298. package/skeleton/protocols/experiment/templates/notes.md +11 -71
  299. package/skeleton/protocols/maintain/builder-prompt.md +24 -45
  300. package/skeleton/protocols/maintain/consult-types/impl-review.md +17 -44
  301. package/skeleton/protocols/maintain/consult-types/pr-review.md +13 -39
  302. package/skeleton/protocols/maintain/prompts/maintain.md +1 -1
  303. package/skeleton/protocols/maintain/protocol.md +32 -266
  304. package/skeleton/protocols/maintain/templates/maintenance-run.md +11 -0
  305. package/skeleton/protocols/pir/builder-prompt.md +30 -64
  306. package/skeleton/protocols/pir/consult-types/impl-review.md +17 -50
  307. package/skeleton/protocols/pir/consult-types/pr-review.md +11 -40
  308. package/skeleton/protocols/pir/prompts/implement.md +1 -2
  309. package/skeleton/protocols/pir/prompts/plan.md +0 -3
  310. package/skeleton/protocols/pir/prompts/review.md +1 -1
  311. package/skeleton/protocols/pir/protocol.md +54 -177
  312. package/skeleton/protocols/research/builder-prompt.md +27 -58
  313. package/skeleton/protocols/research/protocol.md +28 -153
  314. package/skeleton/protocols/spike/builder-prompt.md +30 -48
  315. package/skeleton/protocols/spike/protocol.md +24 -107
  316. package/skeleton/protocols/spike/templates/findings.md +9 -39
  317. package/skeleton/protocols/spir/builder-prompt.md +33 -62
  318. package/skeleton/protocols/spir/consult-types/impl-review.md +17 -44
  319. package/skeleton/protocols/spir/consult-types/phase-review.md +17 -44
  320. package/skeleton/protocols/spir/consult-types/plan-review.md +14 -38
  321. package/skeleton/protocols/spir/consult-types/pr-review.md +13 -39
  322. package/skeleton/protocols/spir/consult-types/spec-review.md +13 -33
  323. package/skeleton/protocols/spir/prompts/implement.md +26 -195
  324. package/skeleton/protocols/spir/prompts/plan.md +20 -84
  325. package/skeleton/protocols/spir/prompts/review.md +28 -227
  326. package/skeleton/protocols/spir/prompts/specify.md +24 -110
  327. package/skeleton/protocols/spir/protocol.json +10 -0
  328. package/skeleton/protocols/spir/protocol.md +84 -633
  329. package/skeleton/protocols/spir/templates/plan.md +28 -147
  330. package/skeleton/protocols/spir/templates/review.md +21 -80
  331. package/skeleton/protocols/spir/templates/spec.md +24 -118
  332. package/skeleton/resources/commands/agent-farm.md +259 -0
  333. package/skeleton/resources/commands/consult.md +236 -8
  334. package/skeleton/resources/commands/overview.md +1 -0
  335. package/skeleton/roles/architect.md +67 -317
  336. package/skeleton/roles/builder.md +77 -191
  337. package/skeleton/templates/AGENTS.md +2 -0
  338. package/skeleton/templates/CLAUDE.md +2 -0
  339. package/templates/tower.html +39 -2
  340. package/dashboard-dist/assets/index-BZ1x7x1X.js.map +0 -1
  341. package/dashboard-dist/assets/index-DB1KBSNE.css +0 -32
  342. package/dist/agent-farm/servers/send-buffer.d.ts +0 -59
  343. package/dist/agent-farm/servers/send-buffer.d.ts.map +0 -1
  344. package/dist/agent-farm/servers/send-buffer.js +0 -116
  345. package/dist/agent-farm/servers/send-buffer.js.map +0 -1
  346. package/skeleton/porch/prompts/defend.md +0 -108
  347. package/skeleton/porch/prompts/diagnose.md +0 -70
  348. package/skeleton/porch/prompts/evaluate.md +0 -132
  349. package/skeleton/porch/prompts/fix.md +0 -59
  350. package/skeleton/porch/prompts/implement.md +0 -88
  351. package/skeleton/porch/prompts/plan.md +0 -74
  352. package/skeleton/porch/prompts/pr.md +0 -84
  353. package/skeleton/porch/prompts/review.md +0 -249
  354. package/skeleton/porch/prompts/specify.md +0 -53
  355. package/skeleton/porch/prompts/test.md +0 -63
  356. package/skeleton/protocols/aspir/templates/plan.md +0 -184
  357. package/skeleton/protocols/aspir/templates/review.md +0 -120
  358. package/skeleton/protocols/aspir/templates/spec.md +0 -154
@@ -31,12 +31,25 @@ import { getWorktreeConfig, getActivityHooks } from '../utils/config.js';
31
31
  import { ensureCodevConfigWatcher } from './codev-config-watcher.js';
32
32
  import { hasTeam, loadTeamMembers, loadMessages } from '../../lib/team.js';
33
33
  import { fetchTeamGitHubData } from '../../lib/team-github.js';
34
- import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js';
34
+ import { resolveTarget, resolveAgentInRegistry, broadcastMessage, isResolveError } from './tower-messages.js';
35
35
  import { handleCommandRoute, COMMAND_ROUTE } from './command-relay.js';
36
36
  import { formatArchitectMessage, formatBuilderMessage } from '../utils/message-format.js';
37
- import { SendBuffer } from './send-buffer.js';
38
- import { writeMessageToSession } from './message-write.js';
39
- import { getKnownWorkspacePaths, getInstances, getDirectorySuggestions, launchInstance, killTerminalWithShellper, stopInstance, addArchitect, removeArchitect, } from './tower-instances.js';
37
+ import { writeMessageToSession, writeEscapeToSession } from './message-write.js';
38
+ import { makeDeliveryPorts, getMailboxDrainer } from './mailbox-wiring.js';
39
+ import { deliverAgentMailSerialized } from './mailbox-delivery.js';
40
+ import { deliverCronMail, CRON_SENDER } from './cron-delivery.js';
41
+ import { enqueue as enqueueMailbox, getById as getMailboxById, markDelivered as markMailboxDelivered, listHeld as listHeldMailbox, dismiss as dismissMailbox, } from '../db/mailbox.js';
42
+ // Spec 1273 per-terminal submission lock — preserved across the Spec 1313 merge for
43
+ // the two explicit human-bypass paths (escape + interrupt), which do NOT route
44
+ // through the mailbox's per-agent serializer and so need their own anti-fusion lock.
45
+ import { submitToSession } from './session-submit.js';
46
+ // Spec 1307 `--delay` — Tower-side deferred delivery, re-homed onto the Spec 1313
47
+ // mailbox (the merge that carried this feature was flattened by a later rebase, so it
48
+ // is grafted here explicitly): the due-time callback enqueues to the mailbox and
49
+ // triggers a gated drain (see handleSend), so a delayed message delivers onto a
50
+ // render-verified empty prompt like any normal send — never force-injected.
51
+ import { scheduleDelayedSend, validateDelaySeconds } from './delayed-send.js';
52
+ import { getKnownWorkspacePaths, getInstances, getDirectorySuggestions, launchInstance, killTerminalWithShellper, instancesReady, stopInstance, addArchitect, removeArchitect, } from './tower-instances.js';
40
53
  import { OverviewCache } from './overview.js';
41
54
  import { fetchIssue, searchIssues, fetchPRList, fetchCurrentUser, parseLinkedIssue, parseArea, } from '../../lib/github.js';
42
55
  import { computeAnalytics } from './analytics.js';
@@ -50,23 +63,6 @@ const __filename = fileURLToPath(import.meta.url);
50
63
  const __dirname = path.dirname(__filename);
51
64
  // Singleton cache for overview endpoint (Spec 0126 Phase 4)
52
65
  const overviewCache = new OverviewCache();
53
- // Singleton send buffer for typing-aware message delivery (Spec 403)
54
- const sendBuffer = new SendBuffer();
55
- /** Deliver a buffered message to a session (write + broadcast + log).
56
- * Returns the ms timestamp when all writes complete (for serialization). */
57
- function deliverBufferedMessage(session, msg, delayOffset = 0) {
58
- const endTime = writeMessageToSession(session, msg.formattedMessage, msg.noEnter, delayOffset);
59
- broadcastMessage(msg.broadcastPayload);
60
- return endTime;
61
- }
62
- /** Start the send buffer flush timer (called from tower-server during init). */
63
- export function startSendBuffer(log) {
64
- sendBuffer.start((id) => getTerminalManager().getSession(id), deliverBufferedMessage, log);
65
- }
66
- /** Stop the send buffer and deliver remaining messages (called from tower-server during shutdown). */
67
- export function stopSendBuffer() {
68
- sendBuffer.stop();
69
- }
70
66
  const ROUTES = {
71
67
  'GET /health': (_req, res, _url, ctx) => handleHealthCheck(res, ctx),
72
68
  'GET /api/shellpers/husks': (_req, res, _url, ctx) => handleHuskPreview(res, ctx),
@@ -90,10 +86,29 @@ const ROUTES = {
90
86
  'POST /api/launch': (req, res) => handleLaunchInstance(req, res),
91
87
  'POST /api/stop': (req, res) => handleStopInstance(req, res),
92
88
  'POST /api/send': (req, res, _url, ctx) => handleSend(req, res, ctx),
89
+ 'GET /api/inbox': (_req, res, url) => handleInboxList(res, url),
93
90
  'GET /api/cron/tasks': (_req, res, url) => handleCronList(res, url),
94
91
  'GET /': (_req, res, _url, ctx) => handleDashboard(res, ctx),
95
92
  'GET /index.html': (_req, res, _url, ctx) => handleDashboard(res, ctx),
96
93
  };
94
+ /**
95
+ * Issue #1261: tell "Tower isn't wired up yet" apart from "no such thing".
96
+ * A route that needs the instances module must not report a missing terminal
97
+ * (404) or a successful kill (204) when the truth is that it could not act at
98
+ * all. 503 + Retry-After says so, and matches what `stopInstance()` already
99
+ * returns in the same situation.
100
+ *
101
+ * Tower's readiness gate holds requests until boot completes, so these guards
102
+ * should be unreachable in practice — they exist so the failure mode, if the
103
+ * gate is ever bypassed, is honest rather than misleading.
104
+ */
105
+ function respondStartingUp(res) {
106
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '1' });
107
+ res.end(JSON.stringify({
108
+ error: 'STARTING_UP',
109
+ message: 'Tower is still starting up. Try again shortly.',
110
+ }));
111
+ }
97
112
  // ============================================================================
98
113
  // Main request handler
99
114
  // ============================================================================
@@ -165,6 +180,18 @@ export async function handleRequest(req, res, ctx) {
165
180
  if (cronTaskMatch) {
166
181
  return await handleCronTaskAction(req, res, url, cronTaskMatch);
167
182
  }
183
+ // Inbox dismiss: POST /api/inbox/:id/dismiss (Spec 1313, Phase 7)
184
+ const inboxDismissMatch = url.pathname.match(/^\/api\/inbox\/([^/]+)\/dismiss$/);
185
+ if (inboxDismissMatch) {
186
+ return handleInboxDismiss(req, res, ctx, inboxDismissMatch);
187
+ }
188
+ // Inbox show: GET /api/inbox/:id — a single row INCLUDING its body (Spec 1313 §178).
189
+ // Checked AFTER the dismiss match, so /:id/dismiss never falls through here (its
190
+ // trailing segment can't match this single-segment pattern anyway).
191
+ const inboxShowMatch = url.pathname.match(/^\/api\/inbox\/([^/]+)$/);
192
+ if (inboxShowMatch) {
193
+ return handleInboxShow(req, res, inboxShowMatch);
194
+ }
168
195
  // Workspace routes: /workspace/:base64urlPath/* (Spec 0090 Phase 4)
169
196
  if (url.pathname.startsWith('/workspace/')) {
170
197
  return await handleWorkspaceRoutes(req, res, ctx, url);
@@ -581,6 +608,11 @@ async function handleTerminalCreate(req, res, ctx) {
581
608
  const session = manager.createSessionRaw({
582
609
  label: label || `terminal-${sessionId.slice(0, 8)}`,
583
610
  cwd,
611
+ // Spec 1313: thread the launch command so the render-gate can resolve
612
+ // this session's profile (builders keep the `.builder-start.sh` backstop
613
+ // too; this makes identity direct and restart-safe via the persisted row).
614
+ command,
615
+ args,
584
616
  });
585
617
  const ptySession = manager.getSession(session.id);
586
618
  if (ptySession) {
@@ -597,7 +629,7 @@ async function handleTerminalCreate(req, res, ctx) {
597
629
  else {
598
630
  entry.shells.set(roleId, session.id);
599
631
  }
600
- saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null);
632
+ saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null, command ?? null);
601
633
  ctx.log('INFO', `Registered shellper terminal ${session.id} as ${termType} "${roleId}" for workspace ${workspacePath}`);
602
634
  }
603
635
  }
@@ -619,7 +651,7 @@ async function handleTerminalCreate(req, res, ctx) {
619
651
  else {
620
652
  entry.shells.set(roleId, info.id);
621
653
  }
622
- saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid, null, null, null, null, cwd ?? null);
654
+ saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid, null, null, null, null, cwd ?? null, command ?? null);
623
655
  ctx.log('WARN', `Terminal ${info.id} for ${workspacePath} is non-persistent (shellper unavailable)`);
624
656
  }
625
657
  }
@@ -656,6 +688,12 @@ async function handleTerminalRoutes(req, res, url, match) {
656
688
  }
657
689
  // DELETE /api/terminals/:id - Kill terminal (disable shellper auto-restart if applicable)
658
690
  if (req.method === 'DELETE' && (!subpath || subpath === '')) {
691
+ // Issue #1261: without this, a not-yet-wired Tower answers 404 for a
692
+ // terminal that exists — the exact symptom the issue reports.
693
+ if (!instancesReady()) {
694
+ respondStartingUp(res);
695
+ return;
696
+ }
659
697
  if (!(await killTerminalWithShellper(manager, terminalId))) {
660
698
  res.writeHead(404, { 'Content-Type': 'application/json' });
661
699
  res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
@@ -852,7 +890,7 @@ async function handleOverview(res, url, workspaceOverride, ctx) {
852
890
  // every collection field is required ('never undefined' for `architects`,
853
891
  // Issue 1104), so emit them all empty rather than a partial payload.
854
892
  res.writeHead(200, { 'Content-Type': 'application/json' });
855
- res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [] }));
893
+ res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [], heldCount: 0, mailboxEscalated: false }));
856
894
  return;
857
895
  }
858
896
  // Build set of active builder role_ids (lowercased) from live terminal sessions.
@@ -1161,6 +1199,249 @@ async function handleNotify(req, res, ctx) {
1161
1199
  // ============================================================================
1162
1200
  // POST /api/send — send a message to a resolved agent terminal
1163
1201
  // ============================================================================
1202
+ /** Minimal JSON responder for the send route. */
1203
+ function sendJson(res, status, payload) {
1204
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1205
+ res.end(JSON.stringify(payload));
1206
+ }
1207
+ /**
1208
+ * The specific architect NAME whose live terminal is `terminalId`, or null when
1209
+ * `terminalId` is a builder/shell terminal. Reverse-maps via the routing registry
1210
+ * (Spec 1313): storing the canonical architect name on a mailbox row is what lets
1211
+ * held mail redeliver to the right terminal after a respawn, and it also tells an
1212
+ * architect target from a builder target for message formatting.
1213
+ */
1214
+ function architectNameForTerminal(workspacePath, terminalId) {
1215
+ const entry = getWorkspaceTerminals().get(workspacePath);
1216
+ if (!entry)
1217
+ return null;
1218
+ for (const [name, tid] of entry.architects) {
1219
+ if (tid === terminalId)
1220
+ return name;
1221
+ }
1222
+ return null;
1223
+ }
1224
+ /**
1225
+ * The canonical mailbox identity of a live-resolved target: architect targets are
1226
+ * stored under their SPECIFIC architect name (reverse-mapped from the terminal),
1227
+ * everything else under its resolved agent id.
1228
+ */
1229
+ function liveTargetIdentity(result) {
1230
+ const archName = architectNameForTerminal(result.workspacePath, result.terminalId);
1231
+ return { toAgent: archName ?? result.agent, isArchitectTarget: archName !== null };
1232
+ }
1233
+ /** Format a message per sender/target — preserves the pre-1313 formatting rules. */
1234
+ function formatMessageForTarget(isArchitectTarget, from, message, raw) {
1235
+ if (isArchitectTarget && from)
1236
+ return formatBuilderMessage(from, message, undefined, raw); // builder → architect
1237
+ if (!isArchitectTarget)
1238
+ return formatArchitectMessage(message, undefined, raw); // any → builder
1239
+ return raw ? message : formatArchitectMessage(message, undefined, false); // unknown → architect
1240
+ }
1241
+ /**
1242
+ * Route a cron notification through the Spec 1313 mailbox + gate — the Tower-wired
1243
+ * front half of {@link deliverCronMail} (Phase 6). Resolves the task's target to a
1244
+ * canonical recipient agent: a live terminal via {@link resolveTarget} plus the
1245
+ * architect reverse-map ({@link liveTargetIdentity}, because a bare `architect`
1246
+ * target resolves to the generic id, which the mailbox can't address), or — when the
1247
+ * agent is known but has no live PTY — via {@link resolveAgentInRegistry}, so the
1248
+ * message HOLDS as `no-live-pty` instead of vanishing (spec decision 9). Then it
1249
+ * hands off to the registry-free core. Cron is a non-builder sender, so no
1250
+ * sender-affinity or spoofing check applies. Wired into the cron scheduler as its
1251
+ * `deliver` port (see `initCron`), keeping the scheduler ignorant of mailbox
1252
+ * internals and giving cron exactly one gated path shared with `handleSend`.
1253
+ */
1254
+ export async function deliverCronMessage(task, message, log) {
1255
+ const db = getGlobalDb();
1256
+ const ports = makeDeliveryPorts(log);
1257
+ // Preserve the pre-1313 cron framing: a message FROM the `af-cron` pseudo-builder,
1258
+ // regardless of whether the target is an architect or a builder.
1259
+ const base = {
1260
+ body: message,
1261
+ formattedMessage: formatBuilderMessage(CRON_SENDER, message),
1262
+ supersedeKey: task.name,
1263
+ };
1264
+ const live = resolveTarget(task.target, task.workspacePath);
1265
+ if (!isResolveError(live)) {
1266
+ const { toAgent } = liveTargetIdentity(live);
1267
+ return deliverCronMail(ports, db, {
1268
+ ...base,
1269
+ workspacePath: live.workspacePath,
1270
+ toAgent,
1271
+ terminalId: live.terminalId,
1272
+ });
1273
+ }
1274
+ // Live resolution failed. A NOT_FOUND target may still be a known agent with no
1275
+ // live PTY (Tower restarting, builder between respawns) — hold its mail so a
1276
+ // respawn drains it, instead of the old blind drop-with-WARN (decision 9).
1277
+ if (live.code === 'NOT_FOUND') {
1278
+ const reg = resolveAgentInRegistry(task.target, task.workspacePath);
1279
+ if (!isResolveError(reg)) {
1280
+ return deliverCronMail(ports, db, {
1281
+ ...base,
1282
+ workspacePath: reg.workspacePath,
1283
+ toAgent: reg.agent,
1284
+ terminalId: null,
1285
+ });
1286
+ }
1287
+ }
1288
+ log('WARN', `Cron '${task.name}': target '${task.target}' not found — message not delivered`);
1289
+ return { outcome: 'unresolved', reason: null, mailboxId: null };
1290
+ }
1291
+ /**
1292
+ * Persist a `held` mailbox row and write the Spec 1313 `held` send response. Used
1293
+ * for both dead-session cases (no live PTY, held `no-live-pty`). The row exists
1294
+ * before the response returns, so the backstop drainer will redeliver it once the
1295
+ * agent has a clean prompt — nothing is dropped.
1296
+ */
1297
+ function holdAndRespond(res, ctx, input, reason) {
1298
+ const row = enqueueMailbox(getGlobalDb(), { ...input, reason });
1299
+ ctx.log('INFO', `Message held (${reason}) → ${input.toAgent} @ ${path.basename(input.workspacePath)} (mailbox ${row.id.slice(0, 8)}...)`);
1300
+ // A new held row appeared → refresh the held-count indicator (Spec 1313, Phase 7).
1301
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: `held ${reason}` });
1302
+ sendJson(res, 200, {
1303
+ ok: true,
1304
+ terminalId: input.terminalId ?? null,
1305
+ resolvedTo: input.toAgent,
1306
+ deferred: true, // back-compat: a held message is "deferred" to old binaries
1307
+ delivered: false,
1308
+ held: true,
1309
+ reason,
1310
+ mailboxId: row.id,
1311
+ });
1312
+ }
1313
+ /**
1314
+ * Handle a delayed (`--delay`) send (Spec 1307, re-homed onto the Spec 1313 mailbox; round 3
1315
+ * durable rework). The row is RESOLVED, authorized (the builder-spoofing check inside
1316
+ * `resolveTarget`), formatted, and PERSISTED here at REQUEST time with `not_before = now +
1317
+ * delay*1000` — the security property the immediate path documents (a delayed send must not
1318
+ * defer an authorization check past the conditions that would fail it) is preserved, and only
1319
+ * DELIVERY is deferred. Persisting at request time is what makes `--delay` DURABLE across a
1320
+ * Tower restart (the conscious, architect+maintainer-approved reversal of Spec 1307's
1321
+ * drop-on-restart semantics): the render gate still guarantees a post-restart delivery only
1322
+ * ever lands on a verified-empty prompt, and a pre-due row is visible/cancellable in
1323
+ * `afx inbox`.
1324
+ *
1325
+ * Resolution is live-first then registry (a known agent with no live PTY) so a
1326
+ * dead/unwritable/registry-only target schedules UNIFORMLY — a delayed send does not require a
1327
+ * live session now. reason is left null: the row is SCHEDULED, not held-for-a-reason; the
1328
+ * drainer re-evaluates liveness at due time.
1329
+ *
1330
+ * The body is NEVER written from here. A normal delayed send is delivered by the gated
1331
+ * backstop drainer once `not_before` passes (≤ one 1.5 s tick after due — the delay is a lower
1332
+ * bound). A delayed `--interrupt` (change 2 reshape) additionally keeps a small in-memory timer
1333
+ * that fires ONLY the Ctrl+C at due time (guarded by `isStillLive` + a re-fetched writable
1334
+ * session, inside the submission lock); the ^C ends the turn and the body then delivers through
1335
+ * the SAME gate every send uses — nothing is marked delivered here, so a #1198 dropped write or
1336
+ * a shutdown during the wait can never falsely report delivery or double-deliver. A restart
1337
+ * during the wait loses only the ^C nudge, never the message.
1338
+ */
1339
+ function handleDelayedSend(res, ctx, db, params) {
1340
+ const { to, workspace, from, message, raw, noEnter, interrupt, deliverAfter, senderWorkspace } = params;
1341
+ const now = Date.now();
1342
+ const notBefore = now + deliverAfter * 1000;
1343
+ // Resolve to a canonical AGENT — live first, then registry (known agent, no live PTY yet).
1344
+ let workspacePath;
1345
+ let toAgent;
1346
+ let isArchitectTarget;
1347
+ let terminalId;
1348
+ const live = resolveTarget(to, workspace, from);
1349
+ if (!isResolveError(live)) {
1350
+ const identity = liveTargetIdentity(live);
1351
+ workspacePath = live.workspacePath;
1352
+ toAgent = identity.toAgent;
1353
+ isArchitectTarget = identity.isArchitectTarget;
1354
+ terminalId = live.terminalId;
1355
+ }
1356
+ else if (live.code === 'NOT_FOUND') {
1357
+ const reg = resolveAgentInRegistry(to, workspace, from);
1358
+ if (isResolveError(reg)) {
1359
+ const statusCode = reg.code === 'AMBIGUOUS' ? 409 : reg.code === 'NO_CONTEXT' ? 400 : 404;
1360
+ const errorCode = reg.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : reg.code;
1361
+ sendJson(res, statusCode, { error: errorCode, message: reg.message });
1362
+ return;
1363
+ }
1364
+ workspacePath = reg.workspacePath;
1365
+ toAgent = reg.agent;
1366
+ isArchitectTarget = reg.kind === 'architect';
1367
+ terminalId = null;
1368
+ }
1369
+ else {
1370
+ const statusCode = live.code === 'AMBIGUOUS' ? 409 : live.code === 'NO_CONTEXT' ? 400 : 404;
1371
+ const errorCode = live.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : live.code;
1372
+ sendJson(res, statusCode, { error: errorCode, message: live.message });
1373
+ return;
1374
+ }
1375
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1376
+ // Persist NOW, at request time, with the due time. reason=null → SCHEDULED, not stuck.
1377
+ const row = enqueueMailbox(db, {
1378
+ workspacePath,
1379
+ toAgent,
1380
+ body: message,
1381
+ formattedMessage,
1382
+ fromAgent: from ?? null,
1383
+ fromWorkspace: senderWorkspace,
1384
+ noEnter,
1385
+ terminalId,
1386
+ notBefore,
1387
+ });
1388
+ // A new held (scheduled) row appeared → refresh the held-count indicator (Phase 7).
1389
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: 'scheduled' });
1390
+ if (interrupt) {
1391
+ // Change 2 reshape: an in-memory timer that fires ONLY the Ctrl+C at due time. It writes
1392
+ // no message body and marks nothing delivered — the body delivers through the gated
1393
+ // drainer after the ^C ends the turn. Everything the ^C depends on — `isStillLive`
1394
+ // (delayed-send.ts's generation guard), the session's existence, and its writability — is
1395
+ // re-checked INSIDE the submission lock, right before the write, so a shutdown OR a session
1396
+ // teardown/respawn during the lock-wait writes nothing (invariant 2; the directive's
1397
+ // preferred shape). Diagnostics arg falls back to toAgent when the target has no terminal
1398
+ // id yet.
1399
+ scheduleDelayedSend(deliverAfter, terminalId ?? toAgent, (isStillLive) => {
1400
+ if (!isStillLive())
1401
+ return; // shutdown before/at due → drop the ^C nudge (body survives)
1402
+ if (terminalId) {
1403
+ let fired = false;
1404
+ void submitToSession(terminalId, () => {
1405
+ // Re-check EVERYTHING inside the lock: the queued submission can acquire the lock only
1406
+ // AFTER a shutdown or a session teardown/respawn that landed while it waited behind an
1407
+ // in-flight write. Re-fetch the session and re-check live + writable here; bail with no
1408
+ // ^C otherwise (the body still delivers via the gate).
1409
+ if (!isStillLive())
1410
+ return 0;
1411
+ const live = getTerminalManager().getSession(terminalId);
1412
+ if (!live || !live.writable)
1413
+ return 0;
1414
+ live.write('\x03'); // Ctrl+C only — end the turn; body follows via the gate
1415
+ fired = true;
1416
+ return 0; // no body, no Enter on this path
1417
+ })
1418
+ .then(() => ctx.log('INFO', fired
1419
+ ? `Delayed interrupt ^C fired → ${toAgent} (terminal ${terminalId.slice(0, 8)}...); body delivers via the gate`
1420
+ : `Delayed interrupt for ${toAgent}: session not live/writable at write time — body delivers via the gate on the next clean pass`))
1421
+ .catch((err) => ctx.log('ERROR', `Delayed interrupt ^C failed for ${toAgent}: ${err.message}`));
1422
+ }
1423
+ else {
1424
+ ctx.log('INFO', `Delayed interrupt for ${toAgent}: no live terminal at due time — body delivers via the gate on the next clean pass`);
1425
+ }
1426
+ // Nudge the gated drainer so the now-due body delivers promptly (gated — a spurious
1427
+ // nudge onto a busy/mid-turn screen simply re-holds; the backstop is the ultimate net).
1428
+ void getMailboxDrainer().scheduleDrain(workspacePath, toAgent);
1429
+ });
1430
+ }
1431
+ // A normal delayed send keeps NO timer: the persisted `not_before` row is delivered by the
1432
+ // gated backstop drainer once due — durable across restart by construction.
1433
+ ctx.log('INFO', `Message scheduled (+${deliverAfter}s): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
1434
+ sendJson(res, 200, {
1435
+ ok: true,
1436
+ terminalId,
1437
+ resolvedTo: toAgent,
1438
+ deferred: false,
1439
+ scheduled: true,
1440
+ deliverAfter,
1441
+ mailboxId: row.id,
1442
+ notBefore,
1443
+ });
1444
+ }
1164
1445
  async function handleSend(req, res, ctx) {
1165
1446
  const body = await parseJsonBody(req);
1166
1447
  // Validate required fields
@@ -1186,111 +1467,381 @@ async function handleSend(req, res, ctx) {
1186
1467
  const raw = options.raw === true;
1187
1468
  const noEnter = options.noEnter === true;
1188
1469
  const interrupt = options.interrupt === true;
1189
- // Resolve the target address to a terminal ID.
1470
+ const escape = options.escape === true;
1471
+ // Spec 1307 `--delay` (re-homed onto the mailbox): optional deferred delivery.
1472
+ // Validated here as well as at the CLI boundary — /api/send is a public route, so an
1473
+ // unvalidated value would become a setTimeout that fires instantly (NaN) or never
1474
+ // (Infinity). `escape` cannot be combined with a delay: an ESC interrupts the CURRENT
1475
+ // turn by design, so deferring it is contradictory — refuse rather than silently drop
1476
+ // one of the two (a quietly-dropped delay would look like it worked).
1477
+ let deliverAfter;
1478
+ if (options.deliverAfter !== undefined && options.deliverAfter !== null) {
1479
+ const delayError = validateDelaySeconds(options.deliverAfter);
1480
+ if (delayError) {
1481
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1482
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: delayError }));
1483
+ return;
1484
+ }
1485
+ if (escape) {
1486
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1487
+ res.end(JSON.stringify({
1488
+ error: 'INVALID_PARAMS',
1489
+ message: 'escape cannot be combined with a delay: an ESC keystroke bypasses buffering by design so that it interrupts the CURRENT turn. Send the ESC now, or send a delayed message without escape.',
1490
+ }));
1491
+ return;
1492
+ }
1493
+ deliverAfter = options.deliverAfter;
1494
+ }
1495
+ const db = getGlobalDb();
1496
+ const senderWorkspace = fromWorkspace ?? workspace ?? 'unknown';
1497
+ // Spec 1313 round 3: a delayed send (`--delay`) is RESOLVED, authorized, formatted, and
1498
+ // PERSISTED here at REQUEST time, then deferred — it does NOT require a live session now,
1499
+ // so it is handled BEFORE the immediate live/dead/unwritable/escape/interrupt branches
1500
+ // below (those would 404/503 a delayed send to a target that has no live PTY yet). Only
1501
+ // DELIVERY is deferred, through the same render gate every send uses; the row's `not_before`
1502
+ // makes the delay durable across a Tower restart.
1503
+ if (deliverAfter !== undefined) {
1504
+ handleDelayedSend(res, ctx, db, {
1505
+ to, workspace, from, message, raw, noEnter, interrupt, deliverAfter, senderWorkspace,
1506
+ });
1507
+ return;
1508
+ }
1509
+ // Resolve the target address against LIVE terminals.
1190
1510
  // Spec 755: pass `from` so architect resolution is sender-affinity-aware
1191
1511
  // when the sender is a builder. Non-builder senders see unchanged behavior.
1192
1512
  const result = resolveTarget(to, workspace, from);
1513
+ // --- Resolution failed against live terminals ---
1193
1514
  if (isResolveError(result)) {
1194
- const statusCode = result.code === 'AMBIGUOUS' ? 409
1195
- : result.code === 'NO_CONTEXT' ? 400
1196
- : 404;
1515
+ // Spec 1313 dead-session seam: a NOT_FOUND target may still be a KNOWN agent
1516
+ // with no live PTY (e.g. registered in global.db while Tower restarts). Hold
1517
+ // its mail instead of 404ing. escape/interrupt act on a live session only, so
1518
+ // an unresolved target keeps the original error for them.
1519
+ if (result.code === 'NOT_FOUND' && !escape && !interrupt) {
1520
+ const reg = resolveAgentInRegistry(to, workspace, from);
1521
+ if (!isResolveError(reg)) {
1522
+ holdAndRespond(res, ctx, {
1523
+ workspacePath: reg.workspacePath,
1524
+ toAgent: reg.agent,
1525
+ body: message,
1526
+ formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, message, raw),
1527
+ fromAgent: from ?? null,
1528
+ fromWorkspace: senderWorkspace,
1529
+ noEnter,
1530
+ terminalId: null,
1531
+ }, 'no-live-pty');
1532
+ return;
1533
+ }
1534
+ if (reg.code === 'AMBIGUOUS') {
1535
+ sendJson(res, 409, { error: 'AMBIGUOUS', message: reg.message });
1536
+ return;
1537
+ }
1538
+ // else: fall through to the original live-resolution error below.
1539
+ }
1540
+ const statusCode = result.code === 'AMBIGUOUS' ? 409 : result.code === 'NO_CONTEXT' ? 400 : 404;
1197
1541
  // Map NO_CONTEXT to INVALID_PARAMS per plan's error contract
1198
1542
  const errorCode = result.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : result.code;
1199
- res.writeHead(statusCode, { 'Content-Type': 'application/json' });
1200
- res.end(JSON.stringify({ error: errorCode, message: result.message }));
1543
+ sendJson(res, statusCode, { error: errorCode, message: result.message });
1201
1544
  return;
1202
1545
  }
1203
- // Get the terminal session
1546
+ // --- Live target resolved; locate its session ---
1204
1547
  const manager = getTerminalManager();
1205
1548
  const session = manager.getSession(result.terminalId);
1549
+ const { toAgent, isArchitectTarget } = liveTargetIdentity(result);
1550
+ // Dead session: the routing entry resolved but the PTY is gone (exited > 30s).
1551
+ // Hold a normal message; escape/interrupt need a live session → original 404.
1206
1552
  if (!session) {
1207
- res.writeHead(404, { 'Content-Type': 'application/json' });
1208
- res.end(JSON.stringify({
1209
- error: 'NOT_FOUND',
1210
- message: `Terminal session ${result.terminalId} not found (agent '${result.agent}' resolved but terminal is gone).`,
1211
- }));
1553
+ if (escape || interrupt) {
1554
+ sendJson(res, 404, {
1555
+ error: 'NOT_FOUND',
1556
+ message: `Terminal session ${result.terminalId} not found (agent '${result.agent}' resolved but terminal is gone).`,
1557
+ });
1558
+ return;
1559
+ }
1560
+ holdAndRespond(res, ctx, {
1561
+ workspacePath: result.workspacePath,
1562
+ toAgent,
1563
+ body: message,
1564
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1565
+ fromAgent: from ?? null,
1566
+ fromWorkspace: senderWorkspace,
1567
+ noEnter,
1568
+ terminalId: result.terminalId,
1569
+ }, 'no-live-pty');
1212
1570
  return;
1213
1571
  }
1214
1572
  // #1198: a session whose shellper connection died still reports status
1215
- // 'running', but every write to it is dropped. Fail the send loudly
1216
- // instead of logging "Message sent" for a message that went nowhere.
1573
+ // 'running', but every write is dropped. Hold a normal message (it delivers
1574
+ // when the connection recovers); keep the loud 503 for escape/interrupt, which
1575
+ // are operator actions that require the live PTY here and now.
1217
1576
  if (!session.writable) {
1218
- ctx.log('ERROR', `Message DROPPED: ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...): terminal not writable (shellper connection down)`);
1219
- res.writeHead(503, { 'Content-Type': 'application/json' });
1220
- res.end(JSON.stringify({
1221
- error: 'TERMINAL_NOT_WRITABLE',
1222
- message: `Terminal for '${result.agent}' is not accepting input (its process connection is down). Retry shortly; if this persists, check Tower logs.`,
1223
- }));
1577
+ if (escape || interrupt) {
1578
+ ctx.log('ERROR', `Interrupt/ESC not deliverable: ${from ?? 'unknown'} → ${result.agent} (terminal not writable, shellper connection down)`);
1579
+ sendJson(res, 503, {
1580
+ error: 'TERMINAL_NOT_WRITABLE',
1581
+ message: `Terminal for '${result.agent}' is not accepting input (its process connection is down). Retry shortly; if this persists, check Tower logs.`,
1582
+ });
1583
+ return;
1584
+ }
1585
+ holdAndRespond(res, ctx, {
1586
+ workspacePath: result.workspacePath,
1587
+ toAgent,
1588
+ body: message,
1589
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1590
+ fromAgent: from ?? null,
1591
+ fromWorkspace: senderWorkspace,
1592
+ noEnter,
1593
+ terminalId: result.terminalId,
1594
+ }, 'no-live-pty');
1595
+ return;
1596
+ }
1597
+ // --- Live, writable session ---
1598
+ // Spec 1273: `escape` delivers a bare ESC keystroke and returns. Explicit human
1599
+ // bypass — no gate, no mailbox row. ESC ends the running turn so already-queued
1600
+ // messages process; the trailing Enter (default) is what lets them through
1601
+ // (matching the verified recovery `afx send <b> --raw "$(printf '\x1b')"`).
1602
+ if (escape) {
1603
+ // Awaited: the response must not claim delivery before the ESC and its
1604
+ // Enter have actually been written (Spec 1273 verify).
1605
+ await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter));
1606
+ broadcastMessage({
1607
+ type: 'message',
1608
+ from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
1609
+ to: { project: path.basename(result.workspacePath), agent: toAgent },
1610
+ content: '<ESC>',
1611
+ metadata: { raw: true, source: 'api', escape: true },
1612
+ timestamp: new Date().toISOString(),
1613
+ });
1614
+ ctx.log('INFO', `Interrupt (ESC) sent: ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1615
+ sendJson(res, 200, { ok: true, terminalId: result.terminalId, resolvedTo: toAgent, deferred: false });
1224
1616
  return;
1225
1617
  }
1226
- // Format the message based on sender/target
1227
- const isArchitectTarget = result.agent === 'architect';
1228
- let formattedMessage;
1229
- if (isArchitectTarget && from) {
1230
- // Builder → Architect
1231
- formattedMessage = formatBuilderMessage(from, message, undefined, raw);
1232
- }
1233
- else if (!isArchitectTarget) {
1234
- // Architect → Builder (or any → builder)
1235
- formattedMessage = formatArchitectMessage(message, undefined, raw);
1236
- }
1237
- else {
1238
- // Unknown sender to architect — use raw
1239
- formattedMessage = raw ? message : formatArchitectMessage(message, undefined, false);
1240
- }
1241
- // Build broadcast payload (used for both immediate and deferred delivery)
1242
- const senderWorkspace = fromWorkspace ?? workspace ?? 'unknown';
1243
- const broadcastPayload = {
1244
- type: 'message',
1245
- from: {
1246
- project: path.basename(senderWorkspace),
1247
- agent: from ?? 'unknown',
1248
- },
1249
- to: {
1250
- project: path.basename(result.workspacePath),
1251
- agent: result.agent,
1252
- },
1253
- content: message,
1254
- metadata: { raw, source: 'api' },
1255
- timestamp: new Date().toISOString(),
1256
- };
1257
- const logMessage = `Message sent: ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...)`;
1258
- // Optionally interrupt first — bypass buffering entirely
1618
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1619
+ // NB: `--delay` (deliverAfter) is handled earlier by handleDelayedSend, before this
1620
+ // immediate-path block — a delayed send is resolved + persisted at request time and does
1621
+ // not fall through here.
1622
+ // Spec 1313: `interrupt` is the explicit human bypass. Ctrl+C, then deliver
1623
+ // WITHOUT the render-gate (the operator is looking at this terminal). A row is
1624
+ // still persisted and marked delivered for audit parity — every send is a row.
1259
1625
  if (interrupt) {
1260
- session.write('\x03'); // Ctrl+C
1261
- await new Promise(resolve => setTimeout(resolve, 100));
1262
- }
1263
- // Check if user is idle — deliver immediately or buffer (Spec 403, Bugfix #450)
1264
- // Defer only when user has typed recently (within idle threshold).
1265
- // Bugfix #492: removed session.composing check — composing gets stuck true
1266
- // after non-Enter keystrokes (Ctrl+C, arrows, Tab), causing 60s delays.
1267
- const shouldDefer = !interrupt && !session.isUserIdle(sendBuffer.idleThresholdMs);
1268
- if (shouldDefer) {
1269
- // User is actively typing — buffer for deferred delivery
1270
- sendBuffer.enqueue({
1271
- sessionId: result.terminalId,
1626
+ const row = enqueueMailbox(db, {
1627
+ workspacePath: result.workspacePath,
1628
+ toAgent,
1629
+ body: message,
1272
1630
  formattedMessage,
1631
+ fromAgent: from ?? null,
1632
+ fromWorkspace: senderWorkspace,
1273
1633
  noEnter,
1274
- timestamp: Date.now(),
1275
- broadcastPayload,
1276
- logMessage,
1634
+ terminalId: result.terminalId,
1635
+ });
1636
+ // Claim the row as delivered SYNCHRONOUSLY, before any await (CMAP round 2 — Codex): the
1637
+ // interrupt writes the message itself (a gate bypass), so the row must never be visible to
1638
+ // the mailbox drainer as `held`, or a concurrent backstop/scheduleDrain pass could gate-deliver
1639
+ // the SAME row and put the bytes on the wire twice. enqueue→markDelivered are both synchronous
1640
+ // (no await between), so there is no window for the drainer to pick it up before we own it.
1641
+ // Tradeoff (CMAP round 3 — Codex/Claude): claiming BEFORE the write below means that if
1642
+ // submitToSession throws/crashes, the row reads `delivered` for audit though no bytes reached
1643
+ // the PTY — the message is lost, not retried. This is the deliberate choice: the write is not
1644
+ // transactional, so a partial write may already have put bytes on the wire, and re-holding (the
1645
+ // alternative) would let the backstop gate-deliver a SECOND copy. Losing a crashed interrupt is
1646
+ // preferred over double-delivering it; `--interrupt` is the explicit human gate-bypass anyway.
1647
+ markMailboxDelivered(db, row.id);
1648
+ // Deliver the interrupt as ONE atomic critical section under the Spec 1273 per-terminal
1649
+ // submission lock (CMAP round 1 — Gemini/Codex/Claude): the Ctrl+C, its 100 ms settle, and
1650
+ // the message write all occur inside a single lock acquisition. Previously the \x03 + the
1651
+ // settle sat OUTSIDE the lock, so a concurrent submission to the same terminal could land
1652
+ // its Ctrl+C inside another submission's text→Enter window (killing that composer) or run
1653
+ // during the 100 ms gap. `writeMessageToSession(..., 100)` schedules the text 100 ms after
1654
+ // the ^C (the settle) and returns the completion offset, so the lock is held until the
1655
+ // whole interrupt is on the wire; uncontended, it runs at once.
1656
+ // Scope of the guarantee: this serializes interrupt against interrupt/escape — the only
1657
+ // /api/send writers that take this per-terminal lock. It does NOT serialize against a
1658
+ // concurrent mailbox/backstop delivery (which writes through the per-AGENT serializer, a
1659
+ // disjoint lock); interrupt is the explicit gate-bypassing human action, and closing that
1660
+ // cross-path race would require the mailbox write edge to take this lock too (a separate,
1661
+ // larger change — flagged, not done here).
1662
+ await submitToSession(result.terminalId, () => {
1663
+ session.write('\x03'); // Ctrl+C
1664
+ return writeMessageToSession(session, formattedMessage, noEnter, 100);
1665
+ });
1666
+ broadcastMessage({
1667
+ type: 'message',
1668
+ from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
1669
+ to: { project: path.basename(result.workspacePath), agent: toAgent },
1670
+ content: message,
1671
+ metadata: { raw, source: 'api' },
1672
+ timestamp: new Date().toISOString(),
1673
+ });
1674
+ ctx.log('INFO', `Message delivered (interrupt): ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1675
+ sendJson(res, 200, {
1676
+ ok: true,
1677
+ terminalId: result.terminalId,
1678
+ resolvedTo: toAgent,
1679
+ deferred: false,
1680
+ delivered: true,
1681
+ held: false,
1682
+ mailboxId: row.id,
1683
+ reason: null,
1277
1684
  });
1278
- ctx.log('INFO', `Message deferred (user typing): ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1685
+ return;
1279
1686
  }
1280
- else {
1281
- // User is idle (or interrupt) — deliver immediately.
1282
- // Bugfix #584: paces multi-line output to avoid paste detection.
1283
- writeMessageToSession(session, formattedMessage, noEnter);
1284
- broadcastMessage(broadcastPayload);
1285
- ctx.log('INFO', logMessage);
1687
+ // Spec 1313 normal path: PERSIST first (survives a crash), then attempt gated
1688
+ // delivery through the single serialized path. The response reports the row's
1689
+ // real first outcome — a clean, render-verified empty prompt delivers now;
1690
+ // anything else (busy/menu/wrapper/no-profile) stays held for the backstop.
1691
+ const row = enqueueMailbox(db, {
1692
+ workspacePath: result.workspacePath,
1693
+ toAgent,
1694
+ body: message,
1695
+ formattedMessage,
1696
+ fromAgent: from ?? null,
1697
+ fromWorkspace: senderWorkspace,
1698
+ noEnter,
1699
+ terminalId: result.terminalId,
1700
+ });
1701
+ // Deliver to the session THIS request already resolved rather than re-resolving
1702
+ // by agent: the base resolver would repeat the routing-map lookup (redundant) and
1703
+ // could target a different terminal if the map changed mid-request. The backstop
1704
+ // drainer, which only has the agent, still uses the base resolver.
1705
+ const basePorts = makeDeliveryPorts(ctx.log);
1706
+ const ports = {
1707
+ ...basePorts,
1708
+ getSessionForAgent: (ws, agent) => ws === result.workspacePath && agent === toAgent ? session : basePorts.getSessionForAgent(ws, agent),
1709
+ };
1710
+ try {
1711
+ await deliverAgentMailSerialized(ports, db, result.workspacePath, toAgent);
1286
1712
  }
1287
- res.writeHead(200, { 'Content-Type': 'application/json' });
1288
- res.end(JSON.stringify({
1713
+ catch (err) {
1714
+ // A gate/write error leaves the row HELD (markDelivered only runs on a
1715
+ // completed write); the backstop drainer will retry. Report held, not a 500.
1716
+ ctx.log('ERROR', `Delivery attempt errored for ${toAgent} (row ${row.id.slice(0, 8)}... stays held): ${err.message}`);
1717
+ }
1718
+ const stored = getMailboxById(db, row.id);
1719
+ if (stored?.status === 'delivered') {
1720
+ ctx.log('INFO', `Message delivered: ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1721
+ sendJson(res, 200, {
1722
+ ok: true,
1723
+ terminalId: result.terminalId,
1724
+ resolvedTo: toAgent,
1725
+ deferred: false,
1726
+ delivered: true,
1727
+ held: false,
1728
+ mailboxId: row.id,
1729
+ reason: null,
1730
+ });
1731
+ return;
1732
+ }
1733
+ const reason = stored?.reason ?? 'busy';
1734
+ ctx.log('INFO', `Message held (${reason}): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
1735
+ // The message stayed held → a new held row is in the set; refresh the indicator
1736
+ // count (Spec 1313, Phase 7). The delivered branch above needs no fire — the
1737
+ // delivery path's onHeldStateChange already broadcast when the row left the set.
1738
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: `held ${reason}` });
1739
+ sendJson(res, 200, {
1289
1740
  ok: true,
1290
1741
  terminalId: result.terminalId,
1291
- resolvedTo: result.agent,
1292
- deferred: shouldDefer,
1742
+ resolvedTo: toAgent,
1743
+ deferred: true,
1744
+ delivered: false,
1745
+ held: true,
1746
+ reason,
1747
+ mailboxId: row.id,
1748
+ });
1749
+ }
1750
+ /**
1751
+ * GET /api/inbox — list held (undelivered) mailbox rows for a workspace. Backs the
1752
+ * workspace-scoped `afx inbox` (Spec 1313 decision 8): `?workspace=<path>` selects the
1753
+ * workspace (the CLI passes the current one by default); the path is normalized to the
1754
+ * same realpath form the enqueue path stores, so a raw workspace root still matches its
1755
+ * held rows. Omitting `?workspace=` lists every workspace — an API-level convenience the
1756
+ * CLI never triggers, kept for direct callers. Metadata-only projection (Spec 1313
1757
+ * redaction rule): id, addresses, why-held reason, escalation flag, and enqueue time —
1758
+ * the message BODY is deliberately never surfaced here (it travels only over the live
1759
+ * terminal stream on delivery). `escalated` is normalized from SQLite's 0/1 to a bool.
1760
+ */
1761
+ function handleInboxList(res, url) {
1762
+ const rawWorkspace = url.searchParams.get('workspace');
1763
+ // Normalize to the stored realpath key (mailbox workspace_path is normalized at
1764
+ // enqueue — tower-routes handleSend / holdAndRespond — matching overview.ts). Without
1765
+ // this a symlinked workspace root would miss its own held rows.
1766
+ const workspace = rawWorkspace ? normalizeWorkspacePath(rawWorkspace) : undefined;
1767
+ const rows = listHeldMailbox(getGlobalDb(), workspace);
1768
+ const projected = rows.map((r) => ({
1769
+ id: r.id,
1770
+ workspacePath: r.workspace_path,
1771
+ toAgent: r.to_agent,
1772
+ fromAgent: r.from_agent,
1773
+ reason: r.reason,
1774
+ escalated: r.escalated === 1,
1775
+ createdAt: r.created_at,
1776
+ // Spec 1313 round 3: due time of a pre-due delayed (`--delay`) row; null = deliver-ASAP.
1777
+ // The CLI renders "in Ns" for a row whose notBefore is still in the future.
1778
+ notBefore: r.not_before,
1293
1779
  }));
1780
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1781
+ res.end(JSON.stringify(projected));
1782
+ }
1783
+ /**
1784
+ * POST /api/inbox/:id/dismiss — mark a held row `dismissed` (operator-cleared via
1785
+ * `afx inbox dismiss`). Soft transition: the row is marked, not deleted, and NEVER
1786
+ * delivered. The dispatch matches this path for ANY method, so the method is guarded
1787
+ * here: a non-POST request (e.g. GET) must not mutate state → 405. 404 when the id names
1788
+ * no currently-held row (already terminal or unknown), so the CLI reports a clean error.
1789
+ * On success, fires `overview-changed` so the held-count indicator drops immediately.
1790
+ * Authorized at the workspace-human trust level — any local operator may dismiss any held
1791
+ * row (Spec 1313 decision 8); no ownership check.
1792
+ */
1793
+ function handleInboxDismiss(req, res, ctx, match) {
1794
+ // Dismissal mutates state — only POST may reach it. The path match in the dispatch is
1795
+ // method-agnostic, so without this guard a GET (or any method) to this URL would dismiss
1796
+ // mail. Matches the method-guard convention used by the cron action routes.
1797
+ if (req.method !== 'POST') {
1798
+ sendJson(res, 405, { error: 'Method not allowed' });
1799
+ return;
1800
+ }
1801
+ const id = decodeURIComponent(match[1]);
1802
+ if (!dismissMailbox(getGlobalDb(), id)) {
1803
+ sendJson(res, 404, { error: 'NOT_FOUND', message: `No held message with id '${id}'` });
1804
+ return;
1805
+ }
1806
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: 'dismissed' });
1807
+ ctx.log('INFO', `Inbox: dismissed held message ${id.slice(0, 8)}...`);
1808
+ sendJson(res, 200, { ok: true });
1809
+ }
1810
+ /**
1811
+ * GET /api/inbox/:id — return a single mailbox row INCLUDING its body. Backs
1812
+ * `afx inbox show <id>` (Spec 1313 §178: `afx inbox` is a UI surface that legitimately
1813
+ * displays message bodies over the local Tower connection — the redaction rule applies to
1814
+ * logs/diagnostics/telemetry only, never this view). Mirrors dismiss's addressing model:
1815
+ * by unique id at the workspace-human trust level, no per-recipient/workspace ownership
1816
+ * check (decision 8). GET-only (the path match in the dispatch is method-agnostic, so a
1817
+ * non-GET is rejected here); 404 when the id names no row. The body is returned to the
1818
+ * caller but never logged.
1819
+ */
1820
+ function handleInboxShow(req, res, match) {
1821
+ if (req.method !== 'GET') {
1822
+ sendJson(res, 405, { error: 'Method not allowed' });
1823
+ return;
1824
+ }
1825
+ const id = decodeURIComponent(match[1]);
1826
+ const row = getMailboxById(getGlobalDb(), id);
1827
+ if (!row) {
1828
+ sendJson(res, 404, { error: 'NOT_FOUND', message: `No message with id '${id}'` });
1829
+ return;
1830
+ }
1831
+ sendJson(res, 200, {
1832
+ id: row.id,
1833
+ workspacePath: row.workspace_path,
1834
+ toAgent: row.to_agent,
1835
+ fromAgent: row.from_agent,
1836
+ fromWorkspace: row.from_workspace,
1837
+ status: row.status,
1838
+ reason: row.reason,
1839
+ escalated: row.escalated === 1,
1840
+ body: row.body,
1841
+ createdAt: row.created_at,
1842
+ notBefore: row.not_before,
1843
+ resolvedAt: row.resolved_at,
1844
+ });
1294
1845
  }
1295
1846
  async function handleBrowse(res, url) {
1296
1847
  const inputPath = url.searchParams.get('path') || '';
@@ -1854,6 +2405,13 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
1854
2405
  const session = manager.createSessionRaw({
1855
2406
  label: `Shell ${shellId.replace('shell-', '')}`,
1856
2407
  cwd: workspacePath,
2408
+ // Spec 1313: thread/persist for reconstruction symmetry with the other
2409
+ // createSessionRaw sites. A workspace-root shell resolves to no-profile
2410
+ // (its command is a shell, not an agent, and the cwd has no launch script),
2411
+ // so `afx send` correctly holds. (A shell whose cwd happened to be a builder
2412
+ // worktree would resolve that worktree's harness via the launch-script fallback.)
2413
+ command: shellCmd,
2414
+ args: shellArgs,
1857
2415
  });
1858
2416
  const ptySession = manager.getSession(session.id);
1859
2417
  if (ptySession) {
@@ -1861,7 +2419,7 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
1861
2419
  }
1862
2420
  const entry = getWorkspaceTerminalsEntry(workspacePath);
1863
2421
  entry.shells.set(shellId, session.id);
1864
- saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath);
2422
+ saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath, shellCmd);
1865
2423
  shellCreated = true;
1866
2424
  res.writeHead(200, { 'Content-Type': 'application/json' });
1867
2425
  res.end(JSON.stringify({
@@ -1890,7 +2448,7 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
1890
2448
  });
1891
2449
  const entry = getWorkspaceTerminalsEntry(workspacePath);
1892
2450
  entry.shells.set(shellId, session.id);
1893
- saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid, null, null, null, session.label, workspacePath);
2451
+ saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid, null, null, null, session.label, workspacePath, shellCmd);
1894
2452
  ctx.log('WARN', `Shell ${shellId} for ${workspacePath} is non-persistent (shellper unavailable)`);
1895
2453
  res.writeHead(200, { 'Content-Type': 'application/json' });
1896
2454
  res.end(JSON.stringify({
@@ -2143,6 +2701,13 @@ async function handleWorkspaceTabDelete(res, ctx, workspacePath, tabId) {
2143
2701
  return;
2144
2702
  }
2145
2703
  if (terminalId) {
2704
+ // Issue #1261: this path discards the kill result and answers 204 either
2705
+ // way, so a not-yet-wired Tower would report a successful close for a
2706
+ // terminal it never touched. Refuse instead.
2707
+ if (!instancesReady()) {
2708
+ respondStartingUp(res);
2709
+ return;
2710
+ }
2146
2711
  // Disable shellper auto-restart if applicable, then kill the PtySession
2147
2712
  await killTerminalWithShellper(manager, terminalId);
2148
2713
  // TICK-001: Delete from SQLite