@cluesmith/codev 3.3.1 → 3.3.2

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 (118) hide show
  1. package/dashboard-dist/assets/index-D3XaKpZW.js.map +1 -1
  2. package/dist/agent-farm/cli.d.ts.map +1 -1
  3. package/dist/agent-farm/cli.js +52 -1
  4. package/dist/agent-farm/cli.js.map +1 -1
  5. package/dist/agent-farm/commands/reset/constants.d.ts +118 -0
  6. package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -1
  7. package/dist/agent-farm/commands/reset/constants.js +118 -0
  8. package/dist/agent-farm/commands/reset/constants.js.map +1 -1
  9. package/dist/agent-farm/commands/reset/context.d.ts +18 -0
  10. package/dist/agent-farm/commands/reset/context.d.ts.map +1 -1
  11. package/dist/agent-farm/commands/reset/context.js +41 -0
  12. package/dist/agent-farm/commands/reset/context.js.map +1 -1
  13. package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -1
  14. package/dist/agent-farm/commands/reset/receipt.js +16 -5
  15. package/dist/agent-farm/commands/reset/receipt.js.map +1 -1
  16. package/dist/agent-farm/commands/reset/reorient.d.ts +1 -1
  17. package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -1
  18. package/dist/agent-farm/commands/reset/reorient.js +8 -1
  19. package/dist/agent-farm/commands/reset/reorient.js.map +1 -1
  20. package/dist/agent-farm/commands/reset/self.d.ts +335 -0
  21. package/dist/agent-farm/commands/reset/self.d.ts.map +1 -0
  22. package/dist/agent-farm/commands/reset/self.js +636 -0
  23. package/dist/agent-farm/commands/reset/self.js.map +1 -0
  24. package/dist/agent-farm/commands/reset.d.ts.map +1 -1
  25. package/dist/agent-farm/commands/reset.js +23 -18
  26. package/dist/agent-farm/commands/reset.js.map +1 -1
  27. package/dist/agent-farm/commands/self-refresh.d.ts +60 -0
  28. package/dist/agent-farm/commands/self-refresh.d.ts.map +1 -0
  29. package/dist/agent-farm/commands/self-refresh.js +506 -0
  30. package/dist/agent-farm/commands/self-refresh.js.map +1 -0
  31. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/send.js +27 -2
  33. package/dist/agent-farm/commands/send.js.map +1 -1
  34. package/dist/agent-farm/commands/spawn.d.ts +7 -0
  35. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  36. package/dist/agent-farm/commands/spawn.js +13 -1
  37. package/dist/agent-farm/commands/spawn.js.map +1 -1
  38. package/dist/agent-farm/lib/tower-client.d.ts +12 -0
  39. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  40. package/dist/agent-farm/lib/tower-client.js +12 -0
  41. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  42. package/dist/agent-farm/servers/mailbox-delivery.d.ts +141 -13
  43. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
  44. package/dist/agent-farm/servers/mailbox-delivery.js +172 -16
  45. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
  46. package/dist/agent-farm/servers/mailbox-wiring.d.ts +23 -1
  47. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
  48. package/dist/agent-farm/servers/mailbox-wiring.js +91 -31
  49. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
  50. package/dist/agent-farm/servers/message-write.d.ts +58 -18
  51. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  52. package/dist/agent-farm/servers/message-write.js +73 -28
  53. package/dist/agent-farm/servers/message-write.js.map +1 -1
  54. package/dist/agent-farm/servers/render-gate.d.ts +13 -0
  55. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
  56. package/dist/agent-farm/servers/render-gate.js +21 -0
  57. package/dist/agent-farm/servers/render-gate.js.map +1 -1
  58. package/dist/agent-farm/servers/session-submit.d.ts +220 -27
  59. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
  60. package/dist/agent-farm/servers/session-submit.js +333 -42
  61. package/dist/agent-farm/servers/session-submit.js.map +1 -1
  62. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  63. package/dist/agent-farm/servers/tower-routes.js +114 -25
  64. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  65. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
  66. package/dist/agent-farm/servers/tower-tunnel.js +14 -0
  67. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
  68. package/dist/agent-farm/types.d.ts +36 -1
  69. package/dist/agent-farm/types.d.ts.map +1 -1
  70. package/dist/agent-farm/utils/message-format.d.ts +60 -4
  71. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  72. package/dist/agent-farm/utils/message-format.js +115 -22
  73. package/dist/agent-farm/utils/message-format.js.map +1 -1
  74. package/dist/agent-farm/utils/server-utils.d.ts +8 -2
  75. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  76. package/dist/agent-farm/utils/server-utils.js +19 -2
  77. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  78. package/dist/commands/porch/context-refresh.d.ts +181 -0
  79. package/dist/commands/porch/context-refresh.d.ts.map +1 -0
  80. package/dist/commands/porch/context-refresh.js +270 -0
  81. package/dist/commands/porch/context-refresh.js.map +1 -0
  82. package/dist/commands/porch/index.d.ts +6 -0
  83. package/dist/commands/porch/index.d.ts.map +1 -1
  84. package/dist/commands/porch/index.js +46 -0
  85. package/dist/commands/porch/index.js.map +1 -1
  86. package/dist/commands/porch/next.d.ts +0 -9
  87. package/dist/commands/porch/next.d.ts.map +1 -1
  88. package/dist/commands/porch/next.js +133 -4
  89. package/dist/commands/porch/next.js.map +1 -1
  90. package/dist/commands/porch/protocol.d.ts.map +1 -1
  91. package/dist/commands/porch/protocol.js +102 -0
  92. package/dist/commands/porch/protocol.js.map +1 -1
  93. package/dist/commands/porch/types.d.ts +61 -0
  94. package/dist/commands/porch/types.d.ts.map +1 -1
  95. package/dist/lib/self-refresh-invocation.d.ts +60 -0
  96. package/dist/lib/self-refresh-invocation.d.ts.map +1 -0
  97. package/dist/lib/self-refresh-invocation.js +64 -0
  98. package/dist/lib/self-refresh-invocation.js.map +1 -0
  99. package/dist/lib/test-env.d.ts +50 -0
  100. package/dist/lib/test-env.d.ts.map +1 -1
  101. package/dist/lib/test-env.js +76 -0
  102. package/dist/lib/test-env.js.map +1 -1
  103. package/package.json +4 -4
  104. package/skeleton/.claude/skills/arch-save/SKILL.md +8 -2
  105. package/skeleton/.claude/skills/builder-refresh/SKILL.md +138 -0
  106. package/skeleton/.codex/skills/arch-save/SKILL.md +8 -2
  107. package/skeleton/.codex/skills/builder-refresh/SKILL.md +138 -0
  108. package/skeleton/protocol-schema.json +17 -0
  109. package/skeleton/protocols/air/protocol.json +1 -1
  110. package/skeleton/protocols/aspir/protocol.json +5 -1
  111. package/skeleton/protocols/bugfix/protocol.json +1 -1
  112. package/skeleton/protocols/experiment/protocol.json +1 -1
  113. package/skeleton/protocols/maintain/protocol.json +1 -1
  114. package/skeleton/protocols/pir/protocol.json +1 -1
  115. package/skeleton/protocols/protocol-schema.json +17 -0
  116. package/skeleton/protocols/research/protocol.json +1 -1
  117. package/skeleton/protocols/spike/protocol.json +1 -1
  118. package/skeleton/protocols/spir/protocol.json +5 -1
