switchroom 0.17.10 → 0.18.6

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 (149) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +29 -2
  3. package/dist/auth-broker/index.js +6163 -152
  4. package/dist/cli/notion-write-pretool.mjs +31 -3
  5. package/dist/cli/switchroom.js +695 -526
  6. package/dist/host-control/main.js +6184 -173
  7. package/dist/vault/approvals/kernel-server.js +5893 -165
  8. package/dist/vault/broker/server.js +6666 -921
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +2 -2
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/switchroom-release/SKILL.md +78 -0
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -25
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +740 -106
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  25. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  26. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  27. package/telegram-plugin/operator-events.ts +21 -0
  28. package/telegram-plugin/package.json +6 -0
  29. package/telegram-plugin/pending-work-progress.ts +42 -7
  30. package/telegram-plugin/quota-bar-format.ts +360 -0
  31. package/telegram-plugin/quota-watch.ts +4 -6
  32. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  33. package/telegram-plugin/registry/turns-schema.ts +78 -0
  34. package/telegram-plugin/render/ir.ts +209 -0
  35. package/telegram-plugin/render/parse.ts +363 -0
  36. package/telegram-plugin/render/render.ts +440 -0
  37. package/telegram-plugin/render/rich-render.ts +72 -0
  38. package/telegram-plugin/stream-controller.ts +14 -3
  39. package/telegram-plugin/subagent-watcher.ts +27 -9
  40. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  41. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  42. package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
  43. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  44. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  45. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  46. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  47. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  48. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  49. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  50. package/telegram-plugin/tests/model-command.test.ts +193 -16
  51. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  52. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  53. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  54. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  55. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  57. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  58. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  59. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  60. package/telegram-plugin/tests/render/render.test.ts +436 -0
  61. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  62. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  63. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  64. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  65. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  66. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  67. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  68. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  69. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  70. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  71. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  72. package/telegram-plugin/worker-activity-feed.ts +220 -15
  73. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  74. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  75. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  76. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  78. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  79. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
  80. package/profiles/default/CLAUDE.md +0 -116
  81. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  82. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  126. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -35,6 +35,7 @@ import {
35
35
  writeActivityCardRecord,
36
36
  clearActivityCardRecord,
37
37
  runActivityCardBootReaper,
38
+ runActivityCardMidSessionReaper,
38
39
  restartOrphanCardFinalizeText,
39
40
  type ActivityCardRecord,
40
41
  type ActivityCardStoreFsSeam,
@@ -434,3 +435,96 @@ describe('restartOrphanCardFinalizeText', () => {
434
435
  expect(text).not.toContain('was running')
435
436
  })
436
437
  })
