@cluesmith/codev 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/dashboard-dist/assets/{index-D3XaKpZW.js → index-CUIjS_Vb.js} +41 -41
  2. package/dashboard-dist/assets/index-CUIjS_Vb.js.map +1 -0
  3. package/dashboard-dist/assets/index-DnYLy5C_.css +32 -0
  4. package/dashboard-dist/index.html +2 -2
  5. package/dist/agent-farm/cli.d.ts.map +1 -1
  6. package/dist/agent-farm/cli.js +52 -1
  7. package/dist/agent-farm/cli.js.map +1 -1
  8. package/dist/agent-farm/commands/inbox.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/inbox.js +67 -10
  10. package/dist/agent-farm/commands/inbox.js.map +1 -1
  11. package/dist/agent-farm/commands/interrupt.d.ts +4 -1
  12. package/dist/agent-farm/commands/interrupt.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/interrupt.js +6 -3
  14. package/dist/agent-farm/commands/interrupt.js.map +1 -1
  15. package/dist/agent-farm/commands/reset/constants.d.ts +118 -0
  16. package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -1
  17. package/dist/agent-farm/commands/reset/constants.js +118 -0
  18. package/dist/agent-farm/commands/reset/constants.js.map +1 -1
  19. package/dist/agent-farm/commands/reset/context.d.ts +18 -0
  20. package/dist/agent-farm/commands/reset/context.d.ts.map +1 -1
  21. package/dist/agent-farm/commands/reset/context.js +41 -0
  22. package/dist/agent-farm/commands/reset/context.js.map +1 -1
  23. package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -1
  24. package/dist/agent-farm/commands/reset/receipt.js +16 -5
  25. package/dist/agent-farm/commands/reset/receipt.js.map +1 -1
  26. package/dist/agent-farm/commands/reset/reorient.d.ts +1 -1
  27. package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -1
  28. package/dist/agent-farm/commands/reset/reorient.js +8 -1
  29. package/dist/agent-farm/commands/reset/reorient.js.map +1 -1
  30. package/dist/agent-farm/commands/reset/self.d.ts +335 -0
  31. package/dist/agent-farm/commands/reset/self.d.ts.map +1 -0
  32. package/dist/agent-farm/commands/reset/self.js +636 -0
  33. package/dist/agent-farm/commands/reset/self.js.map +1 -0
  34. package/dist/agent-farm/commands/reset.d.ts +4 -1
  35. package/dist/agent-farm/commands/reset.d.ts.map +1 -1
  36. package/dist/agent-farm/commands/reset.js +29 -21
  37. package/dist/agent-farm/commands/reset.js.map +1 -1
  38. package/dist/agent-farm/commands/self-refresh.d.ts +60 -0
  39. package/dist/agent-farm/commands/self-refresh.d.ts.map +1 -0
  40. package/dist/agent-farm/commands/self-refresh.js +506 -0
  41. package/dist/agent-farm/commands/self-refresh.js.map +1 -0
  42. package/dist/agent-farm/commands/send.d.ts +28 -0
  43. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  44. package/dist/agent-farm/commands/send.js +90 -7
  45. package/dist/agent-farm/commands/send.js.map +1 -1
  46. package/dist/agent-farm/commands/spawn.d.ts +7 -0
  47. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  48. package/dist/agent-farm/commands/spawn.js +13 -1
  49. package/dist/agent-farm/commands/spawn.js.map +1 -1
  50. package/dist/agent-farm/db/index.d.ts +3 -1
  51. package/dist/agent-farm/db/index.d.ts.map +1 -1
  52. package/dist/agent-farm/db/index.js +8 -443
  53. package/dist/agent-farm/db/index.js.map +1 -1
  54. package/dist/agent-farm/db/mailbox.d.ts +47 -7
  55. package/dist/agent-farm/db/mailbox.d.ts.map +1 -1
  56. package/dist/agent-farm/db/mailbox.js +56 -12
  57. package/dist/agent-farm/db/mailbox.js.map +1 -1
  58. package/dist/agent-farm/db/migrations.d.ts +57 -0
  59. package/dist/agent-farm/db/migrations.d.ts.map +1 -0
  60. package/dist/agent-farm/db/migrations.js +530 -0
  61. package/dist/agent-farm/db/migrations.js.map +1 -0
  62. package/dist/agent-farm/db/schema.d.ts +1 -1
  63. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  64. package/dist/agent-farm/db/schema.js +1 -0
  65. package/dist/agent-farm/db/schema.js.map +1 -1
  66. package/dist/agent-farm/db/types.d.ts +22 -0
  67. package/dist/agent-farm/db/types.d.ts.map +1 -1
  68. package/dist/agent-farm/db/types.js.map +1 -1
  69. package/dist/agent-farm/lib/tower-client.d.ts +12 -0
  70. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  71. package/dist/agent-farm/lib/tower-client.js +12 -0
  72. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  73. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  74. package/dist/agent-farm/lib/tunnel-client.js +43 -14
  75. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  76. package/dist/agent-farm/servers/cron-delivery.d.ts +7 -1
  77. package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -1
  78. package/dist/agent-farm/servers/cron-delivery.js +2 -1
  79. package/dist/agent-farm/servers/cron-delivery.js.map +1 -1
  80. package/dist/agent-farm/servers/gate-profiles.d.ts +21 -0
  81. package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -1
  82. package/dist/agent-farm/servers/gate-profiles.js +36 -4
  83. package/dist/agent-farm/servers/gate-profiles.js.map +1 -1
  84. package/dist/agent-farm/servers/mailbox-delivery.d.ts +218 -15
  85. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
  86. package/dist/agent-farm/servers/mailbox-delivery.js +259 -27
  87. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
  88. package/dist/agent-farm/servers/mailbox-wiring.d.ts +66 -11
  89. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
  90. package/dist/agent-farm/servers/mailbox-wiring.js +202 -48
  91. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
  92. package/dist/agent-farm/servers/message-write.d.ts +58 -18
  93. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  94. package/dist/agent-farm/servers/message-write.js +73 -28
  95. package/dist/agent-farm/servers/message-write.js.map +1 -1
  96. package/dist/agent-farm/servers/render-gate.d.ts +37 -1
  97. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
  98. package/dist/agent-farm/servers/render-gate.js +71 -12
  99. package/dist/agent-farm/servers/render-gate.js.map +1 -1
  100. package/dist/agent-farm/servers/session-submit.d.ts +220 -27
  101. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
  102. package/dist/agent-farm/servers/session-submit.js +333 -42
  103. package/dist/agent-farm/servers/session-submit.js.map +1 -1
  104. package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
  105. package/dist/agent-farm/servers/tower-cron.js +3 -2
  106. package/dist/agent-farm/servers/tower-cron.js.map +1 -1
  107. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
  108. package/dist/agent-farm/servers/tower-instances.js +11 -3
  109. package/dist/agent-farm/servers/tower-instances.js.map +1 -1
  110. package/dist/agent-farm/servers/tower-routes.d.ts +32 -0
  111. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  112. package/dist/agent-farm/servers/tower-routes.js +245 -36
  113. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  114. package/dist/agent-farm/servers/tower-server.js +15 -2
  115. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  116. package/dist/agent-farm/servers/tower-terminals.d.ts +21 -0
  117. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  118. package/dist/agent-farm/servers/tower-terminals.js +53 -5
  119. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  120. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
  121. package/dist/agent-farm/servers/tower-tunnel.js +14 -0
  122. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
  123. package/dist/agent-farm/servers/tower-utils.d.ts +35 -0
  124. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
  125. package/dist/agent-farm/servers/tower-utils.js +39 -0
  126. package/dist/agent-farm/servers/tower-utils.js.map +1 -1
  127. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  128. package/dist/agent-farm/servers/tower-websocket.js +8 -1
  129. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  130. package/dist/agent-farm/types.d.ts +36 -1
  131. package/dist/agent-farm/types.d.ts.map +1 -1
  132. package/dist/agent-farm/utils/message-format.d.ts +136 -4
  133. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  134. package/dist/agent-farm/utils/message-format.js +212 -22
  135. package/dist/agent-farm/utils/message-format.js.map +1 -1
  136. package/dist/agent-farm/utils/server-utils.d.ts +8 -2
  137. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  138. package/dist/agent-farm/utils/server-utils.js +19 -2
  139. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  140. package/dist/cli.d.ts.map +1 -1
  141. package/dist/cli.js +3 -1
  142. package/dist/cli.js.map +1 -1
  143. package/dist/commands/doctor.js +2 -2
  144. package/dist/commands/generate-image.d.ts +20 -1
  145. package/dist/commands/generate-image.d.ts.map +1 -1
  146. package/dist/commands/generate-image.js +188 -1
  147. package/dist/commands/generate-image.js.map +1 -1
  148. package/dist/commands/porch/context-refresh.d.ts +181 -0
  149. package/dist/commands/porch/context-refresh.d.ts.map +1 -0
  150. package/dist/commands/porch/context-refresh.js +270 -0
  151. package/dist/commands/porch/context-refresh.js.map +1 -0
  152. package/dist/commands/porch/index.d.ts +6 -0
  153. package/dist/commands/porch/index.d.ts.map +1 -1
  154. package/dist/commands/porch/index.js +46 -0
  155. package/dist/commands/porch/index.js.map +1 -1
  156. package/dist/commands/porch/next.d.ts +0 -9
  157. package/dist/commands/porch/next.d.ts.map +1 -1
  158. package/dist/commands/porch/next.js +133 -4
  159. package/dist/commands/porch/next.js.map +1 -1
  160. package/dist/commands/porch/prompts.d.ts.map +1 -1
  161. package/dist/commands/porch/prompts.js +18 -2
  162. package/dist/commands/porch/prompts.js.map +1 -1
  163. package/dist/commands/porch/protocol.d.ts.map +1 -1
  164. package/dist/commands/porch/protocol.js +102 -0
  165. package/dist/commands/porch/protocol.js.map +1 -1
  166. package/dist/commands/porch/types.d.ts +61 -0
  167. package/dist/commands/porch/types.d.ts.map +1 -1
  168. package/dist/lib/forge-contracts.d.ts +66 -5
  169. package/dist/lib/forge-contracts.d.ts.map +1 -1
  170. package/dist/lib/forge-contracts.js.map +1 -1
  171. package/dist/lib/forge.d.ts +1 -1
  172. package/dist/lib/forge.d.ts.map +1 -1
  173. package/dist/lib/forge.js +33 -11
  174. package/dist/lib/forge.js.map +1 -1
  175. package/dist/lib/self-refresh-invocation.d.ts +60 -0
  176. package/dist/lib/self-refresh-invocation.d.ts.map +1 -0
  177. package/dist/lib/self-refresh-invocation.js +64 -0
  178. package/dist/lib/self-refresh-invocation.js.map +1 -0
  179. package/dist/lib/test-env.d.ts +50 -0
  180. package/dist/lib/test-env.d.ts.map +1 -1
  181. package/dist/lib/test-env.js +76 -0
  182. package/dist/lib/test-env.js.map +1 -1
  183. package/dist/terminal/pty-manager.d.ts +10 -1
  184. package/dist/terminal/pty-manager.d.ts.map +1 -1
  185. package/dist/terminal/pty-manager.js +38 -9
  186. package/dist/terminal/pty-manager.js.map +1 -1
  187. package/dist/terminal/pty-session.d.ts +52 -4
  188. package/dist/terminal/pty-session.d.ts.map +1 -1
  189. package/dist/terminal/pty-session.js +157 -18
  190. package/dist/terminal/pty-session.js.map +1 -1
  191. package/dist/terminal/shellper-client.d.ts +73 -0
  192. package/dist/terminal/shellper-client.d.ts.map +1 -1
  193. package/dist/terminal/shellper-client.js +151 -0
  194. package/dist/terminal/shellper-client.js.map +1 -1
  195. package/dist/terminal/shellper-main.js +20 -3
  196. package/dist/terminal/shellper-main.js.map +1 -1
  197. package/dist/terminal/shellper-process.d.ts +2 -0
  198. package/dist/terminal/shellper-process.d.ts.map +1 -1
  199. package/dist/terminal/shellper-process.js +17 -1
  200. package/dist/terminal/shellper-process.js.map +1 -1
  201. package/dist/terminal/shellper-protocol.d.ts +28 -0
  202. package/dist/terminal/shellper-protocol.d.ts.map +1 -1
  203. package/dist/terminal/shellper-protocol.js.map +1 -1
  204. package/package.json +4 -4
  205. package/scripts/forge/gitea/_lib.sh +214 -0
  206. package/scripts/forge/gitea/issue-comment.sh +8 -1
  207. package/scripts/forge/gitea/issue-view.sh +95 -4
  208. package/scripts/forge/gitea/pr-create.sh +155 -0
  209. package/scripts/forge/gitea/pr-exists.sh +32 -4
  210. package/scripts/forge/gitea/pr-list.sh +40 -31
  211. package/scripts/forge/gitea/pr-view.sh +65 -4
  212. package/scripts/forge/gitea/recently-merged.sh +91 -21
  213. package/scripts/forge/gitea/user-identity.sh +30 -1
  214. package/scripts/forge/github/issue-view.sh +5 -2
  215. package/scripts/forge/github/pr-create.sh +47 -0
  216. package/scripts/forge/github/pr-search.sh +3 -1
  217. package/scripts/forge/gitlab/issue-view.sh +24 -4
  218. package/scripts/forge/gitlab/pr-create.sh +53 -0
  219. package/scripts/forge/gitlab/pr-search.sh +3 -1
  220. package/scripts/forge/linear/issue-view.sh +13 -2
  221. package/skeleton/.claude/skills/arch-save/SKILL.md +8 -2
  222. package/skeleton/.claude/skills/builder-refresh/SKILL.md +138 -0
  223. package/skeleton/.claude/skills/generate-image/SKILL.md +22 -1
  224. package/skeleton/.codex/skills/arch-save/SKILL.md +8 -2
  225. package/skeleton/.codex/skills/builder-refresh/SKILL.md +138 -0
  226. package/skeleton/.codex/skills/generate-image/SKILL.md +22 -1
  227. package/skeleton/protocol-schema.json +17 -0
  228. package/skeleton/protocols/air/prompts/pr.md +6 -1
  229. package/skeleton/protocols/air/protocol.json +1 -1
  230. package/skeleton/protocols/aspir/prompts/review.md +6 -1
  231. package/skeleton/protocols/aspir/protocol.json +5 -1
  232. package/skeleton/protocols/bugfix/prompts/pr.md +6 -1
  233. package/skeleton/protocols/bugfix/protocol.json +1 -1
  234. package/skeleton/protocols/bugfix/protocol.md +2 -1
  235. package/skeleton/protocols/experiment/protocol.json +1 -1
  236. package/skeleton/protocols/maintain/prompts/review.md +6 -1
  237. package/skeleton/protocols/maintain/protocol.json +1 -1
  238. package/skeleton/protocols/pir/prompts/review.md +10 -7
  239. package/skeleton/protocols/pir/protocol.json +1 -1
  240. package/skeleton/protocols/protocol-schema.json +17 -0
  241. package/skeleton/protocols/research/protocol.json +1 -1
  242. package/skeleton/protocols/spike/protocol.json +1 -1
  243. package/skeleton/protocols/spir/prompts/review.md +6 -1
  244. package/skeleton/protocols/spir/protocol.json +5 -1
  245. package/skeleton/resources/commands/agent-farm.md +1 -1
  246. package/templates/tower.html +16 -1
  247. package/dashboard-dist/assets/index-BZRTEMtH.css +0 -32
  248. package/dashboard-dist/assets/index-D3XaKpZW.js.map +0 -1
