switchroom 0.16.22 → 0.16.24
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/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2865 -48
- package/dist/cli/switchroom.js +338 -92
- package/dist/host-control/main.js +1 -1
- package/package.json +5 -5
- package/telegram-plugin/answer-stream.ts +13 -43
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +101 -662
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +751 -624
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +36 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +44 -2
- package/telegram-plugin/retry-api-call.ts +15 -7
- package/telegram-plugin/rich-send.ts +57 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +23 -6
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +40 -97
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-title.test.ts +45 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -250,21 +250,21 @@ export function formatResetRelative(target: Date | null, now: Date = new Date())
|
|
|
250
250
|
*/
|
|
251
251
|
export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): string {
|
|
252
252
|
const lines: string[] = [];
|
|
253
|
-
lines.push("
|
|
253
|
+
lines.push("**Claude plan quota**");
|
|
254
254
|
lines.push("");
|
|
255
255
|
lines.push(
|
|
256
|
-
|
|
256
|
+
`**5h window** ${Math.round(q.fiveHourUtilizationPct)}% · ${formatResetRelative(q.fiveHourResetAt, now)}`,
|
|
257
257
|
);
|
|
258
258
|
lines.push(
|
|
259
|
-
|
|
259
|
+
`**7d window** ${Math.round(q.sevenDayUtilizationPct)}% · ${formatResetRelative(q.sevenDayResetAt, now)}`,
|
|
260
260
|
);
|
|
261
261
|
if (q.representativeClaim) {
|
|
262
262
|
lines.push("");
|
|
263
|
-
lines.push(
|
|
263
|
+
lines.push(`_Binding window: ${q.representativeClaim.replace(/_/g, " ")}_`);
|
|
264
264
|
}
|
|
265
265
|
if (q.overageStatus && q.overageStatus !== "allowed") {
|
|
266
266
|
const reason = q.overageDisabledReason ? ` (${q.overageDisabledReason})` : "";
|
|
267
|
-
lines.push(
|
|
267
|
+
lines.push(`_Overage: ${q.overageStatus}${reason}_`);
|
|
268
268
|
}
|
|
269
269
|
return lines.join("\n");
|
|
270
270
|
}
|
|
@@ -498,12 +498,12 @@ function buildAllExhaustedMessage(
|
|
|
498
498
|
? `Earliest reset: ${formatRelative(new Date(earliest), new Date(now))}.`
|
|
499
499
|
: `Reset time unknown (no window data).`;
|
|
500
500
|
return [
|
|
501
|
-
`🔴
|
|
501
|
+
`🔴 **All accounts exhausted**`,
|
|
502
502
|
``,
|
|
503
503
|
`Every Anthropic account (${accounts.length}) is quota-walled — there is no healthy account to fail over to.`,
|
|
504
504
|
resetLine,
|
|
505
505
|
``,
|
|
506
|
-
|
|
506
|
+
`_This is self-healing: agents resume and deferred scheduled jobs run automatically once a window resets. Nothing is lost. Add headroom with \`/auth add\` if this recurs._`,
|
|
507
507
|
].join("\n");
|
|
508
508
|
}
|
|
509
509
|
|
|
@@ -511,11 +511,11 @@ function buildFleetRecoveredMessage(
|
|
|
511
511
|
accounts: Array<{ label: string; exhausted: boolean }>,
|
|
512
512
|
): string {
|
|
513
513
|
const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
|
|
514
|
-
const which = healthy.length > 0 ? ` (
|
|
514
|
+
const which = healthy.length > 0 ? ` (\`${escapeHtml(healthy[0]!)}\`)` : "";
|
|
515
515
|
return [
|
|
516
|
-
`🟢
|
|
516
|
+
`🟢 **Fleet recovered** — at least one account is healthy again${which}.`,
|
|
517
517
|
``,
|
|
518
|
-
|
|
518
|
+
`_Agents are back; any deferred scheduled jobs will run on their next occurrence._`,
|
|
519
519
|
].join("\n");
|
|
520
520
|
}
|
|
521
521
|
|
|
@@ -535,21 +535,21 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
|
|
|
535
535
|
|
|
536
536
|
const activeNote = snap.isActive
|
|
537
537
|
? ""
|
|
538
|
-
: `\nThis is a non-active account. Consider
|
|
538
|
+
: `\nThis is a non-active account. Consider \`/auth use ${escapeHtml(snap.label)}\` to switch, or keep it as a fallback reserve.`;
|
|
539
539
|
|
|
540
540
|
const altNote = snap.isActive
|
|
541
|
-
? `\nConsider
|
|
541
|
+
? `\nConsider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
|
|
542
542
|
: "";
|
|
543
543
|
|
|
544
544
|
return [
|
|
545
|
-
`🟡
|
|
545
|
+
`🟡 **Quota approaching limit** — \`${escapeHtml(snap.label)}\``,
|
|
546
546
|
``,
|
|
547
547
|
`${fiveStr} of 5h · ${sevenStr} of 7d`,
|
|
548
548
|
`Binding window: ${winLabel}${resetStr}`,
|
|
549
549
|
`${activeNote}${altNote}`,
|
|
550
550
|
``,
|
|
551
|
-
|
|
552
|
-
|
|
551
|
+
`_Threshold: ${THROTTLING_THRESHOLD_PCT}% on either window. Live-probe corroborated (#2495)._`,
|
|
552
|
+
`_Run /auth for full fleet status or /usage for the active account._`,
|
|
553
553
|
]
|
|
554
554
|
.join("\n")
|
|
555
555
|
.replace(/\n\n\n+/g, "\n\n")
|
|
@@ -563,21 +563,16 @@ function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string
|
|
|
563
563
|
: "Current quota data unavailable.";
|
|
564
564
|
|
|
565
565
|
return [
|
|
566
|
-
`🟢
|
|
566
|
+
`🟢 **Quota back in healthy range** — \`${escapeHtml(snap.label)}\``,
|
|
567
567
|
``,
|
|
568
568
|
utilLine,
|
|
569
569
|
``,
|
|
570
|
-
|
|
570
|
+
`_Below ${THROTTLING_THRESHOLD_PCT}% on both windows._`,
|
|
571
571
|
].join("\n");
|
|
572
572
|
}
|
|
573
573
|
|
|
574
574
|
function escapeHtml(s: string): string {
|
|
575
|
-
return s
|
|
576
|
-
.replace(/&/g, "&")
|
|
577
|
-
.replace(/</g, "<")
|
|
578
|
-
.replace(/>/g, ">")
|
|
579
|
-
.replace(/"/g, """)
|
|
580
|
-
.replace(/'/g, "'");
|
|
575
|
+
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
581
576
|
}
|
|
582
577
|
|
|
583
578
|
// ─── State persistence ────────────────────────────────────────────────────────
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* (raw markdown, since reply tools don't always render HTML).
|
|
16
16
|
*
|
|
17
17
|
* Smoking-gun evidence: klanker chat 12345, msgs 5025 + 5027,
|
|
18
|
-
* 11s apart.
|
|
19
|
-
*
|
|
18
|
+
* 11s apart. The two paths historically rendered identical content two
|
|
19
|
+
* different ways (one HTML-escaped, one raw markdown) and shipped both.
|
|
20
20
|
* Same content, different formatting, two messages.
|
|
21
21
|
*
|
|
22
22
|
* Fix shape: maintain a small in-memory cache of "what we just sent"
|
|
@@ -188,9 +188,9 @@ function makeKey(chatId: string, threadId: number | undefined): string {
|
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
190
|
* Normalise text for content equality. The bug we're defending
|
|
191
|
-
* against produces the SAME content rendered two different ways
|
|
192
|
-
* one
|
|
193
|
-
*
|
|
191
|
+
* against produces the SAME content rendered two different ways
|
|
192
|
+
* (historically one HTML-rendered, the other raw markdown). Both must
|
|
193
|
+
* hash identically — so we strip both HTML tags and markdown markers.
|
|
194
194
|
*
|
|
195
195
|
* Steps:
|
|
196
196
|
* 1. Strip HTML tags (`<b>foo</b>` → `foo`).
|
|
@@ -218,7 +218,14 @@ export function openTurnsDb(agentDir: string): SqliteDatabase {
|
|
|
218
218
|
const db = new Database(path, { create: true })
|
|
219
219
|
applySchema(db)
|
|
220
220
|
try {
|
|
221
|
-
|
|
221
|
+
// 0o644 on all three SQLite files so the switchroom-web container
|
|
222
|
+
// (different UID, same host bind-mount) can read turn history.
|
|
223
|
+
// WAL mode requires read access to registry.db-shm and registry.db-wal
|
|
224
|
+
// in addition to the main file — all three must be world-readable.
|
|
225
|
+
chmodSync(path, 0o644)
|
|
226
|
+
for (const suffix of ['-shm', '-wal']) {
|
|
227
|
+
try { chmodSync(path + suffix, 0o644) } catch { /* doesn't exist yet */ }
|
|
228
|
+
}
|
|
222
229
|
} catch {
|
|
223
230
|
/* ignore — chmod not supported on some FUSE mounts */
|
|
224
231
|
}
|
|
@@ -496,11 +503,46 @@ export function findRecentTurnsForChat(
|
|
|
496
503
|
*
|
|
497
504
|
* `limit` defaults to 20, max 200.
|
|
498
505
|
*/
|
|
506
|
+
/**
|
|
507
|
+
* Return distinct thread_ids (null = general topic) for a given chat_id.
|
|
508
|
+
* Used by the Hermes adapter to enumerate forum topics as separate sessions.
|
|
509
|
+
*/
|
|
510
|
+
export function listDistinctThreadIds(
|
|
511
|
+
db: SqliteDatabase,
|
|
512
|
+
chatId: string,
|
|
513
|
+
): (string | null)[] {
|
|
514
|
+
const rows = db.prepare(`
|
|
515
|
+
SELECT DISTINCT thread_id FROM turns
|
|
516
|
+
WHERE chat_id = ?
|
|
517
|
+
ORDER BY thread_id ASC
|
|
518
|
+
`).all(chatId) as { thread_id: string | null }[]
|
|
519
|
+
return rows.map((r) => r.thread_id)
|
|
520
|
+
}
|
|
521
|
+
|
|
499
522
|
export function listTurnsForAgent(
|
|
500
523
|
db: SqliteDatabase,
|
|
501
|
-
opts: { limit?: number } = {},
|
|
524
|
+
opts: { limit?: number; chatId?: string; threadId?: string | null } = {},
|
|
502
525
|
): Turn[] {
|
|
503
526
|
const limit = Math.min(Math.max(1, opts.limit ?? 20), 200)
|
|
527
|
+
if (opts.chatId && 'threadId' in opts) {
|
|
528
|
+
// threadId may be a string ID or null (general topic)
|
|
529
|
+
const rows = db.prepare(`
|
|
530
|
+
SELECT * FROM turns
|
|
531
|
+
WHERE chat_id = ? AND thread_id IS ?
|
|
532
|
+
ORDER BY started_at DESC
|
|
533
|
+
LIMIT ?
|
|
534
|
+
`).all(opts.chatId, opts.threadId ?? null, limit) as RawTurnRow[]
|
|
535
|
+
return rows.map(mapRow)
|
|
536
|
+
}
|
|
537
|
+
if (opts.chatId) {
|
|
538
|
+
const rows = db.prepare(`
|
|
539
|
+
SELECT * FROM turns
|
|
540
|
+
WHERE chat_id = ?
|
|
541
|
+
ORDER BY started_at DESC
|
|
542
|
+
LIMIT ?
|
|
543
|
+
`).all(opts.chatId, limit) as RawTurnRow[]
|
|
544
|
+
return rows.map(mapRow)
|
|
545
|
+
}
|
|
504
546
|
const rows = db.prepare(`
|
|
505
547
|
SELECT * FROM turns
|
|
506
548
|
ORDER BY started_at DESC
|
|
@@ -253,13 +253,16 @@ export async function retryWithThreadFallback<T>(
|
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
255
|
* True when Telegram rejected a message because it couldn't parse the
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
256
|
+
* markdown entities we sent on the rich-message path (#2669). There is no
|
|
257
|
+
* new rich-specific error class in grammy 1.44 — a malformed-markdown
|
|
258
|
+
* failure still throws `GrammyError` with the standard
|
|
259
|
+
* `{ ok:false, error_code:400, description }` shape, the same family as the
|
|
260
|
+
* legacy "can't parse entities" 400. These 400s are deliberately NOT
|
|
261
|
+
* swallowed or retried by `retryApiCall` (only not-modified / not-found /
|
|
262
|
+
* thread-not-found are) — they surface to the caller, which recovers by
|
|
263
|
+
* resending the chunk as plain text (no rich-message wrapper, so the
|
|
264
|
+
* parser never runs). Same "caller-level fallback" shape as the
|
|
265
|
+
* THREAD_NOT_FOUND contract above.
|
|
263
266
|
*/
|
|
264
267
|
export function isHtmlParseRejectError(err: unknown): boolean {
|
|
265
268
|
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
@@ -267,9 +270,14 @@ export function isHtmlParseRejectError(err: unknown): boolean {
|
|
|
267
270
|
return (
|
|
268
271
|
d.includes("can't parse entities") ||
|
|
269
272
|
d.includes('can’t parse entities') ||
|
|
273
|
+
d.includes("can't parse") ||
|
|
274
|
+
d.includes('can’t parse') ||
|
|
275
|
+
d.includes('parse markdown') ||
|
|
276
|
+
d.includes('parse rich') ||
|
|
270
277
|
d.includes('unsupported start tag') ||
|
|
271
278
|
d.includes('unclosed start tag') ||
|
|
272
279
|
d.includes("can't find end of the entity") ||
|
|
280
|
+
d.includes('can’t find end of the entity') ||
|
|
273
281
|
// covers both "expected end tag" and "unexpected end tag"
|
|
274
282
|
d.includes('expected end tag')
|
|
275
283
|
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich-message send/edit helpers (Bot API 10.1, #2669).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound message in the plugin goes through `sendRichMessage` /
|
|
5
|
+
* `editMessageText({ markdown })` with raw GFM markdown. These two tiny
|
|
6
|
+
* helpers build the canonical call shape so the ~dozens of card surfaces
|
|
7
|
+
* and the gateway core all share ONE rendering path:
|
|
8
|
+
*
|
|
9
|
+
* - `richMessage(text)` → `{ markdown: text }`, the `InputRichMessage`
|
|
10
|
+
* accepted by both `sendRichMessage` and `editMessageText`.
|
|
11
|
+
* - `isParseEntitiesError(err)` → true when Telegram rejected the body
|
|
12
|
+
* because it couldn't parse the markdown entities. There is NO new
|
|
13
|
+
* rich-specific error class in grammy 1.44 — a malformed-markdown
|
|
14
|
+
* failure still throws `GrammyError` with the standard
|
|
15
|
+
* `{ ok:false, error_code:400, description }` shape, the same family
|
|
16
|
+
* as the legacy "can't parse entities" 400. Callers recover by
|
|
17
|
+
* resending the SAME message id as plain text (no rich, no parse_mode).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { GrammyError } from 'grammy'
|
|
21
|
+
|
|
22
|
+
/** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
|
|
23
|
+
export interface InputRichMessageMarkdown {
|
|
24
|
+
markdown: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Wrap raw GFM markdown into the rich-message input object. */
|
|
28
|
+
export function richMessage(markdown: string): InputRichMessageMarkdown {
|
|
29
|
+
return { markdown }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when Telegram rejected a message because it couldn't parse the
|
|
34
|
+
* markdown entities we sent. These 400s are deliberately NOT swallowed or
|
|
35
|
+
* retried by the retry policy — they surface to the caller, which recovers
|
|
36
|
+
* by resending the body as plain text (a literal string, no rich-message
|
|
37
|
+
* wrapper, so the parser never runs). Same "caller-level fallback" contract
|
|
38
|
+
* the old HTML path used.
|
|
39
|
+
*/
|
|
40
|
+
export function isParseEntitiesError(err: unknown): boolean {
|
|
41
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
42
|
+
const d = (err.description || '').toLowerCase()
|
|
43
|
+
return (
|
|
44
|
+
d.includes("can't parse entities") ||
|
|
45
|
+
d.includes('can’t parse entities') ||
|
|
46
|
+
d.includes("can't parse") ||
|
|
47
|
+
d.includes('can’t parse') ||
|
|
48
|
+
d.includes('parse markdown') ||
|
|
49
|
+
d.includes('parse rich') ||
|
|
50
|
+
d.includes("can't find end of the entity") ||
|
|
51
|
+
d.includes('can’t find end of the entity') ||
|
|
52
|
+
d.includes('unsupported start tag') ||
|
|
53
|
+
d.includes('unclosed start tag') ||
|
|
54
|
+
// covers both "expected end tag" and "unexpected end tag"
|
|
55
|
+
d.includes('expected end tag')
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -80,7 +80,7 @@ describe("renderVaultCliError", () => {
|
|
|
80
80
|
// appeared in output (so the operator knew which key triggered
|
|
81
81
|
// the card). New copy keeps the key in <code>…</code> form via
|
|
82
82
|
// htmlEscape — assert it.
|
|
83
|
-
expect(out.html).toContain("
|
|
83
|
+
expect(out.html).toContain("`my_key`");
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
it("renders sandbox_context for verb=set WITHOUT a key (defensive fallback)", () => {
|
|
@@ -92,7 +92,7 @@ describe("renderVaultCliError", () => {
|
|
|
92
92
|
{ verb: "set" },
|
|
93
93
|
);
|
|
94
94
|
expect(out.html).toMatch(/vault_request_save/);
|
|
95
|
-
expect(out.html).not.toContain("
|
|
95
|
+
expect(out.html).not.toContain("``");
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
it("renders sandbox_context for verb=get with /vault get", () => {
|
|
@@ -119,7 +119,7 @@ describe("renderVaultCliError", () => {
|
|
|
119
119
|
);
|
|
120
120
|
expect(out.suppressRaw).toBe(true);
|
|
121
121
|
expect(out.html).toContain("operator approval required");
|
|
122
|
-
expect(out.html).toContain("
|
|
122
|
+
expect(out.html).toContain("`telegram_bot_token`");
|
|
123
123
|
expect(out.html).toMatch(/vault_request_save/);
|
|
124
124
|
expect(out.html).not.toMatch(/on the way/i);
|
|
125
125
|
});
|
|
@@ -175,12 +175,12 @@ describe("renderVaultCliError", () => {
|
|
|
175
175
|
expect(out.html).toBe("");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it("
|
|
178
|
+
it("keeps the key literal inside the code span (< > stay verbatim, #2669)", () => {
|
|
179
179
|
const out = renderVaultCliError(
|
|
180
180
|
{ kind: "needs_approval", original: "x", key: "key<with>html" },
|
|
181
181
|
{ verb: "save" },
|
|
182
182
|
);
|
|
183
|
-
|
|
184
|
-
expect(out.html).toContain("key
|
|
183
|
+
// < > are literal in rich markdown and ride verbatim in the `code span`.
|
|
184
|
+
expect(out.html).toContain("`key<with>html`");
|
|
185
185
|
});
|
|
186
186
|
});
|
|
@@ -127,10 +127,17 @@ export interface VaultErrorRendering {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
function htmlEscape(s: string): string {
|
|
130
|
-
return s
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Make a string safe to interpolate INSIDE a `code span` without escaping —
|
|
135
|
+
* content there is literal, so markdown-escaping a key would leak visible
|
|
136
|
+
* backslashes (e.g. `my\_key`). Only a backtick can prematurely close the
|
|
137
|
+
* span, so split it with a zero-width space (#2669).
|
|
138
|
+
*/
|
|
139
|
+
function codeSpanSafe(s: string): string {
|
|
140
|
+
return s.replace(/`/g, "`");
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
/**
|
|
@@ -173,13 +180,13 @@ export function renderVaultCliError(
|
|
|
173
180
|
return {
|
|
174
181
|
suppressRaw: true,
|
|
175
182
|
html:
|
|
176
|
-
`⚠️
|
|
183
|
+
`⚠️ **New vault key — operator approval required.**\n` +
|
|
177
184
|
(key
|
|
178
|
-
? `The agent tried to save
|
|
185
|
+
? `The agent tried to save \`${codeSpanSafe(key)}\`, but `
|
|
179
186
|
: `The agent tried to save a new key, but `) +
|
|
180
187
|
`agents can only rotate existing keys via the broker; introducing ` +
|
|
181
188
|
`a new key needs an operator action.\n\n` +
|
|
182
|
-
|
|
189
|
+
`**Ask the agent** to call its \`vault_request_save\` ` +
|
|
183
190
|
`tool — it'll render an approval card in this chat with ` +
|
|
184
191
|
`[✅ Save once] [🚫 Discard] [✏️ Rename] buttons. One tap saves the ` +
|
|
185
192
|
`secret to the vault; no re-paste needed.`,
|
|
@@ -194,12 +201,12 @@ export function renderVaultCliError(
|
|
|
194
201
|
return {
|
|
195
202
|
suppressRaw: true,
|
|
196
203
|
html:
|
|
197
|
-
`⚠️
|
|
204
|
+
`⚠️ **Vault broker isn't reachable.**\n` +
|
|
198
205
|
`From inside the agent sandbox there's no fallback path. ` +
|
|
199
206
|
`Telegram-native broker recovery is tracked as a follow-up — ` +
|
|
200
207
|
`for now, on the host:\n` +
|
|
201
|
-
|
|
202
|
-
|
|
208
|
+
"```\nswitchroom vault broker status\n```\n" +
|
|
209
|
+
`_Or, if the broker is wedged: \`docker compose -p switchroom restart vault-broker\`._`,
|
|
203
210
|
};
|
|
204
211
|
case "broker_denied":
|
|
205
212
|
// Telegram-native grant flow shipped in #969 P2b + #1012:
|
|
@@ -210,14 +217,14 @@ export function renderVaultCliError(
|
|
|
210
217
|
return {
|
|
211
218
|
suppressRaw: true,
|
|
212
219
|
html:
|
|
213
|
-
`⚠️
|
|
220
|
+
`⚠️ **Vault broker refused the request.**\n` +
|
|
214
221
|
(key
|
|
215
|
-
? `The agent isn't authorized to access
|
|
222
|
+
? `The agent isn't authorized to access \`${codeSpanSafe(key)}\`. `
|
|
216
223
|
: `The agent isn't authorized to access this key. `) +
|
|
217
224
|
`Grant access in two taps:\n` +
|
|
218
|
-
`•
|
|
225
|
+
`• \`/vault audit <agent>\` in this chat → tap ` +
|
|
219
226
|
`[🔓 Allow${key ? ` ${htmlEscape(key)}` : ""}] on the recent denial, OR\n` +
|
|
220
|
-
`• ask the agent to call
|
|
227
|
+
`• ask the agent to call \`vault_request_access\` — ` +
|
|
221
228
|
`an approval card lands here with [✅ Approve] / [🚫 Deny].`,
|
|
222
229
|
};
|
|
223
230
|
case "other":
|
|
@@ -234,36 +241,36 @@ function renderSandboxContextSuggestion(
|
|
|
234
241
|
verb: "set" | "get" | "list" | "init" | "remove" | "save",
|
|
235
242
|
key: string | undefined,
|
|
236
243
|
): string {
|
|
237
|
-
const head = `⚠️
|
|
244
|
+
const head = `⚠️ **Direct vault file IO isn't available from inside the agent sandbox.**\n`;
|
|
238
245
|
switch (verb) {
|
|
239
246
|
case "set":
|
|
240
247
|
case "save":
|
|
241
248
|
return (
|
|
242
249
|
head +
|
|
243
250
|
(key
|
|
244
|
-
?
|
|
245
|
-
`tool for
|
|
251
|
+
? `**Ask the agent** to call its \`vault_request_save\` ` +
|
|
252
|
+
`tool for \`${codeSpanSafe(key)}\` — an approval card ` +
|
|
246
253
|
`lands here with [✅ Save once] / [🚫 Discard] / [✏️ Rename] buttons.`
|
|
247
|
-
:
|
|
254
|
+
: `**Ask the agent** to call its \`vault_request_save\` ` +
|
|
248
255
|
`tool — an approval card lands here with [✅ Save once] / [🚫 Discard] / [✏️ Rename] buttons.`)
|
|
249
256
|
);
|
|
250
257
|
case "get":
|
|
251
258
|
return (
|
|
252
259
|
head +
|
|
253
|
-
`From this chat:
|
|
260
|
+
`From this chat: \`/vault get${key ? ` ${codeSpanSafe(key)}` : " <key>"}\``
|
|
254
261
|
);
|
|
255
262
|
case "list":
|
|
256
|
-
return head + `From this chat:
|
|
263
|
+
return head + `From this chat: \`/vault list\``;
|
|
257
264
|
case "remove":
|
|
258
265
|
return (
|
|
259
266
|
head +
|
|
260
267
|
`Use the operator host CLI for now — Telegram-native delete is tracked as a follow-up. ` +
|
|
261
|
-
|
|
268
|
+
`_(Removing a vault key is a rare, irreversible operation; an in-chat confirmation card is on the punch list.)_`
|
|
262
269
|
);
|
|
263
270
|
case "init":
|
|
264
271
|
return (
|
|
265
272
|
head +
|
|
266
|
-
`Vault bootstrap is a one-time host-shell step:
|
|
273
|
+
`Vault bootstrap is a one-time host-shell step: \`switchroom vault init\`.`
|
|
267
274
|
);
|
|
268
275
|
}
|
|
269
276
|
}
|
|
@@ -154,14 +154,25 @@ export function createRobustApiCall() {
|
|
|
154
154
|
})
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
// ───
|
|
157
|
+
// ─── Markdown escape helpers (#2669) ──────────────────────────────────────
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Escape GFM-markdown specials in a dynamic value interpolated into prose.
|
|
161
|
+
* Kept under the legacy `escapeHtmlForTg` name so callers don't churn.
|
|
162
|
+
*/
|
|
159
163
|
export function escapeHtmlForTg(text: string): string {
|
|
160
|
-
return text.replace(
|
|
164
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
161
165
|
}
|
|
162
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Wrap CLI / command output in a fenced code block. Inside a fence the
|
|
169
|
+
* content is literal (no escaping), so we pass `text` through verbatim —
|
|
170
|
+
* except a fence-closing ``` sequence in the content, which we defuse so it
|
|
171
|
+
* can't terminate the block early.
|
|
172
|
+
*/
|
|
163
173
|
export function preBlock(text: string): string {
|
|
164
|
-
|
|
174
|
+
const safe = text.replace(/```/g, '```')
|
|
175
|
+
return '```\n' + safe + '\n```'
|
|
165
176
|
}
|
|
166
177
|
|
|
167
178
|
export function stripAnsi(text: string): string {
|
|
@@ -278,11 +289,17 @@ export function makeSwitchroomReply(
|
|
|
278
289
|
): Promise<void> {
|
|
279
290
|
const chatId = String(ctx.chat!.id)
|
|
280
291
|
const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
281
|
-
|
|
292
|
+
const opts = {
|
|
282
293
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
283
|
-
...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
|
|
284
294
|
...(options.reply_markup ? { reply_markup: options.reply_markup } : {}),
|
|
285
|
-
}
|
|
295
|
+
}
|
|
296
|
+
// #2669: `options.html:true` now means "render `text` as GFM markdown via
|
|
297
|
+
// the rich-message path" (legacy field name kept). Plain otherwise.
|
|
298
|
+
if (options.html) {
|
|
299
|
+
await ctx.replyWithRichMessage({ markdown: text }, opts)
|
|
300
|
+
} else {
|
|
301
|
+
await ctx.reply(text, opts)
|
|
302
|
+
}
|
|
286
303
|
}
|
|
287
304
|
}
|
|
288
305
|
|
|
@@ -377,17 +377,20 @@ export function silenceMsForKey(key: string, now: number): number | null {
|
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
380
|
+
* Framework-fallback text for the 300s silence threshold. Returns `null` for
|
|
381
|
+
* the pure-stall cases (the "still working / still thinking" / "running <Tool>
|
|
382
|
+
* for Nm" notice) — that timer-fired stall ping was a stop-gap from before the
|
|
383
|
+
* live-updating reply/draft carried the progress beats natively, and the
|
|
384
|
+
* operator has retired it (it's the exact cadence-based "still working" update
|
|
385
|
+
* the conversational-pacing RFC's Anti-patterns section bans). The 300s
|
|
386
|
+
* fallback's surviving job — unwedging a turn that produced no output at all —
|
|
387
|
+
* is the gateway's turn-teardown after this call, NOT a user-visible message,
|
|
388
|
+
* so dropping the stall send keeps the safety net's real work intact (see
|
|
389
|
+
* `reference/rfcs/conversational-pacing.md` § Silence-poke fallback).
|
|
384
390
|
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* 2. The verb is `working` by default, `thinking` only when the session
|
|
389
|
-
* stream has emitted a `kind: 'thinking'` event in the last 30s. Picked
|
|
390
|
-
* by the caller via `fallbackKind`; this helper just formats.
|
|
391
|
+
* The ONE case that still returns a string is `blockedOnApproval`: the turn is
|
|
392
|
+
* parked on an approval card waiting for YOUR tap. That is not a stall — it
|
|
393
|
+
* tells the user the ball is in their court — so it keeps pinging.
|
|
391
394
|
*
|
|
392
395
|
* Extracted from the gateway's `onFrameworkFallback` callback so the wording
|
|
393
396
|
* can be snapshot-tested in isolation. CC-4 in `docs/status-ask-cause-classes.md`.
|
|
@@ -397,73 +400,27 @@ export function formatFrameworkFallbackText(
|
|
|
397
400
|
silenceMs: number,
|
|
398
401
|
inFlightTools: ToolSnapshot[] = [],
|
|
399
402
|
blockedOnApproval = false,
|
|
400
|
-
): string {
|
|
403
|
+
): string | null {
|
|
401
404
|
const minutes = Math.max(1, Math.round(silenceMs / 60_000))
|
|
402
405
|
// The turn isn't stalled — it's parked on an approval card waiting for YOUR
|
|
403
406
|
// tap (the dominant live "wedge" class is benign approval-latency, not a
|
|
404
407
|
// hang). Saying "still working…" here actively lies; name the real blocker so
|
|
405
408
|
// the operator knows the ball is in their court. Takes precedence over the
|
|
406
|
-
// in-flight-tool framing (a tool awaiting approval isn't "running").
|
|
409
|
+
// in-flight-tool framing (a tool awaiting approval isn't "running"). This is
|
|
410
|
+
// the only branch that still emits a user-visible message.
|
|
407
411
|
if (blockedOnApproval) {
|
|
408
412
|
return `waiting for your approval — tap Approve or Deny on the card above (${minutes} min)`
|
|
409
413
|
}
|
|
410
|
-
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
// and look like a leak when surfaced to a user. When the tool name
|
|
421
|
-
// matches that shape AND a human-friendly label is available, drop
|
|
422
|
-
// the raw name and lead with the label instead:
|
|
423
|
-
// Searching memory for 4m (no update from agent in 5 min)
|
|
424
|
-
// Built-in tool names (Grep, Read, Bash) stay as-is — they ARE
|
|
425
|
-
// human-readable, and the label is supplementary detail (e.g. the
|
|
426
|
-
// search pattern) that reads naturally after the verb.
|
|
427
|
-
if (inFlightTools.length > 0) {
|
|
428
|
-
const longest = inFlightTools[0]!
|
|
429
|
-
const dur = formatDurationShort(longest.durationMs)
|
|
430
|
-
const labelTail = longest.label && longest.label.length > 0
|
|
431
|
-
? ` ${truncateLabel(longest.label)}`
|
|
432
|
-
: ''
|
|
433
|
-
const more = inFlightTools.length > 1
|
|
434
|
-
? ` + ${inFlightTools.length - 1} more`
|
|
435
|
-
: ''
|
|
436
|
-
const isMcpRawName = /^mcp__/.test(longest.name)
|
|
437
|
-
if (isMcpRawName && labelTail !== '') {
|
|
438
|
-
// Label-only: "Searching memory for 4m (…)". Drop the raw
|
|
439
|
-
// `mcp__server__tool` and the leading "running" because the
|
|
440
|
-
// label already reads as a gerund phrase.
|
|
441
|
-
return `${truncateLabel(longest.label!)}${more} for ${dur} ${suffix}`
|
|
442
|
-
}
|
|
443
|
-
return `running ${longest.name}${labelTail}${more} for ${dur} ${suffix}`
|
|
444
|
-
}
|
|
445
|
-
return fallbackKind === 'thinking'
|
|
446
|
-
? `still thinking… ${suffix}`
|
|
447
|
-
: `still working… ${suffix}`
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** Compact m/s rendering for the fallback message. Anything under a
|
|
451
|
-
* minute reads as `${s}s`, otherwise `${m}m`. Always rounds toward the
|
|
452
|
-
* user-honest direction — "4m" for 4m 30s, "5m" for 4m 45s. */
|
|
453
|
-
function formatDurationShort(ms: number): string {
|
|
454
|
-
const totalSec = Math.max(0, Math.round(ms / 1000))
|
|
455
|
-
if (totalSec < 60) return `${totalSec}s`
|
|
456
|
-
const minutes = Math.round(totalSec / 60)
|
|
457
|
-
return `${minutes}m`
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** Telegram lines are short on mobile. Clip the label to keep the
|
|
461
|
-
* fallback message readable. Truncation point is generous (60 chars)
|
|
462
|
-
* because tool labels are pre-truncated by `toolLabel()` already. */
|
|
463
|
-
function truncateLabel(label: string): string {
|
|
464
|
-
const MAX = 60
|
|
465
|
-
if (label.length <= MAX) return label
|
|
466
|
-
return label.slice(0, MAX - 1) + '…'
|
|
414
|
+
// Stop-gap retired: the "still working… (no update from agent in N min)" /
|
|
415
|
+
// "running <Tool> for Nm" stall notice (including the #1292 tool-aware
|
|
416
|
+
// enrichment) no longer sends. The live draft + the model's own pacing beats
|
|
417
|
+
// carry progress; a timer-fired stall ping on top of that is the banned
|
|
418
|
+
// cadence-based update. `fallbackKind` and `inFlightTools` are now unused for
|
|
419
|
+
// the stall path but kept on the signature so the gateway's call sites — and
|
|
420
|
+
// the deterministic update-status / unwedge paths around them — are untouched.
|
|
421
|
+
void fallbackKind
|
|
422
|
+
void inFlightTools
|
|
423
|
+
return null
|
|
467
424
|
}
|
|
468
425
|
|
|
469
426
|
/** Snapshot in-flight tools sorted longest-running first — for the honest
|