switchroom 0.18.3 → 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 (101) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +2 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +119 -74
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +37 -8
  8. package/package.json +1 -1
  9. package/skills/switchroom-release/SKILL.md +78 -0
  10. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +922 -459
  12. package/telegram-plugin/gateway/gateway.ts +260 -21
  13. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  14. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  15. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  16. package/telegram-plugin/operator-events.ts +21 -0
  17. package/telegram-plugin/pending-work-progress.ts +42 -7
  18. package/telegram-plugin/quota-bar-format.ts +360 -0
  19. package/telegram-plugin/subagent-watcher.ts +27 -9
  20. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  21. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  22. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  23. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  24. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  25. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  26. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  27. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  28. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  29. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  30. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  31. package/telegram-plugin/worker-activity-feed.ts +220 -15
  32. package/profiles/default/CLAUDE.md +0 -116
  33. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  34. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  35. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  36. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  37. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  38. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  39. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  41. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  42. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  43. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  44. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  45. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  46. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  47. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  48. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  49. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  50. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  51. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  53. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  54. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  55. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  56. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  57. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  58. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  59. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  60. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  62. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  63. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  64. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  65. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  66. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  67. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  68. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  69. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  70. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  72. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  74. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  75. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  78. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  84. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  85. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  87. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  89. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  91. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  93. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  95. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  97. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  99. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -419,9 +419,15 @@ function tick(now: number): void {
419
419
  newText,
420
420
  literalText: s.anchorLiteralText,
421
421
  }
422
- // Fire-and-forget so a slow edit doesn't block the tick loop.
423
- // Errors are logged but never bubble (a 429 / "message not modified"
424
- // / chat-deleted is a soft failure).
422
+ // Fire-and-forget so a slow edit doesn't block the tick loop. The
423
+ // production `editMessage` dep is `swallowingApiCall`-wrapped, which
424
+ // catches every transport outcome (not-modified / not-found / 429 /
425
+ // network) upstream and never rejects — so in production this `.catch`
426
+ // is a backstop that rarely fires. It is kept as a contract-level guard
427
+ // (a throwing dep, or a future non-swallowing wiring, must not log a
428
+ // scary "edit failed" warning for a best-effort liveness surface nor
429
+ // keep hammering a dead anchor). Only a genuinely unexpected error
430
+ // reaches the fallthrough; transport classes are silent.
425
431
  void Promise.resolve()
426
432
  .then(() => activeDeps!.editMessage(editCtx))
427
433
  .then(() => {
@@ -432,10 +438,39 @@ function tick(now: number): void {
432
438
  })
433
439
  })
434
440
  .catch((err) => {
435
- process.stderr.write(
436
- `pending-work-progress: edit failed key=${key} ` +
437
- `msg=${editCtx.messageId}: ${(err as Error).message}\n`,
438
- )
441
+ const desc =
442
+ err instanceof Error ? err.message : err != null && typeof err === 'object' && 'description' in err
443
+ ? String((err as { description?: unknown }).description)
444
+ : String(err)
445
+ const low = desc.toLowerCase()
446
+ // "message is not modified" — the anchor already shows this suffix.
447
+ // The card is correct; count it as an edit and move on silently.
448
+ if (low.includes('not modified')) {
449
+ activeDeps!.emitMetric?.({
450
+ kind: 'pending_progress_edited',
451
+ chatKey: key,
452
+ elapsedMs: elapsed,
453
+ })
454
+ return
455
+ }
456
+ // Message / chat gone, or the 48h edit window closed. The anchor
457
+ // is dead — stop retrying it (clear state) so the tick isn't
458
+ // hammering a non-existent message every EDIT_INTERVAL_MS. The
459
+ // next outbound reply re-establishes a fresh anchor. Silent: no
460
+ // card to update is not a liveness-logic error.
461
+ if (
462
+ low.includes('not found') ||
463
+ low.includes("can't be edited") ||
464
+ low.includes('cannot be edited') ||
465
+ low.includes('not enough rights')
466
+ ) {
467
+ clearPending(key, 'stale_turn')
468
+ return
469
+ }
470
+ // 429 / transient network blip — leave state intact; the next tick
471
+ // retries. No stderr: a transport hiccup on a best-effort card is
472
+ // not a logic error, and the production wiring already applies
473
+ // retry_after backoff upstream.
439
474
  })
440
475
  }
441
476
  }
