@cluesmith/codev 3.3.2 → 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 (157) 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/commands/inbox.d.ts.map +1 -1
  6. package/dist/agent-farm/commands/inbox.js +67 -10
  7. package/dist/agent-farm/commands/inbox.js.map +1 -1
  8. package/dist/agent-farm/commands/interrupt.d.ts +4 -1
  9. package/dist/agent-farm/commands/interrupt.d.ts.map +1 -1
  10. package/dist/agent-farm/commands/interrupt.js +6 -3
  11. package/dist/agent-farm/commands/interrupt.js.map +1 -1
  12. package/dist/agent-farm/commands/reset.d.ts +4 -1
  13. package/dist/agent-farm/commands/reset.d.ts.map +1 -1
  14. package/dist/agent-farm/commands/reset.js +6 -3
  15. package/dist/agent-farm/commands/reset.js.map +1 -1
  16. package/dist/agent-farm/commands/send.d.ts +28 -0
  17. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  18. package/dist/agent-farm/commands/send.js +64 -6
  19. package/dist/agent-farm/commands/send.js.map +1 -1
  20. package/dist/agent-farm/db/index.d.ts +3 -1
  21. package/dist/agent-farm/db/index.d.ts.map +1 -1
  22. package/dist/agent-farm/db/index.js +8 -443
  23. package/dist/agent-farm/db/index.js.map +1 -1
  24. package/dist/agent-farm/db/mailbox.d.ts +47 -7
  25. package/dist/agent-farm/db/mailbox.d.ts.map +1 -1
  26. package/dist/agent-farm/db/mailbox.js +56 -12
  27. package/dist/agent-farm/db/mailbox.js.map +1 -1
  28. package/dist/agent-farm/db/migrations.d.ts +57 -0
  29. package/dist/agent-farm/db/migrations.d.ts.map +1 -0
  30. package/dist/agent-farm/db/migrations.js +530 -0
  31. package/dist/agent-farm/db/migrations.js.map +1 -0
  32. package/dist/agent-farm/db/schema.d.ts +1 -1
  33. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  34. package/dist/agent-farm/db/schema.js +1 -0
  35. package/dist/agent-farm/db/schema.js.map +1 -1
  36. package/dist/agent-farm/db/types.d.ts +22 -0
  37. package/dist/agent-farm/db/types.d.ts.map +1 -1
  38. package/dist/agent-farm/db/types.js.map +1 -1
  39. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  40. package/dist/agent-farm/lib/tunnel-client.js +43 -14
  41. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  42. package/dist/agent-farm/servers/cron-delivery.d.ts +7 -1
  43. package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -1
  44. package/dist/agent-farm/servers/cron-delivery.js +2 -1
  45. package/dist/agent-farm/servers/cron-delivery.js.map +1 -1
  46. package/dist/agent-farm/servers/gate-profiles.d.ts +21 -0
  47. package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -1
  48. package/dist/agent-farm/servers/gate-profiles.js +36 -4
  49. package/dist/agent-farm/servers/gate-profiles.js.map +1 -1
  50. package/dist/agent-farm/servers/mailbox-delivery.d.ts +83 -8
  51. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
  52. package/dist/agent-farm/servers/mailbox-delivery.js +102 -26
  53. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
  54. package/dist/agent-farm/servers/mailbox-wiring.d.ts +48 -15
  55. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
  56. package/dist/agent-farm/servers/mailbox-wiring.js +116 -22
  57. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
  58. package/dist/agent-farm/servers/render-gate.d.ts +24 -1
  59. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
  60. package/dist/agent-farm/servers/render-gate.js +50 -12
  61. package/dist/agent-farm/servers/render-gate.js.map +1 -1
  62. package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
  63. package/dist/agent-farm/servers/tower-cron.js +3 -2
  64. package/dist/agent-farm/servers/tower-cron.js.map +1 -1
  65. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
  66. package/dist/agent-farm/servers/tower-instances.js +11 -3
  67. package/dist/agent-farm/servers/tower-instances.js.map +1 -1
  68. package/dist/agent-farm/servers/tower-routes.d.ts +32 -0
  69. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  70. package/dist/agent-farm/servers/tower-routes.js +133 -13
  71. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  72. package/dist/agent-farm/servers/tower-server.js +15 -2
  73. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  74. package/dist/agent-farm/servers/tower-terminals.d.ts +21 -0
  75. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  76. package/dist/agent-farm/servers/tower-terminals.js +53 -5
  77. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  78. package/dist/agent-farm/servers/tower-utils.d.ts +35 -0
  79. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
  80. package/dist/agent-farm/servers/tower-utils.js +39 -0
  81. package/dist/agent-farm/servers/tower-utils.js.map +1 -1
  82. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  83. package/dist/agent-farm/servers/tower-websocket.js +8 -1
  84. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  85. package/dist/agent-farm/utils/message-format.d.ts +78 -2
  86. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  87. package/dist/agent-farm/utils/message-format.js +102 -5
  88. package/dist/agent-farm/utils/message-format.js.map +1 -1
  89. package/dist/cli.d.ts.map +1 -1
  90. package/dist/cli.js +3 -1
  91. package/dist/cli.js.map +1 -1
  92. package/dist/commands/doctor.js +2 -2
  93. package/dist/commands/generate-image.d.ts +20 -1
  94. package/dist/commands/generate-image.d.ts.map +1 -1
  95. package/dist/commands/generate-image.js +188 -1
  96. package/dist/commands/generate-image.js.map +1 -1
  97. package/dist/commands/porch/prompts.d.ts.map +1 -1
  98. package/dist/commands/porch/prompts.js +18 -2
  99. package/dist/commands/porch/prompts.js.map +1 -1
  100. package/dist/lib/forge-contracts.d.ts +66 -5
  101. package/dist/lib/forge-contracts.d.ts.map +1 -1
  102. package/dist/lib/forge-contracts.js.map +1 -1
  103. package/dist/lib/forge.d.ts +1 -1
  104. package/dist/lib/forge.d.ts.map +1 -1
  105. package/dist/lib/forge.js +33 -11
  106. package/dist/lib/forge.js.map +1 -1
  107. package/dist/terminal/pty-manager.d.ts +10 -1
  108. package/dist/terminal/pty-manager.d.ts.map +1 -1
  109. package/dist/terminal/pty-manager.js +38 -9
  110. package/dist/terminal/pty-manager.js.map +1 -1
  111. package/dist/terminal/pty-session.d.ts +52 -4
  112. package/dist/terminal/pty-session.d.ts.map +1 -1
  113. package/dist/terminal/pty-session.js +157 -18
  114. package/dist/terminal/pty-session.js.map +1 -1
  115. package/dist/terminal/shellper-client.d.ts +73 -0
  116. package/dist/terminal/shellper-client.d.ts.map +1 -1
  117. package/dist/terminal/shellper-client.js +151 -0
  118. package/dist/terminal/shellper-client.js.map +1 -1
  119. package/dist/terminal/shellper-main.js +20 -3
  120. package/dist/terminal/shellper-main.js.map +1 -1
  121. package/dist/terminal/shellper-process.d.ts +2 -0
  122. package/dist/terminal/shellper-process.d.ts.map +1 -1
  123. package/dist/terminal/shellper-process.js +17 -1
  124. package/dist/terminal/shellper-process.js.map +1 -1
  125. package/dist/terminal/shellper-protocol.d.ts +28 -0
  126. package/dist/terminal/shellper-protocol.d.ts.map +1 -1
  127. package/dist/terminal/shellper-protocol.js.map +1 -1
  128. package/package.json +4 -4
  129. package/scripts/forge/gitea/_lib.sh +214 -0
  130. package/scripts/forge/gitea/issue-comment.sh +8 -1
  131. package/scripts/forge/gitea/issue-view.sh +95 -4
  132. package/scripts/forge/gitea/pr-create.sh +155 -0
  133. package/scripts/forge/gitea/pr-exists.sh +32 -4
  134. package/scripts/forge/gitea/pr-list.sh +40 -31
  135. package/scripts/forge/gitea/pr-view.sh +65 -4
  136. package/scripts/forge/gitea/recently-merged.sh +91 -21
  137. package/scripts/forge/gitea/user-identity.sh +30 -1
  138. package/scripts/forge/github/issue-view.sh +5 -2
  139. package/scripts/forge/github/pr-create.sh +47 -0
  140. package/scripts/forge/github/pr-search.sh +3 -1
  141. package/scripts/forge/gitlab/issue-view.sh +24 -4
  142. package/scripts/forge/gitlab/pr-create.sh +53 -0
  143. package/scripts/forge/gitlab/pr-search.sh +3 -1
  144. package/scripts/forge/linear/issue-view.sh +13 -2
  145. package/skeleton/.claude/skills/generate-image/SKILL.md +22 -1
  146. package/skeleton/.codex/skills/generate-image/SKILL.md +22 -1
  147. package/skeleton/protocols/air/prompts/pr.md +6 -1
  148. package/skeleton/protocols/aspir/prompts/review.md +6 -1
  149. package/skeleton/protocols/bugfix/prompts/pr.md +6 -1
  150. package/skeleton/protocols/bugfix/protocol.md +2 -1
  151. package/skeleton/protocols/maintain/prompts/review.md +6 -1
  152. package/skeleton/protocols/pir/prompts/review.md +10 -7
  153. package/skeleton/protocols/spir/prompts/review.md +6 -1
  154. package/skeleton/resources/commands/agent-farm.md +1 -1
  155. package/templates/tower.html +16 -1
  156. package/dashboard-dist/assets/index-BZRTEMtH.css +0 -32
  157. 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,12 +35,13 @@ 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, formatArchitectToBuilderMessage, formatBuilderMessage, formatUserViaVsCodeMessage, messageTooLargeError, MAX_MESSAGE_BYTES, } 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
