@cluesmith/codev 3.2.4 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (394) hide show
  1. package/dashboard-dist/assets/index-BZRTEMtH.css +32 -0
  2. package/dashboard-dist/assets/{index-BZ1x7x1X.js → index-D3XaKpZW.js} +46 -44
  3. package/dashboard-dist/assets/index-D3XaKpZW.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 +154 -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 +34 -0
  40. package/dist/agent-farm/commands/reset.d.ts.map +1 -0
  41. package/dist/agent-farm/commands/reset.js +309 -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/commands/tower.d.ts.map +1 -1
  57. package/dist/agent-farm/commands/tower.js +13 -0
  58. package/dist/agent-farm/commands/tower.js.map +1 -1
  59. package/dist/agent-farm/db/index.d.ts +1 -1
  60. package/dist/agent-farm/db/index.d.ts.map +1 -1
  61. package/dist/agent-farm/db/index.js +80 -1
  62. package/dist/agent-farm/db/index.js.map +1 -1
  63. package/dist/agent-farm/db/mailbox.d.ts +222 -0
  64. package/dist/agent-farm/db/mailbox.d.ts.map +1 -0
  65. package/dist/agent-farm/db/mailbox.js +304 -0
  66. package/dist/agent-farm/db/mailbox.js.map +1 -0
  67. package/dist/agent-farm/db/schema.d.ts +1 -1
  68. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  69. package/dist/agent-farm/db/schema.js +35 -0
  70. package/dist/agent-farm/db/schema.js.map +1 -1
  71. package/dist/agent-farm/db/types.d.ts +45 -0
  72. package/dist/agent-farm/db/types.d.ts.map +1 -1
  73. package/dist/agent-farm/db/types.js.map +1 -1
  74. package/dist/agent-farm/lib/builder-lookup.d.ts +1 -1
  75. package/dist/agent-farm/lib/builder-lookup.js +2 -2
  76. package/dist/agent-farm/lib/builder-lookup.js.map +1 -1
  77. package/dist/agent-farm/lib/reconnect-backoff.d.ts +64 -0
  78. package/dist/agent-farm/lib/reconnect-backoff.d.ts.map +1 -0
  79. package/dist/agent-farm/lib/reconnect-backoff.js +57 -0
  80. package/dist/agent-farm/lib/reconnect-backoff.js.map +1 -0
  81. package/dist/agent-farm/lib/tower-client.d.ts +16 -6
  82. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  83. package/dist/agent-farm/lib/tower-client.js +37 -6
  84. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  85. package/dist/agent-farm/lib/tunnel-client.d.ts +69 -1
  86. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  87. package/dist/agent-farm/lib/tunnel-client.js +225 -30
  88. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  89. package/dist/agent-farm/servers/canvas-relay.d.ts +60 -0
  90. package/dist/agent-farm/servers/canvas-relay.d.ts.map +1 -0
  91. package/dist/agent-farm/servers/canvas-relay.js +376 -0
  92. package/dist/agent-farm/servers/canvas-relay.js.map +1 -0
  93. package/dist/agent-farm/servers/cron-delivery.d.ts +72 -0
  94. package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -0
  95. package/dist/agent-farm/servers/cron-delivery.js +80 -0
  96. package/dist/agent-farm/servers/cron-delivery.js.map +1 -0
  97. package/dist/agent-farm/servers/delayed-send.d.ts +85 -0
  98. package/dist/agent-farm/servers/delayed-send.d.ts.map +1 -0
  99. package/dist/agent-farm/servers/delayed-send.js +165 -0
  100. package/dist/agent-farm/servers/delayed-send.js.map +1 -0
  101. package/dist/agent-farm/servers/gate-profiles.d.ts +67 -0
  102. package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -0
  103. package/dist/agent-farm/servers/gate-profiles.js +113 -0
  104. package/dist/agent-farm/servers/gate-profiles.js.map +1 -0
  105. package/dist/agent-farm/servers/mailbox-delivery.d.ts +370 -0
  106. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -0
  107. package/dist/agent-farm/servers/mailbox-delivery.js +617 -0
  108. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -0
  109. package/dist/agent-farm/servers/mailbox-wiring.d.ts +125 -0
  110. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -0
  111. package/dist/agent-farm/servers/mailbox-wiring.js +425 -0
  112. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -0
  113. package/dist/agent-farm/servers/message-write.d.ts +53 -1
  114. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  115. package/dist/agent-farm/servers/message-write.js +63 -0
  116. package/dist/agent-farm/servers/message-write.js.map +1 -1
  117. package/dist/agent-farm/servers/overview.d.ts +19 -0
  118. package/dist/agent-farm/servers/overview.d.ts.map +1 -1
  119. package/dist/agent-farm/servers/overview.js +100 -4
  120. package/dist/agent-farm/servers/overview.js.map +1 -1
  121. package/dist/agent-farm/servers/render-gate.d.ts +141 -0
  122. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -0
  123. package/dist/agent-farm/servers/render-gate.js +251 -0
  124. package/dist/agent-farm/servers/render-gate.js.map +1 -0
  125. package/dist/agent-farm/servers/session-submit.d.ts +90 -0
  126. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -0
  127. package/dist/agent-farm/servers/session-submit.js +127 -0
  128. package/dist/agent-farm/servers/session-submit.js.map +1 -0
  129. package/dist/agent-farm/servers/tower-cron.d.ts +10 -7
  130. package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
  131. package/dist/agent-farm/servers/tower-cron.js +58 -53
  132. package/dist/agent-farm/servers/tower-cron.js.map +1 -1
  133. package/dist/agent-farm/servers/tower-instances.d.ts +14 -2
  134. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
  135. package/dist/agent-farm/servers/tower-instances.js +78 -16
  136. package/dist/agent-farm/servers/tower-instances.js.map +1 -1
  137. package/dist/agent-farm/servers/tower-messages.d.ts +38 -1
  138. package/dist/agent-farm/servers/tower-messages.d.ts.map +1 -1
  139. package/dist/agent-farm/servers/tower-messages.js +106 -1
  140. package/dist/agent-farm/servers/tower-messages.js.map +1 -1
  141. package/dist/agent-farm/servers/tower-routes.d.ts +16 -4
  142. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  143. package/dist/agent-farm/servers/tower-routes.js +826 -145
  144. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  145. package/dist/agent-farm/servers/tower-server.js +188 -34
  146. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  147. package/dist/agent-farm/servers/tower-terminals.d.ts +23 -2
  148. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  149. package/dist/agent-farm/servers/tower-terminals.js +134 -127
  150. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  151. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
  152. package/dist/agent-farm/servers/tower-tunnel.js +63 -4
  153. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
  154. package/dist/agent-farm/servers/tower-types.d.ts +2 -1
  155. package/dist/agent-farm/servers/tower-types.d.ts.map +1 -1
  156. package/dist/agent-farm/servers/tower-utils.d.ts +64 -1
  157. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
  158. package/dist/agent-farm/servers/tower-utils.js +168 -1
  159. package/dist/agent-farm/servers/tower-utils.js.map +1 -1
  160. package/dist/agent-farm/servers/tower-websocket.d.ts +6 -1
  161. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  162. package/dist/agent-farm/servers/tower-websocket.js +95 -57
  163. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  164. package/dist/agent-farm/servers/write-queue.d.ts +35 -0
  165. package/dist/agent-farm/servers/write-queue.d.ts.map +1 -0
  166. package/dist/agent-farm/servers/write-queue.js +56 -0
  167. package/dist/agent-farm/servers/write-queue.js.map +1 -0
  168. package/dist/agent-farm/types.d.ts +39 -0
  169. package/dist/agent-farm/types.d.ts.map +1 -1
  170. package/dist/agent-farm/utils/agent-names.d.ts +2 -2
  171. package/dist/agent-farm/utils/agent-names.d.ts.map +1 -1
  172. package/dist/agent-farm/utils/agent-names.js +3 -3
  173. package/dist/agent-farm/utils/agent-names.js.map +1 -1
  174. package/dist/agent-farm/utils/architect-name.d.ts +2 -2
  175. package/dist/agent-farm/utils/architect-name.d.ts.map +1 -1
  176. package/dist/agent-farm/utils/architect-name.js +3 -3
  177. package/dist/agent-farm/utils/architect-name.js.map +1 -1
  178. package/dist/agent-farm/utils/config.d.ts +13 -0
  179. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  180. package/dist/agent-farm/utils/config.js +33 -1
  181. package/dist/agent-farm/utils/config.js.map +1 -1
  182. package/dist/agent-farm/utils/harness.d.ts +103 -7
  183. package/dist/agent-farm/utils/harness.d.ts.map +1 -1
  184. package/dist/agent-farm/utils/harness.js +126 -19
  185. package/dist/agent-farm/utils/harness.js.map +1 -1
  186. package/dist/agent-farm/utils/message-format.d.ts +8 -0
  187. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  188. package/dist/agent-farm/utils/message-format.js +20 -0
  189. package/dist/agent-farm/utils/message-format.js.map +1 -1
  190. package/dist/agent-farm/utils/server-utils.d.ts +80 -4
  191. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  192. package/dist/agent-farm/utils/server-utils.js +286 -5
  193. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  194. package/dist/cli.d.ts.map +1 -1
  195. package/dist/cli.js +6 -32
  196. package/dist/cli.js.map +1 -1
  197. package/dist/commands/consult/cli-options.d.ts +34 -0
  198. package/dist/commands/consult/cli-options.d.ts.map +1 -0
  199. package/dist/commands/consult/cli-options.js +67 -0
  200. package/dist/commands/consult/cli-options.js.map +1 -0
  201. package/dist/commands/consult/index.d.ts +56 -2
  202. package/dist/commands/consult/index.d.ts.map +1 -1
  203. package/dist/commands/consult/index.js +272 -28
  204. package/dist/commands/consult/index.js.map +1 -1
  205. package/dist/commands/consult/metrics.d.ts +42 -0
  206. package/dist/commands/consult/metrics.d.ts.map +1 -1
  207. package/dist/commands/consult/metrics.js +129 -9
  208. package/dist/commands/consult/metrics.js.map +1 -1
  209. package/dist/commands/doctor.d.ts.map +1 -1
  210. package/dist/commands/doctor.js +71 -19
  211. package/dist/commands/doctor.js.map +1 -1
  212. package/dist/commands/porch/checks.d.ts +23 -0
  213. package/dist/commands/porch/checks.d.ts.map +1 -1
  214. package/dist/commands/porch/checks.js +60 -0
  215. package/dist/commands/porch/checks.js.map +1 -1
  216. package/dist/commands/porch/config.d.ts +20 -0
  217. package/dist/commands/porch/config.d.ts.map +1 -1
  218. package/dist/commands/porch/config.js +20 -0
  219. package/dist/commands/porch/config.js.map +1 -1
  220. package/dist/commands/porch/index.js +21 -28
  221. package/dist/commands/porch/index.js.map +1 -1
  222. package/dist/commands/porch/next.d.ts.map +1 -1
  223. package/dist/commands/porch/next.js +2 -41
  224. package/dist/commands/porch/next.js.map +1 -1
  225. package/dist/lib/config.d.ts +69 -3
  226. package/dist/lib/config.d.ts.map +1 -1
  227. package/dist/lib/config.js +67 -3
  228. package/dist/lib/config.js.map +1 -1
  229. package/dist/lib/consult-lanes.d.ts +149 -0
  230. package/dist/lib/consult-lanes.d.ts.map +1 -0
  231. package/dist/lib/consult-lanes.js +360 -0
  232. package/dist/lib/consult-lanes.js.map +1 -0
  233. package/dist/lib/forge-contracts.d.ts +9 -0
  234. package/dist/lib/forge-contracts.d.ts.map +1 -1
  235. package/dist/lib/forge-contracts.js.map +1 -1
  236. package/dist/lib/github.d.ts +15 -1
  237. package/dist/lib/github.d.ts.map +1 -1
  238. package/dist/lib/github.js +18 -1
  239. package/dist/lib/github.js.map +1 -1
  240. package/dist/lib/prompt-behavior-metrics.d.ts +67 -0
  241. package/dist/lib/prompt-behavior-metrics.d.ts.map +1 -0
  242. package/dist/lib/prompt-behavior-metrics.js +290 -0
  243. package/dist/lib/prompt-behavior-metrics.js.map +1 -0
  244. package/dist/lib/skeleton.d.ts +22 -1
  245. package/dist/lib/skeleton.d.ts.map +1 -1
  246. package/dist/lib/skeleton.js +120 -0
  247. package/dist/lib/skeleton.js.map +1 -1
  248. package/dist/lib/test-env.d.ts +74 -0
  249. package/dist/lib/test-env.d.ts.map +1 -0
  250. package/dist/lib/test-env.js +92 -0
  251. package/dist/lib/test-env.js.map +1 -0
  252. package/dist/terminal/attach-replay.d.ts +39 -0
  253. package/dist/terminal/attach-replay.d.ts.map +1 -0
  254. package/dist/terminal/attach-replay.js +76 -0
  255. package/dist/terminal/attach-replay.js.map +1 -0
  256. package/dist/terminal/pty-manager.d.ts +2 -0
  257. package/dist/terminal/pty-manager.d.ts.map +1 -1
  258. package/dist/terminal/pty-manager.js +51 -23
  259. package/dist/terminal/pty-manager.js.map +1 -1
  260. package/dist/terminal/pty-session.d.ts +201 -1
  261. package/dist/terminal/pty-session.d.ts.map +1 -1
  262. package/dist/terminal/pty-session.js +327 -46
  263. package/dist/terminal/pty-session.js.map +1 -1
  264. package/dist/terminal/ring-buffer.d.ts +39 -6
  265. package/dist/terminal/ring-buffer.d.ts.map +1 -1
  266. package/dist/terminal/ring-buffer.js +74 -6
  267. package/dist/terminal/ring-buffer.js.map +1 -1
  268. package/dist/terminal/session-manager.d.ts +53 -0
  269. package/dist/terminal/session-manager.d.ts.map +1 -1
  270. package/dist/terminal/session-manager.js +94 -9
  271. package/dist/terminal/session-manager.js.map +1 -1
  272. package/dist/terminal/session-screen.d.ts +116 -0
  273. package/dist/terminal/session-screen.d.ts.map +1 -0
  274. package/dist/terminal/session-screen.js +180 -0
  275. package/dist/terminal/session-screen.js.map +1 -0
  276. package/dist/terminal/shellper-main.js +2 -1
  277. package/dist/terminal/shellper-main.js.map +1 -1
  278. package/dist/terminal/shellper-process.d.ts +1 -1
  279. package/dist/terminal/shellper-process.d.ts.map +1 -1
  280. package/dist/terminal/shellper-process.js +17 -8
  281. package/dist/terminal/shellper-process.js.map +1 -1
  282. package/dist/terminal/shellper-replay-buffer.d.ts +69 -7
  283. package/dist/terminal/shellper-replay-buffer.d.ts.map +1 -1
  284. package/dist/terminal/shellper-replay-buffer.js +167 -31
  285. package/dist/terminal/shellper-replay-buffer.js.map +1 -1
  286. package/package.json +13 -8
  287. package/scripts/forge/gitea/pr-view.sh +4 -1
  288. package/scripts/forge/github/pr-view.sh +1 -1
  289. package/scripts/forge/gitlab/pr-view.sh +4 -1
  290. package/skeleton/.claude/skills/afx/SKILL.md +68 -1
  291. package/skeleton/.claude/skills/arch-init/SKILL.md +22 -9
  292. package/skeleton/.claude/skills/arch-save/SKILL.md +183 -0
  293. package/skeleton/.claude/skills/codev/SKILL.md +31 -0
  294. package/skeleton/.claude/skills/runnable-worktrees/SKILL.md +119 -0
  295. package/skeleton/.codex/skills/afx/SKILL.md +68 -1
  296. package/skeleton/.codex/skills/arch-init/SKILL.md +22 -9
  297. package/skeleton/.codex/skills/arch-save/SKILL.md +183 -0
  298. package/skeleton/.codex/skills/codev/SKILL.md +31 -0
  299. package/skeleton/.codex/skills/runnable-worktrees/SKILL.md +119 -0
  300. package/skeleton/protocols/air/builder-prompt.md +28 -47
  301. package/skeleton/protocols/air/consult-types/impl-review.md +17 -34
  302. package/skeleton/protocols/air/consult-types/pr-review.md +14 -37
  303. package/skeleton/protocols/air/prompts/implement.md +20 -73
  304. package/skeleton/protocols/air/prompts/pr.md +22 -54
  305. package/skeleton/protocols/air/protocol.md +40 -71
  306. package/skeleton/protocols/aspir/builder-prompt.md +29 -66
  307. package/skeleton/protocols/aspir/consult-types/impl-review.md +17 -44
  308. package/skeleton/protocols/aspir/consult-types/phase-review.md +17 -44
  309. package/skeleton/protocols/aspir/consult-types/plan-review.md +14 -38
  310. package/skeleton/protocols/aspir/consult-types/pr-review.md +13 -39
  311. package/skeleton/protocols/aspir/consult-types/spec-review.md +13 -33
  312. package/skeleton/protocols/aspir/prompts/implement.md +27 -196
  313. package/skeleton/protocols/aspir/prompts/plan.md +21 -85
  314. package/skeleton/protocols/aspir/prompts/review.md +29 -228
  315. package/skeleton/protocols/aspir/prompts/specify.md +25 -111
  316. package/skeleton/protocols/aspir/protocol.json +10 -0
  317. package/skeleton/protocols/aspir/protocol.md +36 -82
  318. package/skeleton/protocols/bugfix/builder-prompt.md +23 -45
  319. package/skeleton/protocols/bugfix/consult-types/impl-review.md +21 -45
  320. package/skeleton/protocols/bugfix/consult-types/pr-review.md +18 -52
  321. package/skeleton/protocols/bugfix/prompts/fix.md +16 -61
  322. package/skeleton/protocols/bugfix/prompts/investigate.md +18 -62
  323. package/skeleton/protocols/bugfix/prompts/pr.md +22 -57
  324. package/skeleton/protocols/bugfix/protocol.md +44 -50
  325. package/skeleton/protocols/experiment/builder-prompt.md +25 -58
  326. package/skeleton/protocols/experiment/protocol.md +22 -188
  327. package/skeleton/protocols/experiment/templates/notes.md +11 -71
  328. package/skeleton/protocols/maintain/builder-prompt.md +24 -45
  329. package/skeleton/protocols/maintain/consult-types/impl-review.md +17 -44
  330. package/skeleton/protocols/maintain/consult-types/pr-review.md +13 -39
  331. package/skeleton/protocols/maintain/prompts/maintain.md +1 -1
  332. package/skeleton/protocols/maintain/protocol.md +32 -266
  333. package/skeleton/protocols/maintain/templates/maintenance-run.md +11 -0
  334. package/skeleton/protocols/pir/builder-prompt.md +31 -64
  335. package/skeleton/protocols/pir/consult-types/impl-review.md +17 -50
  336. package/skeleton/protocols/pir/consult-types/pr-review.md +11 -40
  337. package/skeleton/protocols/pir/prompts/implement.md +2 -3
  338. package/skeleton/protocols/pir/prompts/plan.md +1 -4
  339. package/skeleton/protocols/pir/prompts/review.md +2 -2
  340. package/skeleton/protocols/pir/protocol.md +54 -177
  341. package/skeleton/protocols/research/builder-prompt.md +27 -58
  342. package/skeleton/protocols/research/protocol.md +28 -153
  343. package/skeleton/protocols/spike/builder-prompt.md +30 -48
  344. package/skeleton/protocols/spike/protocol.md +24 -107
  345. package/skeleton/protocols/spike/templates/findings.md +9 -39
  346. package/skeleton/protocols/spir/builder-prompt.md +33 -62
  347. package/skeleton/protocols/spir/consult-types/impl-review.md +17 -44
  348. package/skeleton/protocols/spir/consult-types/phase-review.md +17 -44
  349. package/skeleton/protocols/spir/consult-types/plan-review.md +14 -38
  350. package/skeleton/protocols/spir/consult-types/pr-review.md +13 -39
  351. package/skeleton/protocols/spir/consult-types/spec-review.md +13 -33
  352. package/skeleton/protocols/spir/prompts/implement.md +26 -195
  353. package/skeleton/protocols/spir/prompts/plan.md +20 -84
  354. package/skeleton/protocols/spir/prompts/review.md +28 -227
  355. package/skeleton/protocols/spir/prompts/specify.md +24 -110
  356. package/skeleton/protocols/spir/protocol.json +10 -0
  357. package/skeleton/protocols/spir/protocol.md +84 -633
  358. package/skeleton/protocols/spir/templates/plan.md +28 -147
  359. package/skeleton/protocols/spir/templates/review.md +21 -80
  360. package/skeleton/protocols/spir/templates/spec.md +24 -118
  361. package/skeleton/resources/commands/agent-farm.md +262 -0
  362. package/skeleton/resources/commands/consult.md +236 -8
  363. package/skeleton/resources/commands/overview.md +1 -0
  364. package/skeleton/roles/architect.md +67 -317
  365. package/skeleton/roles/builder.md +76 -191
  366. package/skeleton/templates/AGENTS.md +2 -0
  367. package/skeleton/templates/CLAUDE.md +2 -0
  368. package/templates/3d-viewer.html +32 -4
  369. package/templates/open.html +58 -12
  370. package/templates/tower.html +65 -9
  371. package/templates/vendor/three-3MFLoader.js +1478 -0
  372. package/templates/vendor/three-STLLoader.js +410 -0
  373. package/templates/vendor/three-TrackballControls.js +828 -0
  374. package/templates/vendor/three-fflate.module.js +2474 -0
  375. package/templates/vendor/three.module.js +53044 -0
  376. package/dashboard-dist/assets/index-BZ1x7x1X.js.map +0 -1
  377. package/dashboard-dist/assets/index-DB1KBSNE.css +0 -32
  378. package/dist/agent-farm/servers/send-buffer.d.ts +0 -59
  379. package/dist/agent-farm/servers/send-buffer.d.ts.map +0 -1
  380. package/dist/agent-farm/servers/send-buffer.js +0 -116
  381. package/dist/agent-farm/servers/send-buffer.js.map +0 -1
  382. package/skeleton/porch/prompts/defend.md +0 -108
  383. package/skeleton/porch/prompts/diagnose.md +0 -70
  384. package/skeleton/porch/prompts/evaluate.md +0 -132
  385. package/skeleton/porch/prompts/fix.md +0 -59
  386. package/skeleton/porch/prompts/implement.md +0 -88
  387. package/skeleton/porch/prompts/plan.md +0 -74
  388. package/skeleton/porch/prompts/pr.md +0 -84
  389. package/skeleton/porch/prompts/review.md +0 -249
  390. package/skeleton/porch/prompts/specify.md +0 -53
  391. package/skeleton/porch/prompts/test.md +0 -63
  392. package/skeleton/protocols/aspir/templates/plan.md +0 -184
  393. package/skeleton/protocols/aspir/templates/review.md +0 -120
  394. package/skeleton/protocols/aspir/templates/spec.md +0 -154