@@ -26,7 +26,7 @@ import { getBuilders, setArchitectByName } from '../state.js';
26
26
  import { DEFAULT_COLS, defaultSessionOptions } from '../../terminal/index.js';
27
27
  import { parseJsonBody, isRequestAllowed, isAllowedOrigin, isAllowedHost, getExpectedKey, escapeHtml } from '../utils/server-utils.js';
28
28
  import { TOWER_KEY_HEADER, LEGACY_WEB_KEY_HEADER, VSCODE_USER_SENDER } from '@cluesmith/codev-types';
29
- import { isRateLimited, normalizeWorkspacePath, getLanguageForExt, getMimeTypeForFile, serveStaticFile, } from './tower-utils.js';
29
+ import { isRateLimited, normalizeWorkspacePath, getLanguageForExt, getMimeTypeForFile, serveStaticFile, persistableCommand, logSessionIdentity, } from './tower-utils.js';
30
30
  import { handleTunnelEndpoint } from './tower-tunnel.js';
31
31
  import { getWorktreeConfig, getActivityHooks } from '../utils/config.js';
32
32
  import { ensureCodevConfigWatcher } from './codev-config-watcher.js';
@@ -35,16 +35,18 @@ import { fetchTeamGitHubData } from '../../lib/team-github.js';
35
35
  import { resolveTarget, resolveAgentInRegistry, broadcastMessage, isResolveError } from './tower-messages.js';