+ import { formatVerdict } from '@cluesmith/codev-sdk/hold-verdict';
44
45
  // Spec 1273 per-terminal submission lock. Originally the anti-fusion lock for the two
45
46
  // explicit human-bypass paths (escape + interrupt); since Issue #1365 the gated mailbox
46
47
  // delivery path takes it too, so it now serializes the whole write edge of a terminal.
@@ -650,7 +651,12 @@ async function handleTerminalCreate(req, res, ctx) {
650
651
  else {
651
652
  entry.shells.set(roleId, session.id);
652
653
  }
653
- 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);
654
660
  ctx.log('INFO', `Registered shellper terminal ${session.id} as ${termType} "${roleId}" for workspace ${workspacePath}`);
655
661
  }
656
662
  }
@@ -765,8 +771,20 @@ async function handleTerminalRoutes(req, res, url, match) {
765
771
  }
766
772
  const info = manager.resizeSession(terminalId, body.cols, body.rows);
767
773
  if (!info) {
768
- res.writeHead(404, { 'Content-Type': 'application/json' });
769
- 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
+ }
770
788
  return;
771
789
  }
772
790
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1282,6 +1300,11 @@ function liveTargetIdentity(result) {
1282
1300
  * architect reverse-map for architect targets, the resolved agent id otherwise),