438
+
439
+ describe('runActivityCardMidSessionReaper (#2918 mid-session sweep)', () => {
440
+ const NOW = 2_000_000_000_000
441
+ const TTL = 15 * 60_000
442
+
443
+ it('finalizes + unpins an ownerless stale card, and deletes its record', async () => {
444
+ const dead = card({ turnKey: 'c:dead', activityMessageId: 715, startedAt: NOW - 30 * 60_000 })
445
+ const { fs, files } = memFs()
446
+ writeActivityCardRecord(PATH, fs, dead)
447
+ const finalized: number[] = []
448
+ const unpinned: number[] = []
449
+ const res = await runActivityCardMidSessionReaper({
450
+ path: PATH, fs,
451
+ isLive: () => false, // process gone, no live owner
452
+ ttlMs: TTL, now: NOW,
453
+ finalizeCard: async (r) => { finalized.push(r.activityMessageId); return { message_id: r.activityMessageId } },
454
+ unpinCard: async (r) => { unpinned.push(r.activityMessageId); return true },
455
+ })
456
+ expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 1, total: 1 })
457
+ expect(finalized).toEqual([715])
458
+ expect(unpinned).toEqual([715])
459
+ // Record deleted BEFORE the edit (at-most-once idempotency guard).
460
+ expect(loadActivityCards(PATH, fs)).toEqual([])
461
+ void files
462
+ })
463
+
464
+ it('NEVER touches a live card (isLive true) — spinner keeps spinning', async () => {
465
+ const live = card({ turnKey: 'c:live', activityMessageId: 900, startedAt: NOW - 6 * 60 * 60_000 })
466
+ const { fs } = memFs()
467
+ writeActivityCardRecord(PATH, fs, live)
468
+ let edits = 0
469
+ const res = await runActivityCardMidSessionReaper({
470
+ path: PATH, fs,
471
+ isLive: (r) => r.turnKey === 'c:live',
472
+ ttlMs: TTL, now: NOW,
473
+ finalizeCard: async () => { edits++; return {} },
474
+ unpinCard: async () => true,
475
+ })
476
+ expect(res.total).toBe(0)
477
+ expect(edits).toBe(0)
478
+ // Live record untouched on disk.
479
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
480
+ })
481
+
482
+ it('does not reap an ownerless card younger than the TTL (race guard)', async () => {
483
+ const fresh = card({ turnKey: 'c:fresh', startedAt: NOW - 60_000 })
484
+ const { fs } = memFs()
485
+ writeActivityCardRecord(PATH, fs, fresh)
486
+ let edits = 0
487
+ const res = await runActivityCardMidSessionReaper({
488
+ path: PATH, fs,
489
+ isLive: () => false,
490
+ ttlMs: TTL, now: NOW,
491
+ finalizeCard: async () => { edits++; return {} },
492
+ unpinCard: async () => true,
493
+ })
494
+ expect(res.total).toBe(0)
495
+ expect(edits).toBe(0)
496
+ expect(loadActivityCards(PATH, fs)).toHaveLength(1)
497
+ })
498
+
499
+ it('reaps the dead card while sparing a concurrently-live one', async () => {
500
+ const { fs } = memFs()
501
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:dead', activityMessageId: 1, startedAt: NOW - 30 * 60_000 }))
502
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:live', activityMessageId: 2, startedAt: NOW - 30 * 60_000 }))
503
+ const finalized: number[] = []
504
+ const res = await runActivityCardMidSessionReaper({
505
+ path: PATH, fs,
506
+ isLive: (r) => r.turnKey === 'c:live',
507
+ ttlMs: TTL, now: NOW,
508
+ finalizeCard: async (r) => { finalized.push(r.activityMessageId); return {} },
509
+ unpinCard: async () => true,
510
+ })
511
+ expect(res.total).toBe(1)
512
+ expect(finalized).toEqual([1])
513
+ const remaining = loadActivityCards(PATH, fs)
514
+ expect(remaining).toHaveLength(1)
515
+ expect(remaining[0]!.turnKey).toBe('c:live')
516
+ })
517
+
518
+ it('counts a benign-400 (vanished) card as vanished, not finalized', async () => {
519
+ const { fs } = memFs()
520
+ writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:gone', pinned: false, startedAt: NOW - 30 * 60_000 }))
521
+ const res = await runActivityCardMidSessionReaper({
522
+ path: PATH, fs,
523
+ isLive: () => false,
524
+ ttlMs: TTL, now: NOW,
525
+ finalizeCard: async () => undefined, // robustApiCall swallowed a benign 400
526
+ unpinCard: async () => true,
527
+ })
528
+ expect(res).toEqual({ finalized: 0, vanished: 1, unpinned: 0, total: 1 })
529
+ })
530
+ })
@@ -94,7 +94,7 @@ describe('renderShowText — Format 2 vs legacy', () => {
94
94
  expect(out).toContain('🔋 **Auth — fleet status**');
95
95
  expect(out).toContain('Recommendation:');
96
96
  // GFM table card (#2700): State emoji per row, not group headers.
97
- expect(out).toContain('| State | Account | 5h | 7d | Status |');
97
+ expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
98
98
  expect(out).toContain('| 🔴 |'); // the blocked account's State cell
99
99
  expect(out).toContain('| 🟢 |'); // a healthy account's State cell
100
100
  expect(out).not.toContain('**BLOCKED**');
@@ -262,11 +262,13 @@ describe('renderAuthSnapshotFormat2', () => {
262
262
  return rows;
263
263
  }
264
264
 
265
- it('renders a GFM table with the State/Account/5h/7d/Status header', () => {
265
+ it('renders a GFM table with the State/Account/5h/5h resets/7d/7d resets header', () => {
266
266
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
267
267
  expect(out).toContain('🔋 **Auth — fleet status**');
268
- expect(out).toContain('| State | Account | 5h | 7d | Status |');
269
- expect(out).toContain('| --- | --- | --- | --- | --- |');
268
+ expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
269
+ expect(out).toContain('| --- | --- | --- | --- | --- | --- |');
270
+ // The single collapsed Status column is gone.
271
+ expect(out).not.toContain('| 7d | Status |');
270
272
  // No legacy group headers / health-section titles remain.
271
273
  expect(out).not.toContain('**BLOCKED**');
272
274
  expect(out).not.toContain('**HEALTHY**');
@@ -307,16 +309,22 @@ describe('renderAuthSnapshotFormat2', () => {
307
309
  expect(bob).toBeLessThan(alice);
308
310
  });
309
311
 
310
- it('Status column shows "back <when>" for a blocked account', () => {
312
+ it('reset columns show a "<time> (in ...)" cell for a blocked account (7d binding window)', () => {
311
313
  const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
312
314
  const bob = rows.find((r) => r[1].includes('bob@example.com'))!;
313
- expect(bob[4]).toMatch(/^back .* \(in .+\)$/);
315
+ // bob is 7d-maxed; its 7d reset (2026-05-17T10:00Z) is ~2 days out and still
316
+ // renders in its own reset cell — no "back" prefix in the new per-window shape.
317
+ expect(bob[5]).toMatch(/^.* \(in .+\)$/);
318
+ expect(bob[5]).not.toContain('back');
314
319
  });
315
320
 
316
- it('Status column shows "refills <when>" for a healthy account', () => {
321
+ it('reset columns show a "<time> (in ...)" cell for each window of a healthy account', () => {
317
322
  const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
318
323
  const you = rows.find((r) => r[1].includes('you@example.com'))!;
319
- expect(you[4]).toMatch(/^refills .* \(in .+\)$/);
324
+ // 5h resets cell [3] and 7d resets cell [5] both carry a relative hint.
325
+ expect(you[3]).toMatch(/^.* \(in .+\)$/);
326
+ expect(you[5]).toMatch(/^.* \(in .+\)$/);
327
+ expect(you[3]).not.toContain('refills');
320
328
  });
321
329
 
322
330
  it('NEVER displays a percentage over 100% even on an over-cap account', () => {
@@ -330,13 +338,15 @@ describe('renderAuthSnapshotFormat2', () => {
330
338
  ];
331
339
  const rows = tableRows(renderAuthSnapshotFormat2(overSnaps, { now: NOW, tz: 'UTC' }));
332
340
  expect(rows[0][2]).toBe('100%'); // 5h
333
- expect(rows[0][3]).toBe('100%'); // 7d
334
- // And the blocked state still surfaces via the emoji (🔴) + Status.
341
+ expect(rows[0][4]).toBe('100%'); // 7d
342
+ // And the blocked state still surfaces via the emoji (🔴). No resets known
343
+ // (fixture has no reset timestamps) → both reset cells degrade to "—".
335
344
  expect(rows[0][0]).toBe('🔴');
336
- expect(rows[0][4]).toMatch(/^back/);
345
+ expect(rows[0][3]).toBe('—'); // 5h resets
346
+ expect(rows[0][5]).toBe('—'); // 7d resets
337
347
  });
338
348
 
339
- it('renders dates in the Status column only when NOT today (user tz)', () => {
349
+ it('renders dates in the reset columns only when NOT today (user tz)', () => {
340
350
  // now = Fri 3:00 PM Melbourne. A reset later the SAME Melbourne day shows
341
351
  // time-only; a reset on the next day shows the weekday.
342
352
  const MEL = 'Australia/Melbourne';
@@ -349,8 +359,9 @@ describe('renderAuthSnapshotFormat2', () => {
349
359
  }) }),
350
360
  ];
351
361
  const todayRows = tableRows(renderAuthSnapshotFormat2(sameDay, { now: NOW_MEL, tz: MEL }));
352
- expect(todayRows[0][4]).toContain('9:00 PM');
353
- expect(todayRows[0][4]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
362
+ // 5h resets cell [3] — same Melbourne day → time only.
363
+ expect(todayRows[0][3]).toContain('9:00 PM');
364
+ expect(todayRows[0][3]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
354
365
 
355
366
  const nextDay = [
356
367
  snap({ label: 'tomorrow@example.com', isActive: true, quota: quota({
@@ -360,7 +371,8 @@ describe('renderAuthSnapshotFormat2', () => {
360
371
  }) }),
361
372
  ];
362
373
  const tomorrowRows = tableRows(renderAuthSnapshotFormat2(nextDay, { now: NOW_MEL, tz: MEL }));
363
- expect(tomorrowRows[0][4]).toContain('Sat 1:00 AM');
374
+ // 5h resets cell [3] — next Melbourne day → weekday prefix.
375
+ expect(tomorrowRows[0][3]).toContain('Sat 1:00 AM');
364
376
  });
365
377
 
366
378
  it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
@@ -419,6 +431,27 @@ describe('renderAuthSnapshotFormat2', () => {
419
431
  expect(out).not.toContain('Live · refreshed');
420
432
  });
421
433
 
434
+ it('#2959 honesty fix — probeFailed renders an explicit marker instead of a false bare "Live" footer', () => {
435
+ // Total probe failure: no rows, no cache. The footer must not claim
436
+ // "Live" next to no-data rows. Pre-fix this rendered a bare `_Live_`.
437
+ const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
438
+ now: NOW,
439
+ probeFailed: true,
440
+ });
441
+ expect(out).toContain('_⚠ probe failed — no live data_');
442
+ expect(out).not.toContain('_Live_');
443
+ });
444
+
445
+ it('#2959 honesty fix — staleCachedAtMs (real cached data) takes precedence over probeFailed', () => {
446
+ const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
447
+ now: NOW,
448
+ staleCachedAtMs: NOW.getTime() - 2 * 60_000,
449
+ probeFailed: true,
450
+ });
451
+ expect(out).toContain('⚠ cached 2m ago');
452
+ expect(out).not.toContain('probe failed');
453
+ });
454
+
422
455
  // ── demo mode (the `/usage demo` / `/auth demo` suffix) ──────────────
423
456
  describe('demo mode masks email labels', () => {
424
457
  it('WITHOUT demo, the real account emails still render', () => {
@@ -1120,14 +1153,16 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
1120
1153
  expect(allText).toContain('Switch fleet → carol@example.com');
1121
1154
  });
1122
1155
 
1123
- it('quota-exhausted row shows a 🔴 + "back <when>" Status, not "billing disabled"', () => {
1156
+ it('quota-exhausted row shows a 🔴 + its 5h reset time in the reset cell, not "billing disabled"', () => {
1124
1157
  const futureReset = new Date(NOW.getTime() + 45 * 60_000);
1125
1158
  const out = renderAuthSnapshotFormat2(
1126
1159
  [snap({ label: 'ex@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) })],
1127
1160
  { now: NOW },
1128
1161
  );
1129
1162
  expect(out).toContain('| 🔴 |');
1130
- expect(out).toMatch(/back .* \(in 45m\)/);
1163
+ // The 5h reset (45m out) renders in its own reset cell — no "back" prefix.
1164
+ expect(out).toMatch(/\(in 45m\)/);
1165
+ expect(out).not.toContain('back ');
1131
1166
  expect(out).not.toContain('billing disabled');
1132
1167
  expect(out).not.toContain('overage off');
1133
1168
  });
@@ -137,6 +137,54 @@ describe('Claude Code event-stream contract (canary)', () => {
137
137
  ).toBe(false)
138
138
  })
139
139
 
140
+ it('CANARY (Fix 3 precondition): Task tool_use still projects {id, name, input} — the shape the foreground-sub-agent-tracking / post-answer-liveness-freeze fix keys "still dispatched" off of', () => {
141
+ // The Fix 3 gap-closer (turn-liveness-floor.ts `stillDispatched`) is only
142
+ // as good as `turn.foregroundSubAgents` staying populated for the
143
+ // lifetime of a real Task dispatch. That population is driven by THIS
144
+ // exact tool_use projection (toolName === 'Task') plus the matching
145
+ // tool_result closing it out — if a future Claude Code release renames
146
+ // `name`/`input`/`id` or nests them differently, the fix silently goes
147
+ // inert (stillDispatched always false, freeze regresses) rather than
148
+ // failing loudly. Pin the shape here so drift fails CI instead.
149
+ const line = JSON.stringify({
150
+ type: 'assistant',
151
+ message: {
152
+ id: 'msg_task',
153
+ stop_reason: 'tool_use',
154
+ content: [
155
+ {
156
+ type: 'tool_use',
157
+ id: 'toolu_task_1',
158
+ name: 'Task',
159
+ input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
160
+ },
161
+ ],
162
+ },
163
+ })
164
+ const evs = projectTranscriptLine(line)
165
+ expect(evs).toEqual([
166
+ {
167
+ kind: 'tool_use',
168
+ toolName: 'Task',
169
+ toolUseId: 'toolu_task_1',
170
+ input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
171
+ },
172
+ ])
173
+ // The matching tool_result (Task returning) must still carry tool_use_id
174
+ // so the gateway can close out the foreground-sub-agent tracking entry.
175
+ const resultLine = JSON.stringify({
176
+ type: 'user',
177
+ message: { content: [{ type: 'tool_result', tool_use_id: 'toolu_task_1', is_error: false, content: 'done' }] },
178
+ })
179
+ const resultEvs = projectTranscriptLine(resultLine)
180
+ expect(resultEvs).toHaveLength(1)
181
+ // `is_error: false` projects to `isError: undefined` (only `true` is
182
+ // ever stamped — see session-tail.ts's `c.is_error === true ? true :
183
+ // undefined`), so assert the falsy/absent form, not a literal `false`.
184
+ expect(resultEvs[0]).toMatchObject({ kind: 'tool_result', toolUseId: 'toolu_task_1' })
185
+ expect((resultEvs[0] as { isError?: boolean }).isError).toBeFalsy()
186
+ })
187
+
140
188
  it('sub-agent kickoff: first user message string prompt fires sub_agent_started', () => {
141
189
  const st = { hasEmittedStart: false }
142
190
  const line = JSON.stringify({
@@ -153,6 +153,17 @@ describe('H-2: feedHeartbeatTick post-answer background-agent liveness (Fix 2 /
153
153
  expect(postAnswerBlock).toMatch(/livenessVerdict\s*!==\s*'emit'/)
154
154
  })
155
155
 
156
+ it('Fix 3: post-answer branch feeds stillDispatched from turn.foregroundSubAgents so a tracked foreground sub-agent bypasses the staleness cap', () => {
157
+ const body = feedHeartbeatTickSrc()
158
+ const afterPostAnswer = body.split('if (turn.finalAnswerDelivered)')[1] ?? ''
159
+ const postAnswerBlock = afterPostAnswer.split('\n }\n')[0] ?? ''
160
+ // A positive foreground-tracking signal must be computed and threaded
161
+ // into the pure decision — this is what stops the card freezing mid-
162
+ // delegation while a foreground Task/Agent is still outstanding.
163
+ expect(postAnswerBlock).toMatch(/stillDispatched\s*=\s*turn\.foregroundSubAgents\.size\s*>\s*0/)
164
+ expect(postAnswerBlock).toMatch(/stillDispatched,/)
165
+ })
166
+
156
167
  it('staleness cap (concern 3) is parsed default-ON (30s) from SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS', () => {
157
168
  // The cap const must default to 30_000 when the env is unset (the `|| 30_000`
158
169
  // fallback over the positive-or-0 parse) so the post-answer card stops
@@ -47,6 +47,8 @@ import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
47
47
  import {
48
48
  ORPHANED_REPLY_TIMEOUT_MS,
49
49
  ORPHANED_REPLY_MAX_REARMS,
50
+ ORPHANED_REPLY_STREAM_WINDOW_MS,
51
+ LivenessTracker,
50
52
  } from '../context-exhaustion.js'
51
53
 
52
54
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -524,3 +526,40 @@ describe('silence-poke — hard ceiling bounds the defer', () => {
524
526
  expect(__getStateForTests('chat:0')!.fallbackFired).toBe(true)
525
527
  })
526
528
  })
529
+
530
+ // ─── Thinking-pause liveness via the REAL LivenessTracker (orphaned-reply fix) ─
531
+
532
+ describe('silence-poke — thinking-pause liveness via the real LivenessTracker', () => {
533
+ // Drives the REAL LivenessTracker as the isLegitimatelyWorking source: a
534
+ // genuine stream event within ORPHANED_REPLY_STREAM_WINDOW_MS keeps the feed
535
+ // alive (a model reasoning pause is survivable); a gap longer than the window
536
+ // with no work tears the feed down.
537
+ it('a stream event within the window keeps the feed alive (thinking-pause survivable)', () => {
538
+ let clockNow = 0
539
+ const tracker = new LivenessTracker(0)
540
+ const f = setupSilenceDeps({
541
+ thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
542
+ isLegitimatelyWorking: () => tracker.recentlyStreaming(clockNow, ORPHANED_REPLY_STREAM_WINDOW_MS),
543
+ })
544
+ startTurn('chat:0', 0)
545
+ // A genuine stream event lands mid-turn (e.g. the model resumes after a
546
+ // reasoning pause) — only 50s before the 300s fallback tick.
547
+ tracker.onStreamEvent('text', undefined, 250_000)
548
+ clockNow = 300_000
549
+ __tickForTests(300_000) // 50s since last stream < 120s window → deferred
550
+ expect(f.fallbacks).toHaveLength(0)
551
+ })
552
+
553
+ it('no stream event for longer than the window tears the feed down', () => {
554
+ let clockNow = 0
555
+ const tracker = new LivenessTracker(0) // seed at t=0, nothing after
556
+ const f = setupSilenceDeps({
557
+ thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
558
+ isLegitimatelyWorking: () => tracker.recentlyStreaming(clockNow, ORPHANED_REPLY_STREAM_WINDOW_MS),
559
+ })
560
+ startTurn('chat:0', 0)
561
+ clockNow = 300_000
562
+ __tickForTests(300_000) // 300s since last stream >> 120s window → fires
563
+ expect(f.fallbacks).toHaveLength(1)
564
+ })
565
+ })
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
43
43
  // window to ~5KB so we don't pick up the unrelated
44
44
  // `removeTurnActiveMarker` call in the onTurnComplete handler
45
45
  // way down at the bottom of the file.
46
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
46
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
47
47
  const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
48
48
  expect(clearMatches.length).toBe(1)
49
49
  })
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
53
53
  // must not prevent the gateway from coming up. Pin the try-wrapping
54
54
  // to make sure a future refactor doesn't drop it.
55
55
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
56
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
56
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
57
57
  expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
58
58
  })
59
59
 
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
62
62
  // Clearing the marker first means the watchdog sees a clean slate
63
63
  // immediately, even if pin sweep takes 30s+ to finish.
64
64
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
65
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
65
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
66
66
  const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
67
67
  const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
68
68
  expect(clearIdx).toBeGreaterThan(0)
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Structural pins for the session-only model relaunch wiring in gateway.ts.
3
+ *
4
+ * The behaviour lives in un-exported inline closures (buildModelDeps's
5
+ * scheduleModelRelaunch, the model-menu callback sr-* branch, and the boot
6
+ * re-hydration / alert-sentinel block inside the startup IIFE), so — mirroring
7
+ * the other gateway-*.test.ts source-pins — we assert on the source structure.
8
+ * The end-to-end behaviour of the carrier itself is exercised in
9
+ * tests/scaffold.session-model-override.test.ts (rendered start.sh) and the
10
+ * handler contract in telegram-plugin/tests/model-command.test.ts.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { readFileSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { dirname, resolve } from 'node:path'
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
20
+
21
+ describe('gateway: scheduleModelRelaunch dep', () => {
22
+ it('writes the carrier with exact "<model>\\n" bytes', () => {
23
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
24
+ expect(idx).toBeGreaterThan(0)
25
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
26
+ expect(win).toMatch(/writeFileSync\(\s*join\(agentDir, '\.session-model-override'\),\s*`\$\{model\}\\n`/)
27
+ })
28
+
29
+ it('sets the in-memory activeSessionModelOverride before dispatching the restart', () => {
30
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
31
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
32
+ const setIdx = win.indexOf('activeSessionModelOverride = model')
33
+ const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
34
+ expect(setIdx).toBeGreaterThan(0)
35
+ expect(restartIdx).toBeGreaterThan(setIdx)
36
+ })
37
+
38
+ it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
39
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
40
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
41
+ expect(win).toContain('await deps.scheduleRestart(reason)')
42
+ })
43
+ })
44
+
45
+ describe('gateway: model-menu sr-* target relaunches via the carrier', () => {
46
+ it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
47
+ const idx = GATEWAY_SRC.indexOf('if (data.startsWith(MODEL_CALLBACK_SR))')
48
+ expect(idx).toBeGreaterThan(0)
49
+ const win = GATEWAY_SRC.slice(idx, idx + 1400)
50
+ expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
51
+ // It must return before falling through to handleModelMenuCallback (which
52
+ // would inject an sr-* id claude's picker rejects).
53
+ expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
54
+ })
55
+ })
56
+
57
+ describe('gateway boot: session-model re-hydration + LiteLLM-down alert', () => {
58
+ it('re-hydrates activeSessionModelOverride from .active-session-model', () => {
59
+ const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
60
+ expect(idx).toBeGreaterThan(0)
61
+ const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
62
+ // Only an override when the launched model differs from the configured one.
63
+ expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
64
+ // The configured value must be resolved through resolveMainModel (the SAME
65
+ // resolver start.sh's scaffold uses) so an unset/`default` model config does
66
+ // not get flagged as a phantom session override on an ordinary restart.
67
+ expect(win).toContain('resolveMainModel(raw ?? undefined)')
68
+ })
69
+
70
+ it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
71
+ const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
72
+ expect(idx).toBeGreaterThan(0)
73
+ const win = GATEWAY_SRC.slice(idx, idx + 1100)
74
+ expect(win).toContain('unlinkSync(alertPath)')
75
+ // Broadcasts to every operator in allowFrom, not just allowFrom[0].
76
+ expect(win).toContain('const operators = loadAccess().allowFrom')
77
+ expect(win).toContain('for (const operator of operators)')
78
+ expect(win).toContain('lockedBot.api')
79
+ expect(win).toContain('.sendMessage(operator')
80
+ })
81
+ })
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Structural pin: the delivery state machine's `inbound` event MUST be
3
+ * emitted from `handleInbound` only AFTER the intercept/early-return
4
+ * gauntlet — never eagerly at handler entry.
5
+ *
6
+ * The wedge this guards (overlord `/usage` went dead, 2026-07-08). The
7
+ * inbound emit drives the now-AUTHORITATIVE turn-in-flight gate
8
+ * (turnInFlightForGate → isMachineInTurn). When it fired at handler entry
9
+ * — before the permission-reply / `/auth` paste-back / interrupt-empty /
10
+ * secret-detect-drop intercepts — an intercepted message (e.g. an approval
11
+ * reply) drove the machine idle→`bridge_alive_in_turn`, then early-returned
12
+ * as an intercept: no delivery, no claudeBusyKeys mark, and critically no
13
+ * `turnEnd`. The machine held the gate closed until the 5-min TTL tick
14
+ * force-cleared it, buffering every inbound (including `/usage`) meanwhile —
15
+ * the dangerous `machine_over_holds` divergence gate-parity-probe.ts flags.
16
+ *
17
+ * The behaviour lives inside the un-exported `handleInbound` closure, so —
18
+ * mirroring the other gateway-*.test.ts source-pins — we assert on source
19
+ * structure: the single inbound `shadowEmit` must sit below the intercepts
20
+ * and carry the real `isSteering` classification. Machine self-heal + gate
21
+ * accessors are behaviour-tested in inbound-delivery-cutover-gate.test.ts.
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest'
25
+ import { readFileSync } from 'node:fs'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { dirname, resolve } from 'node:path'
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url))
30
+ const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
31
+
32
+ /** Byte offset of the sole `shadowEmit({ kind: 'inbound' ... })` call. */
33
+ function inboundEmitOffset(): number {
34
+ const m = GATEWAY_SRC.match(/shadowEmit\(\{\s*\n\s*kind:\s*'inbound'/)
35
+ expect(m, 'expected exactly one multi-line inbound shadowEmit').not.toBeNull()
36
+ const idx = GATEWAY_SRC.indexOf(m![0])
37
+ // There must be only ONE such emit — a second eager site would reintroduce
38
+ // the wedge.
39
+ const second = GATEWAY_SRC.indexOf(m![0], idx + 1)
40
+ expect(second, 'a second inbound shadowEmit would reintroduce the eager-emit wedge').toBe(-1)
41
+ return idx
42
+ }
43
+
44
+ describe('gateway: inbound machine-emit is deferred past the intercepts', () => {
45
+ it('carries the DEFERRED_INBOUND_EMIT marker so the intent is greppable', () => {
46
+ expect(GATEWAY_SRC).toContain('DEFERRED_INBOUND_EMIT')
47
+ })
48
+
49
+ it('emits the machine `inbound` event AFTER the permission-reply intercept', () => {
50
+ const emitIdx = inboundEmitOffset()
51
+ // The permission-reply intercept is one of the early-return paths that
52
+ // must NOT drive the machine into a turn.
53
+ const permIdx = GATEWAY_SRC.indexOf('const permMatch = PERMISSION_REPLY_RE.exec(text)')
54
+ expect(permIdx).toBeGreaterThan(0)
55
+ expect(emitIdx).toBeGreaterThan(permIdx)
56
+ })
57
+
58
+ it('emits AFTER the `/auth add` paste-back intercept', () => {
59
+ const emitIdx = inboundEmitOffset()
60
+ const authIdx = GATEWAY_SRC.indexOf('pendingAuthAddFlows.get(interceptKey)')
61
+ expect(authIdx).toBeGreaterThan(0)
62
+ expect(emitIdx).toBeGreaterThan(authIdx)
63
+ })
64
+
65
+ it('emits AFTER isSteering is classified, and passes the real value (not a hardcoded false)', () => {
66
+ const emitIdx = inboundEmitOffset()
67
+ const steerIdx = GATEWAY_SRC.indexOf('isSteering = priorTurnInFlight && isSteerPrefix')
68
+ expect(steerIdx).toBeGreaterThan(0)
69
+ expect(emitIdx).toBeGreaterThan(steerIdx)
70
+ // The emit's msg object must forward the live `isSteering` binding, so a
71
+ // mid-turn steer is not mis-modelled as a fresh turn.
72
+ const win = GATEWAY_SRC.slice(emitIdx, emitIdx + 260)
73
+ expect(win).toMatch(/msg:\s*\{[\s\S]*?\bisSteering,[\s\S]*?\}/)
74
+ })
75
+
76
+ it('emits BEFORE the deliver-or-buffer decision (reserveInboundDelivery)', () => {
77
+ const emitIdx = inboundEmitOffset()
78
+ const gateIdx = GATEWAY_SRC.indexOf('const deliveryGate = reserveInboundDelivery({')
79
+ expect(gateIdx).toBeGreaterThan(0)
80
+ expect(emitIdx).toBeLessThan(gateIdx)
81
+ })
82
+ })