36
36
  import { handleCommandRoute, COMMAND_ROUTE } from './command-relay.js';
37
37
  import { handleCanvasRoute, CANVAS_ROUTE_PREFIX } from './canvas-relay.js';
38
- import { formatArchitectMessage, formatBuilderMessage, formatUserViaVsCodeMessage } from '../utils/message-format.js';
38
+ import { formatArchitectMessage, formatArchitectToBuilderMessage, formatBuilderMessage, formatUserViaVsCodeMessage, isSafeSenderId, messageTooLargeError, MAX_MESSAGE_BYTES, } from '../utils/message-format.js';
39
39
  import { writeMessageToSession, writeEscapeToSession } from './message-write.js';
40
40
  import { makeDeliveryPorts, getMailboxDrainer } from './mailbox-wiring.js';
41
41
  import { deliverAgentMailSerialized } from './mailbox-delivery.js';
42
42
  import { deliverCronMail, CRON_SENDER } from './cron-delivery.js';
43
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';
44
+ import { formatVerdict } from '@cluesmith/codev-sdk/hold-verdict';
45
+ // Spec 1273 per-terminal submission lock. Originally the anti-fusion lock for the two
46
+ // explicit human-bypass paths (escape + interrupt); since Issue #1365 the gated mailbox
47
+ // delivery path takes it too, so it now serializes the whole write edge of a terminal.
48
+ // The operator paths pass a wait ceiling — see OPERATOR_SUBMIT_WAIT_CEILING_MS.
49
+ import { submitToSession, OPERATOR_SUBMIT_WAIT_CEILING_MS } from './session-submit.js';
48
50
  // Spec 1307 `--delay` — Tower-side deferred delivery, re-homed onto the Spec 1313
