switchroom 0.16.23 → 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/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +671 -430
- package/dist/host-control/main.js +156 -156
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- 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/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
- package/telegram-plugin/dist/server.js +160 -160
- 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 +43 -3
- 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
|
@@ -200,7 +200,10 @@ export interface FinalizeCallbackContext {
|
|
|
200
200
|
answerCallbackQuery: (
|
|
201
201
|
opts?: { text?: string; show_alert?: boolean },
|
|
202
202
|
) => Promise<unknown>
|
|
203
|
-
editMessageText: (
|
|
203
|
+
editMessageText: (
|
|
204
|
+
text: string | { markdown: string },
|
|
205
|
+
opts?: Record<string, unknown>,
|
|
206
|
+
) => Promise<unknown>
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
export interface FinalizeCallbackOptions {
|
|
@@ -225,11 +228,12 @@ export interface FinalizeCallbackOptions {
|
|
|
225
228
|
*/
|
|
226
229
|
newText: string
|
|
227
230
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
+
* When true, `newText` is edited as a LITERAL plain string (no markdown
|
|
232
|
+
* parsing) — match the original message's send shape. Default (false) →
|
|
233
|
+
* the rich-markdown path, so `newText` is rendered as GFM markdown
|
|
234
|
+
* (#2669, successor to the old `parseMode` option).
|
|
231
235
|
*/
|
|
232
|
-
|
|
236
|
+
literalText?: boolean
|
|
233
237
|
/**
|
|
234
238
|
* Side effect invoked AFTER `editMessageText` resolves. Use for
|
|
235
239
|
* synthesizing the `<channel source="...">` inbound that wakes the
|
|
@@ -274,14 +278,16 @@ export async function finalizeCallback(
|
|
|
274
278
|
})
|
|
275
279
|
// Invariant 2 — strip keyboard + append status line, atomic edit.
|
|
276
280
|
try {
|
|
277
|
-
await ctx.editMessageText(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
await ctx.editMessageText(
|
|
282
|
+
opts.literalText ? opts.newText : { markdown: opts.newText },
|
|
283
|
+
{
|
|
284
|
+
reply_markup: { inline_keyboard: [] },
|
|
285
|
+
// Default link_preview_options off — most finalized cards don't
|
|
286
|
+
// benefit from preview cards, and a stale preview survives the
|
|
287
|
+
// edit otherwise.
|
|
288
|
+
link_preview_options: { is_disabled: true },
|
|
289
|
+
},
|
|
290
|
+
)
|
|
285
291
|
} catch (err) {
|
|
286
292
|
// MESSAGE_NOT_MODIFIED (text didn't change) and MESSAGE_TO_EDIT_NOT_FOUND
|
|
287
293
|
// (operator already deleted the card) are both benign. Other failures
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
20
|
-
import {
|
|
20
|
+
import { escapeMarkdown } from "./card-format.js";
|
|
21
21
|
import type { IssueEvent, IssueSeverity } from "../src/issues/index.js";
|
|
22
22
|
|
|
23
23
|
export interface BotApiForIssuesCard {
|
|
@@ -92,7 +92,7 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
|
|
|
92
92
|
const maxSeverity = sorted[0].severity;
|
|
93
93
|
const headerEmoji = SEVERITY_EMOJI[maxSeverity];
|
|
94
94
|
const count = sorted.length;
|
|
95
|
-
const header = `${headerEmoji}
|
|
95
|
+
const header = `${headerEmoji} **${escapeMarkdown(opts.agentName)}** · ${count} ${count === 1 ? "issue" : "issues"}`;
|
|
96
96
|
|
|
97
97
|
const maxRows = opts.maxRows ?? DEFAULT_MAX_ROWS;
|
|
98
98
|
const visible = sorted.slice(0, maxRows);
|
|
@@ -101,22 +101,22 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
|
|
|
101
101
|
const now = opts.now ?? Date.now();
|
|
102
102
|
const rows = visible.map((e) => {
|
|
103
103
|
const emoji = SEVERITY_EMOJI[e.severity];
|
|
104
|
-
const occ = e.occurrences > 1 ? `
|
|
104
|
+
const occ = e.occurrences > 1 ? ` _(×${e.occurrences})_` : "";
|
|
105
105
|
const ago = relTime(now - e.last_seen);
|
|
106
|
-
const head = `${emoji}
|
|
106
|
+
const head = `${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`;
|
|
107
107
|
// Render the `detail` line below the summary when present and short
|
|
108
108
|
// enough to be a remediation hint (not a multi-line stderr tail).
|
|
109
109
|
// Convention from the cron prompt template: agents put "Fix: <cmd>"
|
|
110
110
|
// or "→ <cmd>" in detail. Long stderr details are omitted from the
|
|
111
111
|
// card to keep the layout tight; users can run /issues to see them.
|
|
112
112
|
const remediation = formatRemediation(e.detail);
|
|
113
|
-
return remediation == null ? head : `${head}\n →
|
|
113
|
+
return remediation == null ? head : `${head}\n → _${escapeMarkdown(remediation)}_`;
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
const lines = [header, "", ...rows];
|
|
117
117
|
if (overflow > 0) {
|
|
118
118
|
lines.push("");
|
|
119
|
-
lines.push(
|
|
119
|
+
lines.push(`_+${overflow} more not shown — run \`/issues\`_`);
|
|
120
120
|
}
|
|
121
121
|
return lines.join("\n");
|
|
122
122
|
}
|
|
@@ -326,7 +326,6 @@ export function createIssuesCardHandle(
|
|
|
326
326
|
if (body === lastBody && messageId != null) return;
|
|
327
327
|
|
|
328
328
|
const sendOpts: Record<string, unknown> = {
|
|
329
|
-
parse_mode: "HTML",
|
|
330
329
|
disable_web_page_preview: true,
|
|
331
330
|
// Status card, not the user's answer — silence the open ping.
|
|
332
331
|
// (editMessageText ignores disable_notification, so the shared
|
|
@@ -366,10 +366,10 @@ export function formatModelUnavailableCard(
|
|
|
366
366
|
opts: FormatCardOptions = {},
|
|
367
367
|
): string {
|
|
368
368
|
const now = opts.now ?? new Date()
|
|
369
|
-
const slotPart = opts.slot ? ` (slot
|
|
369
|
+
const slotPart = opts.slot ? ` (slot **${escHtml(opts.slot)}**)` : ''
|
|
370
370
|
const reason = formatReason(detection, now)
|
|
371
371
|
const lines = [
|
|
372
|
-
`⚠️
|
|
372
|
+
`⚠️ **Model unavailable** on agent **${escHtml(agent)}**${slotPart}`,
|
|
373
373
|
`Reason: ${reason}`,
|
|
374
374
|
'',
|
|
375
375
|
]
|
|
@@ -378,7 +378,7 @@ export function formatModelUnavailableCard(
|
|
|
378
378
|
// swap; a follow-up announcement (causal-shape) will land within
|
|
379
379
|
// ~1s. Mention it explicitly so the user knows not to react.
|
|
380
380
|
lines.push(
|
|
381
|
-
'
|
|
381
|
+
'_Auto-failover in progress — see the announcement below._',
|
|
382
382
|
)
|
|
383
383
|
} else {
|
|
384
384
|
// Default — kinds where auto-fallback can't help (network)
|
|
@@ -386,10 +386,10 @@ export function formatModelUnavailableCard(
|
|
|
386
386
|
// a verb (post-RFC-H); `/auth use <label>` is the canonical
|
|
387
387
|
// fleet-wide swap.
|
|
388
388
|
lines.push(
|
|
389
|
-
'
|
|
390
|
-
'•
|
|
391
|
-
'•
|
|
392
|
-
'•
|
|
389
|
+
'**What to try**',
|
|
390
|
+
'• \`/auth use <label>\` — switch the fleet to a healthy account',
|
|
391
|
+
'• \`/auth add\` — attach another subscription',
|
|
392
|
+
'• \`/usage\` — show quota breakdown',
|
|
393
393
|
)
|
|
394
394
|
}
|
|
395
395
|
return lines.join('\n')
|
|
@@ -471,9 +471,5 @@ export function resolveModelUnavailableFromOperatorEvent(
|
|
|
471
471
|
// ─── HTML escape (mirrors operator-events.ts) ────────────────────────────────
|
|
472
472
|
|
|
473
473
|
function escHtml(text: string): string {
|
|
474
|
-
return text
|
|
475
|
-
.replace(/&/g, '&')
|
|
476
|
-
.replace(/</g, '<')
|
|
477
|
-
.replace(/>/g, '>')
|
|
478
|
-
.replace(/"/g, '"')
|
|
474
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
479
475
|
}
|
|
@@ -78,7 +78,7 @@ export function formatLastEventLine(
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const kindIcon = EVENT_KIND_ICON[ev.kind] ?? '⚪'
|
|
81
|
-
return `
|
|
81
|
+
return ` _last: ${kindIcon} ${ev.kind} (${age})_`
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const EVENT_KIND_ICON: Record<string, string> = {
|
|
@@ -217,9 +217,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
217
217
|
case 'credentials-expired':
|
|
218
218
|
return {
|
|
219
219
|
text: [
|
|
220
|
-
`🔑
|
|
221
|
-
detail ?
|
|
222
|
-
`Tap
|
|
220
|
+
`🔑 **Claude login expired** for **${agent}**.`,
|
|
221
|
+
detail ? `_${detail}_` : '',
|
|
222
|
+
`Tap **Reauth now** to refresh credentials.`,
|
|
223
223
|
]
|
|
224
224
|
.filter(Boolean)
|
|
225
225
|
.join('\n'),
|
|
@@ -236,9 +236,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
236
236
|
case 'credentials-invalid':
|
|
237
237
|
return {
|
|
238
238
|
text: [
|
|
239
|
-
`🔑
|
|
240
|
-
detail ?
|
|
241
|
-
`Run
|
|
239
|
+
`🔑 **Invalid Claude credentials** for **${agent}**.`,
|
|
240
|
+
detail ? `_${detail}_` : '',
|
|
241
|
+
`Run \`/auth reauth ${agent}\` or tap below.`,
|
|
242
242
|
]
|
|
243
243
|
.filter(Boolean)
|
|
244
244
|
.join('\n'),
|
|
@@ -255,9 +255,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
255
255
|
case 'credit-exhausted':
|
|
256
256
|
return {
|
|
257
257
|
text: [
|
|
258
|
-
`💳
|
|
259
|
-
detail ?
|
|
260
|
-
`Use
|
|
258
|
+
`💳 **Credit balance too low** for **${agent}**.`,
|
|
259
|
+
detail ? `_${detail}_` : '',
|
|
260
|
+
`Use \`/auth use <label>\` to switch account slot or \`/auth add\` to add one.`,
|
|
261
261
|
]
|
|
262
262
|
.filter(Boolean)
|
|
263
263
|
.join('\n'),
|
|
@@ -274,9 +274,9 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
274
274
|
// are the historical source; this is now the single owner.
|
|
275
275
|
return {
|
|
276
276
|
text: [
|
|
277
|
-
`⚠️
|
|
278
|
-
detail ?
|
|
279
|
-
`All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available. Use
|
|
277
|
+
`⚠️ **Quota exhausted** for **${agent}**.`,
|
|
278
|
+
detail ? `_${detail}_` : '',
|
|
279
|
+
`All account slots are at the usage limit. Switchroom will auto-fallback when another slot is available. Use \`/auth use <label>\` to switch manually.`,
|
|
280
280
|
]
|
|
281
281
|
.filter(Boolean)
|
|
282
282
|
.join('\n'),
|
|
@@ -290,8 +290,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
290
290
|
case 'rate-limited':
|
|
291
291
|
return {
|
|
292
292
|
text: [
|
|
293
|
-
`🚦
|
|
294
|
-
detail ?
|
|
293
|
+
`🚦 **Rate limited** for **${agent}**.`,
|
|
294
|
+
detail ? `_${detail}_` : '',
|
|
295
295
|
`Claude is temporarily rate-limiting requests. Will retry automatically.`,
|
|
296
296
|
]
|
|
297
297
|
.filter(Boolean)
|
|
@@ -306,8 +306,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
306
306
|
case 'agent-crashed':
|
|
307
307
|
return {
|
|
308
308
|
text: [
|
|
309
|
-
`💥
|
|
310
|
-
detail ?
|
|
309
|
+
`💥 **Agent crashed**: **${agent}**.`,
|
|
310
|
+
detail ? `_${detail}_` : '',
|
|
311
311
|
]
|
|
312
312
|
.filter(Boolean)
|
|
313
313
|
.join('\n'),
|
|
@@ -324,8 +324,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
324
324
|
case 'agent-restarted-unexpectedly':
|
|
325
325
|
return {
|
|
326
326
|
text: [
|
|
327
|
-
`🔄
|
|
328
|
-
detail ?
|
|
327
|
+
`🔄 **Agent restarted unexpectedly**: **${agent}**.`,
|
|
328
|
+
detail ? `_${detail}_` : '',
|
|
329
329
|
`This may indicate a crash-loop. Check logs if it happens again.`,
|
|
330
330
|
]
|
|
331
331
|
.filter(Boolean)
|
|
@@ -343,8 +343,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
343
343
|
case 'unknown-4xx':
|
|
344
344
|
return {
|
|
345
345
|
text: [
|
|
346
|
-
`⚠️
|
|
347
|
-
detail ?
|
|
346
|
+
`⚠️ **API error (4xx)** for **${agent}**.`,
|
|
347
|
+
detail ? `\`${detail}\`` : '',
|
|
348
348
|
]
|
|
349
349
|
.filter(Boolean)
|
|
350
350
|
.join('\n'),
|
|
@@ -361,8 +361,8 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
361
361
|
case 'unknown-5xx':
|
|
362
362
|
return {
|
|
363
363
|
text: [
|
|
364
|
-
`🔥
|
|
365
|
-
detail ?
|
|
364
|
+
`🔥 **Server error (5xx)** for **${agent}**.`,
|
|
365
|
+
detail ? `\`${detail}\`` : '',
|
|
366
366
|
`Anthropic may be experiencing issues. Will retry automatically.`,
|
|
367
367
|
]
|
|
368
368
|
.filter(Boolean)
|
|
@@ -411,12 +411,8 @@ export function resetAllCooldowns(): void {
|
|
|
411
411
|
cooldownMap.clear()
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
-
// ───
|
|
414
|
+
// ─── Markdown escape (#2669) ──────────────────────────────────────────────────
|
|
415
415
|
|
|
416
416
|
function escHtml(text: string): string {
|
|
417
|
-
return text
|
|
418
|
-
.replace(/&/g, '&')
|
|
419
|
-
.replace(/</g, '<')
|
|
420
|
-
.replace(/>/g, '>')
|
|
421
|
-
.replace(/"/g, '"')
|
|
417
|
+
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
422
418
|
}
|
|
@@ -49,14 +49,13 @@
|
|
|
49
49
|
* interval is short (5s) but edits are spaced at EDIT_INTERVAL_MS so
|
|
50
50
|
* the Telegram bot.api editMessageText rate stays well under limits.
|
|
51
51
|
*
|
|
52
|
-
* Edits preserve the anchor's original
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* the
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* suffixes.
|
|
52
|
+
* Edits preserve the anchor's original send shape — rich-markdown vs the
|
|
53
|
+
* literal `format:'text'` path (#2669, successor to the #1698 parse-mode
|
|
54
|
+
* contract). The anchor was sent through the reply tool, which defaults to
|
|
55
|
+
* the rich-markdown path; the edit re-sends via the SAME path so the
|
|
56
|
+
* anchor body keeps rendering the way it first did. On subsequent edits
|
|
57
|
+
* the prior suffix is stripped before re-appending so the message never
|
|
58
|
+
* accumulates duplicate suffixes.
|
|
60
59
|
*
|
|
61
60
|
* Kill switch: `SWITCHROOM_DISABLE_PENDING_PROGRESS=1` disables the
|
|
62
61
|
* whole subsystem. The conversational-pacing prompt is unaffected.
|
|
@@ -65,32 +64,36 @@
|
|
|
65
64
|
export const EDIT_INTERVAL_MS = 60_000
|
|
66
65
|
export const POLL_INTERVAL_MS = 5_000
|
|
67
66
|
export const MAX_LIFETIME_MS = 30 * 60_000
|
|
68
|
-
/**
|
|
67
|
+
/** Rich-message wire cap is 32768 (#2669); budget headroom for the
|
|
69
68
|
* suffix and any escape expansion. If the anchor text plus suffix
|
|
70
69
|
* would exceed this, we skip the edit (the user still sees the
|
|
71
70
|
* original) rather than truncate the model's authored prose. */
|
|
72
|
-
export const TELEGRAM_MSG_CAP =
|
|
71
|
+
export const TELEGRAM_MSG_CAP = 32768
|
|
73
72
|
|
|
74
73
|
/**
|
|
75
74
|
* Regex matching the suffix we append. Used to strip a prior suffix
|
|
76
75
|
* before appending the next one. The (\d+) covers "1m" / "12m" / etc.
|
|
77
76
|
* The reachability clause is optional so anchors carrying a pre-v0.14.30
|
|
78
|
-
* suffix (no clause) are still stripped during a rolling upgrade.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
77
|
+
* suffix (no clause) are still stripped during a rolling upgrade. Both the
|
|
78
|
+
* legacy em-dash prefix (`— still working`) and the rich-markdown italic
|
|
79
|
+
* form (`_still working … _`, #2669) are matched so a rolling upgrade
|
|
80
|
+
* strips either. Kept anchored to end-of-string so it only matches OUR
|
|
81
|
+
* suffix, not something the model happened to write.
|
|
81
82
|
*/
|
|
82
83
|
const SUFFIX_RE =
|
|
83
|
-
/\n\n
|
|
84
|
+
/\n\n(?:— |_)still working \(\d+m\)( · message me anytime, I'll keep you posted)?_?$/
|
|
84
85
|
|
|
85
86
|
export interface PendingProgressEditCtx {
|
|
86
87
|
chatId: string
|
|
87
88
|
threadId: number | null
|
|
88
89
|
messageId: number
|
|
89
90
|
newText: string
|
|
90
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
91
|
+
/** True when the original anchor was a literal `format:'text'` send
|
|
92
|
+
* (plain `sendMessage`, no rich-message wrapper). The edit must match:
|
|
93
|
+
* a rich anchor re-edits via `editMessageText({ markdown })`, a literal
|
|
94
|
+
* anchor re-edits as a plain string (#2669 single-rich-path migration of
|
|
95
|
+
* the #1698 parse-mode-preservation contract). */
|
|
96
|
+
literalText: boolean
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
/**
|
|
@@ -145,11 +148,11 @@ interface State {
|
|
|
145
148
|
/** The captured anchor text — what the model wrote, *minus* any
|
|
146
149
|
* prior pending-progress suffix. Used as the base for every edit. */
|
|
147
150
|
anchorOriginalText: string
|
|
148
|
-
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
|
|
151
|
+
/** True when the anchor was a literal `format:'text'` send. Edits must
|
|
152
|
+
* match the original send shape (rich vs literal) or the suffix re-edit
|
|
153
|
+
* changes how the anchor body renders (the #2669 single-rich-path
|
|
154
|
+
* successor to the #1698 parse-mode-preservation contract). */
|
|
155
|
+
anchorLiteralText: boolean
|
|
153
156
|
/** Wall-clock ms when the cross-turn ambient state was *activated*
|
|
154
157
|
* (at turn_end with pending+anchor). null before activation. */
|
|
155
158
|
activatedAt: number | null
|
|
@@ -178,7 +181,7 @@ function ensure(key: string): State {
|
|
|
178
181
|
pending: false,
|
|
179
182
|
anchorMessageId: null,
|
|
180
183
|
anchorOriginalText: '',
|
|
181
|
-
|
|
184
|
+
anchorLiteralText: false,
|
|
182
185
|
activatedAt: null,
|
|
183
186
|
lastEditAt: null,
|
|
184
187
|
}
|
|
@@ -216,7 +219,7 @@ export function startTurn(key: string): void {
|
|
|
216
219
|
s.pending = false
|
|
217
220
|
s.anchorMessageId = null
|
|
218
221
|
s.anchorOriginalText = ''
|
|
219
|
-
s.
|
|
222
|
+
s.anchorLiteralText = false
|
|
220
223
|
}
|
|
221
224
|
|
|
222
225
|
/**
|
|
@@ -241,21 +244,18 @@ export function noteOutbound(
|
|
|
241
244
|
opts: {
|
|
242
245
|
messageId: number
|
|
243
246
|
text: string
|
|
244
|
-
/**
|
|
245
|
-
* cross-turn edit tick
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
|
|
249
|
-
* fixtures don't have to thread it through where they're
|
|
250
|
-
* asserting other behaviour. */
|
|
251
|
-
parseMode?: 'HTML' | 'MarkdownV2' | undefined
|
|
247
|
+
/** True when the anchor was a literal `format:'text'` send. Captured
|
|
248
|
+
* so the cross-turn edit tick re-edits with the same shape (#2669
|
|
249
|
+
* single-rich-path successor to the #1698 parse-mode contract).
|
|
250
|
+
* Omitted ⇒ false (the rich-markdown default). */
|
|
251
|
+
literalText?: boolean
|
|
252
252
|
},
|
|
253
253
|
): void {
|
|
254
254
|
if (!enabled()) return
|
|
255
255
|
const s = ensure(key)
|
|
256
256
|
s.anchorMessageId = opts.messageId
|
|
257
257
|
s.anchorOriginalText = opts.text.replace(SUFFIX_RE, '')
|
|
258
|
-
s.
|
|
258
|
+
s.anchorLiteralText = opts.literalText ?? false
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
/**
|
|
@@ -399,7 +399,7 @@ function tick(now: number): void {
|
|
|
399
399
|
// user-visible counter reads honestly (we only edit at intervals
|
|
400
400
|
// ≥ EDIT_INTERVAL_MS = 60s).
|
|
401
401
|
const minutes = Math.max(1, Math.round(elapsed / 60_000))
|
|
402
|
-
const suffix = `\n\
|
|
402
|
+
const suffix = `\n\n_still working (${minutes}m) · message me anytime, I'll keep you posted_`
|
|
403
403
|
const newText = s.anchorOriginalText + suffix
|
|
404
404
|
|
|
405
405
|
if (newText.length > TELEGRAM_MSG_CAP) {
|
|
@@ -417,7 +417,7 @@ function tick(now: number): void {
|
|
|
417
417
|
threadId,
|
|
418
418
|
messageId: s.anchorMessageId,
|
|
419
419
|
newText,
|
|
420
|
-
|
|
420
|
+
literalText: s.anchorLiteralText,
|
|
421
421
|
}
|
|
422
422
|
// Fire-and-forget so a slow edit doesn't block the tick loop.
|
|
423
423
|
// Errors are logged but never bubble (a 429 / "message not modified"
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { basename } from "node:path";
|
|
21
|
+
import { escapeMarkdown } from "./card-format.js";
|
|
21
22
|
import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
|
|
22
23
|
import { redact } from "./secret-detect/redact.js";
|
|
23
24
|
|
|
@@ -96,10 +97,10 @@ const HOSTD_AGENT_TARGET_VERBS = new Set([
|
|
|
96
97
|
/**
|
|
97
98
|
* Build the multi-line card body for an approval prompt.
|
|
98
99
|
*
|
|
99
|
-
* 🔐
|
|
100
|
-
* why:
|
|
100
|
+
* 🔐 **Gymbro** wants to edit: supplement-log.md
|
|
101
|
+
* why: _logging today's lifts_
|
|
101
102
|
*
|
|
102
|
-
* Output is
|
|
103
|
+
* Output is GFM markdown for the rich-message path (#2669). The agent name is
|
|
103
104
|
* capitalized for the sentence; dropped (with "wants to") when null —
|
|
104
105
|
* the bridge client can be anonymous during early-boot edge cases.
|
|
105
106
|
*
|
|
@@ -127,10 +128,10 @@ export function formatPermissionCardBody(opts: {
|
|
|
127
128
|
|
|
128
129
|
if (opts.agentName && opts.agentName.length > 0) {
|
|
129
130
|
lines.push(
|
|
130
|
-
`🔐
|
|
131
|
+
`🔐 **${escapeTgHtml(capFirst(opts.agentName))}** wants to ${escapeActionMarkdown(action)}`,
|
|
131
132
|
);
|
|
132
133
|
} else {
|
|
133
|
-
lines.push(`🔐 ${
|
|
134
|
+
lines.push(`🔐 ${escapeActionMarkdown(capFirst(action))}`);
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
// why: the caller-supplied rationale (`reason`/`why` arg), never the
|
|
@@ -143,8 +144,8 @@ export function formatPermissionCardBody(opts: {
|
|
|
143
144
|
: rawWhy;
|
|
144
145
|
lines.push(
|
|
145
146
|
truncatedWhy.length > 0
|
|
146
|
-
? `why:
|
|
147
|
-
: `why:
|
|
147
|
+
? `why: _${escapeTgHtml(truncatedWhy)}_`
|
|
148
|
+
: `why: _not provided_`,
|
|
148
149
|
);
|
|
149
150
|
|
|
150
151
|
// Third line (REST-wrapper MCP writes only): a redaction-safe summary of
|
|
@@ -152,7 +153,7 @@ export function formatPermissionCardBody(opts: {
|
|
|
152
153
|
// endpoint — e.g. "↳ to: lisa@…, subject: Priority access…".
|
|
153
154
|
const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
|
|
154
155
|
if (argSummary) {
|
|
155
|
-
lines.push(`↳
|
|
156
|
+
lines.push(`↳ _${escapeTgHtml(argSummary)}_`);
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
return lines.join("\n");
|
|
@@ -424,10 +425,10 @@ export function describeGrant(
|
|
|
424
425
|
* the operator scrolls past — so this is the legible signal that the tap
|
|
425
426
|
* landed and names the work being (re)started.
|
|
426
427
|
*
|
|
427
|
-
* Mirrors `formatPermissionCardBody`'s style ("🔐
|
|
428
|
-
* edit: log.md" → "▶️
|
|
428
|
+
* Mirrors `formatPermissionCardBody`'s style ("🔐 **Gymbro** wants to
|
|
429
|
+
* edit: log.md" → "▶️ **Gymbro** — got it, continuing: edit: log.md").
|
|
429
430
|
* `action` is a phrase from {@link naturalAction} (already operator-facing,
|
|
430
|
-
* no tool ids). Output is
|
|
431
|
+
* no tool ids). Output is GFM markdown for the rich-message path (#2669).
|
|
431
432
|
*
|
|
432
433
|
* `timeoutMinutes` marks the TTL auto-deny variant (no operator tapped —
|
|
433
434
|
* the request aged out) so the wording reflects "no answer" rather than a
|
|
@@ -441,25 +442,25 @@ export function formatPermissionResumeMessage(opts: {
|
|
|
441
442
|
}): string {
|
|
442
443
|
const who =
|
|
443
444
|
opts.agentName && opts.agentName.length > 0
|
|
444
|
-
?
|
|
445
|
-
:
|
|
445
|
+
? `**${escapeTgHtml(capFirst(opts.agentName))}**`
|
|
446
|
+
: `**Agent**`;
|
|
446
447
|
const act = (opts.action ?? "").trim();
|
|
447
448
|
const hasAction = act.length > 0;
|
|
448
449
|
|
|
449
450
|
if (opts.behavior === "allow") {
|
|
450
451
|
return hasAction
|
|
451
|
-
? `▶️ ${who} — got it, continuing:
|
|
452
|
+
? `▶️ ${who} — got it, continuing: _${escapeActionMarkdown(act)}_`
|
|
452
453
|
: `▶️ ${who} — got it, back to work.`;
|
|
453
454
|
}
|
|
454
455
|
|
|
455
456
|
// deny
|
|
456
457
|
if (opts.timeoutMinutes != null) {
|
|
457
458
|
return hasAction
|
|
458
|
-
? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (
|
|
459
|
+
? `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it (_${escapeActionMarkdown(act)}_).`
|
|
459
460
|
: `🚫 ${who} — no answer in ${opts.timeoutMinutes}m, continuing without it.`;
|
|
460
461
|
}
|
|
461
462
|
return hasAction
|
|
462
|
-
? `🚫 ${who} — noted, I won't ${
|
|
463
|
+
? `🚫 ${who} — noted, I won't ${escapeActionMarkdown(lowerFirst(act))}. Continuing without it.`
|
|
463
464
|
: `🚫 ${who} — noted, continuing without it.`;
|
|
464
465
|
}
|
|
465
466
|
|
|
@@ -520,12 +521,27 @@ function capFirst(text: string): string {
|
|
|
520
521
|
return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
|
521
522
|
}
|
|
522
523
|
|
|
523
|
-
/**
|
|
524
|
+
/** Markdown escape for the rich-message path (#2669). */
|
|
524
525
|
function escapeTgHtml(text: string): string {
|
|
526
|
+
return escapeMarkdown(text);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Escape a phrase that may itself contain DELIBERATE `` `code spans` ``
|
|
531
|
+
* (e.g. the hostd fleet-verb phrases name the target agent in a code span:
|
|
532
|
+
* "restart agent `carrie` in the fleet"). The structural backticks must NOT
|
|
533
|
+
* be escaped — that would render literal `` \`carrie\` `` to the operator
|
|
534
|
+
* (#2669 regression). Everything OUTSIDE a backtick span is still escaped so
|
|
535
|
+
* a hostile Bash command / filename can't inject emphasis. Content INSIDE a
|
|
536
|
+
* span is left verbatim — code spans render literally, so there is no
|
|
537
|
+
* injection risk there.
|
|
538
|
+
*/
|
|
539
|
+
function escapeActionMarkdown(text: string): string {
|
|
540
|
+
// Split on balanced single-backtick spans, keeping the delimiters.
|
|
525
541
|
return text
|
|
526
|
-
.
|
|
527
|
-
.
|
|
528
|
-
.
|
|
542
|
+
.split(/(`[^`]*`)/g)
|
|
543
|
+
.map((seg) => (seg.startsWith("`") && seg.endsWith("`") && seg.length >= 2 ? seg : escapeMarkdown(seg)))
|
|
544
|
+
.join("");
|
|
529
545
|
}
|
|
530
546
|
|
|
531
547
|
function parseInput(raw: string | undefined): Record<string, unknown> | null {
|
|
@@ -48,13 +48,6 @@ export interface PtyHandlerState {
|
|
|
48
48
|
pendingPtyPartial: { text: string } | null
|
|
49
49
|
/** Active streams, keyed by `chat_id:thread_id`. */
|
|
50
50
|
activeDraftStreams: Map<string, DraftStreamHandle>
|
|
51
|
-
/**
|
|
52
|
-
* Parallel to activeDraftStreams: parseMode baked into each controller.
|
|
53
|
-
* Lets `handleStreamReply` detect and rotate streams whose parseMode no
|
|
54
|
-
* longer matches the caller's resolved format (bug 1). Optional for
|
|
55
|
-
* backwards compatibility.
|
|
56
|
-
*/
|
|
57
|
-
activeDraftParseModes?: Map<string, 'HTML' | 'MarkdownV2' | undefined>
|
|
58
51
|
/**
|
|
59
52
|
* Chats whose PTY preview is claimed by an in-flight reply handler.
|
|
60
53
|
* Partials for these chats are dropped to avoid duplicates.
|
|
@@ -67,8 +60,6 @@ export interface PtyHandlerState {
|
|
|
67
60
|
export interface PtyHandlerDeps {
|
|
68
61
|
bot: { api: StreamBotApi }
|
|
69
62
|
retry?: RetryPolicy
|
|
70
|
-
/** Markdown → HTML renderer applied to the text before stream.update. */
|
|
71
|
-
renderText: (text: string) => string
|
|
72
63
|
/** Optional structured event hook, called once per invocation. */
|
|
73
64
|
logEvent?: (ev: {
|
|
74
65
|
kind: 'pty_partial_received'
|
|
@@ -154,7 +145,10 @@ export function handlePtyPartialPure(
|
|
|
154
145
|
bot: deps.bot,
|
|
155
146
|
chatId,
|
|
156
147
|
threadId,
|
|
157
|
-
|
|
148
|
+
// PTY-tail previews are raw terminal/TUI output, not authored
|
|
149
|
+
// markdown — send them literally so control glyphs and box-drawing
|
|
150
|
+
// characters aren't misinterpreted as markdown syntax (#2669).
|
|
151
|
+
literalText: true,
|
|
158
152
|
disableLinkPreview: true,
|
|
159
153
|
throttleMs: 600,
|
|
160
154
|
retry: deps.retry,
|
|
@@ -177,11 +171,9 @@ export function handlePtyPartialPure(
|
|
|
177
171
|
: {}),
|
|
178
172
|
})
|
|
179
173
|
state.activeDraftStreams.set(sKey, stream)
|
|
180
|
-
state.activeDraftParseModes?.set(sKey, 'HTML')
|
|
181
174
|
}
|
|
182
175
|
|
|
183
|
-
|
|
184
|
-
void stream.update(rendered).catch(() => { /* swallow — logged elsewhere */ })
|
|
176
|
+
void stream.update(text).catch(() => { /* swallow — logged elsewhere */ })
|
|
185
177
|
|
|
186
178
|
return created ? 'update-new' : 'update-existing'
|
|
187
179
|
}
|