@@ -24,21 +24,36 @@ import { version } from '../../version.js';
24
24
  const execAsync = promisify(exec);
25
25
  import { getBuilders, setArchitectByName } from '../state.js';
26
26
  import { DEFAULT_COLS, defaultSessionOptions } from '../../terminal/index.js';
27
- import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
27
+ import { parseJsonBody, isRequestAllowed, isAllowedOrigin, isAllowedHost, getExpectedKey, escapeHtml } from '../utils/server-utils.js';
28
+ import { TOWER_KEY_HEADER, LEGACY_WEB_KEY_HEADER, VSCODE_USER_SENDER } from '@cluesmith/codev-types';
28
29
  import { isRateLimited, normalizeWorkspacePath, getLanguageForExt, getMimeTypeForFile, serveStaticFile, } from './tower-utils.js';
29
30
  import { handleTunnelEndpoint } from './tower-tunnel.js';
30
31
  import { getWorktreeConfig, getActivityHooks } from '../utils/config.js';
31
32
  import { ensureCodevConfigWatcher } from './codev-config-watcher.js';
32
33
  import { hasTeam, loadTeamMembers, loadMessages } from '../../lib/team.js';
33
34
  import { fetchTeamGitHubData } from '../../lib/team-github.js';
34
- import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js';
35
+ import { resolveTarget, resolveAgentInRegistry, broadcastMessage, isResolveError } from './tower-messages.js';
35
36
  import { handleCommandRoute, COMMAND_ROUTE } from './command-relay.js';
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 { handleCanvasRoute, CANVAS_ROUTE_PREFIX } from './canvas-relay.js';
38
+ import { formatArchitectMessage, formatBuilderMessage, formatUserViaVsCodeMessage } from '../utils/message-format.js';
39
+ import { writeMessageToSession, writeEscapeToSession } from './message-write.js';
40
+ import { makeDeliveryPorts, getMailboxDrainer } from './mailbox-wiring.js';
41
+ import { deliverAgentMailSerialized } from './mailbox-delivery.js';
42
+ import { deliverCronMail, CRON_SENDER } from './cron-delivery.js';
43
+ import { enqueue as enqueueMailbox, getById as getMailboxById, markDelivered as markMailboxDelivered, listHeld as listHeldMailbox, dismiss as dismissMailbox, } from '../db/mailbox.js';
44
+ // Spec 1273 per-terminal submission lock — preserved across the Spec 1313 merge for
45
+ // the two explicit human-bypass paths (escape + interrupt), which do NOT route
46
+ // through the mailbox's per-agent serializer and so need their own anti-fusion lock.
47
+ import { submitToSession } from './session-submit.js';
48
+ // Spec 1307 `--delay` — Tower-side deferred delivery, re-homed onto the Spec 1313
49
+ // mailbox (the merge that carried this feature was flattened by a later rebase, so it
50
+ // is grafted here explicitly): the due-time callback enqueues to the mailbox and
51
+ // triggers a gated drain (see handleSend), so a delayed message delivers onto a
52
+ // render-verified empty prompt like any normal send — never force-injected.
53
+ import { scheduleDelayedSend, validateDelaySeconds } from './delayed-send.js';
54
+ import { getKnownWorkspacePaths, getInstances, getDirectorySuggestions, launchInstance, killTerminalWithShellper, instancesReady, stopInstance, addArchitect, removeArchitect, } from './tower-instances.js';
40
55
  import { OverviewCache } from './overview.js';