@@ -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
+ }
@@ -918,8 +918,8 @@ export function readSubTail(
918
918
  // (stage-on-text, resolve-on-tool/turn_end) can replay a previously
919
919
  // pending block exactly once. `latestSummary` carries the worker's
920
920
  // narrative result (entry.lastResultText), never tool labels.
921
- const fireNarrativeProgress = (): void => {
922
- if (onProgress == null || entry.state !== 'running' || entry.historical) return
921
+ const fireNarrativeProgress = (): boolean => {
922
+ if (onProgress == null || entry.state !== 'running' || entry.historical) return false
923
923
  try {
924
924
  onProgress({
925
925
  agentId: entry.agentId,
@@ -933,8 +933,10 @@ export function readSubTail(
933
933
  lastTool: entry.lastTool,
934
934
  toolCount: entry.toolCount,
935
935
  })
936
+ return true
936
937
  } catch (cbErr) {
937
938
  log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
939
+ return false
938
940
  }
939
941
  }
940
942
  // Resolve a pending sub-agent narrative against a lookahead event.
@@ -952,21 +954,27 @@ export function readSubTail(
952
954
  // compare the trailing block against the worker's last reply text
953
955
  // (`entry.lastReplyText`) and suppress a draft. Background workers
954
956
  // never set lastReplyText, so their trailing narration still SHOWs.
957
+ // Returns true iff a narrative onProgress cue actually fired this
958
+ // call — callers use this to skip a redundant/clobbering tool-label
959
+ // onProgress cue for the SAME tick (see #1042 below: without this,
960
+ // the tool-description onProgress unconditionally fires right after
961
+ // and its replace-on-write onProgress always wins, so the narration
962
+ // shown here is never actually visible on the pinned card).
955
963
  const resolvePendingSubNarrative = (
956
964
  toolName: string | null,
957
965
  toolInput: Record<string, unknown> | undefined,
958
- ): void => {
959
- if (entry.pendingNarrative == null) return
966
+ ): boolean => {
967
+ if (entry.pendingNarrative == null) return false
960
968
  const pending = entry.pendingNarrative
961
969
  entry.pendingNarrative = null
962
970
  if (toolName != null && REPLY_TOOLS.has(toolName)) {
963
971
  const replyText = typeof toolInput?.text === 'string' ? (toolInput.text as string) : ''
964
- if (isDraftOfReply(pending.text, replyText)) return // draft of the reply → SUPPRESS
972
+ if (isDraftOfReply(pending.text, replyText)) return false // draft of the reply → SUPPRESS
965
973
  } else if (toolName == null && entry.lastReplyText != null && entry.lastReplyText.length > 0) {
966
974
  // turn_end path: suppress a trailing draft of the delivered answer.
967
- if (isDraftOfReply(pending.text, entry.lastReplyText)) return
975
+ if (isDraftOfReply(pending.text, entry.lastReplyText)) return false
968
976
  }
969
- fireNarrativeProgress()
977
+ return fireNarrativeProgress()
970
978
  }
971
979
  for (const ev of events) {
972
980
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
@@ -1012,7 +1020,7 @@ export function readSubTail(
1012
1020
  // this tool is the lookahead that decides it (SHOW unless it drafts
1013
1021
  // a reply tool's text). Runs before the tool's own progress cue so
1014
1022
  // a working preamble surfaces just ahead of its tool step.
1015
- resolvePendingSubNarrative(ev.toolName, ev.input)
1023
+ const narrativeJustFired = resolvePendingSubNarrative(ev.toolName, ev.input)
1016
1024
  // NIT 3: capture a foreground sub-agent's actual reply text so the
1017
1025
  // turn_end path can suppress a trailing draft of it (see
1018
1026
  // resolvePendingSubNarrative). Only REPLY_TOOLS carry the answer.
@@ -1039,7 +1047,17 @@ export function readSubTail(
1039
1047
  // stays the worker's narrative result (never polluted with tool
1040
1048
  // labels — the handback payload depends on it). Pure jsonl-tail →
1041
1049
  // render, no model call.
1042
- if (onProgress != null && entry.state === 'running' && !entry.historical) {
1050
+ //
1051
+ // Clobber guard: if a pending narrative just fired an onProgress
1052
+ // cue THIS SAME tick (above), skip this one — replace-on-write
1053
+ // rendering means whichever onProgress call fires last wins, so
1054
+ // firing both back-to-back always threw away the narration in
1055
+ // favour of the generic tool label. Narration already told the
1056
+ // user what's happening for this tick; the label is redundant
1057
+ // here. When nothing preceded this tool call (no pending
1058
+ // narrative), this still fires — that's the named foreground
1059
+ // blindspot fix, unchanged.
1060
+ if (onProgress != null && entry.state === 'running' && !entry.historical && !narrativeJustFired) {
1043
1061
  const toolLine = describeToolUse(ev.toolName, ev.input ?? {})
1044
1062
  if (toolLine != null && toolLine.length > 0) {
1045
1063
  try {
@@ -431,6 +431,27 @@ describe('renderAuthSnapshotFormat2', () => {
431
431
  expect(out).not.toContain('Live · refreshed');
432
432
  });
433
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
+
434
455
  // ── demo mode (the `/usage demo` / `/auth demo` suffix) ──────────────
435
456
  describe('demo mode masks email labels', () => {
436
457
  it('WITHOUT demo, the real account emails still render', () => {
@@ -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)
@@ -253,6 +253,21 @@ describe('renderOperatorEvent — unknown-5xx', () => {
253
253
  })
254
254
  })
255
255
 
256
+ describe('renderOperatorEvent — config-warning', () => {
257
+ it('is framed as non-urgent (dismiss-only, no restart/reauth/logs actions)', () => {
258
+ const { text, keyboard } = renderOperatorEvent(
259
+ makeEvent('config-warning', { detail: 'person_id: dropped 1 malformed users: entry at boot — lisa (empty person_id)' }),
260
+ )
261
+ expect(text).toContain('Config warning')
262
+ expect(text).toContain('Non-urgent')
263
+ const buttons = keyboard.inline_keyboard.flat()
264
+ expect(buttons.every(b => b.callback_data?.includes('dismiss'))).toBe(true)
265
+ expect(buttons.some(b => b.callback_data?.includes('restart'))).toBe(false)
266
+ expect(buttons.some(b => b.callback_data?.includes('reauth'))).toBe(false)
267
+ expect(buttons.some(b => b.callback_data?.includes('logs'))).toBe(false)
268
+ })
269
+ })
270
+
256
271
  describe('renderOperatorEvent — markdown escaping (#2669)', () => {
257
272
  it('passes < > literally in agent name (markdown, #2669)', () => {
258
273
  const { text } = renderOperatorEvent(makeEvent('unknown-4xx', { agent: '<evil>' }))
@@ -276,6 +291,7 @@ describe('renderOperatorEvent — all kinds produce valid keyboard structure', (
276
291
  'agent-restarted-unexpectedly',
277
292
  'unknown-4xx',
278
293
  'unknown-5xx',
294
+ 'config-warning',
279
295
  ]
280
296
 
281
297
  for (const kind of allKinds) {