@@ -35,16 +35,17 @@ 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, 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
+ // Spec 1273 per-terminal submission lock. Originally the anti-fusion lock for the two
45
+ // explicit human-bypass paths (escape + interrupt); since Issue #1365 the gated mailbox
46
+ // delivery path takes it too, so it now serializes the whole write edge of a terminal.
47
+ // The operator paths pass a wait ceiling — see OPERATOR_SUBMIT_WAIT_CEILING_MS.
48
+ import { submitToSession, OPERATOR_SUBMIT_WAIT_CEILING_MS } from './session-submit.js';
48
49
  // Spec 1307 `--delay` — Tower-side deferred delivery, re-homed onto the Spec 1313
49
50
  // mailbox (the merge that carried this feature was flattened by a later rebase, so it
50
51
  // is grafted here explicitly): the due-time callback enqueues to the mailbox and
@@ -1274,17 +1275,24 @@ function liveTargetIdentity(result) {
1274
1275
  const archName = architectNameForTerminal(result.workspacePath, result.terminalId);
1275
1276
  return { toAgent: archName ?? result.agent, isArchitectTarget: archName !== null };
1276
1277
  }
1277
- /** Format a message per sender/target — preserves the pre-1313 formatting rules. */
1278
- function formatMessageForTarget(isArchitectTarget, from, message, raw) {
1278
+ /**
1279
+ * Format a message per sender/target — preserves the pre-1313 formatting rules.
1280
+ *
1281
+ * #1574: `toAgent` is the CANONICAL recipient the mailbox row is keyed on (the
1282
+ * architect reverse-map for architect targets, the resolved agent id otherwise),
1283
+ * so the header a recipient reads names the same identity the row was addressed
1284
+ * to. Passing anything else here would make the frame attest to a fiction.
1285
+ */
1286
+ function formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw) {
1279
1287
  // #1494: a human's approval relayed by the VS Code extension gets its own
1280
1288
  // header, so the architect can tell it from a peer-architect instruction.
1281
1289
  if (isArchitectTarget && from === VSCODE_USER_SENDER)
1282
- return formatUserViaVsCodeMessage(message, undefined, raw);
1290
+ return formatUserViaVsCodeMessage(toAgent, message, undefined, raw);
1283
1291
  if (isArchitectTarget && from)
1284
- return formatBuilderMessage(from, message, undefined, raw); // builder → architect
1292
+ return formatBuilderMessage(from, toAgent, message, undefined, raw); // builder → architect
1285
1293
  if (!isArchitectTarget)
1286
- return formatArchitectMessage(message, undefined, raw); // any → builder
1287
- return raw ? message : formatArchitectMessage(message, undefined, false); // unknown → architect
1294
+ return formatArchitectToBuilderMessage(toAgent, message, undefined, raw); // any → builder
1295
+ return raw ? message : formatArchitectMessage(toAgent, message, undefined, false); // unknown → architect
1288
1296
  }