41
- import { fetchIssue, searchIssues, fetchPRList, fetchCurrentUser, parseLinkedIssue, parseArea, } from '../../lib/github.js';
56
+ import { fetchIssue, fetchPR, searchIssues, fetchPRList, fetchCurrentUser, parseLinkedIssue, parseArea, } from '../../lib/github.js';
42
57
  import { computeAnalytics } from './analytics.js';
43
58
  import { getAllTasks, executeTask, getTaskId } from './tower-cron.js';
44
59
  import { getGlobalDb } from '../db/index.js';
@@ -50,23 +65,6 @@ const __filename = fileURLToPath(import.meta.url);
50
65
  const __dirname = path.dirname(__filename);
51
66
  // Singleton cache for overview endpoint (Spec 0126 Phase 4)
52
67
  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
68
  const ROUTES = {
71
69
  'GET /health': (_req, res, _url, ctx) => handleHealthCheck(res, ctx),
72
70
  'GET /api/shellpers/husks': (_req, res, _url, ctx) => handleHuskPreview(res, ctx),
@@ -78,6 +76,7 @@ const ROUTES = {
78
76
  'GET /api/version': (_req, res, _url, ctx) => handleVersion(res, ctx),
79
77
  'GET /api/overview': (_req, res, url, ctx) => handleOverview(res, url, undefined, ctx),
80
78
  'GET /api/issue': (_req, res, url) => handleIssueView(res, url),
79
+ 'GET /api/pr': (_req, res, url) => handlePRView(res, url),
81
80
  'GET /api/issue-search': (_req, res, url) => handleIssueSearch(res, url),
82
81
  'GET /api/worktree-config': (_req, res, url) => handleWorktreeConfigView(res, url),
83
82
  'GET /api/activity-hooks': (_req, res, url) => handleActivityHooksView(res, url),
@@ -90,35 +89,65 @@ const ROUTES = {
90
89
  'POST /api/launch': (req, res) => handleLaunchInstance(req, res),
91
90
  'POST /api/stop': (req, res) => handleStopInstance(req, res),
92
91
  'POST /api/send': (req, res, _url, ctx) => handleSend(req, res, ctx),
92
+ 'GET /api/inbox': (_req, res, url) => handleInboxList(res, url),
93
93
  'GET /api/cron/tasks': (_req, res, url) => handleCronList(res, url),
94
94
  'GET /': (_req, res, _url, ctx) => handleDashboard(res, ctx),
95
95
  'GET /index.html': (_req, res, _url, ctx) => handleDashboard(res, ctx),
96
96
  };
97
+ /**
98
+ * Issue #1261: tell "Tower isn't wired up yet" apart from "no such thing".
99
+ * A route that needs the instances module must not report a missing terminal
100
+ * (404) or a successful kill (204) when the truth is that it could not act at
101
+ * all. 503 + Retry-After says so, and matches what `stopInstance()` already
102
+ * returns in the same situation.
103
+ *
104
+ * Tower's readiness gate holds requests until boot completes, so these guards
105
+ * should be unreachable in practice — they exist so the failure mode, if the
106
+ * gate is ever bypassed, is honest rather than misleading.
107
+ */
108
+ function respondStartingUp(res) {
109
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '1' });
110
+ res.end(JSON.stringify({
111
+ error: 'STARTING_UP',
112
+ message: 'Tower is still starting up. Try again shortly.',
113
+ }));
114
+ }
97
115
  // ============================================================================
98
116
  // Main request handler
99
117
  // ============================================================================
