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
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Quota-bar block — a compact per-account ASCII-bar rendering of the 5-hour
3
+ * and 7-day utilization windows, for a live-refreshing Telegram card.
4
+ *
5
+ * JTBD: "at a glance, how much headroom does each account have, and how far
6
+ * through its reset window are we" — denser than the `/auth` table (Format 2,
7
+ * `auth-snapshot-format.ts`), meant for a small always-visible strip rather
8
+ * than a full snapshot.
9
+ *
10
+ * Locked output shape (operator-confirmed via live Telegram iteration —
11
+ * do not reformat without re-confirming):
12
+ *
13
+ * ```
14
+ * - **you@example.com** (active)
15
+ * - 🟢 5h `[┃░░░░░░░░░] 0% / 4h20m left`
16
+ * - 🟡 7d `[████┃█░░░░] 47% / 3d1h left`
17
+ * - **alice@example.com** (exhausted)
18
+ * - 🟢 5h `[┃░░░░░░░░░] 0% / resets now`
19
+ * - 🔴 7d `[██████┃███] 100% / 2d16h left`
20
+ * ```
21
+ *
22
+ * Rules:
23
+ * 1. GFM `- ` bullet marker on EVERY line, including the account title
24
+ * line — a tight one-item-per-line list, no blank lines between rows
25
+ * (a no-bullet hard-break variant was tried live and rejected as
26
+ * "worse" — bullets are the final mechanism).
27
+ * 2. Title line: `- **<email>** (<status>)`, status derived from data
28
+ * (`active` | `exhausted` | `idle` — see `accountStatus`).
29
+ * 3. One row per window (5h, 7d): `- <dot> <window> \`[<bar>] <pct>% /
30
+ * <time-left>\``.
31
+ * 4. Bar is 10 cells of `█` (filled, proportional to utilization) /
32
+ * `░` (empty), with a single `┃` "pace" tick marking how far through
33
+ * the reset window we currently are. The tick ALWAYS renders at its
34
+ * computed position, overriding whatever fill character is there —
35
+ * even at 100% utilization — since the pace signal must stay visible
36
+ * (operator-confirmed; see `buildBar`).
37
+ */
38
+
39
+ import type { QuotaUtilization } from './quota-check.js';
40
+ import { refillNormalizedUtils, isProbeThin } from '../src/auth/quota.js';
41
+ import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
42
+ import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
43
+ import { escapeMarkdown } from './card-format.js';
44
+ import { maskEmail } from './demo-mask.js';
45
+
46
+ // ── dot thresholds ───────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Per-row status dot, purely a function of THAT window's own utilization
50
+ * percentage (not the account's overall exhausted flag — an exhausted
51
+ * account can still show a green 5h row if that window is fresh).
52
+ *
53
+ * - < 50% → 🟢 healthy
54
+ * - 50-89% → 🟡 getting close
55
+ * - >= 90% → 🔴 at/near the wall
56
+ */
57
+ export function pickDot(pct: number): '🟢' | '🟡' | '🔴' {
58
+ const clamped = Math.max(0, Math.min(100, pct));
59
+ if (clamped >= 90) return '🔴';
60
+ if (clamped >= 50) return '🟡';
61
+ return '🟢';
62
+ }
63
+
64
+ // ── time-left formatting ─────────────────────────────────────────────
65
+
66
+ /**
67
+ * Compact time-left string for the quota-bar row: `4h20m left`,
68
+ * `3d1h left`, `45m left`, or `resets now` when the reset has already
69
+ * passed (or is unknown — no reset timestamp to count down to).
70
+ *
71
+ * Deliberately more compact than `formatRelative` in
72
+ * `auth-snapshot-format.ts` (no space between the number and unit) to
73
+ * keep the fixed-width code-span row from wrapping on a phone screen.
74
+ */
75
+ export function formatTimeLeft(target: Date | null, now: Date = new Date()): string {
76
+ if (!target) return 'resets now';
77
+ const deltaMs = target.getTime() - now.getTime();
78
+ // A malformed reset timestamp (invalid Date → NaN delta) must not leak a
79
+ // `"NaNm left"` string; treat a non-finite delta as "resets now".
80
+ if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 'resets now';
81
+ const totalMin = Math.round(deltaMs / 60_000);
82
+ if (totalMin < 60) return `${totalMin}m left`;
83
+ const totalHours = Math.floor(totalMin / 60);
84
+ const m = totalMin % 60;
85
+ if (totalHours < 24) return `${totalHours}h${m}m left`;
86
+ const d = Math.floor(totalHours / 24);
87
+ const h = totalHours % 24;
88
+ return h > 0 ? `${d}d${h}h left` : `${d}d left`;
89
+ }
90
+
91
+ // ── pace (elapsed-through-window) fraction ───────────────────────────
92
+
93
+ const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
94
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
95
+
96
+ /**
97
+ * How far through the reset window we currently are, as a 0..1 fraction,
98
+ * derived from the time LEFT to reset and the window's nominal duration
99
+ * (we're never told the window's start time, only its end/reset — so this
100
+ * is an approximation, not an exact elapsed-time read).
101
+ *
102
+ * - no reset timestamp, or reset already passed → 0 (treat as a freshly
103
+ * started window rather than claiming we're at the very end of one we
104
+ * have no data for)
105
+ * - otherwise → `1 - timeLeftMs / windowDurationMs`, clamped to [0, 1]
106
+ */
107
+ export function elapsedFraction(
108
+ target: Date | null,
109
+ windowDurationMs: number,
110
+ now: Date = new Date(),
111
+ ): number {
112
+ if (!target) return 0;
113
+ const timeLeftMs = target.getTime() - now.getTime();
114
+ // A malformed reset timestamp (invalid Date → NaN delta) must not corrupt
115
+ // the tick placement; treat a non-finite delta as a freshly-started window.
116
+ if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return 0;
117
+ return Math.max(0, Math.min(1, 1 - timeLeftMs / windowDurationMs));
118
+ }
119
+
120
+ // ── bar rendering ─────────────────────────────────────────────────────
121
+
122
+ const BAR_WIDTH = 10;
123
+
124
+ /**
125
+ * Build the 10-cell `[█…░…]` bar body (no surrounding brackets — callers
126
+ * add those) for one window row.
127
+ *
128
+ * - `fillCount = round(pct / 100 * 10)` cells are `█` from the left.
129
+ * - a single `┃` pace tick is placed at
130
+ * `round(elapsedFrac * (width - 1))`, and ALWAYS overwrites whatever
131
+ * cell is there — including a filled `█` cell, even at 100% utilization
132
+ * (rule 4 above; the pace signal must always be visible).
133
+ */
134
+ export function buildBar(pct: number, elapsedFrac: number): string {
135
+ const clampedPct = Math.max(0, Math.min(100, pct));
136
+ const fillCount = Math.round((clampedPct / 100) * BAR_WIDTH);
137
+ const cells: string[] = new Array(BAR_WIDTH).fill('░');
138
+ for (let i = 0; i < fillCount; i++) cells[i] = '█';
139
+ const tickIndex = Math.max(
140
+ 0,
141
+ Math.min(BAR_WIDTH - 1, Math.round(elapsedFrac * (BAR_WIDTH - 1))),
142
+ );
143
+ cells[tickIndex] = '┃';
144
+ return cells.join('');
145
+ }
146
+
147
+ // ── account status (title-line suffix) ───────────────────────────────
148
+
149
+ export type QuotaBarAccountStatus = 'active' | 'exhausted' | 'idle';
150
+
151
+ /**
152
+ * Title-line status word. `active` wins over `exhausted` (the fleet's
153
+ * pinned account is reported as active even if the broker also flags it
154
+ * exhausted — matches the locked example where the operator wants to know
155
+ * WHICH account is live first, and its health second, from the two window
156
+ * rows underneath). Otherwise: `exhausted` if the broker's own flag says
157
+ * so, else `idle` (present, healthy, just not the current pick).
158
+ */
159
+ export function accountStatus(isActive: boolean, exhausted: boolean): QuotaBarAccountStatus {
160
+ if (isActive) return 'active';
161
+ if (exhausted) return 'exhausted';
162
+ return 'idle';
163
+ }
164
+
165
+ // ── row / block assembly ─────────────────────────────────────────────
166
+
167
+ /** One `- <dot> <window> \`[<bar>] <pct>% / <time-left>\`` line. */
168
+ export function formatWindowRow(
169
+ window: '5h' | '7d',
170
+ pct: number,
171
+ resetAt: Date | null,
172
+ now: Date = new Date(),
173
+ ): string {
174
+ const windowMs = window === '5h' ? FIVE_HOUR_MS : SEVEN_DAY_MS;
175
+ const dot = pickDot(pct);
176
+ const bar = buildBar(pct, elapsedFraction(resetAt, windowMs, now));
177
+ const pctStr = `${Math.round(Math.max(0, Math.min(100, pct)))}%`;
178
+ const timeLeft = formatTimeLeft(resetAt, now);
179
+ return `- ${dot} ${window} \`[${bar}] ${pctStr} / ${timeLeft}\``;
180
+ }
181
+
182
+ /** One account's title line + its two window rows (5h then 7d). */
183
+ export function renderQuotaBarAccount(
184
+ label: string,
185
+ isActive: boolean,
186
+ exhausted: boolean,
187
+ quota: QuotaUtilization | null,
188
+ now: Date = new Date(),
189
+ demo = false,
190
+ ): string[] {
191
+ const status = accountStatus(isActive, exhausted);
192
+ // Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
193
+ // needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
194
+ // `codeSpanSafe` (which only defuses backticks and is only correct
195
+ // inside literal `code spans` — see format.ts). Using codeSpanSafe here
196
+ // was a bug: a label containing e.g. `**` or `[x](url)` would break the
197
+ // bold run or inject a markdown link into the card.
198
+ const displayLabel = demo ? maskEmail(label) : label;
199
+ const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${status})`];
200
+ if (!quota || isProbeThin(quota)) {
201
+ // Data-quality gap. A failed / thin probe carries NO real utilization
202
+ // signal, so it must NOT render as a healthy 🟢 0% bar — that's
203
+ // indistinguishable from a fresh account with full headroom, which was
204
+ // the #2959 review's blocking bug. Emit a distinct ⚠️ warning row per
205
+ // window with a "no data" label (thin vs failed) instead of a fake bar,
206
+ // keeping two rows so the block stays shape-stable.
207
+ const reason = !quota ? 'no data — probe failed' : 'no data — thin probe';
208
+ lines.push(`- ⚠️ 5h \`${reason}\``);
209
+ lines.push(`- ⚠️ 7d \`${reason}\``);
210
+ return lines;
211
+ }
212
+ const norm = refillNormalizedUtils(quota, now);
213
+ lines.push(formatWindowRow('5h', norm.fiveHourUtilizationPct, quota.fiveHourResetAt, now));
214
+ lines.push(formatWindowRow('7d', norm.sevenDayUtilizationPct, quota.sevenDayResetAt, now));
215
+ return lines;
216
+ }
217
+
218
+ export interface QuotaBarRenderOpts {
219
+ now?: Date;
220
+ /** Demo mode (the `/usage demo` suffix) — masks account-email labels. */
221
+ demo?: boolean;
222
+ }
223
+
224
+ /**
225
+ * Relative-age stamp for the freshness footer: "0s ago", "3m ago". Measured
226
+ * against `now` so tests with an injected clock get deterministic output.
227
+ * (Mirrors `formatAgeStamp` in auth-snapshot-format.ts, which is not exported.)
228
+ */
229
+ function formatAgeStamp(atMs: number, now: Date = new Date()): string {
230
+ const ageSec = Math.max(0, Math.round((now.getTime() - atMs) / 1000));
231
+ return ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
232
+ }
233
+
234
+ export interface UsageCardRenderOpts extends QuotaBarRenderOpts {
235
+ /**
236
+ * The probe-on-open attempted a live refresh but it FAILED (or hit the TTL),
237
+ * so the card is served off the durable cache. When set, the footer shows an
238
+ * explicit "⚠ cached Nm ago" warning (age from this `capturedAt`) instead of
239
+ * a false live stamp. Takes precedence over `liveProbedAtMs`. Restores the
240
+ * signal `renderAuthSnapshotFormat2` carried before /usage went bar-only.
241
+ */
242
+ staleCachedAtMs?: number;
243
+ /** Timestamp of the most recent live probe; renders "Live · refreshed Nm
244
+ * ago" when no stale-cache marker applies. Omit to render a bare "Live".
245
+ * Do NOT set this when the probe failed and no live data was obtained —
246
+ * set `probeFailed: true` instead so the footer doesn't claim "Live". */
247
+ liveProbedAtMs?: number;
248
+ /**
249
+ * True when the live probe returned no usable data for ANY account (the
250
+ * probe threw / timed out / returned zero rows, AND nothing was served
251
+ * from cache either). The footer then renders an explicit `⚠ probe failed
252
+ * — no live data` instead of a false "Live" stamp. Subscription-honesty:
253
+ * the /usage card exists to tell the operator the truth about quota state,
254
+ * so a footer that says "Live" while every row shows "⚠️ no data" is the
255
+ * exact lie this flag closes. Takes precedence over `liveProbedAtMs`;
256
+ * `staleCachedAtMs` (cache-served data) still takes precedence over this
257
+ * because in that case there IS real data, just stale.
258
+ */
259
+ probeFailed?: boolean;
260
+ }
261
+
262
+ /**
263
+ * Render the full multi-account quota-bar block from `AccountSnapshot[]`
264
+ * (the same shape `auth-snapshot-format.ts` builds — reuse
265
+ * `buildSnapshotsFromCachedState` / `buildSnapshotsFromState` /
266
+ * `quotaBarSnapshotsFromListState` to get one).
267
+ *
268
+ * Additionally needs each account's `exhausted` flag (not carried on
269
+ * `AccountSnapshot`), passed as a parallel lookup keyed by label.
270
+ */
271
+ export function renderQuotaBarBlock(
272
+ snapshots: AccountSnapshot[],
273
+ exhaustedByLabel: ReadonlyMap<string, boolean>,
274
+ opts: QuotaBarRenderOpts = {},
275
+ ): string {
276
+ const now = opts.now ?? new Date();
277
+ const demo = opts.demo ?? false;
278
+ const lines: string[] = [];
279
+ for (const snap of snapshots) {
280
+ const exhausted = exhaustedByLabel.get(snap.label) ?? false;
281
+ lines.push(
282
+ ...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo),
283
+ );
284
+ }
285
+ return lines.join('\n');
286
+ }
287
+
288
+ /**
289
+ * Convenience one-shot: build the quota-bar block directly from the shape
290
+ * `switchroom auth list --json` prints (`ListStateData`), using each
291
+ * account's cached `last_quota` (no live probe — same cache path
292
+ * `buildSnapshotsFromCachedState` uses). This is what the CLI script
293
+ * entrypoint (`scripts/print-quota-bar.ts`) calls.
294
+ */
295
+ export function renderQuotaBarBlockFromListState(
296
+ state: ListStateData,
297
+ opts: QuotaBarRenderOpts = {},
298
+ ): string {
299
+ const now = opts.now ?? new Date();
300
+ const exhaustedByLabel = new Map<string, boolean>(
301
+ state.accounts.map((a: AccountState) => [a.label, a.exhausted]),
302
+ );
303
+ const snapshots: AccountSnapshot[] = state.accounts.map((acc: AccountState) => ({
304
+ label: acc.label,
305
+ isActive: acc.label === state.active,
306
+ quota: reviveLastQuota(acc.last_quota ?? null),
307
+ quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
308
+ expiresAtMs: acc.expiresAt,
309
+ capturedAtMs: acc.last_quota?.capturedAt,
310
+ }));
311
+ return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
312
+ }
313
+
314
+ /**
315
+ * The live `/usage` card — the compact quota-bar block, and ONLY the bar
316
+ * block. `/usage` used to append the full Format 2 health-grouped table
317
+ * (`renderAuthSnapshotFormat2`) underneath; the operator asked for the bar
318
+ * card alone (2026-07-10) since the two views were redundant and the table
319
+ * doubled the message length. Every field the table carried per-account
320
+ * (5h/7d utilization + reset) has an equivalent in the bar rows — the `pct%
321
+ * / <time-left>` segment of each window row is the reset info, just
322
+ * relative instead of absolute. `opts.now` is shared with the bar block;
323
+ * `opts.demo` masks account-email labels in the title line the same way the
324
+ * table used to.
325
+ *
326
+ * Footer: after the bar rows, two footer lines are appended —
327
+ * 1. the synthesized cross-account "switch now" verdict (`recommendation`,
328
+ * shared with the /auth snapshot) — the single most actionable line,
329
+ * dropped when /usage went bar-only and restored here (#2959 review).
330
+ * 2. a freshness marker — `⚠ cached Nm ago` when the data was served stale
331
+ * from cache (`staleCachedAtMs`), else `Live · refreshed Nm ago` /
332
+ * `Live` — so a cache-served card is never mistaken for a live one.
333
+ */
334
+ export function renderUsageCard(
335
+ snapshots: AccountSnapshot[],
336
+ exhaustedByLabel: ReadonlyMap<string, boolean>,
337
+ opts: UsageCardRenderOpts = {},
338
+ ): string {
339
+ const now = opts.now ?? new Date();
340
+ const demo = opts.demo ?? false;
341
+ const bar = renderQuotaBarBlock(snapshots, exhaustedByLabel, { now, demo });
342
+ const lines = [bar];
343
+ // Actionable cross-account verdict — restored from renderAuthSnapshotFormat2.
344
+ lines.push(`_${recommendation(snapshots, now, demo)}_`);
345
+ // Freshness signal: stale-cache warning takes precedence over a live stamp,
346
+ // which takes precedence over an explicit probe-failed marker (no live data
347
+ // AND no cache — the card is showing "⚠️ no data" rows, so "Live" would be
348
+ // a lie). The probeFailed branch is the honesty backstop: without it, a
349
+ // total probe failure rendered a bare `_Live_` footer next to no-data rows.
350
+ if (opts.staleCachedAtMs != null) {
351
+ lines.push(`_⚠ cached ${formatAgeStamp(opts.staleCachedAtMs, now)}_`);
352
+ } else if (opts.liveProbedAtMs != null) {
353
+ lines.push(`_Live · refreshed ${formatAgeStamp(opts.liveProbedAtMs, now)}_`);
354
+ } else if (opts.probeFailed) {
355
+ lines.push('_⚠ probe failed — no live data_');
356
+ } else {
357
+ lines.push('_Live_');
358
+ }
359
+ return lines.join('\n');
360
+ }
@@ -97,9 +97,10 @@ export function emptyAccountState(): QuotaWatchAccountState {
97
97
  * SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP "0" disables the broker claim
98
98
  * (every agent sends, pre-incident
99
99
  * behaviour)
100
- * SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL "1" restores sending from
101
- * cached data when the pre-send
102
- * validation probe fails
100
+ *
101
+ * When the pre-send validation probe fails, the alert is unconditionally
102
+ * suppressed (a quota notification must never carry numbers we could not
103
+ * verify live); the transition re-evaluates on the next poll tick.
103
104
  */
104
105
  export interface QuotaWatchTuning {
105
106
  /** Cached snapshots older than this are treated as unknown (no opinion). 0 = off. */
@@ -108,8 +109,6 @@ export interface QuotaWatchTuning {
108
109
  lateRecoveryMs: number;
109
110
  /** Route sends through the broker's claim-notification dedup. */
110
111
  fleetDedup: boolean;
111
- /** Legacy: send from cached data when the validation probe fails. */
112
- sendOnProbeFail: boolean;
113
112
  }
114
113
 
115
114
  export const DEFAULT_QUOTA_WATCH_MAX_STALE_MS = 60 * 60_000;
@@ -133,7 +132,6 @@ export function resolveQuotaWatchTuning(
133
132
  maxStaleMs: num(env.SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS, DEFAULT_QUOTA_WATCH_MAX_STALE_MS),
134
133
  lateRecoveryMs: num(env.SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS, DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS),
135
134
  fleetDedup: env.SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP !== "0",
136
- sendOnProbeFail: env.SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL === "1",
137
135
  };
138
136
  }
139
137
 
@@ -21,6 +21,7 @@ import {
21
21
  recordTurnEnd,
22
22
  findRecentTurnsForChat,
23
23
  getTurnByKey,
24
+ reapStaleOpenTurns,
24
25
  } from './turns-schema.js'
25
26
 
26
27
  // ---------------------------------------------------------------------------
@@ -157,3 +158,99 @@ describe('getTurnByKey', () => {
157
158
  db.close()
158
159
  })
159
160
  })
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // reapStaleOpenTurns — mid-session periodic orphan sweep (#2918)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe('reapStaleOpenTurns (#2918 mid-session sweep)', () => {
167
+ // Helper: force a row's started_at into the past so the TTL gate is met.
168
+ function ageRow(db: ReturnType<typeof openTurnsDbInMemory>, turnKey: string, startedAt: number): void {
169
+ db.prepare('UPDATE turns SET started_at = ? WHERE turn_key = ?').run(startedAt, turnKey)
170
+ }
171
+
172
+ it('stamps an ownerless open row aged past the TTL as restart', () => {
173
+ const db = openTurnsDbInMemory()
174
+ const now = 1_000_000_000_000
175
+ recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
176
+ ageRow(db, 'dm:dead', now - 30 * 60_000) // 30 min old
177
+ const res = reapStaleOpenTurns(db, {
178
+ activeTurnKeys: new Set<string>(), // process gone → no live owner
179
+ ttlMs: 15 * 60_000,
180
+ now,
181
+ })
182
+ expect(res.reaped).toBe(1)
183
+ expect(res.reapedTurnKeys).toEqual(['dm:dead'])
184
+ const turn = getTurnByKey(db, 'dm:dead')
185
+ expect(turn?.ended_at).toBe(now)
186
+ expect(turn?.ended_via).toBe('restart')
187
+ db.close()
188
+ })
189
+
190
+ it('NEVER touches a healthy in-flight turn (turn_key in activeTurnKeys)', () => {
191
+ const db = openTurnsDbInMemory()
192
+ const now = 1_000_000_000_000
193
+ recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
194
+ // Even though it is aged well past the TTL, a live owner protects it.
195
+ ageRow(db, 'dm:live', now - 6 * 60 * 60_000) // 6h "long-running" turn
196
+ const res = reapStaleOpenTurns(db, {
197
+ activeTurnKeys: new Set(['dm:live']),
198
+ ttlMs: 15 * 60_000,
199
+ now,
200
+ })
201
+ expect(res.reaped).toBe(0)
202
+ const turn = getTurnByKey(db, 'dm:live')
203
+ expect(turn?.ended_at).toBeNull()
204
+ expect(turn?.ended_via).toBeNull()
205
+ db.close()
206
+ })
207
+
208
+ it('does not reap an ownerless row younger than the TTL (race guard)', () => {
209
+ const db = openTurnsDbInMemory()
210
+ const now = 1_000_000_000_000
211
+ recordTurnStart(db, { turnKey: 'dm:fresh', chatId: '333' })
212
+ ageRow(db, 'dm:fresh', now - 60_000) // 1 min old, ownerless
213
+ const res = reapStaleOpenTurns(db, {
214
+ activeTurnKeys: new Set<string>(),
215
+ ttlMs: 15 * 60_000,
216
+ now,
217
+ })
218
+ expect(res.reaped).toBe(0)
219
+ expect(getTurnByKey(db, 'dm:fresh')?.ended_at).toBeNull()
220
+ db.close()
221
+ })
222
+
223
+ it('reaps the dead orphan while sparing a concurrently-live turn', () => {
224
+ const db = openTurnsDbInMemory()
225
+ const now = 1_000_000_000_000
226
+ recordTurnStart(db, { turnKey: 'dm:dead', chatId: '111' })
227
+ recordTurnStart(db, { turnKey: 'dm:live', chatId: '222' })
228
+ ageRow(db, 'dm:dead', now - 30 * 60_000)
229
+ ageRow(db, 'dm:live', now - 30 * 60_000)
230
+ const res = reapStaleOpenTurns(db, {
231
+ activeTurnKeys: new Set(['dm:live']),
232
+ ttlMs: 15 * 60_000,
233
+ now,
234
+ })
235
+ expect(res.reapedTurnKeys).toEqual(['dm:dead'])
236
+ expect(getTurnByKey(db, 'dm:dead')?.ended_via).toBe('restart')
237
+ expect(getTurnByKey(db, 'dm:live')?.ended_at).toBeNull()
238
+ db.close()
239
+ })
240
+
241
+ it('leaves already-ended rows alone (idempotent)', () => {
242
+ const db = openTurnsDbInMemory()
243
+ const now = 1_000_000_000_000
244
+ recordTurnStart(db, { turnKey: 'dm:done', chatId: '444' })
245
+ ageRow(db, 'dm:done', now - 30 * 60_000)
246
+ recordTurnEnd(db, { turnKey: 'dm:done', endedVia: 'stop' })
247
+ const res = reapStaleOpenTurns(db, {
248
+ activeTurnKeys: new Set<string>(),
249
+ ttlMs: 15 * 60_000,
250
+ now,
251
+ })
252
+ expect(res.reaped).toBe(0)
253
+ expect(getTurnByKey(db, 'dm:done')?.ended_via).toBe('stop')
254
+ db.close()
255
+ })
256
+ })
@@ -497,6 +497,84 @@ export function markOrphanedWithTimeoutClassification(
497
497
  return { reaped: (timeoutTurnKey ? 1 : 0) + rest.changes, timeoutTurnKey }
498
498
  }