1283
1301
  * so the header a recipient reads names the same identity the row was addressed
1284
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?".
1285
1308
  */
1286
1309
  function formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw) {
1287
1310
  // #1494: a human's approval relayed by the VS Code extension gets its own
@@ -1290,8 +1313,12 @@ function formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw)
1290
1313
  return formatUserViaVsCodeMessage(toAgent, message, undefined, raw);
1291
1314
  if (isArchitectTarget && from)
1292
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`.
1293
1318
  if (!isArchitectTarget)
1294
- return formatArchitectToBuilderMessage(toAgent, message, undefined, raw); // any → builder
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.
1295
1322
  return raw ? message : formatArchitectMessage(toAgent, message, undefined, false); // unknown → architect
1296
1323
  }
1297
1324
  /**
@@ -1347,7 +1374,7 @@ export async function deliverCronMessage(task, message, log) {
1347
1374
  }
1348
1375
  }
1349
1376
  log('WARN', `Cron '${task.name}': target '${task.target}' not found — message not delivered`);
1350
- return { outcome: 'unresolved', reason: null, mailboxId: null };
1377
+ return { outcome: 'unresolved', reason: null, detail: null, mailboxId: null };
1351
1378
  }
1352
1379
  /**
1353
1380
  * Persist a `held` mailbox row and write the Spec 1313 `held` send response. Used
@@ -1368,6 +1395,10 @@ function holdAndRespond(res, ctx, input, reason) {
1368
1395
  delivered: false,
1369
1396
  held: true,
1370
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,
1371
1402
  mailboxId: row.id,
1372
1403
  bodyLength: Buffer.byteLength(input.body, 'utf8'),
1373
1404
  });
@@ -1561,6 +1592,25 @@ async function handleSend(req, res, ctx) {
1561
1592
  }
1562
1593
  // Optional fields
1563
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
+ }
1564
1614
  const workspace = typeof body.workspace === 'string' ? body.workspace : undefined;
1565
1615
  const fromWorkspace = typeof body.fromWorkspace === 'string' ? body.fromWorkspace : undefined;
1566
1616
  const options = typeof body.options === 'object' && body.options !== null
@@ -1813,6 +1863,7 @@ async function handleSend(req, res, ctx) {
1813
1863
  held: false,
1814
1864
  mailboxId: row.id,
1815
1865
  reason: null,
1866
+ detail: null,
1816
1867
  bodyLength,
1817
1868
  // Additive and present only when it happened, so older clients are unaffected.
1818
1869
  ...(degraded ? { degraded: true, degradedReason: DEGRADED_SUBMIT_REASON } : {}),
@@ -1842,8 +1893,12 @@ async function handleSend(req, res, ctx) {
1842
1893
  ...basePorts,
1843
1894
  getSessionForAgent: (ws, agent) => ws === result.workspacePath && agent === toAgent ? session : basePorts.getSessionForAgent(ws, agent),
1844
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;
1845
1900
  try {
1846
- await deliverAgentMailSerialized(ports, db, result.workspacePath, toAgent);
1901
+ outcome = await deliverAgentMailSerialized(ports, db, result.workspacePath, toAgent);
1847
1902
  }
1848
1903
  catch (err) {
1849
1904
  // A gate/write error leaves the row HELD (markDelivered only runs on a
@@ -1863,11 +1918,24 @@ async function handleSend(req, res, ctx) {
1863
1918
  mailboxId: row.id,
1864
1919
  reason: null,
1865
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
+ : {}),
1866
1930
  });
1867
1931
  return;
1868
1932
  }
1869
1933
  const reason = stored?.reason ?? 'busy';
1870
- 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)}...)`);
1871
1939
  // The message stayed held → a new held row is in the set; refresh the indicator