100
118
  export async function handleRequest(req, res, ctx) {
101
- // Security: Validate Host and Origin headers
102
- if (!isRequestAllowed(req)) {
103
- res.writeHead(403, { 'Content-Type': 'text/plain' });
104
- res.end('Forbidden');
105
- return;
106
- }
107
- // CORS headers — allow localhost and tunnel proxy origins
119
+ // CORS headers — reflect only allowlisted origins (advisory Layer 3).
108
120
  const origin = req.headers.origin;
109
- if (origin && (origin.startsWith('http://localhost:') ||
110
- origin.startsWith('http://127.0.0.1:') ||
111
- origin.startsWith('https://'))) {
121
+ if (origin && isAllowedOrigin(origin)) {
112
122
  res.setHeader('Access-Control-Allow-Origin', origin);
123
+ res.setHeader('Vary', 'Origin');
113
124
  }
114
125
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
115
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
126
+ res.setHeader('Access-Control-Allow-Headers', `Content-Type, ${TOWER_KEY_HEADER}, ${LEGACY_WEB_KEY_HEADER}`);
116
127
  res.setHeader('Cache-Control', 'no-store');
128
+ // A CORS preflight carries no credentials and performs no action, so it is
129
+ // answered before the key check — otherwise browser clients could never
130
+ // complete the preflight that precedes an authenticated request.
117
131
  if (req.method === 'OPTIONS') {
118
132
  res.writeHead(200);
119
133
  res.end();
120
134
  return;
121
135
  }
136
+ // Request authentication (advisory GHSA-xvjp-7748-v88v): every route outside
137
+ // the narrow public-route allowlist must present the shared local key. This
138
+ // is the single HTTP choke point every route passes through. CORS above is
139
+ // defense-in-depth only — a no-preflight "simple" request still lands here.
140
+ if (!isRequestAllowed(req)) {
141
+ // Log the reason so a broken legitimate client (disallowed Host vs missing
142
+ // key) is diagnosable — a bare 401 is indistinguishable from either side.
143
+ const reason = isAllowedHost(req.headers.host)
144
+ ? 'missing or invalid key'
145
+ : `disallowed Host "${req.headers.host ?? ''}"`;
146
+ ctx.log('WARN', `401 ${req.method ?? 'GET'} ${req.url ?? '/'} — ${reason}`);
147
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
148
+ res.end('Unauthorized');
149
+ return;
150
+ }
122
151
  const url = new URL(req.url || '/', `http://localhost:${ctx.port}`);
123
152
  try {
124
153
  // Exact-match route dispatch (O(1) lookup)
@@ -140,6 +169,12 @@ export async function handleRequest(req, res, ctx) {
140
169
  if (url.pathname === COMMAND_ROUTE) {
141
170
  return await handleCommandRoute(req, res, url, ctx);
142
171
  }
172
+ // Canvas command channel: /api/canvas/* — same self-routing shape as the command relay, but
173
+ // targeted rather than broadcast: it keeps a registry of live canvas views so it can resolve
174
+ // exactly one and answer when none is open (spec 1401).
175
+ if (url.pathname.startsWith(CANVAS_ROUTE_PREFIX)) {
176
+ return await handleCanvasRoute(req, res, url, ctx);
177
+ }
143
178
  // Workspace API: /api/workspaces/:encodedPath/activate|deactivate|status (Spec 0090 Phase 1)
144
179
  const workspaceApiMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/(activate|deactivate|status)$/);
145
180
  if (workspaceApiMatch) {
@@ -165,6 +200,18 @@ export async function handleRequest(req, res, ctx) {
165
200
  if (cronTaskMatch) {
166
201
  return await handleCronTaskAction(req, res, url, cronTaskMatch);
167
202
  }
203
+ // Inbox dismiss: POST /api/inbox/:id/dismiss (Spec 1313, Phase 7)
204
+ const inboxDismissMatch = url.pathname.match(/^\/api\/inbox\/([^/]+)\/dismiss$/);
205
+ if (inboxDismissMatch) {
206
+ return handleInboxDismiss(req, res, ctx, inboxDismissMatch);
207
+ }
208
+ // Inbox show: GET /api/inbox/:id — a single row INCLUDING its body (Spec 1313 §178).
209
+ // Checked AFTER the dismiss match, so /:id/dismiss never falls through here (its
210
+ // trailing segment can't match this single-segment pattern anyway).
211
+ const inboxShowMatch = url.pathname.match(/^\/api\/inbox\/([^/]+)$/);
212
+ if (inboxShowMatch) {
213
+ return handleInboxShow(req, res, inboxShowMatch);
214
+ }
168
215
  // Workspace routes: /workspace/:base64urlPath/* (Spec 0090 Phase 4)
169
216
  if (url.pathname.startsWith('/workspace/')) {
170
217
  return await handleWorkspaceRoutes(req, res, ctx, url);
@@ -581,6 +628,11 @@ async function handleTerminalCreate(req, res, ctx) {
581
628
  const session = manager.createSessionRaw({
582
629
  label: label || `terminal-${sessionId.slice(0, 8)}`,
583
630
  cwd,
631
+ // Spec 1313: thread the launch command so the render-gate can resolve
632
+ // this session's profile (builders keep the `.builder-start.sh` backstop
633
+ // too; this makes identity direct and restart-safe via the persisted row).
634
+ command,
635
+ args,
584
636
  });
585
637
  const ptySession = manager.getSession(session.id);
586
638
  if (ptySession) {
@@ -597,7 +649,7 @@ async function handleTerminalCreate(req, res, ctx) {
597
649
  else {
598
650
  entry.shells.set(roleId, session.id);
599
651
  }
600
- saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null);
652
+ saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null, command ?? null);
601
653
  ctx.log('INFO', `Registered shellper terminal ${session.id} as ${termType} "${roleId}" for workspace ${workspacePath}`);
602
654
  }
603
655
  }
@@ -619,7 +671,7 @@ async function handleTerminalCreate(req, res, ctx) {
619
671
  else {
620
672
  entry.shells.set(roleId, info.id);
621
673
  }
622
- saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid, null, null, null, null, cwd ?? null);
674
+ saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid, null, null, null, null, cwd ?? null, command ?? null);
623
675
  ctx.log('WARN', `Terminal ${info.id} for ${workspacePath} is non-persistent (shellper unavailable)`);
624
676
  }
625
677
  }
@@ -656,6 +708,12 @@ async function handleTerminalRoutes(req, res, url, match) {
656
708
  }
657
709
  // DELETE /api/terminals/:id - Kill terminal (disable shellper auto-restart if applicable)
658
710
  if (req.method === 'DELETE' && (!subpath || subpath === '')) {
711
+ // Issue #1261: without this, a not-yet-wired Tower answers 404 for a
712
+ // terminal that exists — the exact symptom the issue reports.
713
+ if (!instancesReady()) {
714
+ respondStartingUp(res);
715
+ return;
716
+ }
659
717
  if (!(await killTerminalWithShellper(manager, terminalId))) {
660
718
  res.writeHead(404, { 'Content-Type': 'application/json' });
661
719
  res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
@@ -852,7 +910,7 @@ async function handleOverview(res, url, workspaceOverride, ctx) {
852
910
  // every collection field is required ('never undefined' for `architects`,
853
911
  // Issue 1104), so emit them all empty rather than a partial payload.
854
912
  res.writeHead(200, { 'Content-Type': 'application/json' });
855
- res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [] }));
913
+ res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [], heldCount: 0, mailboxEscalated: false, queuedFeedback: {}, feedbackMode: 'forward' }));
856
914
  return;
857
915
  }
858
916
  // Build set of active builder role_ids (lowercased) from live terminal sessions.
@@ -921,6 +979,30 @@ async function handleIssueView(res, url) {
921
979
  res.writeHead(200, { 'Content-Type': 'application/json' });
922
980
  res.end(JSON.stringify(issue));
923
981
  }