49
51
  // mailbox (the merge that carried this feature was flattened by a later rebase, so it
50
52
  // is grafted here explicitly): the due-time callback enqueues to the mailbox and
@@ -649,7 +651,12 @@ async function handleTerminalCreate(req, res, ctx) {
649
651
  else {
650
652
  entry.shells.set(roleId, session.id);
651
653
  }
652
- saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null, command ?? null);
654
+ // PIR #1475: prefer the identity the shellper reports. A fresh spawn is
655
+ // the no-op case (it reports back what we just asked for), but routing
656
+ // it through the same accessor keeps one rule everywhere: the row
657
+ // records what is RUNNING, not what was requested.
658
+ logSessionIdentity(ctx.log, 'terminal-create', session.id, ptySession, command ?? null);
659
+ saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null, persistableCommand(ptySession) ?? command ?? null);
653
660
  ctx.log('INFO', `Registered shellper terminal ${session.id} as ${termType} "${roleId}" for workspace ${workspacePath}`);
654
661
  }
655
662
  }
@@ -764,8 +771,20 @@ async function handleTerminalRoutes(req, res, url, match) {
764
771
  }
765
772
  const info = manager.resizeSession(terminalId, body.cols, body.rows);
766
773
  if (!info) {
767
- res.writeHead(404, { 'Content-Type': 'application/json' });
768
- res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
774
+ // Issue #1482: a resize that did not reach the process is NOT a missing session, and it
775
+ // is not a success either — echoing back the requested dimensions (what a 200 here used
776
+ // to do) is how Tower came to believe a geometry the TUI never adopted.
777
+ if (!manager.getSession(terminalId)) {
778
+ res.writeHead(404, { 'Content-Type': 'application/json' });
779
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
780
+ }
781
+ else {
782
+ res.writeHead(409, { 'Content-Type': 'application/json' });
783
+ res.end(JSON.stringify({
784
+ error: 'RESIZE_DROPPED',
785
+ message: `Session ${terminalId} did not accept the resize (no live process or dropped shellper write); dimensions unchanged`,
786
+ }));
787
+ }
769
788
  return;
770
789
  }
771
790
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1274,17 +1293,33 @@ function liveTargetIdentity(result) {
1274
1293
  const archName = architectNameForTerminal(result.workspacePath, result.terminalId);
1275
1294
  return { toAgent: archName ?? result.agent, isArchitectTarget: archName !== null };
1276
1295
  }
1277
- /** Format a message per sender/target — preserves the pre-1313 formatting rules. */
1278
- function formatMessageForTarget(isArchitectTarget, from, message, raw) {
1296
+ /**
1297
+ * Format a message per sender/target — preserves the pre-1313 formatting rules.
1298
+ *
1299
+ * #1574: `toAgent` is the CANONICAL recipient the mailbox row is keyed on (the
1300
+ * architect reverse-map for architect targets, the resolved agent id otherwise),
1301
+ * so the header a recipient reads names the same identity the row was addressed
1302
+ * to. Passing anything else here would make the frame attest to a fiction.
1303
+ *
1304
+ * #1478: `from` is the SENDER, and it is threaded on the any → builder path too —
1305
+ * that branch used to discard it, so an `architect:<name>` origin reached the
1306
+ * builder's composer as a bare `ARCHITECT INSTRUCTION`. The two are complementary:
1307
+ * #1574 answers "was this addressed to me?", #1478 answers "who sent it?".
1308
+ */
1309
+ function formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw) {
1279
1310
  // #1494: a human's approval relayed by the VS Code extension gets its own
1280
1311
  // header, so the architect can tell it from a peer-architect instruction.
1281
1312
  if (isArchitectTarget && from === VSCODE_USER_SENDER)
1282
- return formatUserViaVsCodeMessage(message, undefined, raw);
1313
+ return formatUserViaVsCodeMessage(toAgent, message, undefined, raw);
1283
1314
  if (isArchitectTarget && from)
1284
- return formatBuilderMessage(from, message, undefined, raw); // builder → architect
1315
+ return formatBuilderMessage(from, toAgent, message, undefined, raw); // builder → architect
1316
+ // any → builder: thread the sender so an `architect:<name>` origin is named in the
1317
+ // header (issue #1478) instead of collapsing to a bare `ARCHITECT INSTRUCTION`.
1285
1318
  if (!isArchitectTarget)
1286
- return formatArchitectMessage(message, undefined, raw); // any → builder
1287
- return raw ? message : formatArchitectMessage(message, undefined, false); // unknown → architect
1319
+ return formatArchitectToBuilderMessage(toAgent, message, undefined, raw, from); // any → builder
1320
+ // unknown → architect: `from` is absent by construction on this branch, so there is
1321
+ // no sender to name.
1322
+ return raw ? message : formatArchitectMessage(toAgent, message, undefined, false); // unknown → architect
1288
1323
  }
