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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +2 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +119 -74
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +37 -8
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +922 -459
- package/telegram-plugin/gateway/gateway.ts +260 -21
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- 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
|
-
//
|
|
424
|
-
// /
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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 = ():
|
|
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
|
-
):
|
|
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
|
-
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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) {
|