982
+ async function handlePRView(res, url) {
983
+ // Mirror of handleIssueView for PRs: same workspace resolution, same
984
+ // ?number= param, routed through the `pr-view` forge concept. Powers the
985
+ // VSCode `codev.openPRById` / QuickPick "View PR #N" browser-open flow.
986
+ let workspaceRoot = url.searchParams.get('workspace');
987
+ if (!workspaceRoot) {
988
+ const knownPaths = getKnownWorkspacePaths();
989
+ workspaceRoot = knownPaths.find(p => !p.includes('/.builders/')) || null;
990
+ }
991
+ const number = url.searchParams.get('number');
992
+ if (!workspaceRoot || !number) {
993
+ res.writeHead(400, { 'Content-Type': 'application/json' });
994
+ res.end(JSON.stringify({ error: 'Missing workspace or number' }));
995
+ return;
996
+ }
997
+ const pr = await fetchPR(number, { cwd: workspaceRoot });
998
+ if (!pr) {
999
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1000
+ res.end(JSON.stringify({ error: `PR #${number} not found or forge unavailable` }));
1001
+ return;
1002
+ }
1003
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1004
+ res.end(JSON.stringify(pr));
1005
+ }
924
1006
  /**
925
1007
  * GET /api/issue-search — the data source for the VSCode "Search Backlog"
926
1008
  * editor-tab webview (#920). Returns the issue set for the requested
@@ -1161,6 +1243,253 @@ async function handleNotify(req, res, ctx) {
1161
1243
  // ============================================================================
1162
1244
  // POST /api/send — send a message to a resolved agent terminal
1163
1245
  // ============================================================================
1246
+ /** Minimal JSON responder for the send route. */
1247
+ function sendJson(res, status, payload) {
1248
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1249
+ res.end(JSON.stringify(payload));
1250
+ }
1251
+ /**
1252
+ * The specific architect NAME whose live terminal is `terminalId`, or null when
1253
+ * `terminalId` is a builder/shell terminal. Reverse-maps via the routing registry
1254
+ * (Spec 1313): storing the canonical architect name on a mailbox row is what lets
1255
+ * held mail redeliver to the right terminal after a respawn, and it also tells an
1256
+ * architect target from a builder target for message formatting.
1257
+ */
1258
+ function architectNameForTerminal(workspacePath, terminalId) {
1259
+ const entry = getWorkspaceTerminals().get(workspacePath);
1260
+ if (!entry)
1261
+ return null;
1262
+ for (const [name, tid] of entry.architects) {
1263
+ if (tid === terminalId)
1264
+ return name;
1265
+ }
1266
+ return null;
1267
+ }
1268
+ /**
1269
+ * The canonical mailbox identity of a live-resolved target: architect targets are
1270
+ * stored under their SPECIFIC architect name (reverse-mapped from the terminal),
1271
+ * everything else under its resolved agent id.
1272
+ */
1273
+ function liveTargetIdentity(result) {
1274
+ const archName = architectNameForTerminal(result.workspacePath, result.terminalId);
1275
+ return { toAgent: archName ?? result.agent, isArchitectTarget: archName !== null };
1276
+ }
1277
+ /** Format a message per sender/target — preserves the pre-1313 formatting rules. */
1278
+ function formatMessageForTarget(isArchitectTarget, from, message, raw) {
1279
+ // #1494: a human's approval relayed by the VS Code extension gets its own
1280
+ // header, so the architect can tell it from a peer-architect instruction.
1281
+ if (isArchitectTarget && from === VSCODE_USER_SENDER)
1282
+ return formatUserViaVsCodeMessage(message, undefined, raw);
1283
+ if (isArchitectTarget && from)
1284
+ return formatBuilderMessage(from, message, undefined, raw); // builder → architect
1285
+ if (!isArchitectTarget)
1286
+ return formatArchitectMessage(message, undefined, raw); // any → builder
1287
+ return raw ? message : formatArchitectMessage(message, undefined, false); // unknown → architect
1288
+ }
1289
+ /**
1290
+ * Route a cron notification through the Spec 1313 mailbox + gate — the Tower-wired
1291
+ * front half of {@link deliverCronMail} (Phase 6). Resolves the task's target to a
1292
+ * canonical recipient agent: a live terminal via {@link resolveTarget} plus the
1293
+ * architect reverse-map ({@link liveTargetIdentity}, because a bare `architect`
1294
+ * target resolves to the generic id, which the mailbox can't address), or — when the
1295
+ * agent is known but has no live PTY — via {@link resolveAgentInRegistry}, so the
1296
+ * message HOLDS as `no-live-pty` instead of vanishing (spec decision 9). Then it
1297
+ * hands off to the registry-free core. Cron is a non-builder sender, so no
1298
+ * sender-affinity or spoofing check applies. Wired into the cron scheduler as its
1299
+ * `deliver` port (see `initCron`), keeping the scheduler ignorant of mailbox
1300
+ * internals and giving cron exactly one gated path shared with `handleSend`.
1301
+ */
1302
+ export async function deliverCronMessage(task, message, log) {
1303
+ const db = getGlobalDb();
1304
+ const ports = makeDeliveryPorts(log);
1305
+ // Preserve the pre-1313 cron framing: a message FROM the `af-cron` pseudo-builder,
1306
+ // regardless of whether the target is an architect or a builder.
1307
+ const base = {
1308
+ body: message,
1309
+ formattedMessage: formatBuilderMessage(CRON_SENDER, message),
1310
+ supersedeKey: task.name,
1311
+ };
1312
+ const live = resolveTarget(task.target, task.workspacePath);
1313
+ if (!isResolveError(live)) {
1314
+ const { toAgent } = liveTargetIdentity(live);
1315
+ return deliverCronMail(ports, db, {
1316
+ ...base,
1317
+ workspacePath: live.workspacePath,
1318
+ toAgent,
1319
+ terminalId: live.terminalId,
1320
+ });
1321
+ }
1322
+ // Live resolution failed. A NOT_FOUND target may still be a known agent with no
1323
+ // live PTY (Tower restarting, builder between respawns) — hold its mail so a
1324
+ // respawn drains it, instead of the old blind drop-with-WARN (decision 9).
1325
+ if (live.code === 'NOT_FOUND') {
1326
+ const reg = resolveAgentInRegistry(task.target, task.workspacePath);
1327
+ if (!isResolveError(reg)) {
1328
+ return deliverCronMail(ports, db, {
1329
+ ...base,
1330
+ workspacePath: reg.workspacePath,
1331
+ toAgent: reg.agent,
1332
+ terminalId: null,
1333
+ });
1334
+ }
1335
+ }
1336
+ log('WARN', `Cron '${task.name}': target '${task.target}' not found — message not delivered`);
1337
+ return { outcome: 'unresolved', reason: null, mailboxId: null };
1338
+ }
1339
+ /**
1340
+ * Persist a `held` mailbox row and write the Spec 1313 `held` send response. Used
1341
+ * for both dead-session cases (no live PTY, held `no-live-pty`). The row exists
1342
+ * before the response returns, so the backstop drainer will redeliver it once the
1343
+ * agent has a clean prompt — nothing is dropped.
1344
+ */
1345
+ function holdAndRespond(res, ctx, input, reason) {
1346
+ const row = enqueueMailbox(getGlobalDb(), { ...input, reason });
1347
+ ctx.log('INFO', `Message held (${reason}) → ${input.toAgent} @ ${path.basename(input.workspacePath)} (mailbox ${row.id.slice(0, 8)}...)`);
1348
+ // A new held row appeared → refresh the held-count indicator (Spec 1313, Phase 7).
1349
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: `held ${reason}` });
1350
+ sendJson(res, 200, {
1351
+ ok: true,
1352
+ terminalId: input.terminalId ?? null,
1353
+ resolvedTo: input.toAgent,
1354
+ deferred: true, // back-compat: a held message is "deferred" to old binaries
1355
+ delivered: false,
1356
+ held: true,
1357
+ reason,
1358
+ mailboxId: row.id,
1359
+ });
1360
+ }
1361
+ /**
1362
+ * Handle a delayed (`--delay`) send (Spec 1307, re-homed onto the Spec 1313 mailbox; round 3
1363
+ * durable rework). The row is RESOLVED, authorized (the builder-spoofing check inside
1364
+ * `resolveTarget`), formatted, and PERSISTED here at REQUEST time with `not_before = now +
1365
+ * delay*1000` — the security property the immediate path documents (a delayed send must not
1366
+ * defer an authorization check past the conditions that would fail it) is preserved, and only
1367
+ * DELIVERY is deferred. Persisting at request time is what makes `--delay` DURABLE across a
1368
+ * Tower restart (the conscious, architect+maintainer-approved reversal of Spec 1307's
1369
+ * drop-on-restart semantics): the render gate still guarantees a post-restart delivery only
1370
+ * ever lands on a verified-empty prompt, and a pre-due row is visible/cancellable in
1371
+ * `afx inbox`.
1372
+ *
1373
+ * Resolution is live-first then registry (a known agent with no live PTY) so a
1374
+ * dead/unwritable/registry-only target schedules UNIFORMLY — a delayed send does not require a
1375
+ * live session now. reason is left null: the row is SCHEDULED, not held-for-a-reason; the
1376
+ * drainer re-evaluates liveness at due time.
1377
+ *
1378
+ * The body is NEVER written from here. A normal delayed send is delivered by the gated
1379
+ * backstop drainer once `not_before` passes (≤ one 1.5 s tick after due — the delay is a lower
1380
+ * bound). A delayed `--interrupt` (change 2 reshape) additionally keeps a small in-memory timer
1381
+ * that fires ONLY the Ctrl+C at due time (guarded by `isStillLive` + a re-fetched writable
1382
+ * session, inside the submission lock); the ^C ends the turn and the body then delivers through
1383
+ * the SAME gate every send uses — nothing is marked delivered here, so a #1198 dropped write or
1384
+ * a shutdown during the wait can never falsely report delivery or double-deliver. A restart
1385
+ * during the wait loses only the ^C nudge, never the message.
1386
+ */
1387
+ function handleDelayedSend(res, ctx, db, params) {
1388
+ const { to, workspace, from, message, raw, noEnter, interrupt, deliverAfter, senderWorkspace } = params;
1389
+ const now = Date.now();
1390
+ const notBefore = now + deliverAfter * 1000;
1391
+ // Resolve to a canonical AGENT — live first, then registry (known agent, no live PTY yet).
1392
+ let workspacePath;
1393
+ let toAgent;
1394
+ let isArchitectTarget;
1395
+ let terminalId;
1396
+ const live = resolveTarget(to, workspace, from);
1397
+ if (!isResolveError(live)) {
1398
+ const identity = liveTargetIdentity(live);
1399
+ workspacePath = live.workspacePath;
1400
+ toAgent = identity.toAgent;
1401
+ isArchitectTarget = identity.isArchitectTarget;
1402
+ terminalId = live.terminalId;
1403
+ }
1404
+ else if (live.code === 'NOT_FOUND') {
1405
+ const reg = resolveAgentInRegistry(to, workspace, from);
1406
+ if (isResolveError(reg)) {
1407
+ const statusCode = reg.code === 'AMBIGUOUS' ? 409 : reg.code === 'NO_CONTEXT' ? 400 : 404;
1408
+ const errorCode = reg.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : reg.code;
1409
+ sendJson(res, statusCode, { error: errorCode, message: reg.message });
1410
+ return;
1411
+ }
1412
+ workspacePath = reg.workspacePath;
1413
+ toAgent = reg.agent;
1414
+ isArchitectTarget = reg.kind === 'architect';
1415
+ terminalId = null;
1416
+ }
1417
+ else {
1418
+ const statusCode = live.code === 'AMBIGUOUS' ? 409 : live.code === 'NO_CONTEXT' ? 400 : 404;
1419
+ const errorCode = live.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : live.code;
1420
+ sendJson(res, statusCode, { error: errorCode, message: live.message });
1421
+ return;
1422
+ }
1423
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1424
+ // Persist NOW, at request time, with the due time. reason=null → SCHEDULED, not stuck.
1425
+ const row = enqueueMailbox(db, {
1426
+ workspacePath,
1427
+ toAgent,
1428
+ body: message,
1429
+ formattedMessage,
1430
+ fromAgent: from ?? null,
1431
+ fromWorkspace: senderWorkspace,
1432
+ noEnter,
1433
+ terminalId,
1434
+ notBefore,
1435
+ });
1436
+ // A new held (scheduled) row appeared → refresh the held-count indicator (Phase 7).
1437
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: 'scheduled' });
1438
+ if (interrupt) {
1439
+ // Change 2 reshape: an in-memory timer that fires ONLY the Ctrl+C at due time. It writes
1440
+ // no message body and marks nothing delivered — the body delivers through the gated
1441
+ // drainer after the ^C ends the turn. Everything the ^C depends on — `isStillLive`
1442
+ // (delayed-send.ts's generation guard), the session's existence, and its writability — is
1443
+ // re-checked INSIDE the submission lock, right before the write, so a shutdown OR a session
1444
+ // teardown/respawn during the lock-wait writes nothing (invariant 2; the directive's
1445
+ // preferred shape). Diagnostics arg falls back to toAgent when the target has no terminal
1446
+ // id yet.
1447
+ scheduleDelayedSend(deliverAfter, terminalId ?? toAgent, (isStillLive) => {
1448
+ if (!isStillLive())
1449
+ return; // shutdown before/at due → drop the ^C nudge (body survives)
1450
+ if (terminalId) {
1451
+ let fired = false;
1452
+ void submitToSession(terminalId, () => {
1453
+ // Re-check EVERYTHING inside the lock: the queued submission can acquire the lock only
1454
+ // AFTER a shutdown or a session teardown/respawn that landed while it waited behind an
1455
+ // in-flight write. Re-fetch the session and re-check live + writable here; bail with no
1456
+ // ^C otherwise (the body still delivers via the gate).
1457
+ if (!isStillLive())
1458
+ return 0;
1459
+ const live = getTerminalManager().getSession(terminalId);
1460
+ if (!live || !live.writable)
1461
+ return 0;
1462
+ live.write('\x03'); // Ctrl+C only — end the turn; body follows via the gate
1463
+ fired = true;
1464
+ return 0; // no body, no Enter on this path
1465
+ })
1466
+ .then(() => ctx.log('INFO', fired
1467
+ ? `Delayed interrupt ^C fired → ${toAgent} (terminal ${terminalId.slice(0, 8)}...); body delivers via the gate`
1468
+ : `Delayed interrupt for ${toAgent}: session not live/writable at write time — body delivers via the gate on the next clean pass`))
1469
+ .catch((err) => ctx.log('ERROR', `Delayed interrupt ^C failed for ${toAgent}: ${err.message}`));
1470
+ }
1471
+ else {
1472
+ ctx.log('INFO', `Delayed interrupt for ${toAgent}: no live terminal at due time — body delivers via the gate on the next clean pass`);
1473
+ }
1474
+ // Nudge the gated drainer so the now-due body delivers promptly (gated — a spurious
1475
+ // nudge onto a busy/mid-turn screen simply re-holds; the backstop is the ultimate net).
1476
+ void getMailboxDrainer().scheduleDrain(workspacePath, toAgent);
1477
+ });
1478
+ }
1479
+ // A normal delayed send keeps NO timer: the persisted `not_before` row is delivered by the
1480
+ // gated backstop drainer once due — durable across restart by construction.
1481
+ ctx.log('INFO', `Message scheduled (+${deliverAfter}s): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
1482
+ sendJson(res, 200, {
1483
+ ok: true,
1484
+ terminalId,
1485
+ resolvedTo: toAgent,
1486
+ deferred: false,
1487
+ scheduled: true,
1488
+ deliverAfter,
1489
+ mailboxId: row.id,
1490
+ notBefore,
1491
+ });
1492
+ }
1164
1493
  async function handleSend(req, res, ctx) {
1165
1494
  const body = await parseJsonBody(req);
1166
1495
  // Validate required fields
@@ -1186,111 +1515,381 @@ async function handleSend(req, res, ctx) {
1186
1515
  const raw = options.raw === true;
1187
1516
  const noEnter = options.noEnter === true;
1188
1517
  const interrupt = options.interrupt === true;
1189
- // Resolve the target address to a terminal ID.
1518
+ const escape = options.escape === true;
1519
+ // Spec 1307 `--delay` (re-homed onto the mailbox): optional deferred delivery.
1520
+ // Validated here as well as at the CLI boundary — /api/send is a public route, so an
1521
+ // unvalidated value would become a setTimeout that fires instantly (NaN) or never
1522
+ // (Infinity). `escape` cannot be combined with a delay: an ESC interrupts the CURRENT
1523
+ // turn by design, so deferring it is contradictory — refuse rather than silently drop
1524
+ // one of the two (a quietly-dropped delay would look like it worked).
1525
+ let deliverAfter;
1526
+ if (options.deliverAfter !== undefined && options.deliverAfter !== null) {
1527
+ const delayError = validateDelaySeconds(options.deliverAfter);
1528
+ if (delayError) {
1529
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1530
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: delayError }));
1531
+ return;
1532
+ }
1533
+ if (escape) {
1534
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1535
+ res.end(JSON.stringify({
1536
+ error: 'INVALID_PARAMS',
1537
+ 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.',
1538
+ }));
1539
+ return;
1540
+ }
1541
+ deliverAfter = options.deliverAfter;
1542
+ }
1543
+ const db = getGlobalDb();
1544
+ const senderWorkspace = fromWorkspace ?? workspace ?? 'unknown';
1545
+ // Spec 1313 round 3: a delayed send (`--delay`) is RESOLVED, authorized, formatted, and
1546
+ // PERSISTED here at REQUEST time, then deferred — it does NOT require a live session now,
1547
+ // so it is handled BEFORE the immediate live/dead/unwritable/escape/interrupt branches
1548
+ // below (those would 404/503 a delayed send to a target that has no live PTY yet). Only
1549
+ // DELIVERY is deferred, through the same render gate every send uses; the row's `not_before`
1550
+ // makes the delay durable across a Tower restart.
1551
+ if (deliverAfter !== undefined) {
1552
+ handleDelayedSend(res, ctx, db, {
1553
+ to, workspace, from, message, raw, noEnter, interrupt, deliverAfter, senderWorkspace,
1554
+ });
1555
+ return;
1556
+ }
1557
+ // Resolve the target address against LIVE terminals.
1190
1558
  // Spec 755: pass `from` so architect resolution is sender-affinity-aware
1191
1559
  // when the sender is a builder. Non-builder senders see unchanged behavior.
1192
1560
  const result = resolveTarget(to, workspace, from);
1561
+ // --- Resolution failed against live terminals ---
1193
1562
  if (isResolveError(result)) {
1194
- const statusCode = result.code === 'AMBIGUOUS' ? 409
1195
- : result.code === 'NO_CONTEXT' ? 400
1196
- : 404;
1563
+ // Spec 1313 dead-session seam: a NOT_FOUND target may still be a KNOWN agent
1564
+ // with no live PTY (e.g. registered in global.db while Tower restarts). Hold
1565
+ // its mail instead of 404ing. escape/interrupt act on a live session only, so
1566
+ // an unresolved target keeps the original error for them.
1567
+ if (result.code === 'NOT_FOUND' && !escape && !interrupt) {
1568
+ const reg = resolveAgentInRegistry(to, workspace, from);
1569
+ if (!isResolveError(reg)) {
1570
+ holdAndRespond(res, ctx, {
1571
+ workspacePath: reg.workspacePath,
1572
+ toAgent: reg.agent,
1573
+ body: message,
1574
+ formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, message, raw),
1575
+ fromAgent: from ?? null,
1576
+ fromWorkspace: senderWorkspace,
1577
+ noEnter,
1578
+ terminalId: null,
1579
+ }, 'no-live-pty');
1580
+ return;
1581
+ }
1582
+ if (reg.code === 'AMBIGUOUS') {
1583
+ sendJson(res, 409, { error: 'AMBIGUOUS', message: reg.message });
1584
+ return;
1585
+ }
1586
+ // else: fall through to the original live-resolution error below.
1587
+ }
1588
+ const statusCode = result.code === 'AMBIGUOUS' ? 409 : result.code === 'NO_CONTEXT' ? 400 : 404;
1197
1589
  // Map NO_CONTEXT to INVALID_PARAMS per plan's error contract
1198
1590
  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 }));
1591
+ sendJson(res, statusCode, { error: errorCode, message: result.message });
1201
1592
  return;
1202
1593
  }
1203
- // Get the terminal session
1594
+ // --- Live target resolved; locate its session ---
1204
1595
  const manager = getTerminalManager();
1205
1596
  const session = manager.getSession(result.terminalId);
1597
+ const { toAgent, isArchitectTarget } = liveTargetIdentity(result);
1598
+ // Dead session: the routing entry resolved but the PTY is gone (exited > 30s).
1599
+ // Hold a normal message; escape/interrupt need a live session → original 404.
1206
1600
  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
- }));
1601
+ if (escape || interrupt) {
1602
+ sendJson(res, 404, {
1603
+ error: 'NOT_FOUND',
1604
+ message: `Terminal session ${result.terminalId} not found (agent '${result.agent}' resolved but terminal is gone).`,
1605
+ });
1606
+ return;
1607
+ }
1608
+ holdAndRespond(res, ctx, {
1609
+ workspacePath: result.workspacePath,
1610
+ toAgent,
1611
+ body: message,
1612
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1613
+ fromAgent: from ?? null,
1614
+ fromWorkspace: senderWorkspace,
1615
+ noEnter,
1616
+ terminalId: result.terminalId,
1617
+ }, 'no-live-pty');
1212
1618
  return;
1213
1619
  }
1214
1620
  // #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.
1621
+ // 'running', but every write is dropped. Hold a normal message (it delivers
1622
+ // when the connection recovers); keep the loud 503 for escape/interrupt, which
1623
+ // are operator actions that require the live PTY here and now.
1217
1624
  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
- }));
1625
+ if (escape || interrupt) {
1626
+ ctx.log('ERROR', `Interrupt/ESC not deliverable: ${from ?? 'unknown'} → ${result.agent} (terminal not writable, shellper connection down)`);
1627
+ sendJson(res, 503, {
1628
+ error: 'TERMINAL_NOT_WRITABLE',
1629
+ message: `Terminal for '${result.agent}' is not accepting input (its process connection is down). Retry shortly; if this persists, check Tower logs.`,
1630
+ });
1631
+ return;
1632
+ }
1633
+ holdAndRespond(res, ctx, {
1634
+ workspacePath: result.workspacePath,
1635
+ toAgent,
1636
+ body: message,
1637
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1638
+ fromAgent: from ?? null,
1639
+ fromWorkspace: senderWorkspace,
1640
+ noEnter,
1641
+ terminalId: result.terminalId,
1642
+ }, 'no-live-pty');
1643
+ return;
1644
+ }
1645
+ // --- Live, writable session ---
1646
+ // Spec 1273: `escape` delivers a bare ESC keystroke and returns. Explicit human
1647
+ // bypass — no gate, no mailbox row. ESC ends the running turn so already-queued
1648
+ // messages process; the trailing Enter (default) is what lets them through
1649
+ // (matching the verified recovery `afx send <b> --raw "$(printf '\x1b')"`).
1650
+ if (escape) {
1651
+ // Awaited: the response must not claim delivery before the ESC and its
1652
+ // Enter have actually been written (Spec 1273 verify).
1653
+ await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter));
1654
+ broadcastMessage({
1655
+ type: 'message',
1656
+ from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
1657
+ to: { project: path.basename(result.workspacePath), agent: toAgent },
1658
+ content: '<ESC>',
1659
+ metadata: { raw: true, source: 'api', escape: true },
1660
+ timestamp: new Date().toISOString(),
1661
+ });
1662
+ ctx.log('INFO', `Interrupt (ESC) sent: ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1663
+ sendJson(res, 200, { ok: true, terminalId: result.terminalId, resolvedTo: toAgent, deferred: false });
1224
1664
  return;
1225
1665
  }
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
1666
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1667
+ // NB: `--delay` (deliverAfter) is handled earlier by handleDelayedSend, before this
1668
+ // immediate-path block — a delayed send is resolved + persisted at request time and does
1669
+ // not fall through here.
1670
+ // Spec 1313: `interrupt` is the explicit human bypass. Ctrl+C, then deliver
1671
+ // WITHOUT the render-gate (the operator is looking at this terminal). A row is
1672
+ // still persisted and marked delivered for audit parity — every send is a row.
1259
1673
  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,