1289
1324
  /**
1290
1325
  * Route a cron notification through the Spec 1313 mailbox + gate — the Tower-wired
@@ -1304,9 +1339,12 @@ export async function deliverCronMessage(task, message, log) {
1304
1339
  const ports = makeDeliveryPorts(log);
1305
1340
  // Preserve the pre-1313 cron framing: a message FROM the `af-cron` pseudo-builder,
1306
1341
  // regardless of whether the target is an architect or a builder.
1342
+ //
1343
+ // #1574: the frame names its recipient, which is only known AFTER resolution —
1344
+ // so it is built per-branch rather than once up front. `base` carries everything
1345
+ // that does not depend on the target.
1307
1346
  const base = {
1308
1347
  body: message,
1309
- formattedMessage: formatBuilderMessage(CRON_SENDER, message),
1310
1348
  supersedeKey: task.name,
1311
1349
  };
1312
1350
  const live = resolveTarget(task.target, task.workspacePath);
@@ -1314,6 +1352,7 @@ export async function deliverCronMessage(task, message, log) {
1314
1352
  const { toAgent } = liveTargetIdentity(live);
1315
1353
  return deliverCronMail(ports, db, {
1316
1354
  ...base,
1355
+ formattedMessage: formatBuilderMessage(CRON_SENDER, toAgent, message),
1317
1356
  workspacePath: live.workspacePath,
1318
1357
  toAgent,
1319
1358
  terminalId: live.terminalId,
@@ -1327,6 +1366,7 @@ export async function deliverCronMessage(task, message, log) {
1327
1366
  if (!isResolveError(reg)) {
1328
1367
  return deliverCronMail(ports, db, {
1329
1368
  ...base,
1369
+ formattedMessage: formatBuilderMessage(CRON_SENDER, reg.agent, message),
1330
1370
  workspacePath: reg.workspacePath,
1331
1371
  toAgent: reg.agent,
1332
1372
  terminalId: null,
@@ -1334,7 +1374,7 @@ export async function deliverCronMessage(task, message, log) {
1334
1374
  }
1335
1375
  }
1336
1376
  log('WARN', `Cron '${task.name}': target '${task.target}' not found — message not delivered`);
1337
- return { outcome: 'unresolved', reason: null, mailboxId: null };
1377
+ return { outcome: 'unresolved', reason: null, detail: null, mailboxId: null };
1338
1378
  }
1339
1379
  /**
1340
1380
  * Persist a `held` mailbox row and write the Spec 1313 `held` send response. Used
@@ -1355,9 +1395,31 @@ function holdAndRespond(res, ctx, input, reason) {
1355
1395
  delivered: false,
1356
1396
  held: true,
1357
1397
  reason,
1398
+ // A hold from THIS path is always a non-gate one (no live PTY), so it carries no gate
1399
+ // detail (Issue #1482). Reported explicitly rather than omitted so the held response has
1400
+ // one shape whichever path produced it.
1401
+ detail: null,
1358
1402
  mailboxId: row.id,
1403
+ bodyLength: Buffer.byteLength(input.body, 'utf8'),
1359
1404
  });
1360
1405
  }
1406
+ /** Machine-readable reason paired with `degraded: true` on an operator send response. */
1407
+ const DEGRADED_SUBMIT_REASON = 'submit-wait-ceiling-expired';
1408
+ /**
1409
+ * Announce that an operator submission gave up waiting for the per-terminal lock and wrote
1410
+ * unserialized (Issue #1365).
1411
+ *
1412
+ * This is the degraded path, and it is deliberately loud. The ceiling arms only against a
1413
+ * DELIVERY write (behind another operator the wait stays unbounded, as before #1365), so what
1414
+ * expiry falls back to is exactly the pre-#1365 operator-vs-delivery behaviour: two disjoint
1415
+ * locks, no serialization. That one pair is therefore no worse than the old status quo — but
1416
+ * it used to be invisible, and now it is not, here and in the `degraded` flag on the response.
1417
+ */
1418
+ function logCeilingExpired(ctx, action, toAgent, terminalId, waitedMs) {
1419
+ ctx.log('WARN', `${action} → ${toAgent} (terminal ${terminalId.slice(0, 8)}...) waited ${waitedMs}ms for an in-flight ` +
1420
+ `write and proceeded UNSERIALIZED — it may interleave with that write. A message long enough to ` +
1421
+ `hold the line this long is the usual cause.`);
1422
+ }
1361
1423
  /**
1362
1424
  * Handle a delayed (`--delay`) send (Spec 1307, re-homed onto the Spec 1313 mailbox; round 3
1363
1425
  * durable rework). The row is RESOLVED, authorized (the builder-spoofing check inside
@@ -1420,7 +1482,7 @@ function handleDelayedSend(res, ctx, db, params) {
1420
1482
  sendJson(res, statusCode, { error: errorCode, message: live.message });
1421
1483
  return;
1422
1484
  }
1423
- const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1485
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw);
1424
1486
  // Persist NOW, at request time, with the due time. reason=null → SCHEDULED, not stuck.
1425
1487
  const row = enqueueMailbox(db, {
1426
1488
  workspacePath,
@@ -1462,6 +1524,17 @@ function handleDelayedSend(res, ctx, db, params) {
1462
1524
  live.write('\x03'); // Ctrl+C only — end the turn; body follows via the gate
1463
1525
  fired = true;
1464
1526
  return 0; // no body, no Enter on this path
1527
+ }, undefined,
1528
+ // The same operator ceiling as the immediate paths (Issue #1365). This one fires
1529
+ // UNATTENDED, so it is the writer most likely to meet a gated delivery mid-pace —
1530
+ // which is precisely why the delayed `^C` must take this lock at all.
1531
+ {
1532
+ waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
1533
+ onCeilingExpired: (waitedMs) => logCeilingExpired(ctx, 'delayed interrupt ^C', toAgent, terminalId, waitedMs),
1534
+ // This is the one operator path whose write can be a NO-OP (the liveness re-check
1535
+ // above). A degraded no-op raced nobody, so it must not bump the bypass counter —
1536
+ // that would make a concurrent delivery hold and re-deliver for nothing.
1537
+ wroteBytes: () => fired,
1465
1538
  })
1466
1539
  .then(() => ctx.log('INFO', fired
1467
1540
  ? `Delayed interrupt ^C fired → ${toAgent} (terminal ${terminalId.slice(0, 8)}...); body delivers via the gate`
@@ -1488,6 +1561,7 @@ function handleDelayedSend(res, ctx, db, params) {
1488
1561
  deliverAfter,
1489
1562
  mailboxId: row.id,
1490
1563
  notBefore,
1564
+ bodyLength: Buffer.byteLength(params.message, 'utf8'),
1491
1565
  });
1492
1566
  }
1493
1567
  async function handleSend(req, res, ctx) {
@@ -1505,8 +1579,38 @@ async function handleSend(req, res, ctx) {
1505
1579
  res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Missing or empty "message" field' }));
1506
1580
  return;
1507
1581
  }
1582
+ // Issue #1573: refuse an over-large body LOUDLY, here at the route rather than only at the
1583
+ // CLI — `/api/send` is a public local route, and the CLI is not its only caller. Nothing
1584
+ // downstream bounded message size, so an oversized body used to be paced onto the line one
1585
+ // line at a time and reported delivered whatever the composer made of it. Rejecting is the
1586
+ // only honest option: truncating would reproduce #1564 on purpose.
1587
+ const bodyLength = Buffer.byteLength(message, 'utf8');
1588
+ if (bodyLength > MAX_MESSAGE_BYTES) {
1589
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1590
+ res.end(JSON.stringify({ error: 'MESSAGE_TOO_LARGE', message: messageTooLargeError(bodyLength) }));
1591
+ return;
1592
+ }
1508
1593
  // Optional fields
1509
1594
  const from = typeof body.from === 'string' ? body.from : undefined;
1595
+ // Maintainer review (PR #1486): `from` was the one unguarded field on this route — the
1596
+ // body above is bounded by MAX_MESSAGE_BYTES, but the sender was taken verbatim, and
1597
+ // `/api/send` is a public local route whose only well-behaved caller is the CLI. It is
1598
+ // stored on the mailbox row and rendered by every attribution surface, and `afx inbox`
1599
+ // now sizes its `FROM → TO` column to the widest value it holds — so an unbounded
1600
+ // identity is a rendering cost paid by every row, not just its own. Enforced with the
1601
+ // SAME predicate the header formatter uses, so the boundary and the renderer cannot
1602
+ // disagree about what an identity is. Refused rather than silently dropped or
1603
+ // truncated: a message delivered under a quietly-discarded sender is exactly the
1604
+ // unattributed frame this PR exists to fix.
1605
+ if (from !== undefined && !isSafeSenderId(from)) {
1606
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1607
+ res.end(JSON.stringify({
1608
+ error: 'INVALID_PARAMS',
1609
+ message: 'Invalid "from" field: a sender identity must be 1-128 characters of ' +
1610
+ '[A-Za-z0-9._:-] (no whitespace, brackets or newlines).',
1611
+ }));
1612
+ return;
1613
+ }
1510
1614
  const workspace = typeof body.workspace === 'string' ? body.workspace : undefined;
1511
1615
  const fromWorkspace = typeof body.fromWorkspace === 'string' ? body.fromWorkspace : undefined;
1512
1616
  const options = typeof body.options === 'object' && body.options !== null
@@ -1571,7 +1675,7 @@ async function handleSend(req, res, ctx) {
1571
1675
  workspacePath: reg.workspacePath,
1572
1676
  toAgent: reg.agent,
1573
1677
  body: message,
1574
- formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, message, raw),
1678
+ formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, reg.agent, message, raw),
1575
1679
  fromAgent: from ?? null,
1576
1680
  fromWorkspace: senderWorkspace,
1577
1681
  noEnter,
@@ -1609,7 +1713,7 @@ async function handleSend(req, res, ctx) {
1609
1713
  workspacePath: result.workspacePath,
1610
1714
  toAgent,
1611
1715
  body: message,
1612
- formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1716
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw),
1613
1717
  fromAgent: from ?? null,
1614
1718
  fromWorkspace: senderWorkspace,
1615
1719
  noEnter,
@@ -1634,7 +1738,7 @@ async function handleSend(req, res, ctx) {
1634
1738
  workspacePath: result.workspacePath,
1635
1739
  toAgent,
1636
1740
  body: message,
1637
- formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1741
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw),
1638
1742
  fromAgent: from ?? null,
1639
1743
  fromWorkspace: senderWorkspace,
1640
1744
  noEnter,
@@ -1650,7 +1754,17 @@ async function handleSend(req, res, ctx) {
1650
1754
  if (escape) {
1651
1755
  // Awaited: the response must not claim delivery before the ESC and its
1652
1756
  // Enter have actually been written (Spec 1273 verify).
1653
- await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter));
1757
+ // Issue #1365: escape is an operator submission — it blocks behind an in-flight write
1758
+ // (including a gated mailbox delivery, which now takes this same lock) so its ESC and
1759
+ // Enter can no longer truncate a delivery mid-pace, but only up to the wait ceiling.
1760
+ let escapeDegraded = false;
1761
+ await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter), undefined, {
1762
+ waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
1763
+ onCeilingExpired: (waitedMs) => {
1764
+ escapeDegraded = true;
1765
+ logCeilingExpired(ctx, 'ESC', toAgent, result.terminalId, waitedMs);
1766
+ },
1767
+ });
1654
1768
  broadcastMessage({
1655
1769
  type: 'message',
1656
1770
  from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
@@ -1660,10 +1774,16 @@ async function handleSend(req, res, ctx) {
1660
1774
  timestamp: new Date().toISOString(),
1661
1775
  });
1662
1776
  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 });
1777
+ sendJson(res, 200, {
1778
+ ok: true,
1779
+ terminalId: result.terminalId,
1780
+ resolvedTo: toAgent,
1781
+ deferred: false,
1782
+ ...(escapeDegraded ? { degraded: true, degradedReason: DEGRADED_SUBMIT_REASON } : {}),
1783
+ });
1664
1784
  return;
1665
1785
  }
1666
- const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1786
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw);
1667
1787
  // NB: `--delay` (deliverAfter) is handled earlier by handleDelayedSend, before this
1668
1788
  // immediate-path block — a delayed send is resolved + persisted at request time and does
1669
1789
  // not fall through here.
@@ -1701,15 +1821,29 @@ async function handleSend(req, res, ctx) {
1701
1821
  // during the 100 ms gap. `writeMessageToSession(..., 100)` schedules the text 100 ms after
1702
1822
  // the ^C (the settle) and returns the completion offset, so the lock is held until the
1703
1823
  // 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).
1824
+ // Scope of the guarantee (Issue #1365 — this used to end at "interrupt vs interrupt/escape"):
1825
+ // the gated mailbox delivery path now takes this SAME per-terminal lock, so an interrupt is
1826
+ // serialized against a concurrent delivery too. That closes the case where a `^C` landed
1827
+ // inside a delivery's own text→Enter window, clearing the composer while every byte still
1828
+ // reported success — the row read `delivered` for a message the agent never saw. The lock
1829
+ // order is per-agent → per-terminal (the delivery takes this one as a leaf), never the
1830
+ // reverse, so there is no cycle. See session-submit.ts for the full boundary.
1831
+ // Whether this interrupt gave up waiting and wrote unserialized. It must reach the SENDER,
1832
+ // not just the Tower log (codex review of PR #1492): the row above was claimed `delivered`
1833
+ // before the write, so a degraded interrupt would otherwise report an unqualified success
1834
+ // for a body that may have interleaved with the delivery it skipped. We keep the
1835
+ // claim-first tradeoff (un-claiming risks a double delivery — see above) and tell the
1836
+ // truth about it instead.
1837
+ let degraded = false;
1710
1838
  await submitToSession(result.terminalId, () => {
1711
1839
  session.write('\x03'); // Ctrl+C
1712
1840
  return writeMessageToSession(session, formattedMessage, noEnter, 100);
1841
+ }, undefined, {
1842
+ waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
1843
+ onCeilingExpired: (waitedMs) => {
1844
+ degraded = true;
1845
+ logCeilingExpired(ctx, 'interrupt', toAgent, result.terminalId, waitedMs);
1846
+ },
1713
1847
  });
1714
1848
  broadcastMessage({
1715
1849
  type: 'message',
@@ -1729,6 +1863,10 @@ async function handleSend(req, res, ctx) {
1729
1863
  held: false,
1730
1864
  mailboxId: row.id,
1731
1865
  reason: null,
1866
+ detail: null,
1867
+ bodyLength,
1868
+ // Additive and present only when it happened, so older clients are unaffected.
1869
+ ...(degraded ? { degraded: true, degradedReason: DEGRADED_SUBMIT_REASON } : {}),
1732
1870
  });
1733
1871
  return;
1734
1872
  }
@@ -1755,8 +1893,12 @@ async function handleSend(req, res, ctx) {
1755
1893
  ...basePorts,
1756
1894
  getSessionForAgent: (ws, agent) => ws === result.workspacePath && agent === toAgent ? session : basePorts.getSessionForAgent(ws, agent),
1757
1895
  };
1896
+ // Issue #1584: keep the outcome — it carries whether the delivered message's header was
1897
+ // actually seen on the receiving terminal, which is the only thing the sender can act on now
1898
+ // that an unconfirmed delivery is recorded rather than re-written.
1899
+ let outcome = null;
1758
1900
  try {
1759
- await deliverAgentMailSerialized(ports, db, result.workspacePath, toAgent);
1901
+ outcome = await deliverAgentMailSerialized(ports, db, result.workspacePath, toAgent);
1760
1902
  }
1761
1903
  catch (err) {
1762
1904
  // A gate/write error leaves the row HELD (markDelivered only runs on a
@@ -1775,11 +1917,25 @@ async function handleSend(req, res, ctx) {
1775
1917
  held: false,
1776
1918
  mailboxId: row.id,
1777
1919
  reason: null,
1920
+ bodyLength,
1921
+ // Additive (Issue #1584). `false` means the bytes were written and accepted but the header
1922
+ // never appeared on the terminal — delivered, flagged, and NOT re-written. Reported only
1923
+ // when THIS row is the one our own pass delivered: a pass picks the agent's OLDEST held
1924
+ // row, so without the id check a concurrent drainer delivery of this row could be
1925
+ // reported with another message's verification. Absent (verification skipped, or someone
1926
+ // else's pass delivered it) reads exactly as it did before this field existed.
1927
+ ...(outcome?.delivered.includes(row.id) && outcome.verified !== undefined
1928
+ ? { verified: outcome.verified }
1929
+ : {}),
1778
1930
  });
1779
1931
  return;
1780
1932
  }
1781
1933
  const reason = stored?.reason ?? 'busy';
1782
- ctx.log('INFO', `Message held (${reason}): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
1934
+ // Issue #1482: read the detail back off the row the delivery pass just refreshed, rather
1935
+ // than re-deriving it — the row is the record every other surface reads, so the sender's
1936
+ // response and `afx inbox` can never disagree about the same hold.
1937
+ const detail = stored?.detail ?? null;
1938
+ ctx.log('INFO', `Message held (${formatVerdict(reason, detail)}): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
1783
1939
  // The message stayed held → a new held row is in the set; refresh the indicator
1784
1940
  // count (Spec 1313, Phase 7). The delivered branch above needs no fire — the
1785
1941
  // delivery path's onHeldStateChange already broadcast when the row left the set.
@@ -1792,7 +1948,9 @@ async function handleSend(req, res, ctx) {
1792
1948
  delivered: false,
1793
1949
  held: true,
1794
1950
  reason,
1951
+ detail,
1795
1952
  mailboxId: row.id,
1953
+ bodyLength,
1796
1954
  });
1797
1955
  }
1798
1956
  /**
@@ -1805,20 +1963,55 @@ async function handleSend(req, res, ctx) {
1805
1963
  * redaction rule): id, addresses, why-held reason, escalation flag, and enqueue time —
1806
1964
  * the message BODY is deliberately never surfaced here (it travels only over the live
1807
1965
  * terminal stream on delivery). `escalated` is normalized from SQLite's 0/1 to a bool.
1966
+ *
1967
+ * Issue 1450: `workspaceOverride` lets the workspace-scoped route
1968
+ * (`/workspace/<base64>/api/inbox`, which backs the dashboard's held-mail popover) pass the
1969
+ * workspace resolved from the URL prefix, exactly as `handleOverview` / `handleAnalytics` do.
1970
+ * The override arrives already normalized by the prefix decoder, so `normalizeWorkspacePath`
1971
+ * re-running on it is a safe no-op.
1972
+ *
1973
+ * Two separate widening hazards are closed here, because a scoped caller must never receive
1974
+ * another workspace's held mail:
1975
+ * - `??` (not `||`) means `?workspace=` can never take effect once an override was passed.
1976
+ * - An EMPTY override does not fall through to the unscoped all-workspaces listing; it
1977
+ * scopes to a path that matches nothing. Today the dispatcher 400s a missing prefix so
1978
+ * this is unreachable, but "unreachable" is a property of the caller, not of this
1979
+ * function, and the safe failure for a scoped call is zero rows, never every row.
1980
+ * The all-workspaces listing therefore remains reachable only when NO override was passed —
1981
+ * the Tower-level route without `?workspace=`, the direct-caller convenience the CLI never uses.
1982
+ *
1983
+ * NOTE: this lists ALL held rows, including pre-due `--delay` rows, while the badge count
1984
+ * (`heldSummaryForWorkspace`) excludes them — see the `HeldMessage` type for why the two
1985
+ * legitimately disagree.
1808
1986
  */
