switchroom 0.16.28 → 0.16.38
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 +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +13 -4
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
*
|
|
16
16
|
* No HTML escaping at the boundary — callers pass already-trusted
|
|
17
17
|
* label strings (broker-vetted account labels). If that ever changes
|
|
18
|
-
* the per-line `
|
|
18
|
+
* the per-line `escapeMarkdown` helper (imported from card-format) is the place to gate.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { QuotaResult, QuotaUtilization } from './quota-check.js';
|
|
22
22
|
import { isProbeThin, refillNormalizedUtils } from '../src/auth/quota.js';
|
|
23
23
|
import type { AccountState, LastQuotaSnapshot, ListStateData } from '../src/auth/broker/client.js';
|
|
24
24
|
import { maskEmail } from './demo-mask.js';
|
|
25
|
+
import { escapeMarkdown, codeSpanSafe } from './card-format.js';
|
|
25
26
|
|
|
26
27
|
// ── shared types ─────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -190,9 +191,68 @@ export function formatAbsolute(
|
|
|
190
191
|
});
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* Render a future absolute time for the table's Status column, showing the
|
|
196
|
+
* DATE only when it's not "today" in the user's timezone — all in that tz so
|
|
197
|
+
* DST is handled by the tz database (never a hard-coded offset).
|
|
198
|
+
*
|
|
199
|
+
* - same calendar day as `now` (user tz) → time only: `11:00 PM`
|
|
200
|
+
* - a different day, but within the next 6 days → weekday + time: `Wed 1:00 AM`
|
|
201
|
+
* - further out → weekday + day + month + time: `Wed 1 Jul 1:00 AM`
|
|
202
|
+
*
|
|
203
|
+
* `tz` is an IANA tz-database name (e.g. `Australia/Melbourne`); the caller
|
|
204
|
+
* sources it from `SWITCHROOM_TIMEZONE` / `TZ`. Returns "—" for null.
|
|
205
|
+
*/
|
|
206
|
+
export function formatStatusTime(
|
|
207
|
+
target: Date | null,
|
|
208
|
+
now: Date = new Date(),
|
|
209
|
+
tz: string = 'UTC',
|
|
210
|
+
): string {
|
|
211
|
+
if (!target) return '—';
|
|
212
|
+
// Compare calendar dates in the user's tz using en-CA (YYYY-MM-DD) so the
|
|
213
|
+
// "is it today?" test is timezone-correct, not UTC-relative.
|
|
214
|
+
const dayFmt = new Intl.DateTimeFormat('en-CA', {
|
|
215
|
+
timeZone: tz,
|
|
216
|
+
year: 'numeric',
|
|
217
|
+
month: '2-digit',
|
|
218
|
+
day: '2-digit',
|
|
219
|
+
});
|
|
220
|
+
const timeFmt = new Intl.DateTimeFormat('en-US', {
|
|
221
|
+
timeZone: tz,
|
|
222
|
+
hour: 'numeric',
|
|
223
|
+
minute: '2-digit',
|
|
224
|
+
hour12: true,
|
|
225
|
+
});
|
|
226
|
+
const timeStr = timeFmt.format(target);
|
|
227
|
+
if (dayFmt.format(target) === dayFmt.format(now)) {
|
|
228
|
+
return timeStr;
|
|
229
|
+
}
|
|
230
|
+
// Different day — always include the weekday. Add the day-of-month + month
|
|
231
|
+
// once the target falls outside the current week (≥ 7 days away), where a
|
|
232
|
+
// bare weekday would be ambiguous.
|
|
233
|
+
const withinWeek = target.getTime() - now.getTime() < 7 * 24 * 60 * 60 * 1000;
|
|
234
|
+
const dateFmt = new Intl.DateTimeFormat('en-US', {
|
|
235
|
+
timeZone: tz,
|
|
236
|
+
weekday: 'short',
|
|
237
|
+
...(withinWeek ? {} : { day: 'numeric', month: 'short' }),
|
|
238
|
+
});
|
|
239
|
+
return `${dateFmt.format(target)} ${timeStr}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Render a utilization percentage for display, CLAMPED to [0, 100].
|
|
244
|
+
*
|
|
245
|
+
* Anthropic's unified rate-limit headers return a decimal utilization that can
|
|
246
|
+
* exceed 1.0 (e.g. 1.01 → 101%) when an account is over its window cap. A
|
|
247
|
+
* displayed value over 100% erodes trust ("how can I be at 101%?"), so the
|
|
248
|
+
* DISPLAYED number is clamped to 100 here at the format boundary. This does NOT
|
|
249
|
+
* mutate the source quota data — `classifyHealth` / `blockedReason` still read
|
|
250
|
+
* the true (possibly >100) value, so the blocked/exhausted state still surfaces
|
|
251
|
+
* via the Status column and the health emoji. Only the rendered number is
|
|
252
|
+
* clamped.
|
|
253
|
+
*/
|
|
194
254
|
export function fmtPct(pct: number): string {
|
|
195
|
-
return `${Math.round(pct)}%`;
|
|
255
|
+
return `${Math.min(100, Math.round(pct))}%`;
|
|
196
256
|
}
|
|
197
257
|
|
|
198
258
|
// ── /auth snapshot — Format 2 ────────────────────────────────────────
|
|
@@ -232,20 +292,7 @@ function displayLabel(label: string, opts: SnapshotRenderOpts): string {
|
|
|
232
292
|
return opts.demo ? maskEmail(label) : label;
|
|
233
293
|
}
|
|
234
294
|
|
|
235
|
-
/**
|
|
236
|
-
* Header line shape: emoji + group title + count.
|
|
237
|
-
*
|
|
238
|
-
* 🟢 HEALTHY (4)
|
|
239
|
-
* 🟡 ACTIVE — REFRESHING SOON (1)
|
|
240
|
-
* 🔴 BLOCKED (1)
|
|
241
|
-
* ⚪ UNKNOWN (1)
|
|
242
|
-
*/
|
|
243
|
-
function groupHeader(health: AccountHealth, count: number): string {
|
|
244
|
-
const emoji = HEALTH_EMOJI[health];
|
|
245
|
-
const title = HEALTH_TITLE[health];
|
|
246
|
-
return `${emoji} **${title}** (${count})`;
|
|
247
|
-
}
|
|
248
|
-
|
|
295
|
+
/** Health → State-column emoji for the snapshot table. */
|
|
249
296
|
const HEALTH_EMOJI: Record<AccountHealth, string> = {
|
|
250
297
|
healthy: '🟢',
|
|
251
298
|
throttling: '🟡',
|
|
@@ -253,13 +300,6 @@ const HEALTH_EMOJI: Record<AccountHealth, string> = {
|
|
|
253
300
|
unknown: '⚪',
|
|
254
301
|
};
|
|
255
302
|
|
|
256
|
-
const HEALTH_TITLE: Record<AccountHealth, string> = {
|
|
257
|
-
healthy: 'HEALTHY',
|
|
258
|
-
throttling: 'THROTTLING',
|
|
259
|
-
blocked: 'BLOCKED',
|
|
260
|
-
unknown: 'UNKNOWN',
|
|
261
|
-
};
|
|
262
|
-
|
|
263
303
|
/**
|
|
264
304
|
* One-line per-account summary inside its health group.
|
|
265
305
|
*
|
|
@@ -284,10 +324,10 @@ function renderAccountRow(
|
|
|
284
324
|
|
|
285
325
|
if (!snap.quota) {
|
|
286
326
|
lines.push(
|
|
287
|
-
`${marker}\`${
|
|
327
|
+
`${marker}\`${codeSpanSafe(label)}\` _quota probe failed_`,
|
|
288
328
|
);
|
|
289
329
|
if (snap.quotaError) {
|
|
290
|
-
lines.push(` _${
|
|
330
|
+
lines.push(` _${escapeMarkdown(snap.quotaError)}_`);
|
|
291
331
|
}
|
|
292
332
|
return lines;
|
|
293
333
|
}
|
|
@@ -297,7 +337,7 @@ function renderAccountRow(
|
|
|
297
337
|
// it as a data-quality gap, never a confident "0% / 0%".
|
|
298
338
|
if (isProbeThin(q)) {
|
|
299
339
|
lines.push(
|
|
300
|
-
`${marker}\`${
|
|
340
|
+
`${marker}\`${codeSpanSafe(label)}\` _quota unknown (thin probe)_`,
|
|
301
341
|
);
|
|
302
342
|
return lines;
|
|
303
343
|
}
|
|
@@ -307,7 +347,7 @@ function renderAccountRow(
|
|
|
307
347
|
const fiveStr = fmtPct(norm.fiveHourUtilizationPct);
|
|
308
348
|
const sevenStr = fmtPct(norm.sevenDayUtilizationPct);
|
|
309
349
|
lines.push(
|
|
310
|
-
`${marker}\`${
|
|
350
|
+
`${marker}\`${codeSpanSafe(label)}\` ${fiveStr} / ${sevenStr}`,
|
|
311
351
|
);
|
|
312
352
|
|
|
313
353
|
const health = classifyHealth(snap, now);
|
|
@@ -318,7 +358,9 @@ function renderAccountRow(
|
|
|
318
358
|
const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
|
|
319
359
|
const winLabel = win === '5h' ? '5-hour' : '7-day';
|
|
320
360
|
lines.push(
|
|
321
|
-
|
|
361
|
+
reset
|
|
362
|
+
? ` _quota exhausted — back ${formatAbsolute(reset, tz)} (\`in ${formatRelative(reset, now)}\`, ${winLabel} cap)_`
|
|
363
|
+
: ` _quota exhausted — ${winLabel} cap, reset time unknown_`,
|
|
322
364
|
);
|
|
323
365
|
return lines;
|
|
324
366
|
}
|
|
@@ -332,10 +374,10 @@ function renderAccountRow(
|
|
|
332
374
|
const sevenResetIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
|
|
333
375
|
const fiveFirst = fiveResetIn <= sevenResetIn;
|
|
334
376
|
const fiveSeg = q.fiveHourResetAt
|
|
335
|
-
? `5h refills ${formatAbsolute(q.fiveHourResetAt, tz)} (in ${formatRelative(q.fiveHourResetAt, now)})`
|
|
377
|
+
? `5h refills ${formatAbsolute(q.fiveHourResetAt, tz)} (\`in ${formatRelative(q.fiveHourResetAt, now)}\`)`
|
|
336
378
|
: '5h refills —';
|
|
337
379
|
const sevenSeg = q.sevenDayResetAt
|
|
338
|
-
? `7d resets ${formatAbsolute(q.sevenDayResetAt, tz)} (in ${formatRelative(q.sevenDayResetAt, now)})`
|
|
380
|
+
? `7d resets ${formatAbsolute(q.sevenDayResetAt, tz)} (\`in ${formatRelative(q.sevenDayResetAt, now)}\`)`
|
|
339
381
|
: '7d resets —';
|
|
340
382
|
lines.push(` _${fiveFirst ? fiveSeg : sevenSeg}_`);
|
|
341
383
|
lines.push(` _${fiveFirst ? sevenSeg : fiveSeg}_`);
|
|
@@ -343,7 +385,7 @@ function renderAccountRow(
|
|
|
343
385
|
// surface it as a sub-line on a healthy/throttling row — NOT a blocked badge.
|
|
344
386
|
if (q.overageDisabledReason != null && OVERAGE_EXHAUSTED_REASONS.has(q.overageDisabledReason)) {
|
|
345
387
|
lines.push(
|
|
346
|
-
` _overage off (${
|
|
388
|
+
` _overage off (${escapeMarkdown(q.overageDisabledReason)}) — serving from quota_`,
|
|
347
389
|
);
|
|
348
390
|
}
|
|
349
391
|
return lines;
|
|
@@ -374,42 +416,110 @@ function formatAgeStamp(atMs: number, now: Date = new Date()): string {
|
|
|
374
416
|
return ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
|
|
375
417
|
}
|
|
376
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Health-group rank for the table's secondary sort (after active-first):
|
|
421
|
+
* problems before good news so the user scans the walled accounts at the top.
|
|
422
|
+
* blocked → throttling → unknown → healthy.
|
|
423
|
+
*/
|
|
424
|
+
const TABLE_HEALTH_RANK: Record<AccountHealth, number> = {
|
|
425
|
+
blocked: 0,
|
|
426
|
+
throttling: 1,
|
|
427
|
+
unknown: 2,
|
|
428
|
+
healthy: 3,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/** Escape a cell value for a GFM table cell: pipes break the column grid, and
|
|
432
|
+
* newlines break the row. Emails don't normally carry either, but be safe. */
|
|
433
|
+
function tableCell(s: string): string {
|
|
434
|
+
return s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The two percent cells (5h / 7d), refill-normalized + clamped, or a
|
|
438
|
+
* data-quality marker when the probe is missing/thin. */
|
|
439
|
+
function pctCells(
|
|
440
|
+
snap: AccountSnapshot,
|
|
441
|
+
now: Date,
|
|
442
|
+
): { five: string; seven: string } {
|
|
443
|
+
if (!snap.quota) return { five: '—', seven: '—' };
|
|
444
|
+
if (isProbeThin(snap.quota)) return { five: '?', seven: '?' };
|
|
445
|
+
const norm = refillNormalizedUtils(snap.quota, now);
|
|
446
|
+
return { five: fmtPct(norm.fiveHourUtilizationPct), seven: fmtPct(norm.sevenDayUtilizationPct) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The Status cell: for a blocked account, when the binding window comes back
|
|
451
|
+
* (`back <when>`); for a healthy/throttling account, when the soonest window
|
|
452
|
+
* refills (`refills <when>`). `<when>` is the tz-aware absolute time (date shown
|
|
453
|
+
* only when not today) plus the concise relative hint in parens.
|
|
454
|
+
*/
|
|
455
|
+
function statusCell(snap: AccountSnapshot, now: Date, tz: string): string {
|
|
456
|
+
if (!snap.quota) {
|
|
457
|
+
return snap.quotaError ? `probe failed (${snap.quotaError})` : 'probe failed';
|
|
458
|
+
}
|
|
459
|
+
if (isProbeThin(snap.quota)) return 'quota unknown';
|
|
460
|
+
const q = snap.quota;
|
|
461
|
+
const health = classifyHealth(snap, now);
|
|
462
|
+
if (health === 'blocked') {
|
|
463
|
+
const win = bindingWindow(q);
|
|
464
|
+
const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
|
|
465
|
+
if (!reset) return 'back — (reset unknown)';
|
|
466
|
+
return `back ${formatStatusTime(reset, now, tz)} (in ${formatRelative(reset, now)})`;
|
|
467
|
+
}
|
|
468
|
+
// healthy / throttling — show the soonest refill across both windows.
|
|
469
|
+
const fiveIn = q.fiveHourResetAt ? q.fiveHourResetAt.getTime() - now.getTime() : Infinity;
|
|
470
|
+
const sevenIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
|
|
471
|
+
const soonest = fiveIn <= sevenIn ? q.fiveHourResetAt : q.sevenDayResetAt;
|
|
472
|
+
if (!soonest) return 'refills —';
|
|
473
|
+
return `refills ${formatStatusTime(soonest, now, tz)} (in ${formatRelative(soonest, now)})`;
|
|
474
|
+
}
|
|
475
|
+
|
|
377
476
|
export function renderAuthSnapshotFormat2(
|
|
378
477
|
snapshots: AccountSnapshot[],
|
|
379
478
|
opts: SnapshotRenderOpts = {},
|
|
380
479
|
): string {
|
|
381
480
|
const now = opts.now ?? new Date();
|
|
481
|
+
const tz = opts.tz ?? 'UTC';
|
|
382
482
|
const lines: string[] = [];
|
|
383
483
|
lines.push('🔋 **Auth — fleet status**');
|
|
384
484
|
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
// Within each group, active first.
|
|
397
|
-
for (const arr of grouped.values()) {
|
|
398
|
-
arr.sort((a, b) => Number(b.isActive) - Number(a.isActive));
|
|
399
|
-
}
|
|
485
|
+
// Single sorted table: the ACTIVE account always sorts to the top row,
|
|
486
|
+
// regardless of its health group. Remaining rows order by health so
|
|
487
|
+
// problems are visible — blocked → throttling → unknown → healthy — then
|
|
488
|
+
// stable/alpha on label for a deterministic order.
|
|
489
|
+
const ordered = [...snapshots].sort((a, b) => {
|
|
490
|
+
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
|
491
|
+
const r = TABLE_HEALTH_RANK[classifyHealth(a, now)] - TABLE_HEALTH_RANK[classifyHealth(b, now)];
|
|
492
|
+
if (r !== 0) return r;
|
|
493
|
+
return a.label.localeCompare(b.label);
|
|
494
|
+
});
|
|
400
495
|
|
|
401
|
-
|
|
402
|
-
const arr = grouped.get(h);
|
|
403
|
-
if (!arr || arr.length === 0) continue;
|
|
496
|
+
if (ordered.length > 0) {
|
|
404
497
|
lines.push('');
|
|
405
|
-
lines.push(
|
|
406
|
-
|
|
407
|
-
|
|
498
|
+
lines.push('| State | Account | 5h | 7d | Status |');
|
|
499
|
+
lines.push('| --- | --- | --- | --- | --- |');
|
|
500
|
+
for (const s of ordered) {
|
|
501
|
+
const emoji = HEALTH_EMOJI[classifyHealth(s, now)];
|
|
502
|
+
// Account cell: FULL email, never truncated; active gets a (active) suffix.
|
|
503
|
+
// The black-dot active marker is intentionally removed — the suffix reads.
|
|
504
|
+
const label = displayLabel(s.label, opts);
|
|
505
|
+
// #2701 — the account label is an identifier (email / slug) that can
|
|
506
|
+
// contain markdown-active chars (`_ * ~` etc.). `tableCell` only guards
|
|
507
|
+
// the table grid (`\ | \n`), NOT inline formatting, so a raw label like
|
|
508
|
+
// `ken_max` was parsed as emphasis and corrupted the row. Render it in a
|
|
509
|
+
// code span (backtick content is literal) exactly like renderAccountRow
|
|
510
|
+
// does, defusing embedded backticks via codeSpanSafe. The wrapping
|
|
511
|
+
// backticks still need pipe/newline guarding for the grid, hence the
|
|
512
|
+
// tableCell wrap around the finished span.
|
|
513
|
+
const accountCell = `\`${codeSpanSafe(s.isActive ? `${label} (active)` : label)}\``;
|
|
514
|
+
const { five, seven } = pctCells(s, now);
|
|
515
|
+
const status = statusCell(s, now, tz);
|
|
516
|
+
lines.push(
|
|
517
|
+
`| ${emoji} | ${tableCell(accountCell)} | ${five} | ${seven} | ${tableCell(status)} |`,
|
|
518
|
+
);
|
|
408
519
|
}
|
|
409
520
|
}
|
|
410
521
|
|
|
411
522
|
lines.push('');
|
|
412
|
-
lines.push('────────────────────────────');
|
|
413
523
|
lines.push(`_${recommendation(snapshots, now, opts.demo ?? false)}_`);
|
|
414
524
|
// #2495 Change 2 — a failed probe-on-open renders an explicit "cached Nm
|
|
415
525
|
// ago" warning, never a false live stamp. The degraded variant takes
|
|
@@ -615,10 +725,10 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
615
725
|
// same per-account row + earliest-recovery helpers the /auth table uses so
|
|
616
726
|
// the formatting stays consistent with the rest of the auth surface.
|
|
617
727
|
lines.push(
|
|
618
|
-
`🔴 **All accounts blocked · ${headerLimit} on ${
|
|
728
|
+
`🔴 **All accounts blocked · ${headerLimit} on ${escapeMarkdown(input.oldLabel)}**`,
|
|
619
729
|
);
|
|
620
730
|
lines.push('');
|
|
621
|
-
lines.push(`Triggered by: agent **${
|
|
731
|
+
lines.push(`Triggered by: agent **${escapeMarkdown(input.triggerAgent)}**`);
|
|
622
732
|
|
|
623
733
|
const fleet = input.fleetSnapshots ?? [];
|
|
624
734
|
if (fleet.length > 0) {
|
|
@@ -639,7 +749,7 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
639
749
|
if (earliest) {
|
|
640
750
|
lines.push('');
|
|
641
751
|
lines.push(
|
|
642
|
-
`Earliest recovery: \`${
|
|
752
|
+
`Earliest recovery: \`${codeSpanSafe(earliest.label)}\` ` +
|
|
643
753
|
`${formatAbsolute(earliest.at, tz)} (in ${formatRelative(earliest.at, now)})`,
|
|
644
754
|
);
|
|
645
755
|
}
|
|
@@ -648,7 +758,7 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
648
758
|
const recovery = recoveryAtFor(input.oldQuota);
|
|
649
759
|
if (recovery) {
|
|
650
760
|
lines.push(
|
|
651
|
-
`${
|
|
761
|
+
`${escapeMarkdown(input.oldLabel)} recovers ${formatAbsolute(recovery, tz)} ` +
|
|
652
762
|
`(in ${formatRelative(recovery, now)})`,
|
|
653
763
|
);
|
|
654
764
|
}
|
|
@@ -663,20 +773,20 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
663
773
|
|
|
664
774
|
// Successful swap.
|
|
665
775
|
lines.push(
|
|
666
|
-
`✓ **Switched fleet · ${headerLimit} on ${
|
|
776
|
+
`✓ **Switched fleet · ${headerLimit} on ${escapeMarkdown(input.oldLabel)}**`,
|
|
667
777
|
);
|
|
668
778
|
lines.push('');
|
|
669
779
|
lines.push(
|
|
670
|
-
`\`${
|
|
780
|
+
`\`${codeSpanSafe(input.oldLabel)}\` → \`${codeSpanSafe(input.newLabel)}\``,
|
|
671
781
|
);
|
|
672
|
-
lines.push(`Triggered by: agent **${
|
|
782
|
+
lines.push(`Triggered by: agent **${escapeMarkdown(input.triggerAgent)}**`);
|
|
673
783
|
lines.push('');
|
|
674
784
|
|
|
675
785
|
if (input.oldQuota) {
|
|
676
786
|
const recovery = recoveryAtFor(input.oldQuota);
|
|
677
787
|
if (recovery) {
|
|
678
788
|
lines.push(
|
|
679
|
-
`\`${
|
|
789
|
+
`\`${codeSpanSafe(input.oldLabel)}\` recovers ` +
|
|
680
790
|
`${formatAbsolute(recovery, tz)} (in ${formatRelative(recovery, now)})`,
|
|
681
791
|
);
|
|
682
792
|
}
|
|
@@ -690,7 +800,7 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
690
800
|
input.newQuota.sevenDayUtilizationPct < THROTTLING_THRESHOLD_PCT;
|
|
691
801
|
const headroomStr = hasHeadroom ? '_(plenty of headroom)_' : '_(near limit — watch this)_';
|
|
692
802
|
lines.push(
|
|
693
|
-
`\`${
|
|
803
|
+
`\`${codeSpanSafe(input.newLabel)}\` now: ${fiveStr} of 5h · ${sevenStr} of 7d ${headroomStr}`,
|
|
694
804
|
);
|
|
695
805
|
} else {
|
|
696
806
|
lines.push(
|
|
@@ -807,10 +917,6 @@ function switchPriority(s: AccountSnapshot, now: Date = new Date()): number {
|
|
|
807
917
|
|
|
808
918
|
// ── shared HTML escape ───────────────────────────────────────────────
|
|
809
919
|
|
|
810
|
-
function escapeHtml(s: string): string {
|
|
811
|
-
return s.replace(/([\\`*_~=\[\]|])/g, '\\$1');
|
|
812
|
-
}
|
|
813
|
-
|
|
814
920
|
// ── snapshot assembly helper ─────────────────────────────────────────
|
|
815
921
|
|
|
816
922
|
/**
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import type { QuotaResult, QuotaUtilization } from './quota-check.js';
|
|
38
|
+
import { escapeMarkdown } from './card-format.js';
|
|
38
39
|
import type { ListStateData } from '../src/auth/broker/client.js';
|
|
39
40
|
import {
|
|
40
41
|
renderFallbackAnnouncement,
|
|
@@ -54,16 +55,12 @@ import {
|
|
|
54
55
|
*/
|
|
55
56
|
export function renderFallbackFailureNotice(triggerAgent: string, reason: string): string {
|
|
56
57
|
return (
|
|
57
|
-
`⚠️ **Auto-failover could not run** (trigger: **${
|
|
58
|
-
`${
|
|
58
|
+
`⚠️ **Auto-failover could not run** (trigger: **${escapeMarkdown(triggerAgent)}**)\n` +
|
|
59
|
+
`${escapeMarkdown(reason)}\n\n` +
|
|
59
60
|
`_Switch manually with \`/auth use <label>\`, or \`/auth\` for fleet status._`
|
|
60
61
|
);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
function escFailureHtml(s: string): string {
|
|
64
|
-
return s.replace(/([\\`*_~=\[\]|])/g, '\\$1');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
64
|
/**
|
|
68
65
|
* Cooldown for the failure notice. The fleetFallbackGate's dedup window
|
|
69
66
|
* deliberately arms ONLY on a successful swap (fleet-fallback-gate.ts:
|
|
@@ -27,13 +27,63 @@
|
|
|
27
27
|
* which is itself state-free.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { escapeMarkdown } from './format.js'
|
|
30
|
+
import { escapeMarkdown, codeSpanSafe } from './format.js'
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Re-export so card surfaces have one import for the markdown escaper they
|
|
34
34
|
* use when interpolating dynamic values into a hand-built markdown card.
|
|
35
|
+
* `codeSpanSafe` is the code-span (backtick) counterpart: use it for values
|
|
36
|
+
* interpolated INSIDE `` `…` `` (where backslash escaping is wrong).
|
|
35
37
|
*/
|
|
36
|
-
export { escapeMarkdown }
|
|
38
|
+
export { escapeMarkdown, codeSpanSafe }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Join a card's pre-rendered, single-line entries so they STACK in the
|
|
42
|
+
* Bot API 10.1 rich-message renderer (#2669) — the same visual result the
|
|
43
|
+
* main reply path gets after `normalizeParagraphBreaks`.
|
|
44
|
+
*
|
|
45
|
+
* Why this exists: the rich GFM renderer treats a LONE `\n` between two
|
|
46
|
+
* non-blank lines as a SOFT break and collapses them onto one visual line
|
|
47
|
+
* (this is the exact reason `normalizeParagraphBreaks` promotes prose `\n`
|
|
48
|
+
* to a hard break on the reply path). Status / worker / issues / boot
|
|
49
|
+
* cards build a list of styled prose lines (`_✓ step_`, `**→ step**`,
|
|
50
|
+
* header lines, a `─────` rule) and join them with `\n`. Sent verbatim via
|
|
51
|
+
* `richMessage`, those lines collapse into a run-on blob — the long-standing
|
|
52
|
+
* "progress-card bullets collapse into one line" bug.
|
|
53
|
+
*
|
|
54
|
+
* Each card line is a SINGLE styled line with no internal newline (steps are
|
|
55
|
+
* pre-clipped to one line, headers are emitted one-per-array-entry), so we
|
|
56
|
+
* can deterministically promote EVERY inter-line break to a GFM hard break
|
|
57
|
+
* (` \n`, two trailing spaces) without risking a list/table/code corruption:
|
|
58
|
+
* card lines are never GFM block-structure lines (no leading `-`/`*`/`|`/```),
|
|
59
|
+
* they are inline-styled prose. A blank entry (intentional `\n\n` gap, if a
|
|
60
|
+
* caller ever inserts one) is preserved as a real paragraph break.
|
|
61
|
+
*
|
|
62
|
+
* This is the card-surface analogue of the reply path's
|
|
63
|
+
* `normalizeParagraphBreaks`: it guarantees a card authored as stacked
|
|
64
|
+
* bullet/step lines renders identically to a normal reply.
|
|
65
|
+
*/
|
|
66
|
+
export function stackCardLines(lines: string[]): string {
|
|
67
|
+
const pieces: string[] = []
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
const line = lines[i]
|
|
70
|
+
pieces.push(line)
|
|
71
|
+
if (i === lines.length - 1) break
|
|
72
|
+
const cur = line.trim()
|
|
73
|
+
const next = lines[i + 1].trim()
|
|
74
|
+
// A blank current or next line is a genuine `\n\n` paragraph gap — leave
|
|
75
|
+
// the separator a plain newline so the blank entry reconstructs the gap.
|
|
76
|
+
if (cur === '' || next === '') {
|
|
77
|
+
pieces.push('\n')
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
// Strip any trailing whitespace the line already carried so we emit
|
|
81
|
+
// exactly one ` \n` hard break (never accumulate spaces on a re-run).
|
|
82
|
+
pieces[pieces.length - 1] = line.replace(/[ \t\r]+$/, '')
|
|
83
|
+
pieces.push(' \n')
|
|
84
|
+
}
|
|
85
|
+
return pieces.join('')
|
|
86
|
+
}
|
|
37
87
|
|
|
38
88
|
/**
|
|
39
89
|
* Render a millisecond duration as `<n>ms` for sub-second values, or
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
25
25
|
import { join } from "path";
|
|
26
|
+
import { escapeMarkdown } from "./card-format.js";
|
|
26
27
|
|
|
27
28
|
const STATE_FILE = "credits-watch.json";
|
|
28
29
|
|
|
@@ -157,7 +158,7 @@ export function evaluateCreditState(args: {
|
|
|
157
158
|
if (!currentIsFatal && prevIsFatal) {
|
|
158
159
|
return {
|
|
159
160
|
kind: "notify",
|
|
160
|
-
message: `✅ **${
|
|
161
|
+
message: `✅ **${escapeMarkdown(agentName)}**: credits restored — agent should resume normal operation.`,
|
|
161
162
|
newState: { lastNotifiedReason: null, lastNotifiedAt: now },
|
|
162
163
|
transition: "exited",
|
|
163
164
|
};
|
|
@@ -193,12 +194,12 @@ export function evaluateCreditState(args: {
|
|
|
193
194
|
function buildEntryMessage(agentName: string, reason: string): string {
|
|
194
195
|
const desc = humanizeReason(reason);
|
|
195
196
|
return [
|
|
196
|
-
`⚠️ **${
|
|
197
|
+
`⚠️ **${escapeMarkdown(agentName)}**: ${desc}`,
|
|
197
198
|
``,
|
|
198
199
|
`Cron tasks and inbound replies will fail until this is resolved. Check`,
|
|
199
200
|
`your subscription or pre-paid usage at [console.anthropic.com](https://console.anthropic.com).`,
|
|
200
201
|
``,
|
|
201
|
-
`_Source: Claude CLI cache (cachedExtraUsageDisabledReason=${
|
|
202
|
+
`_Source: Claude CLI cache (cachedExtraUsageDisabledReason=${escapeMarkdown(reason)})_`,
|
|
202
203
|
].join("\n");
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -217,10 +218,6 @@ function humanizeReason(reason: string): string {
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
function escapeHtml(s: string): string {
|
|
221
|
-
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
222
|
-
}
|
|
223
|
-
|
|
224
221
|
// ─── State persistence ───────────────────────────────────────────────────────
|
|
225
222
|
|
|
226
223
|
export function loadCreditState(stateDir: string): CreditState {
|