switchroom 0.16.23 → 0.16.27
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 +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- 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/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- 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 +881 -633
- 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 +39 -6
- 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/permission-timeout.ts +76 -0
- 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 +39 -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 +42 -7
- package/telegram-plugin/rich-send.ts +86 -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 +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- 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/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- 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/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-tap skill-improvement proposal card (#2670, "one-tap
|
|
3
|
+
* self-improvement").
|
|
4
|
+
*
|
|
5
|
+
* The weekly skill-synthesis cron drafts a proposed personal skill and
|
|
6
|
+
* persists it (full draft bundle) in the self-improve proposal store
|
|
7
|
+
* (`src/self-improve/skill-proposals.ts`). This module renders that
|
|
8
|
+
* proposal as a Telegram Approve / Dismiss card and builds the synthetic
|
|
9
|
+
* inbound the gateway injects when the operator taps Approve — instructing
|
|
10
|
+
* the live agent to APPLY the stored draft through the existing
|
|
11
|
+
* `skill_init_personal` / `skill_edit_personal` write pipeline (so the
|
|
12
|
+
* merged `scanBundleForSecrets` gate runs; the agent never self-applies —
|
|
13
|
+
* the operator tap is the authorization, satisfying `no-self-escalation`).
|
|
14
|
+
*
|
|
15
|
+
* Pure builders, kept out of gateway.ts so the card text + callback shape
|
|
16
|
+
* + inbound shape are pinned by tests independent of the bot plumbing.
|
|
17
|
+
*
|
|
18
|
+
* callback_data shape (must fit Telegram's 64-byte limit):
|
|
19
|
+
* skprop:approve:<id>
|
|
20
|
+
* skprop:deny:<id>
|
|
21
|
+
* where <id> is the proposal's UUID from the store.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
25
|
+
|
|
26
|
+
export const SKILL_PROPOSAL_CALLBACK_PREFIX = 'skprop:'
|
|
27
|
+
|
|
28
|
+
/** Narrow shape of a stored proposal the card/inbound builders need.
|
|
29
|
+
* Mirrors SkillProposal in src/self-improve/skill-proposals.ts without
|
|
30
|
+
* importing across the tsconfig boundary. */
|
|
31
|
+
export interface SkillProposalView {
|
|
32
|
+
id: string
|
|
33
|
+
skill_slug: string
|
|
34
|
+
is_new: boolean
|
|
35
|
+
lesson: string
|
|
36
|
+
evidence: string
|
|
37
|
+
/** SKILL.md body, for the optional "show full draft" / step preview. */
|
|
38
|
+
skill_md?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeHtml(s: string): string {
|
|
42
|
+
return s
|
|
43
|
+
.replace(/&/g, '&')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A short bullet preview of the drafted procedure (first few non-empty,
|
|
49
|
+
* non-frontmatter lines of SKILL.md), capped for the card. */
|
|
50
|
+
export function previewSteps(skillMd: string | undefined, maxLines = 5): string[] {
|
|
51
|
+
if (!skillMd) return []
|
|
52
|
+
// Drop YAML frontmatter block if present.
|
|
53
|
+
let body = skillMd
|
|
54
|
+
if (body.startsWith('---')) {
|
|
55
|
+
const end = body.indexOf('\n---', 3)
|
|
56
|
+
if (end >= 0) body = body.slice(body.indexOf('\n', end + 1) + 1)
|
|
57
|
+
}
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
for (const raw of body.split('\n')) {
|
|
60
|
+
const line = raw.trim()
|
|
61
|
+
if (!line || line.startsWith('#')) continue
|
|
62
|
+
out.push(line.replace(/^[-*\d.)\s]+/, '').slice(0, 120))
|
|
63
|
+
if (out.length >= maxLines) break
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Render the proposal card text (HTML parse mode). */
|
|
69
|
+
export function renderSkillProposalCard(p: SkillProposalView): string {
|
|
70
|
+
const verb = p.is_new ? 'New personal skill' : 'Update to personal skill'
|
|
71
|
+
const lines: string[] = []
|
|
72
|
+
lines.push(`🧩 <b>Skill improvement proposed</b>`)
|
|
73
|
+
lines.push('')
|
|
74
|
+
lines.push(`<b>${escapeHtml(verb)}:</b> <code>${escapeHtml(p.skill_slug)}</code>`)
|
|
75
|
+
lines.push(`<b>Lesson:</b> ${escapeHtml(p.lesson)}`)
|
|
76
|
+
if (p.evidence) lines.push(`<i>${escapeHtml(p.evidence)}</i>`)
|
|
77
|
+
const steps = previewSteps(p.skill_md)
|
|
78
|
+
if (steps.length > 0) {
|
|
79
|
+
lines.push('')
|
|
80
|
+
lines.push('<b>Draft procedure:</b>')
|
|
81
|
+
for (const s of steps) lines.push(`• ${escapeHtml(s)}`)
|
|
82
|
+
}
|
|
83
|
+
lines.push('')
|
|
84
|
+
lines.push(
|
|
85
|
+
'<i>Tap Add to apply it (runs through the secret-scan write pipeline). ' +
|
|
86
|
+
'Tap Dismiss and it won’t be proposed again.</i>',
|
|
87
|
+
)
|
|
88
|
+
return lines.join('\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Inline keyboard for the proposal card. */
|
|
92
|
+
export function skillProposalKeyboard(id: string): {
|
|
93
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
94
|
+
} {
|
|
95
|
+
return {
|
|
96
|
+
inline_keyboard: [
|
|
97
|
+
[
|
|
98
|
+
{ text: '✅ Add skill', callback_data: `${SKILL_PROPOSAL_CALLBACK_PREFIX}approve:${id}` },
|
|
99
|
+
{ text: '🚫 Dismiss', callback_data: `${SKILL_PROPOSAL_CALLBACK_PREFIX}deny:${id}` },
|
|
100
|
+
],
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parse a `skprop:` callback into { action, id }, or null if not ours. */
|
|
106
|
+
export function parseSkillProposalCallback(
|
|
107
|
+
data: string,
|
|
108
|
+
): { action: 'approve' | 'deny'; id: string } | null {
|
|
109
|
+
if (!data.startsWith(SKILL_PROPOSAL_CALLBACK_PREFIX)) return null
|
|
110
|
+
const rest = data.slice(SKILL_PROPOSAL_CALLBACK_PREFIX.length)
|
|
111
|
+
const idx = rest.indexOf(':')
|
|
112
|
+
if (idx < 0) return null
|
|
113
|
+
const action = rest.slice(0, idx)
|
|
114
|
+
const id = rest.slice(idx + 1)
|
|
115
|
+
if ((action !== 'approve' && action !== 'deny') || id.length === 0) return null
|
|
116
|
+
return { action, id }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SkillProposalInboundContext {
|
|
120
|
+
agent: string
|
|
121
|
+
chat_id: string
|
|
122
|
+
threadId?: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the synthetic inbound injected when the operator taps Approve.
|
|
127
|
+
* It instructs the live agent to apply the stored draft via the personal-
|
|
128
|
+
* skill write pipeline — the agent never self-applies; this tap is the
|
|
129
|
+
* authorization. `meta.source = "skill_proposal_apply"` so the bridge
|
|
130
|
+
* renders it as `<channel source="skill_proposal_apply">`.
|
|
131
|
+
*/
|
|
132
|
+
export function buildSkillProposalApplyInbound(opts: {
|
|
133
|
+
ctx: SkillProposalInboundContext
|
|
134
|
+
proposalId: string
|
|
135
|
+
skillSlug: string
|
|
136
|
+
isNew: boolean
|
|
137
|
+
operatorId: string
|
|
138
|
+
nowMs?: number
|
|
139
|
+
}): InboundMessage {
|
|
140
|
+
const ts = opts.nowMs ?? Date.now()
|
|
141
|
+
const tool = opts.isNew ? 'skill_init_personal' : 'skill_edit_personal'
|
|
142
|
+
return {
|
|
143
|
+
type: 'inbound',
|
|
144
|
+
chatId: opts.ctx.chat_id,
|
|
145
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
146
|
+
messageId: ts,
|
|
147
|
+
user: 'skill-synthesis',
|
|
148
|
+
userId: 0,
|
|
149
|
+
ts,
|
|
150
|
+
text:
|
|
151
|
+
`✅ Operator approved your proposed skill \`${opts.skillSlug}\` ` +
|
|
152
|
+
`(proposal ${opts.proposalId}). Apply it now: read the stored draft ` +
|
|
153
|
+
`from the self-improve proposal store and call \`${tool}\` with that ` +
|
|
154
|
+
`exact draft bundle. Do NOT re-draft or add anything from this ` +
|
|
155
|
+
`conversation — write the stored bundle verbatim so the secret-scan ` +
|
|
156
|
+
`write pipeline validates it. Confirm to the operator when it lands.`,
|
|
157
|
+
meta: {
|
|
158
|
+
source: 'skill_proposal_apply',
|
|
159
|
+
agent: opts.ctx.agent,
|
|
160
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
161
|
+
proposal_id: opts.proposalId,
|
|
162
|
+
skill_slug: opts.skillSlug,
|
|
163
|
+
is_new: String(opts.isNew),
|
|
164
|
+
operator_id: opts.operatorId,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -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"
|