1809
- function handleInboxList(res, url) {
1810
- const rawWorkspace = url.searchParams.get('workspace');
1987
+ // Exported as a narrow test seam (Issue 1450): the empty-override branch below cannot be
1988
+ // reached through `handleRequest` — the workspace dispatcher rejects a missing or non-absolute
1989
+ // prefix with a 400 before this runs — so the only way to pin that guard's behaviour is to call
1990
+ // the handler directly. Production callers go through the two route tables, not this export.
1991
+ export function handleInboxList(res, url, workspaceOverride) {
1992
+ const scoped = workspaceOverride !== undefined;
1993
+ const rawWorkspace = workspaceOverride ?? url.searchParams.get('workspace');
1811
1994
  // Normalize to the stored realpath key (mailbox workspace_path is normalized at
1812
1995
  // enqueue — tower-routes handleSend / holdAndRespond — matching overview.ts). Without
1813
1996
  // this a symlinked workspace root would miss its own held rows.
1814
- const workspace = rawWorkspace ? normalizeWorkspacePath(rawWorkspace) : undefined;
1997
+ // `scoped ? '' : undefined` on the empty branch: '' matches no workspace_path, so a scoped
1998
+ // call with a blank override returns nothing rather than widening to every workspace.
1999
+ const workspace = rawWorkspace
2000
+ ? normalizeWorkspacePath(rawWorkspace)
2001
+ : (scoped ? '' : undefined);
1815
2002
  const rows = listHeldMailbox(getGlobalDb(), workspace);
2003
+ // Annotated, not inferred: `HeldMessage` is the shared contract this route owes its
2004
+ // clients, so a projection that drifts from it (a dropped field, or a `body` slipping in)
2005
+ // fails the server build rather than only surprising the dashboard at runtime.
1816
2006
  const projected = rows.map((r) => ({
1817
2007
  id: r.id,
1818
2008
  workspacePath: r.workspace_path,
1819
2009
  toAgent: r.to_agent,
1820
2010
  fromAgent: r.from_agent,
1821
2011
  reason: r.reason,
2012
+ // Issue #1482: the gate verdict behind a `busy` hold, so `afx inbox` and the dashboard
2013
+ // popover can distinguish an occupied composer from one the classifier could not read.
2014
+ detail: r.detail,
1822
2015
  escalated: r.escalated === 1,
1823
2016
  createdAt: r.created_at,
1824
2017
  // Spec 1313 round 3: due time of a pre-due delayed (`--delay`) row; null = deliver-ASAP.
@@ -1884,6 +2077,7 @@ function handleInboxShow(req, res, match) {
1884
2077
  fromWorkspace: row.from_workspace,
1885
2078
  status: row.status,
1886
2079
  reason: row.reason,
2080
+ detail: row.detail, // Issue #1482: the gate verdict behind a `busy` hold
1887
2081
  escalated: row.escalated === 1,
1888
2082
  body: row.body,
1889
2083
  createdAt: row.created_at,
@@ -2303,6 +2497,19 @@ async function handleWorkspaceRoutes(req, res, ctx, url) {
2303
2497
  if (req.method === 'GET' && apiPath === 'overview') {
2304
2498
  return handleOverview(res, url, workspacePath, ctx);
2305
2499
  }
2500
+ // GET /api/inbox - held mailbox rows for THIS workspace (Issue 1450). Backs the
2501
+ // dashboard's clickable held-mail counter. Reuses the Tower-level handler with the
2502
+ // workspace resolved from the /workspace/<base64>/ prefix, the same way `overview`
2503
+ // and `analytics` above do — the dashboard calls relative `./api/...` and has no
2504
+ // absolute workspace path of its own to pass as `?workspace=`.
2505
+ //
2506
+ // Deliberately an EXACT match on 'inbox', not a prefix: `inbox/:id` (which returns the
2507
+ // message BODY) and `inbox/:id/dismiss` (which mutates) do not match, fall through to
2508
+ // the 404 below, and so stay off the dashboard surface. That is what keeps this read
2509
+ // path inside Spec 1313's redaction rule and decision 8 (dismissal is CLI-only).
2510
+ if (req.method === 'GET' && apiPath === 'inbox') {
2511
+ return handleInboxList(res, url, workspacePath);
2512
+ }
2306
2513
  // POST /api/overview/refresh - Invalidate overview cache (Spec 0126 Phase 4)
2307
2514
  if (req.method === 'POST' && apiPath === 'overview/refresh') {
2308
2515
  return handleOverviewRefresh(res, ctx);
@@ -2523,7 +2730,9 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
2523
2730
  }
2524
2731
  const entry = getWorkspaceTerminalsEntry(workspacePath);
2525
2732
  entry.shells.set(shellId, session.id);
2526
- saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath, shellCmd);
2733
+ // PIR #1475: hydrated identity wins; a fresh spawn reports back `shellCmd`.
2734
+ logSessionIdentity(ctx.log, 'shell-create', session.id, ptySession, shellCmd);
2735
+ saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath, persistableCommand(ptySession) ?? shellCmd);
2527
2736
  shellCreated = true;
2528
2737
  res.writeHead(200, { 'Content-Type': 'application/json' });
2529
2738
  res.end(JSON.stringify({