1674
+ const row = enqueueMailbox(db, {
1675
+ workspacePath: result.workspacePath,
1676
+ toAgent,
1677
+ body: message,
1272
1678
  formattedMessage,
1679
+ fromAgent: from ?? null,
1680
+ fromWorkspace: senderWorkspace,
1273
1681
  noEnter,
1274
- timestamp: Date.now(),
1275
- broadcastPayload,
1276
- logMessage,
1682
+ terminalId: result.terminalId,
1683
+ });
1684
+ // Claim the row as delivered SYNCHRONOUSLY, before any await (CMAP round 2 — Codex): the
1685
+ // interrupt writes the message itself (a gate bypass), so the row must never be visible to
1686
+ // the mailbox drainer as `held`, or a concurrent backstop/scheduleDrain pass could gate-deliver
1687
+ // the SAME row and put the bytes on the wire twice. enqueue→markDelivered are both synchronous
1688
+ // (no await between), so there is no window for the drainer to pick it up before we own it.
1689
+ // Tradeoff (CMAP round 3 — Codex/Claude): claiming BEFORE the write below means that if
1690
+ // submitToSession throws/crashes, the row reads `delivered` for audit though no bytes reached
1691
+ // the PTY — the message is lost, not retried. This is the deliberate choice: the write is not
1692
+ // transactional, so a partial write may already have put bytes on the wire, and re-holding (the
1693
+ // alternative) would let the backstop gate-deliver a SECOND copy. Losing a crashed interrupt is
1694
+ // preferred over double-delivering it; `--interrupt` is the explicit human gate-bypass anyway.
1695
+ markMailboxDelivered(db, row.id);
1696
+ // Deliver the interrupt as ONE atomic critical section under the Spec 1273 per-terminal
1697
+ // submission lock (CMAP round 1 — Gemini/Codex/Claude): the Ctrl+C, its 100 ms settle, and
1698
+ // the message write all occur inside a single lock acquisition. Previously the \x03 + the
1699
+ // settle sat OUTSIDE the lock, so a concurrent submission to the same terminal could land
1700
+ // its Ctrl+C inside another submission's text→Enter window (killing that composer) or run
1701
+ // during the 100 ms gap. `writeMessageToSession(..., 100)` schedules the text 100 ms after
1702
+ // the ^C (the settle) and returns the completion offset, so the lock is held until the
1703
+ // whole interrupt is on the wire; uncontended, it runs at once.
1704
+ // Scope of the guarantee: this serializes interrupt against interrupt/escape — the only
1705
+ // /api/send writers that take this per-terminal lock. It does NOT serialize against a
1706
+ // concurrent mailbox/backstop delivery (which writes through the per-AGENT serializer, a
1707
+ // disjoint lock); interrupt is the explicit gate-bypassing human action, and closing that
1708
+ // cross-path race would require the mailbox write edge to take this lock too (a separate,
1709
+ // larger change — flagged, not done here).
1710
+ await submitToSession(result.terminalId, () => {
1711
+ session.write('\x03'); // Ctrl+C
1712
+ return writeMessageToSession(session, formattedMessage, noEnter, 100);
1713
+ });
1714
+ broadcastMessage({
1715
+ type: 'message',
1716
+ from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
1717
+ to: { project: path.basename(result.workspacePath), agent: toAgent },
1718
+ content: message,
1719
+ metadata: { raw, source: 'api' },
1720
+ timestamp: new Date().toISOString(),
1277
1721
  });