1289
1297
  /**
1290
1298
  * Route a cron notification through the Spec 1313 mailbox + gate — the Tower-wired
@@ -1304,9 +1312,12 @@ export async function deliverCronMessage(task, message, log) {
1304
1312
  const ports = makeDeliveryPorts(log);
1305
1313
  // Preserve the pre-1313 cron framing: a message FROM the `af-cron` pseudo-builder,
1306
1314
  // regardless of whether the target is an architect or a builder.
1315
+ //
1316
+ // #1574: the frame names its recipient, which is only known AFTER resolution —
1317
+ // so it is built per-branch rather than once up front. `base` carries everything
1318
+ // that does not depend on the target.
1307
1319
  const base = {
1308
1320
  body: message,
1309
- formattedMessage: formatBuilderMessage(CRON_SENDER, message),
1310
1321
  supersedeKey: task.name,
1311
1322
  };
1312
1323
  const live = resolveTarget(task.target, task.workspacePath);
@@ -1314,6 +1325,7 @@ export async function deliverCronMessage(task, message, log) {
1314
1325
  const { toAgent } = liveTargetIdentity(live);
1315
1326
  return deliverCronMail(ports, db, {
1316
1327
  ...base,
1328
+ formattedMessage: formatBuilderMessage(CRON_SENDER, toAgent, message),
1317
1329
  workspacePath: live.workspacePath,
1318
1330
  toAgent,
1319
1331
  terminalId: live.terminalId,
@@ -1327,6 +1339,7 @@ export async function deliverCronMessage(task, message, log) {
1327
1339
  if (!isResolveError(reg)) {
1328
1340
  return deliverCronMail(ports, db, {
1329
1341
  ...base,
1342
+ formattedMessage: formatBuilderMessage(CRON_SENDER, reg.agent, message),
1330
1343
  workspacePath: reg.workspacePath,
1331
1344
  toAgent: reg.agent,
1332
1345
  terminalId: null,
@@ -1356,8 +1369,26 @@ function holdAndRespond(res, ctx, input, reason) {
1356
1369
  held: true,
1357
1370
  reason,
1358
1371
  mailboxId: row.id,
1372
+ bodyLength: Buffer.byteLength(input.body, 'utf8'),
1359
1373
  });
1360
1374
  }
1375
+ /** Machine-readable reason paired with `degraded: true` on an operator send response. */
1376
+ const DEGRADED_SUBMIT_REASON = 'submit-wait-ceiling-expired';
1377
+ /**
1378
+ * Announce that an operator submission gave up waiting for the per-terminal lock and wrote
1379
+ * unserialized (Issue #1365).
1380
+ *
1381
+ * This is the degraded path, and it is deliberately loud. The ceiling arms only against a
1382
+ * DELIVERY write (behind another operator the wait stays unbounded, as before #1365), so what
1383
+ * expiry falls back to is exactly the pre-#1365 operator-vs-delivery behaviour: two disjoint
1384
+ * locks, no serialization. That one pair is therefore no worse than the old status quo — but
1385
+ * it used to be invisible, and now it is not, here and in the `degraded` flag on the response.
1386
+ */
1387
+ function logCeilingExpired(ctx, action, toAgent, terminalId, waitedMs) {
1388
+ ctx.log('WARN', `${action} → ${toAgent} (terminal ${terminalId.slice(0, 8)}...) waited ${waitedMs}ms for an in-flight ` +
1389
+ `write and proceeded UNSERIALIZED — it may interleave with that write. A message long enough to ` +
1390
+ `hold the line this long is the usual cause.`);
1391
+ }
1361
1392
  /**
1362
1393
  * Handle a delayed (`--delay`) send (Spec 1307, re-homed onto the Spec 1313 mailbox; round 3
1363
1394
  * durable rework). The row is RESOLVED, authorized (the builder-spoofing check inside
@@ -1420,7 +1451,7 @@ function handleDelayedSend(res, ctx, db, params) {
1420
1451
  sendJson(res, statusCode, { error: errorCode, message: live.message });
1421
1452
  return;
1422
1453
  }
1423
- const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1454
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw);
1424
1455
  // Persist NOW, at request time, with the due time. reason=null → SCHEDULED, not stuck.
1425
1456
  const row = enqueueMailbox(db, {
1426
1457
  workspacePath,
@@ -1462,6 +1493,17 @@ function handleDelayedSend(res, ctx, db, params) {
1462
1493
  live.write('\x03'); // Ctrl+C only — end the turn; body follows via the gate
1463
1494
  fired = true;
1464
1495
  return 0; // no body, no Enter on this path
1496
+ }, undefined,
1497
+ // The same operator ceiling as the immediate paths (Issue #1365). This one fires
1498
+ // UNATTENDED, so it is the writer most likely to meet a gated delivery mid-pace —
1499
+ // which is precisely why the delayed `^C` must take this lock at all.
1500
+ {
1501
+ waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
1502
+ onCeilingExpired: (waitedMs) => logCeilingExpired(ctx, 'delayed interrupt ^C', toAgent, terminalId, waitedMs),
1503
+ // This is the one operator path whose write can be a NO-OP (the liveness re-check
1504
+ // above). A degraded no-op raced nobody, so it must not bump the bypass counter —
1505
+ // that would make a concurrent delivery hold and re-deliver for nothing.
1506
+ wroteBytes: () => fired,
1465
1507
  })
1466
1508
  .then(() => ctx.log('INFO', fired
1467
1509
  ? `Delayed interrupt ^C fired → ${toAgent} (terminal ${terminalId.slice(0, 8)}...); body delivers via the gate`
@@ -1488,6 +1530,7 @@ function handleDelayedSend(res, ctx, db, params) {
1488
1530
  deliverAfter,
1489
1531
  mailboxId: row.id,
1490
1532
  notBefore,
1533
+ bodyLength: Buffer.byteLength(params.message, 'utf8'),
1491
1534
  });
1492
1535
  }
1493
1536
  async function handleSend(req, res, ctx) {
@@ -1505,6 +1548,17 @@ async function handleSend(req, res, ctx) {
1505
1548
  res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Missing or empty "message" field' }));
1506
1549
  return;
1507
1550
  }
1551
+ // Issue #1573: refuse an over-large body LOUDLY, here at the route rather than only at the
1552
+ // CLI — `/api/send` is a public local route, and the CLI is not its only caller. Nothing
1553
+ // downstream bounded message size, so an oversized body used to be paced onto the line one
1554
+ // line at a time and reported delivered whatever the composer made of it. Rejecting is the
1555
+ // only honest option: truncating would reproduce #1564 on purpose.
1556
+ const bodyLength = Buffer.byteLength(message, 'utf8');
1557
+ if (bodyLength > MAX_MESSAGE_BYTES) {
1558
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1559
+ res.end(JSON.stringify({ error: 'MESSAGE_TOO_LARGE', message: messageTooLargeError(bodyLength) }));
1560
+ return;
1561
+ }
1508
1562
  // Optional fields
1509
1563
  const from = typeof body.from === 'string' ? body.from : undefined;
1510
1564
  const workspace = typeof body.workspace === 'string' ? body.workspace : undefined;
@@ -1571,7 +1625,7 @@ async function handleSend(req, res, ctx) {
1571
1625
  workspacePath: reg.workspacePath,
1572
1626
  toAgent: reg.agent,
1573
1627
  body: message,
1574
- formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, message, raw),
1628
+ formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, reg.agent, message, raw),
1575
1629
  fromAgent: from ?? null,
1576
1630
  fromWorkspace: senderWorkspace,
1577
1631
  noEnter,
@@ -1609,7 +1663,7 @@ async function handleSend(req, res, ctx) {
1609
1663
  workspacePath: result.workspacePath,
1610
1664
  toAgent,
1611
1665
  body: message,
1612
- formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1666
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw),
1613
1667
  fromAgent: from ?? null,
1614
1668
  fromWorkspace: senderWorkspace,
1615
1669
  noEnter,
@@ -1634,7 +1688,7 @@ async function handleSend(req, res, ctx) {
1634
1688
  workspacePath: result.workspacePath,
1635
1689
  toAgent,
1636
1690
  body: message,
1637
- formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
1691
+ formattedMessage: formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw),
1638
1692
  fromAgent: from ?? null,
1639
1693
  fromWorkspace: senderWorkspace,
1640
1694
  noEnter,
@@ -1650,7 +1704,17 @@ async function handleSend(req, res, ctx) {
1650
1704
  if (escape) {
1651
1705
  // Awaited: the response must not claim delivery before the ESC and its
1652
1706
  // Enter have actually been written (Spec 1273 verify).
1653
- await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter));
1707
+ // Issue #1365: escape is an operator submission — it blocks behind an in-flight write
1708
+ // (including a gated mailbox delivery, which now takes this same lock) so its ESC and
1709
+ // Enter can no longer truncate a delivery mid-pace, but only up to the wait ceiling.
1710
+ let escapeDegraded = false;
1711
+ await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter), undefined, {
1712
+ waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
1713
+ onCeilingExpired: (waitedMs) => {
1714
+ escapeDegraded = true;
1715
+ logCeilingExpired(ctx, 'ESC', toAgent, result.terminalId, waitedMs);
1716
+ },
1717
+ });
1654
1718
  broadcastMessage({
1655
1719
  type: 'message',
1656
1720
  from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
@@ -1660,10 +1724,16 @@ async function handleSend(req, res, ctx) {
1660
1724
  timestamp: new Date().toISOString(),
1661
1725
  });
1662
1726
  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 });
1727
+ sendJson(res, 200, {
1728
+ ok: true,
1729
+ terminalId: result.terminalId,
1730
+ resolvedTo: toAgent,
1731
+ deferred: false,
1732
+ ...(escapeDegraded ? { degraded: true, degradedReason: DEGRADED_SUBMIT_REASON } : {}),
1733
+ });
1664
1734
  return;
1665
1735
  }
1666
- const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
1736
+ const formattedMessage = formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw);
1667
1737
  // NB: `--delay` (deliverAfter) is handled earlier by handleDelayedSend, before this
1668
1738
  // immediate-path block — a delayed send is resolved + persisted at request time and does
1669
1739
  // not fall through here.
@@ -1701,15 +1771,29 @@ async function handleSend(req, res, ctx) {
1701
1771
  // during the 100 ms gap. `writeMessageToSession(..., 100)` schedules the text 100 ms after
1702
1772
  // the ^C (the settle) and returns the completion offset, so the lock is held until the
1703
1773
  // 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).
1774
+ // Scope of the guarantee (Issue #1365 — this used to end at "interrupt vs interrupt/escape"):
1775
+ // the gated mailbox delivery path now takes this SAME per-terminal lock, so an interrupt is
1776
+ // serialized against a concurrent delivery too. That closes the case where a `^C` landed
1777
+ // inside a delivery's own text→Enter window, clearing the composer while every byte still
1778
+ // reported success — the row read `delivered` for a message the agent never saw. The lock
1779
+ // order is per-agent → per-terminal (the delivery takes this one as a leaf), never the
1780
+ // reverse, so there is no cycle. See session-submit.ts for the full boundary.
1781
+ // Whether this interrupt gave up waiting and wrote unserialized. It must reach the SENDER,
1782
+ // not just the Tower log (codex review of PR #1492): the row above was claimed `delivered`
1783
+ // before the write, so a degraded interrupt would otherwise report an unqualified success
1784
+ // for a body that may have interleaved with the delivery it skipped. We keep the
1785
+ // claim-first tradeoff (un-claiming risks a double delivery — see above) and tell the
1786
+ // truth about it instead.
1787
+ let degraded = false;
1710
1788
  await submitToSession(result.terminalId, () => {
1711
1789
  session.write('\x03'); // Ctrl+C
1712
1790
  return writeMessageToSession(session, formattedMessage, noEnter, 100);
1791
+ }, undefined, {
1792
+ waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
1793
+ onCeilingExpired: (waitedMs) => {
1794
+ degraded = true;
1795
+ logCeilingExpired(ctx, 'interrupt', toAgent, result.terminalId, waitedMs);
1796
+ },
1713
1797
  });
1714
1798
  broadcastMessage({
1715
1799
  type: 'message',
@@ -1729,6 +1813,9 @@ async function handleSend(req, res, ctx) {
1729
1813
  held: false,
1730
1814
  mailboxId: row.id,
1731
1815
  reason: null,
1816
+ bodyLength,
1817
+ // Additive and present only when it happened, so older clients are unaffected.
1818
+ ...(degraded ? { degraded: true, degradedReason: DEGRADED_SUBMIT_REASON } : {}),
1732
1819
  });
1733
1820
  return;
1734
1821
  }
@@ -1775,6 +1862,7 @@ async function handleSend(req, res, ctx) {
1775
1862
  held: false,
1776
1863
  mailboxId: row.id,
1777
1864
  reason: null,
1865
+ bodyLength,
1778
1866
  });
1779
1867
  return;
1780
1868
  }
@@ -1793,6 +1881,7 @@ async function handleSend(req, res, ctx) {
1793
1881
  held: true,
1794
1882
  reason,
1795
1883
  mailboxId: row.id,
1884
+ bodyLength,
1796
1885
  });
1797
1886
  }
1798
1887
  /**