1872
1940
  // count (Spec 1313, Phase 7). The delivered branch above needs no fire — the
1873
1941
  // delivery path's onHeldStateChange already broadcast when the row left the set.
@@ -1880,6 +1948,7 @@ async function handleSend(req, res, ctx) {
1880
1948
  delivered: false,
1881
1949
  held: true,
1882
1950
  reason,
1951
+ detail,
1883
1952
  mailboxId: row.id,
1884
1953
  bodyLength,
1885
1954
  });
@@ -1894,20 +1963,55 @@ async function handleSend(req, res, ctx) {
1894
1963
  * redaction rule): id, addresses, why-held reason, escalation flag, and enqueue time —
1895
1964
  * the message BODY is deliberately never surfaced here (it travels only over the live
1896
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.
1897
1986
  */
1898
- function handleInboxList(res, url) {
1899
- 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');
1900
1994
  // Normalize to the stored realpath key (mailbox workspace_path is normalized at
1901
1995
  // enqueue — tower-routes handleSend / holdAndRespond — matching overview.ts). Without
1902
1996
  // this a symlinked workspace root would miss its own held rows.
1903
- 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);
1904
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.
1905
2006
  const projected = rows.map((r) => ({
1906
2007
  id: r.id,
1907
2008
  workspacePath: r.workspace_path,
1908
2009
  toAgent: r.to_agent,
1909
2010
  fromAgent: r.from_agent,
1910
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,
1911
2015
  escalated: r.escalated === 1,
1912
2016
  createdAt: r.created_at,
1913
2017
  // Spec 1313 round 3: due time of a pre-due delayed (`--delay`) row; null = deliver-ASAP.
@@ -1973,6 +2077,7 @@ function handleInboxShow(req, res, match) {
1973
2077
  fromWorkspace: row.from_workspace,
1974
2078
  status: row.status,
1975
2079
  reason: row.reason,
2080
+ detail: row.detail, // Issue #1482: the gate verdict behind a `busy` hold
1976
2081
  escalated: row.escalated === 1,
1977
2082
  body: row.body,
1978
2083
  createdAt: row.created_at,
@@ -2392,6 +2497,19 @@ async function handleWorkspaceRoutes(req, res, ctx, url) {
2392
2497
  if (req.method === 'GET' && apiPath === 'overview') {
2393
2498
  return handleOverview(res, url, workspacePath, ctx);
2394
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
+ }
2395
2513
  // POST /api/overview/refresh - Invalidate overview cache (Spec 0126 Phase 4)
2396
2514
  if (req.method === 'POST' && apiPath === 'overview/refresh') {
2397
2515
  return handleOverviewRefresh(res, ctx);
@@ -2612,7 +2730,9 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
2612
2730
  }
2613
2731
  const entry = getWorkspaceTerminalsEntry(workspacePath);
2614
2732
  entry.shells.set(shellId, session.id);
2615
- 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);
2616
2736
  shellCreated = true;
2617
2737
  res.writeHead(200, { 'Content-Type': 'application/json' });
2618
2738
  res.end(JSON.stringify({