499
499
 
500
+ export interface ReapStaleOpenTurnsOpts {
501
+ /**
502
+ * The set of turn_keys that belong to a turn still LIVE in this process's
503
+ * memory (the gateway's `currentTurnMap` registry keys plus the singleton
504
+ * `currentTurn` mirror). A row whose turn_key is in this set is NEVER
505
+ * reaped — it is a genuinely in-flight turn whose spinner must keep
506
+ * spinning, however long it runs. This is the load-bearing liveness
507
+ * predicate: age alone must never reap; only an open row with NO live
508
+ * owner qualifies.
509
+ */
510
+ activeTurnKeys: ReadonlySet<string>
511
+ /**
512
+ * Minimum age (ms, measured from `started_at`) before an ownerless open row
513
+ * is swept. A secondary guard against races — a turn that has JUST started
514
+ * but not yet populated the live set (or a row recorded microseconds ago) is
515
+ * protected until it ages past this. Liveness (activeTurnKeys) does the real
516
+ * work; the TTL only closes the "recorded-but-not-yet-tracked" window.
517
+ */
518
+ ttlMs: number
519
+ /** Injectable clock for tests. */
520
+ now?: number
521
+ }
522
+
523
+ export interface ReapStaleOpenTurnsResult {
524
+ /** Rows stamped `ended_via='restart'` by this sweep. */
525
+ reaped: number
526
+ /** The turn_keys that were stamped, for logging / card finalization. */
527
+ reapedTurnKeys: string[]
528
+ }
529
+
530
+ /**
531
+ * Mid-session periodic reaper (#2918). The boot-time
532
+ * `markOrphanedWithTimeoutClassification` only runs once, right after
533
+ * `openTurnsDb` — so a turn whose owning process dies MID-session (the SDK
534
+ * subprocess is SIGKILLed / OOMs / crashes) without a clean `recordTurnEnd`
535
+ * leaves its row `ended_at IS NULL`, and its activity card keeps spinning
536
+ * until the NEXT gateway boot (often many hours later). This sweep runs on a
537
+ * periodic timer inside the live gateway and stamps those ownerless open rows
538
+ * `ended_via='restart'` (the same clean-interrupt classification the boot
539
+ * reaper uses for a non-hung orphan) so the stale card can be finalized
540
+ * without waiting for a restart.
541
+ *
542
+ * CORRECTNESS: only rows that are BOTH (a) not owned by any live turn
543
+ * (`turn_key ∉ activeTurnKeys`) AND (b) older than `ttlMs` are swept. A
544
+ * healthy in-flight turn — however long it runs — is always in
545
+ * `activeTurnKeys` and is never touched. Never invents a new state; reuses
546
+ * `'restart'` so the existing resume/report policy applies unchanged.
547
+ */
548
+ export function reapStaleOpenTurns(
549
+ db: SqliteDatabase,
550
+ opts: ReapStaleOpenTurnsOpts,
551
+ ): ReapStaleOpenTurnsResult {
552
+ const now = opts.now ?? Date.now()
553
+ const cutoff = now - opts.ttlMs
554
+ // Candidate = open row aged past the TTL. Liveness is filtered in JS against
555
+ // the injected active set (avoids brittle SQL IN-list binding).
556
+ const candidates = db.prepare(`
557
+ SELECT turn_key FROM turns
558
+ WHERE ended_at IS NULL AND started_at <= ?
559
+ `).all(cutoff) as { turn_key: string }[]
560
+
561
+ const stamp = db.prepare(`
562
+ UPDATE turns
563
+ SET ended_at = ?,
564
+ ended_via = 'restart',
565
+ updated_at = ?
566
+ WHERE turn_key = ? AND ended_at IS NULL
567
+ `)
568
+
569
+ const reapedTurnKeys: string[] = []
570
+ for (const { turn_key } of candidates) {
571
+ if (opts.activeTurnKeys.has(turn_key)) continue // live — never reap
572
+ const r = stamp.run(now, now, turn_key) as { changes: number }
573
+ if (r.changes > 0) reapedTurnKeys.push(turn_key)
574
+ }
575
+ return { reaped: reapedTurnKeys.length, reapedTurnKeys }
576
+ }
577
+
500
578
  /**
501
579
  * Return the most recent N turns for `chatId` (any state — running or ended),
502
580
  * ordered by started_at DESC. Used by the idle-footer renderer to decide