1278
- ctx.log('INFO', `Message deferred (user typing): ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1722
+ ctx.log('INFO', `Message delivered (interrupt): ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1723
+ sendJson(res, 200, {
1724
+ ok: true,
1725
+ terminalId: result.terminalId,
1726
+ resolvedTo: toAgent,
1727
+ deferred: false,
1728
+ delivered: true,
1729
+ held: false,
1730
+ mailboxId: row.id,
1731
+ reason: null,
1732
+ });
1733
+ return;
1279
1734
  }
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);
1735
+ // Spec 1313 normal path: PERSIST first (survives a crash), then attempt gated
1736
+ // delivery through the single serialized path. The response reports the row's
1737
+ // real first outcome — a clean, render-verified empty prompt delivers now;
1738
+ // anything else (busy/menu/wrapper/no-profile) stays held for the backstop.
1739
+ const row = enqueueMailbox(db, {
1740
+ workspacePath: result.workspacePath,
1741
+ toAgent,
1742
+ body: message,
1743
+ formattedMessage,
1744
+ fromAgent: from ?? null,
1745
+ fromWorkspace: senderWorkspace,
1746
+ noEnter,
1747
+ terminalId: result.terminalId,
1748
+ });
1749
+ // Deliver to the session THIS request already resolved rather than re-resolving
1750
+ // by agent: the base resolver would repeat the routing-map lookup (redundant) and
1751
+ // could target a different terminal if the map changed mid-request. The backstop
1752
+ // drainer, which only has the agent, still uses the base resolver.
1753
+ const basePorts = makeDeliveryPorts(ctx.log);
1754
+ const ports = {
1755
+ ...basePorts,
1756
+ getSessionForAgent: (ws, agent) => ws === result.workspacePath && agent === toAgent ? session : basePorts.getSessionForAgent(ws, agent),
1757
+ };
1758
+ try {
1759
+ await deliverAgentMailSerialized(ports, db, result.workspacePath, toAgent);
1286
1760
  }
1287
- res.writeHead(200, { 'Content-Type': 'application/json' });
1288
- res.end(JSON.stringify({
1761
+ catch (err) {
1762
+ // A gate/write error leaves the row HELD (markDelivered only runs on a
1763
+ // completed write); the backstop drainer will retry. Report held, not a 500.
1764
+ ctx.log('ERROR', `Delivery attempt errored for ${toAgent} (row ${row.id.slice(0, 8)}... stays held): ${err.message}`);
1765
+ }
1766
+ const stored = getMailboxById(db, row.id);
1767
+ if (stored?.status === 'delivered') {
1768
+ ctx.log('INFO', `Message delivered: ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
1769
+ sendJson(res, 200, {
1770
+ ok: true,
1771
+ terminalId: result.terminalId,
1772
+ resolvedTo: toAgent,
1773
+ deferred: false,
1774
+ delivered: true,
1775
+ held: false,
1776
+ mailboxId: row.id,
1777
+ reason: null,
1778
+ });
1779
+ return;
1780
+ }
1781
+ const reason = stored?.reason ?? 'busy';
1782
+ ctx.log('INFO', `Message held (${reason}): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
1783
+ // The message stayed held → a new held row is in the set; refresh the indicator
1784
+ // count (Spec 1313, Phase 7). The delivered branch above needs no fire — the
1785
+ // delivery path's onHeldStateChange already broadcast when the row left the set.
1786
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: `held ${reason}` });
1787
+ sendJson(res, 200, {
1289
1788
  ok: true,
1290
1789
  terminalId: result.terminalId,
1291
- resolvedTo: result.agent,
1292
- deferred: shouldDefer,
1790
+ resolvedTo: toAgent,
1791
+ deferred: true,
1792
+ delivered: false,
1793
+ held: true,
1794
+ reason,
1795
+ mailboxId: row.id,
1796
+ });
1797
+ }
1798
+ /**
1799
+ * GET /api/inbox — list held (undelivered) mailbox rows for a workspace. Backs the
1800
+ * workspace-scoped `afx inbox` (Spec 1313 decision 8): `?workspace=<path>` selects the
1801
+ * workspace (the CLI passes the current one by default); the path is normalized to the
1802
+ * same realpath form the enqueue path stores, so a raw workspace root still matches its
1803
+ * held rows. Omitting `?workspace=` lists every workspace — an API-level convenience the
1804
+ * CLI never triggers, kept for direct callers. Metadata-only projection (Spec 1313
1805
+ * redaction rule): id, addresses, why-held reason, escalation flag, and enqueue time —
1806
+ * the message BODY is deliberately never surfaced here (it travels only over the live
1807
+ * terminal stream on delivery). `escalated` is normalized from SQLite's 0/1 to a bool.
1808
+ */
1809
+ function handleInboxList(res, url) {
1810
+ const rawWorkspace = url.searchParams.get('workspace');
1811
+ // Normalize to the stored realpath key (mailbox workspace_path is normalized at
1812
+ // enqueue — tower-routes handleSend / holdAndRespond — matching overview.ts). Without
1813
+ // this a symlinked workspace root would miss its own held rows.
1814
+ const workspace = rawWorkspace ? normalizeWorkspacePath(rawWorkspace) : undefined;
1815
+ const rows = listHeldMailbox(getGlobalDb(), workspace);
1816
+ const projected = rows.map((r) => ({
1817
+ id: r.id,
1818
+ workspacePath: r.workspace_path,
1819
+ toAgent: r.to_agent,
1820
+ fromAgent: r.from_agent,
1821
+ reason: r.reason,
1822
+ escalated: r.escalated === 1,
1823
+ createdAt: r.created_at,
1824
+ // Spec 1313 round 3: due time of a pre-due delayed (`--delay`) row; null = deliver-ASAP.
1825
+ // The CLI renders "in Ns" for a row whose notBefore is still in the future.
1826
+ notBefore: r.not_before,
1293
1827
  }));
1828
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1829
+ res.end(JSON.stringify(projected));
1830
+ }
1831
+ /**
1832
+ * POST /api/inbox/:id/dismiss — mark a held row `dismissed` (operator-cleared via
1833
+ * `afx inbox dismiss`). Soft transition: the row is marked, not deleted, and NEVER
1834
+ * delivered. The dispatch matches this path for ANY method, so the method is guarded
1835
+ * here: a non-POST request (e.g. GET) must not mutate state → 405. 404 when the id names
1836
+ * no currently-held row (already terminal or unknown), so the CLI reports a clean error.
1837
+ * On success, fires `overview-changed` so the held-count indicator drops immediately.
1838
+ * Authorized at the workspace-human trust level — any local operator may dismiss any held
1839
+ * row (Spec 1313 decision 8); no ownership check.
1840
+ */
1841
+ function handleInboxDismiss(req, res, ctx, match) {
1842
+ // Dismissal mutates state — only POST may reach it. The path match in the dispatch is
1843
+ // method-agnostic, so without this guard a GET (or any method) to this URL would dismiss
1844
+ // mail. Matches the method-guard convention used by the cron action routes.
1845
+ if (req.method !== 'POST') {
1846
+ sendJson(res, 405, { error: 'Method not allowed' });
1847
+ return;
1848
+ }
1849
+ const id = decodeURIComponent(match[1]);
1850
+ if (!dismissMailbox(getGlobalDb(), id)) {
1851
+ sendJson(res, 404, { error: 'NOT_FOUND', message: `No held message with id '${id}'` });
1852
+ return;
1853
+ }
1854
+ ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: 'dismissed' });
1855
+ ctx.log('INFO', `Inbox: dismissed held message ${id.slice(0, 8)}...`);
1856
+ sendJson(res, 200, { ok: true });
1857
+ }
1858
+ /**
1859
+ * GET /api/inbox/:id — return a single mailbox row INCLUDING its body. Backs
1860
+ * `afx inbox show <id>` (Spec 1313 §178: `afx inbox` is a UI surface that legitimately
1861
+ * displays message bodies over the local Tower connection — the redaction rule applies to
1862
+ * logs/diagnostics/telemetry only, never this view). Mirrors dismiss's addressing model:
1863
+ * by unique id at the workspace-human trust level, no per-recipient/workspace ownership
1864
+ * check (decision 8). GET-only (the path match in the dispatch is method-agnostic, so a
1865
+ * non-GET is rejected here); 404 when the id names no row. The body is returned to the
1866
+ * caller but never logged.
1867
+ */
1868
+ function handleInboxShow(req, res, match) {
1869
+ if (req.method !== 'GET') {
1870
+ sendJson(res, 405, { error: 'Method not allowed' });
1871
+ return;
1872
+ }
1873
+ const id = decodeURIComponent(match[1]);
1874
+ const row = getMailboxById(getGlobalDb(), id);
1875
+ if (!row) {
1876
+ sendJson(res, 404, { error: 'NOT_FOUND', message: `No message with id '${id}'` });
1877
+ return;
1878
+ }
1879
+ sendJson(res, 200, {
1880
+ id: row.id,
1881
+ workspacePath: row.workspace_path,
1882
+ toAgent: row.to_agent,
1883
+ fromAgent: row.from_agent,
1884
+ fromWorkspace: row.from_workspace,
1885
+ status: row.status,
1886
+ reason: row.reason,
1887
+ escalated: row.escalated === 1,
1888
+ body: row.body,
1889
+ createdAt: row.created_at,
1890
+ notBefore: row.not_before,
1891
+ resolvedAt: row.resolved_at,
1892
+ });
1294
1893
  }
1295
1894
  async function handleBrowse(res, url) {
1296
1895
  const inputPath = url.searchParams.get('path') || '';
@@ -1414,14 +2013,68 @@ function handleDashboard(res, ctx) {
1414
2013
  }
1415
2014
  try {
1416
2015
  const template = fs.readFileSync(ctx.templatePath, 'utf-8');
1417
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1418
- res.end(template);
2016
+ sendKeyInjectedHtml(res, template);
1419
2017
  }
1420
2018
  catch (err) {
1421
2019
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1422
2020
  res.end('Error loading template: ' + err.message);
1423
2021
  }
1424
2022
  }
2023
+ /**
2024
+ * Same-origin key injection (advisory GHSA-xvjp-7748-v88v Layer 4). Writes the
2025
+ * shared local key into an HTML shell Tower serves (tower.html or the React SPA
2026
+ * index.html) so the page can authenticate its own API/WebSocket calls, without
2027
+ * embedding a readable secret in the shipped template. The key is hex, so
2028
+ * JSON.stringify yields a safe `<script>`-embeddable literal.
2029
+ *
2030
+ * tower.html carries an explicit placeholder; the built SPA index.html does not,
2031
+ * so the script is inserted before `</head>` there — ahead of the deferred SPA
2032
+ * module, so `window.__CODEV_TOWER_KEY__` is set before the app's first request.
2033
+ */
2034
+ function injectWebKey(html) {
2035
+ const key = getExpectedKey();
2036
+ // Only embed a well-formed hex key. `ensureLocalKey` always produces 64 hex
2037
+ // chars; validating before embedding means a hand-edited or third-party-written
2038
+ // key file containing e.g. `</script>` can never become stored XSS in a shell.
2039
+ // A malformed key yields no injection (clients then fail closed with 401).
2040
+ const injection = key && /^[0-9a-f]{64}$/.test(key)
2041
+ ? `<script>window.__CODEV_TOWER_KEY__ = ${JSON.stringify(key)};</script>`
2042
+ : '';
2043
+ if (html.includes('<!-- CODEV_TOWER_KEY_INJECTION -->')) {
2044
+ return html.replace('<!-- CODEV_TOWER_KEY_INJECTION -->', injection);
2045
+ }
2046
+ if (injection && html.includes('</head>')) {
2047
+ return html.replace('</head>', `${injection}</head>`);
2048
+ }
2049
+ return html;
2050
+ }
2051
+ /**
2052
+ * Send a key-injected HTML shell. Strips the CORS `Access-Control-Allow-Origin`
2053
+ * header set by the front door: this response body carries the key, and the
2054
+ * shell is only ever loaded by a same-origin navigation, so it must never be
2055
+ * readable by a cross-origin `fetch` (advisory GHSA-xvjp-7748-v88v). Same-origin
2056
+ * reads are unaffected; the key check still guards the actual API routes.
2057
+ */
2058
+ function sendKeyInjectedHtml(res, template) {
2059
+ res.removeHeader('Access-Control-Allow-Origin');
2060
+ res.removeHeader('Vary');
2061
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2062
+ res.end(injectWebKey(template));
2063
+ }
2064
+ /**
2065
+ * Serve the React dashboard's index.html with the key injected (SPA shell + SPA
2066
+ * client-side-routing fallback). Returns false if the file cannot be read.
2067
+ */
2068
+ function serveDashboardIndex(dashboardPath, res) {
2069
+ try {
2070
+ const html = fs.readFileSync(path.join(dashboardPath, 'index.html'), 'utf-8');
2071
+ sendKeyInjectedHtml(res, html);
2072
+ return true;
2073
+ }
2074
+ catch {
2075
+ return false;
2076
+ }
2077
+ }
1425
2078
  // ============================================================================
1426
2079
  // Workspace-scoped route handler
1427
2080
  // ============================================================================
@@ -1484,22 +2137,24 @@ async function handleWorkspaceRoutes(req, res, ctx, url) {
1484
2137
  // 3. React dashboard is available
1485
2138
  // 4. Workspace doesn't need to be running for static files
1486
2139
  if (!isApiCall && !isWsPath && ctx.hasReactDashboard) {
1487
- // Determine which static file to serve
1488
- let staticPath;
1489
- if (!subPath || subPath === '' || subPath === 'index.html') {
1490
- staticPath = path.join(ctx.reactDashboardPath, 'index.html');
2140
+ // The SPA shell (index.html) is served with the shared key injected same-origin
2141
+ // (advisory GHSA-xvjp-7748-v88v) so a direct navigation to a workspace URL can
2142
+ // authenticate without first visiting the Tower root. Other static assets
2143
+ // (JS/CSS/images) carry no secret and stream as-is.
2144
+ const isIndex = !subPath || subPath === '' || subPath === 'index.html';
2145
+ if (isIndex) {
2146
+ if (serveDashboardIndex(ctx.reactDashboardPath, res)) {
2147
+ return;
2148
+ }
1491
2149
  }
1492
2150
  else {
1493
- // Check if it's a static asset
1494
- staticPath = path.join(ctx.reactDashboardPath, subPath);
1495
- }
1496
- // Try to serve the static file
1497
- if (serveStaticFile(staticPath, res)) {
1498
- return;
2151
+ const staticPath = path.join(ctx.reactDashboardPath, subPath);
2152
+ if (serveStaticFile(staticPath, res)) {
2153
+ return;
2154
+ }
1499
2155
  }
1500
- // SPA fallback: serve index.html for client-side routing
1501
- const indexPath = path.join(ctx.reactDashboardPath, 'index.html');
1502
- if (serveStaticFile(indexPath, res)) {
2156
+ // SPA fallback: serve the (key-injected) index.html for client-side routing.
2157
+ if (serveDashboardIndex(ctx.reactDashboardPath, res)) {
1503
2158
  return;
1504
2159
  }
1505
2160
  }
@@ -1854,6 +2509,13 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
1854
2509
  const session = manager.createSessionRaw({
1855
2510
  label: `Shell ${shellId.replace('shell-', '')}`,
1856
2511
  cwd: workspacePath,
2512
+ // Spec 1313: thread/persist for reconstruction symmetry with the other
2513
+ // createSessionRaw sites. A workspace-root shell resolves to no-profile
2514
+ // (its command is a shell, not an agent, and the cwd has no launch script),
2515
+ // so `afx send` correctly holds. (A shell whose cwd happened to be a builder
2516
+ // worktree would resolve that worktree's harness via the launch-script fallback.)
2517
+ command: shellCmd,
2518
+ args: shellArgs,
1857
2519
  });
1858
2520
  const ptySession = manager.getSession(session.id);
1859
2521
  if (ptySession) {
@@ -1861,7 +2523,7 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
1861
2523
  }
1862
2524
  const entry = getWorkspaceTerminalsEntry(workspacePath);
1863
2525
  entry.shells.set(shellId, session.id);
1864
- saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath);
2526
+ saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath, shellCmd);
1865
2527
  shellCreated = true;
1866
2528
  res.writeHead(200, { 'Content-Type': 'application/json' });
1867
2529
  res.end(JSON.stringify({
@@ -1890,7 +2552,7 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
1890
2552
  });
1891
2553
  const entry = getWorkspaceTerminalsEntry(workspacePath);
1892
2554
  entry.shells.set(shellId, session.id);
1893
- saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid, null, null, null, session.label, workspacePath);
2555
+ saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid, null, null, null, session.label, workspacePath, shellCmd);
1894
2556
  ctx.log('WARN', `Shell ${shellId} for ${workspacePath} is non-persistent (shellper unavailable)`);
1895
2557
  res.writeHead(200, { 'Content-Type': 'application/json' });
1896
2558
  res.end(JSON.stringify({
@@ -2143,6 +2805,13 @@ async function handleWorkspaceTabDelete(res, ctx, workspacePath, tabId) {
2143
2805
  return;
2144
2806
  }
2145
2807
  if (terminalId) {
2808
+ // Issue #1261: this path discards the kill result and answers 204 either
2809
+ // way, so a not-yet-wired Tower would report a successful close for a
2810
+ // terminal it never touched. Refuse instead.
2811
+ if (!instancesReady()) {
2812
+ respondStartingUp(res);
2813
+ return;
2814
+ }
2146
2815
  // Disable shellper auto-restart if applicable, then kill the PtySession
2147
2816
  await killTerminalWithShellper(manager, terminalId);
2148
2817
  // TICK-001: Delete from SQLite
@@ -2414,16 +3083,24 @@ function handleWorkspaceAnnotate(req, res, ctx, url, workspacePath, annotateMatc
2414
3083
  let html = fs.readFileSync(tplPath, 'utf-8');
2415
3084
  const fileName = path.basename(filePath);
2416
3085
  const fileSize = fs.statSync(filePath).size;
3086
+ // The shell carries the injected key (advisory GHSA-xvjp-7748-v88v), so a
3087
+ // maliciously-named file must not become XSS (which would read the key).
3088
+ // HTML-escape values that land in markup/attributes (safe in the JS string
3089
+ // contexts too — it blocks `<`, `"`, `'`), and for the JSON-in-<script>
3090
+ // value escape `<` so a filename containing `</script>` can't break out.
3091
+ const safeFileName = escapeHtml(fileName);
3092
+ const safeFilePath = escapeHtml(filePath);
3093
+ const filePathJson = JSON.stringify(filePath).replace(/</g, '\\u003c');
2417
3094
  if (is3D) {
2418
- html = html.replace(/\{\{FILE\}\}/g, fileName);
2419
- html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
2420
- html = html.replace(/\{\{FORMAT\}\}/g, ext);
3095
+ html = html.replace(/\{\{FILE\}\}/g, safeFileName);
3096
+ html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, filePathJson);
3097
+ html = html.replace(/\{\{FORMAT\}\}/g, escapeHtml(ext));
2421
3098
  }
2422
3099
  else {
2423
- html = html.replace(/\{\{FILE\}\}/g, fileName);
2424
- html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
3100
+ html = html.replace(/\{\{FILE\}\}/g, safeFileName);
3101
+ html = html.replace(/\{\{FILE_PATH\}\}/g, safeFilePath);
2425
3102
  html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
2426
- html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
3103
+ html = html.replace(/\{\{LANG\}\}/g, escapeHtml(getLanguageForExt(ext)));
2427
3104
  html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
2428
3105
  html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
2429
3106
  html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
@@ -2442,18 +3119,22 @@ function handleWorkspaceAnnotate(req, res, ctx, url, workspacePath, annotateMatc
2442
3119
  initScript = `initPdf(${fileSize});`;
2443
3120
  }
2444
3121
  else {
2445
- initScript = `fetch('file').then(r=>r.text()).then(init);`;
3122
+ initScript = `fetch('file',{headers:authHeaders()}).then(r=>r.text()).then(init);`;
2446
3123
  }
2447
3124
  html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
2448
3125
  }
2449
- // Handle ?line= query param for scroll-to-line
3126
+ // Handle ?line= query param for scroll-to-line. Validate as a bare integer
3127
+ // before interpolating into the script — untrusted query input must never
3128
+ // reach the key-bearing shell's markup unescaped.
2450
3129
  const lineParam = url.searchParams.get('line');
2451
- if (lineParam) {
3130
+ if (lineParam && /^\d+$/.test(lineParam)) {
2452
3131
  const scrollScript = `<script>window.addEventListener('load',()=>{setTimeout(()=>{const el=document.querySelector('[data-line="${lineParam}"]');if(el){el.scrollIntoView({block:'center'});el.classList.add('highlighted-line');}},200);})</script>`;
2453
3132
  html = html.replace('</body>', `${scrollScript}</body>`);
2454
3133
  }
2455
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2456
- res.end(html);
3134
+ // Same-origin key injection so the annotator's fetches can authenticate
3135
+ // (advisory GHSA-xvjp-7748-v88v). The shell is public (iframe navigation);
3136
+ // its data/media sub-routes stay keyed.
3137
+ sendKeyInjectedHtml(res, html);
2457
3138
  }
2458
3139
  catch (err) {
2459
3140
  res.writeHead(500, { 'Content-Type': 'text/plain' });