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
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
AGENT_LIVE_WINDOW_MS,
|
|
52
52
|
AGENT_LIVE_POLL_INTERVAL_MS,
|
|
53
53
|
} from './boot-probes.js'
|
|
54
|
-
import {
|
|
54
|
+
import { escapeMarkdown } from '../card-format.js'
|
|
55
55
|
import {
|
|
56
56
|
loadCache as loadBootIssueCache,
|
|
57
57
|
diffProbes as diffBootProbes,
|
|
@@ -348,26 +348,28 @@ export interface RenderBootCardOpts {
|
|
|
348
348
|
* the eye; everything else stays out of the way.
|
|
349
349
|
*/
|
|
350
350
|
/**
|
|
351
|
-
* Render a probe's `nextStep` hint as
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
351
|
+
* Render a probe's `nextStep` hint as GFM markdown. The hint is authored
|
|
352
|
+
* as plain text with backtick-quoted commands (one shell idiom across the
|
|
353
|
+
* codebase — search "Run `switchroom"). The backtick spans already ARE
|
|
354
|
+
* markdown code spans, so commands stay tap-to-copy on mobile; non-code
|
|
355
|
+
* text is markdown-escaped so stray syntax chars render literally.
|
|
356
356
|
*/
|
|
357
357
|
function renderNextStep(text: string): string {
|
|
358
358
|
const parts = text.split('`')
|
|
359
|
-
// Odd-count backticks means an unterminated
|
|
360
|
-
// plain-escaped text rather than
|
|
361
|
-
//
|
|
362
|
-
if (parts.length % 2 === 0) return
|
|
363
|
-
|
|
359
|
+
// Odd-count backticks means an unterminated code span — fall back to
|
|
360
|
+
// plain-escaped text rather than emitting a half-open span. Author
|
|
361
|
+
// error, not user-input, but defensive is cheap.
|
|
362
|
+
if (parts.length % 2 === 0) return escapeMarkdown(text)
|
|
363
|
+
// Code-span content (odd indices) is literal — backtick content is not
|
|
364
|
+
// re-interpreted, so it must NOT be markdown-escaped.
|
|
365
|
+
return parts.map((p, i) => (i % 2 === 0 ? escapeMarkdown(p) : `\`${p}\``)).join('')
|
|
364
366
|
}
|
|
365
367
|
|
|
366
368
|
export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
367
369
|
const { agentName, version, probes, restartReason, restartAgeMs } = opts
|
|
368
370
|
const agentSlug = opts.agentSlug ?? agentName
|
|
369
371
|
const ackEmoji = restartReason ? REASON_EMOJI[restartReason] : '✅'
|
|
370
|
-
const ack = `${ackEmoji}
|
|
372
|
+
const ack = `${ackEmoji} **${escapeMarkdown(agentName)}** back up · ${escapeMarkdown(version)}`
|
|
371
373
|
|
|
372
374
|
const degradedRows: string[] = []
|
|
373
375
|
const snoozeSet = new Set<ProbeKey>(opts.snoozeRows ?? [])
|
|
@@ -380,7 +382,7 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
380
382
|
for (const key of opts.resolvedRows) {
|
|
381
383
|
const lbl = PROBE_LABELS[key]
|
|
382
384
|
if (!lbl) continue
|
|
383
|
-
degradedRows.push(`✅
|
|
385
|
+
degradedRows.push(`✅ **${escapeMarkdown(lbl)}** resolved`)
|
|
384
386
|
}
|
|
385
387
|
}
|
|
386
388
|
|
|
@@ -392,15 +394,15 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
392
394
|
const ageStr = restartAgeMs != null && restartAgeMs > 0
|
|
393
395
|
? ` · ${(restartAgeMs / 1000).toFixed(1)}s ago`
|
|
394
396
|
: ''
|
|
395
|
-
degradedRows.push(`⚠️
|
|
397
|
+
degradedRows.push(`⚠️ **Restart** ${escapeMarkdown(REASON_LABEL.crash)}${ageStr}`)
|
|
396
398
|
// Principle 1: every failure carries its next step. The crash row
|
|
397
399
|
// tells the user how to inspect why. Runtime-aware: v0.7+ agents run
|
|
398
400
|
// in Docker (no systemd/journalctl in-container) — the canonical
|
|
399
401
|
// detection is SWITCHROOM_RUNTIME (see doctor-docker.ts).
|
|
400
402
|
const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
401
|
-
? `docker logs --tail 100 switchroom-${
|
|
402
|
-
: `journalctl --user -u switchroom-${
|
|
403
|
-
degradedRows.push(` ↳ Tail logs:
|
|
403
|
+
? `docker logs --tail 100 switchroom-${agentSlug}`
|
|
404
|
+
: `journalctl --user -u switchroom-${agentSlug} -n 100`
|
|
405
|
+
degradedRows.push(` ↳ Tail logs: \`${tailCmd}\``)
|
|
404
406
|
}
|
|
405
407
|
|
|
406
408
|
// Probe rows — only those that surfaced as degraded/fail. Healthy
|
|
@@ -427,7 +429,7 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
427
429
|
// We surface the existing row format unchanged here; the
|
|
428
430
|
// "Still:" / "New:" distinction is conveyed by which rows the
|
|
429
431
|
// user does or doesn't see across consecutive boots.
|
|
430
|
-
degradedRows.push(`${dot}
|
|
432
|
+
degradedRows.push(`${dot} **${PROBE_LABELS[key]}** ${escapeMarkdown(r.detail)}`)
|
|
431
433
|
if (r.nextStep) {
|
|
432
434
|
degradedRows.push(` ↳ ${renderNextStep(r.nextStep)}`)
|
|
433
435
|
}
|
|
@@ -692,7 +694,6 @@ export async function startBootCard(
|
|
|
692
694
|
if (reuseId != null && bot.editMessageTextStrict != null) {
|
|
693
695
|
try {
|
|
694
696
|
const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText, {
|
|
695
|
-
parse_mode: 'HTML',
|
|
696
697
|
link_preview_options: { is_disabled: true },
|
|
697
698
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
698
699
|
})
|
|
@@ -708,7 +709,6 @@ export async function startBootCard(
|
|
|
708
709
|
if (messageId < 0) {
|
|
709
710
|
try {
|
|
710
711
|
const sent = await bot.sendMessage(chatId, ackText, {
|
|
711
|
-
parse_mode: 'HTML',
|
|
712
712
|
link_preview_options: { is_disabled: true },
|
|
713
713
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
714
714
|
...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
|
|
@@ -858,7 +858,6 @@ export async function startBootCard(
|
|
|
858
858
|
if (currentText !== ackText) {
|
|
859
859
|
try {
|
|
860
860
|
await bot.editMessageText(chatId, messageId, currentText, {
|
|
861
|
-
parse_mode: 'HTML',
|
|
862
861
|
link_preview_options: { is_disabled: true },
|
|
863
862
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
864
863
|
})
|
|
@@ -915,7 +914,6 @@ export async function startBootCard(
|
|
|
915
914
|
|
|
916
915
|
try {
|
|
917
916
|
await bot.editMessageText(chatId, messageId, updatedText, {
|
|
918
|
-
parse_mode: 'HTML',
|
|
919
917
|
link_preview_options: { is_disabled: true },
|
|
920
918
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
921
919
|
})
|
|
@@ -41,8 +41,9 @@ export function formatRelativeAgo(iso: string | null): string | null {
|
|
|
41
41
|
*
|
|
42
42
|
* Sanitization: claude --version output is whitespace-collapsed before
|
|
43
43
|
* embedding — a malicious or rogue `claude` on PATH must not be able to
|
|
44
|
-
* smuggle newlines into the ack line.
|
|
45
|
-
* boot-card boundary (see boot-card.ts:
|
|
44
|
+
* smuggle newlines into the ack line. Markdown escaping happens at the
|
|
45
|
+
* boot-card boundary (see boot-card.ts: escapeMarkdown(version)) so any
|
|
46
|
+
* GFM formatting specials in the version render literally (#2669).
|
|
46
47
|
*/
|
|
47
48
|
export function composeBootVersionString(inputs: BootVersionInputs): string {
|
|
48
49
|
const ago = formatRelativeAgo(inputs.commitDate)
|
|
@@ -81,39 +81,45 @@ afterEach(() => {
|
|
|
81
81
|
_resetPendingConfigApprovalsForTest();
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
// #2669: the diff now ships inside a fenced code block (``` … ```) on the
|
|
85
|
+
// rich-message path. Content inside a fence is LITERAL — `<` / `&` need no
|
|
86
|
+
// escaping and cannot inject formatting. The inline-body cap is the rich
|
|
87
|
+
// 32768-char wire limit (RENDERED_BODY_CAP=32000), not the old 4096.
|
|
88
|
+
const RICH_LIMIT = 32768;
|
|
84
89
|
describe("buildConfigApprovalCardBody", () => {
|
|
85
|
-
it("
|
|
90
|
+
it("ships the diff verbatim inside a fenced code block (< / & stay literal)", () => {
|
|
86
91
|
const { body } = buildConfigApprovalCardBody({
|
|
87
92
|
agentName: "klanker",
|
|
88
|
-
reason: "
|
|
93
|
+
reason: "a_reason",
|
|
89
94
|
unifiedDiff: "a & b <c>",
|
|
90
95
|
});
|
|
91
|
-
|
|
92
|
-
expect(body).toContain("
|
|
96
|
+
// The diff content is literal inside the fence — no HTML entities.
|
|
97
|
+
expect(body).toContain("```\na & b <c>\n```");
|
|
93
98
|
});
|
|
94
99
|
|
|
95
|
-
it("rendered body stays under
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
const evilDiff = "&".repeat(3000);
|
|
100
|
+
it("rendered body stays under the rich-message limit when the raw diff is enormous", () => {
|
|
101
|
+
// A diff far larger than the 32000 cap must be truncated with the sentinel.
|
|
102
|
+
const evilDiff = "&".repeat(40000);
|
|
99
103
|
const { body } = buildConfigApprovalCardBody({
|
|
100
104
|
agentName: "klanker",
|
|
101
105
|
reason: "test",
|
|
102
106
|
unifiedDiff: evilDiff,
|
|
103
107
|
});
|
|
104
|
-
expect(body.length).toBeLessThanOrEqual(
|
|
108
|
+
expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
|
|
105
109
|
expect(body).toContain("diff continues, see attached file");
|
|
106
110
|
});
|
|
107
111
|
|
|
108
|
-
it("
|
|
109
|
-
const
|
|
110
|
-
const { body } = buildConfigApprovalCardBody({
|
|
112
|
+
it("a small `<`-heavy diff fits and stays literal (no escape inflation, #2669)", () => {
|
|
113
|
+
const diff = "<".repeat(3000);
|
|
114
|
+
const { body, truncated } = buildConfigApprovalCardBody({
|
|
111
115
|
agentName: "klanker",
|
|
112
116
|
reason: "test",
|
|
113
|
-
unifiedDiff:
|
|
117
|
+
unifiedDiff: diff,
|
|
114
118
|
});
|
|
115
|
-
|
|
116
|
-
expect(
|
|
119
|
+
// 3000 chars is well under the 32000 cap and no longer 5x-inflated.
|
|
120
|
+
expect(truncated).toBe(false);
|
|
121
|
+
expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
|
|
122
|
+
expect(body).toContain("<".repeat(3000));
|
|
117
123
|
});
|
|
118
124
|
|
|
119
125
|
it("clips an unbounded operator-supplied `reason` to ~500 chars with ellipsis", () => {
|
|
@@ -123,7 +129,6 @@ describe("buildConfigApprovalCardBody", () => {
|
|
|
123
129
|
reason: longReason,
|
|
124
130
|
unifiedDiff: "small",
|
|
125
131
|
});
|
|
126
|
-
// The escaped reason should appear, but capped.
|
|
127
132
|
const reasonLine = body
|
|
128
133
|
.split("\n")
|
|
129
134
|
.find((l) => l.startsWith("Reason: "))!;
|
|
@@ -139,48 +144,43 @@ describe("buildConfigApprovalCardBody", () => {
|
|
|
139
144
|
unifiedDiff: "-a\n+b\n",
|
|
140
145
|
});
|
|
141
146
|
expect(truncated).toBe(false);
|
|
142
|
-
expect(body).toContain("
|
|
147
|
+
expect(body).toContain("```\n-a\n+b\n\n```");
|
|
143
148
|
});
|
|
144
149
|
|
|
145
150
|
it("returns truncated:true and appends the sentinel when the body has to shrink", () => {
|
|
146
151
|
const { body, truncated } = buildConfigApprovalCardBody({
|
|
147
152
|
agentName: "klanker",
|
|
148
153
|
reason: "test",
|
|
149
|
-
unifiedDiff: "&".repeat(
|
|
154
|
+
unifiedDiff: "&".repeat(40000),
|
|
150
155
|
});
|
|
151
156
|
expect(truncated).toBe(true);
|
|
152
157
|
expect(body).toContain("diff continues, see attached file");
|
|
153
158
|
});
|
|
154
159
|
|
|
155
160
|
it("handles a single unbroken line (no `\\n` to snap to) by char-truncation fallback", () => {
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
// through to char-truncation rather than returning empty.
|
|
160
|
-
const oneLongLine = "x".repeat(8000);
|
|
161
|
+
// A single 40000-char line, way past the 32000 cap, with no newline to
|
|
162
|
+
// snap to → the helper must fall through to char-truncation.
|
|
163
|
+
const oneLongLine = "x".repeat(40000);
|
|
161
164
|
const { body, truncated } = buildConfigApprovalCardBody({
|
|
162
165
|
agentName: "klanker",
|
|
163
166
|
reason: "test",
|
|
164
167
|
unifiedDiff: oneLongLine,
|
|
165
168
|
});
|
|
166
169
|
expect(truncated).toBe(true);
|
|
167
|
-
expect(body.length).toBeLessThanOrEqual(
|
|
168
|
-
// Should still contain SOME of the line content — the helper
|
|
169
|
-
// shouldn't degenerate to "framing + sentinel only" when char-
|
|
170
|
-
// truncation is available.
|
|
170
|
+
expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
|
|
171
171
|
expect(body).toMatch(/x{100,}/);
|
|
172
172
|
expect(body).toContain("diff continues, see attached file");
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
it("rendered body stays under
|
|
176
|
-
const evilDiff = "&".repeat(
|
|
175
|
+
it("rendered body stays under the rich limit even when reason is also adversarial", () => {
|
|
176
|
+
const evilDiff = "&".repeat(40000);
|
|
177
177
|
const evilReason = "&".repeat(2000);
|
|
178
178
|
const { body } = buildConfigApprovalCardBody({
|
|
179
179
|
agentName: "klanker",
|
|
180
180
|
reason: evilReason,
|
|
181
181
|
unifiedDiff: evilDiff,
|
|
182
182
|
});
|
|
183
|
-
expect(body.length).toBeLessThanOrEqual(
|
|
183
|
+
expect(body.length).toBeLessThanOrEqual(RICH_LIMIT);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -304,8 +304,10 @@ describe("buildLiveNote", () => {
|
|
|
304
304
|
expect(buildLiveNote([], false)).toBe("");
|
|
305
305
|
expect(buildLiveNote(undefined, undefined)).toBe("");
|
|
306
306
|
});
|
|
307
|
-
it("
|
|
308
|
-
|
|
307
|
+
it("markdown-escapes agent names (< > stay literal, #2669)", () => {
|
|
308
|
+
// < > are literal in rich markdown; an emphasis special is escaped.
|
|
309
|
+
expect(buildLiveNote(["a<b>"], false)).toContain("a<b>");
|
|
310
|
+
expect(buildLiveNote(["a_b"], false)).toContain("a\\_b");
|
|
309
311
|
});
|
|
310
312
|
});
|
|
311
313
|
|
|
@@ -412,7 +414,7 @@ describe("oversize diff → attachment fallback (#1762)", () => {
|
|
|
412
414
|
expect(deps.postCard).toHaveBeenCalledTimes(1);
|
|
413
415
|
const postArgs = (deps.postCard as ReturnType<typeof vi.fn>).mock
|
|
414
416
|
.calls[0]![0] as { text: string; replyMarkup: unknown };
|
|
415
|
-
expect(postArgs.text.length).toBeLessThanOrEqual(
|
|
417
|
+
expect(postArgs.text.length).toBeLessThanOrEqual(32768);
|
|
416
418
|
expect(postArgs.text).toMatch(/diff continues, see attached file/);
|
|
417
419
|
expect(postArgs.replyMarkup).toBeDefined();
|
|
418
420
|
|
|
@@ -161,13 +161,13 @@ export function truncateDiffForCard(
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Telegram
|
|
165
|
-
*
|
|
166
|
-
*
|
|
164
|
+
* Telegram rich-message hard limit (#2669). We render as GFM markdown and
|
|
165
|
+
* ship the diff inside a fenced code block (content is literal there, so no
|
|
166
|
+
* escape inflation).
|
|
167
167
|
*/
|
|
168
|
-
const TELEGRAM_SENDMESSAGE_LIMIT =
|
|
168
|
+
const TELEGRAM_SENDMESSAGE_LIMIT = 32768;
|
|
169
169
|
/** Safety margin under the hard limit for invisible framing wobble. */
|
|
170
|
-
const RENDERED_BODY_CAP =
|
|
170
|
+
const RENDERED_BODY_CAP = 32000;
|
|
171
171
|
/** Operator-supplied `reason` is unbounded at the wire — clip it. */
|
|
172
172
|
const REASON_MAX_CHARS = 500;
|
|
173
173
|
const REASON_ELLIPSIS = "…";
|
|
@@ -180,8 +180,8 @@ const REASON_ELLIPSIS = "…";
|
|
|
180
180
|
*/
|
|
181
181
|
export const DIFF_SENTINEL = "\n[… diff continues, see attached file]";
|
|
182
182
|
|
|
183
|
-
function
|
|
184
|
-
return s.replace(
|
|
183
|
+
function escapeMd(s: string): string {
|
|
184
|
+
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
function clipReason(reason: string): string {
|
|
@@ -212,10 +212,10 @@ export function buildConfigApprovalCardBody(args: {
|
|
|
212
212
|
}): { body: string; truncated: boolean } {
|
|
213
213
|
const safeReason = clipReason(args.reason);
|
|
214
214
|
const render = (diff: string): string =>
|
|
215
|
-
`🛠
|
|
216
|
-
`Agent:
|
|
217
|
-
`Reason: ${
|
|
218
|
-
|
|
215
|
+
`🛠 **Config edit proposed**\n` +
|
|
216
|
+
`Agent: \`${args.agentName}\`\n` +
|
|
217
|
+
`Reason: ${escapeMd(safeReason)}\n\n` +
|
|
218
|
+
"```\n" + diff + "\n```";
|
|
219
219
|
|
|
220
220
|
return truncateRawToFit({
|
|
221
221
|
raw: args.unifiedDiff,
|
|
@@ -287,10 +287,10 @@ export async function handleRequestConfigApproval(
|
|
|
287
287
|
return;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
// Pre-flight oversize handling (#1762).
|
|
291
|
-
// at
|
|
292
|
-
//
|
|
293
|
-
//
|
|
290
|
+
// Pre-flight oversize handling (#1762 / #2669). The rich-message body
|
|
291
|
+
// caps at 32768 chars; the diff ships inside a fenced code block (literal,
|
|
292
|
+
// no escape inflation) but the framing still counts. Fast-path with a
|
|
293
|
+
// cheap raw-input cap, then let
|
|
294
294
|
// buildConfigApprovalCardBody enforce the post-escape rendered cap
|
|
295
295
|
// (which re-truncates the diff if escaping blew past the limit). We
|
|
296
296
|
// ship the full diff as a `.patch` attachment whenever truncation
|
|
@@ -409,10 +409,10 @@ export async function resolvePendingConfigApproval(
|
|
|
409
409
|
// Edit the card to an interim/terminal state.
|
|
410
410
|
const interim =
|
|
411
411
|
verdict === "approve"
|
|
412
|
-
? "👀
|
|
412
|
+
? "👀 **Applying…**"
|
|
413
413
|
: verdict === "deny"
|
|
414
|
-
? "🚫
|
|
415
|
-
: "⏱
|
|
414
|
+
? "🚫 **Denied**"
|
|
415
|
+
: "⏱ **Expired**";
|
|
416
416
|
try {
|
|
417
417
|
// Strip the keyboard: once resolved (approve/deny/timeout) the buttons
|
|
418
418
|
// must not stay tappable — a stale tap could otherwise re-hit the
|
|
@@ -442,14 +442,14 @@ export function buildLiveNote(affectedAgents?: string[], fleetWide?: boolean): s
|
|
|
442
442
|
if (fleetWide) {
|
|
443
443
|
return (
|
|
444
444
|
`\n\n⚠️ Shared config changed — affects all agents. Not live until they ` +
|
|
445
|
-
`restart: run
|
|
445
|
+
`restart: run \`switchroom rollout\` (or \`/update apply\`).`
|
|
446
446
|
);
|
|
447
447
|
}
|
|
448
448
|
const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
|
|
449
449
|
if (agents.length === 0) return "";
|
|
450
|
-
const list = agents.map(
|
|
451
|
-
const cmds = agents.map((a) => `/restart ${
|
|
452
|
-
return `\n\n🔄 Not live until restart — affects:
|
|
450
|
+
const list = agents.map(escapeMd).join(", ");
|
|
451
|
+
const cmds = agents.map((a) => `/restart ${escapeMd(a)}`).join(" · ");
|
|
452
|
+
return `\n\n🔄 Not live until restart — affects: **${list}**\n${cmds}`;
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
/** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
|
|
@@ -476,8 +476,8 @@ export async function handleRequestConfigFinalize(
|
|
|
476
476
|
msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
|
|
477
477
|
const body =
|
|
478
478
|
msg.outcome === "applied"
|
|
479
|
-
? `✅
|
|
480
|
-
: `⚠️
|
|
479
|
+
? `✅ **Applied**${msg.detail ? `\n${escapeMd(msg.detail)}` : ""}${liveNote}`
|
|
480
|
+
: `⚠️ **Reconcile failed; rolled back**${msg.detail ? `\n${escapeMd(msg.detail)}` : ""}`;
|
|
481
481
|
try {
|
|
482
482
|
// Finalize is terminal — strip the keyboard so the buttons are gone.
|
|
483
483
|
await deps.editCard({
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { createHash } from 'crypto'
|
|
33
33
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
|
|
34
34
|
import { dirname } from 'path'
|
|
35
|
-
import {
|
|
35
|
+
import { escapeMarkdown } from '../card-format.js'
|
|
36
36
|
|
|
37
37
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
@@ -189,19 +189,19 @@ export function diffSnapshots(
|
|
|
189
189
|
export function renderConfigChangeDim(dim: ConfigChangeDim): string {
|
|
190
190
|
switch (dim.field) {
|
|
191
191
|
case 'model': {
|
|
192
|
-
const from =
|
|
193
|
-
const to =
|
|
194
|
-
return `⚙️
|
|
192
|
+
const from = escapeMarkdown(dim.from ?? '(default)')
|
|
193
|
+
const to = escapeMarkdown(dim.to ?? '(default)')
|
|
194
|
+
return `⚙️ **Config** model: ${from} → ${to}`
|
|
195
195
|
}
|
|
196
196
|
case 'memoryBackend': {
|
|
197
|
-
const from =
|
|
198
|
-
const to =
|
|
199
|
-
return `⚙️
|
|
197
|
+
const from = escapeMarkdown(dim.from ?? '(default)')
|
|
198
|
+
const to = escapeMarkdown(dim.to ?? '(default)')
|
|
199
|
+
return `⚙️ **Config** memory backend: ${from} → ${to}`
|
|
200
200
|
}
|
|
201
201
|
case 'tools':
|
|
202
|
-
return `⚙️
|
|
202
|
+
return `⚙️ **Config** tools allowlist changed — run /status for details`
|
|
203
203
|
case 'skills':
|
|
204
|
-
return `⚙️
|
|
204
|
+
return `⚙️ **Config** skills changed — run /status for details`
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
@@ -45,8 +45,8 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
|
|
|
45
45
|
writeRequestId: "11223344112233441122334411223344",
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
// Body: title bold + all preview lines.
|
|
49
|
-
expect(card.text).toContain("
|
|
48
|
+
// Body: title bold (markdown **) + all preview lines.
|
|
49
|
+
expect(card.text).toContain("**");
|
|
50
50
|
expect(card.text).toContain("klanker");
|
|
51
51
|
expect(card.text).toContain("Q3 Strategy Notes");
|
|
52
52
|
expect(card.text).toContain("📍 after heading 'Goals' (level 2)");
|
|
@@ -152,20 +152,20 @@ describe("buildDiffPreviewCard — fragility guards", () => {
|
|
|
152
152
|
preview,
|
|
153
153
|
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
154
154
|
});
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
// < > are literal in rich markdown — the doc name is shown verbatim and
|
|
156
|
+
// cannot inject formatting (#2669).
|
|
157
|
+
expect(card.text).toContain("<script>");
|
|
157
158
|
});
|
|
158
159
|
|
|
159
|
-
it("
|
|
160
|
+
it("passes < > & literally in the agent-supplied summary; escapes emphasis specials (#2669)", () => {
|
|
160
161
|
const preview = buildDiffPreview(
|
|
161
|
-
baseInput({ agentSummary: "Hi <
|
|
162
|
+
baseInput({ agentSummary: "Hi <tag> & a_b" }),
|
|
162
163
|
);
|
|
163
164
|
const card = buildDiffPreviewCard({
|
|
164
165
|
preview,
|
|
165
166
|
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
166
167
|
});
|
|
167
|
-
expect(card.text).
|
|
168
|
-
expect(card.text).toContain("<b>");
|
|
168
|
+
expect(card.text).toContain("<tag> & a\\_b");
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
|
|
@@ -88,7 +88,7 @@ export function buildDiffPreviewCard(
|
|
|
88
88
|
// The 📍 + line-count rows are surfaced verbatim — they're
|
|
89
89
|
// wrapper-attested and the agent has no input into their content.
|
|
90
90
|
const bodyLines: string[] = [];
|
|
91
|
-
bodyLines.push(
|
|
91
|
+
bodyLines.push(`**${escapeHtml(preview.title)}**`);
|
|
92
92
|
for (const line of preview.lines) {
|
|
93
93
|
bodyLines.push(escapeHtml(line.text));
|
|
94
94
|
}
|
|
@@ -163,8 +163,5 @@ export function buildDiffPreviewCard(
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
function escapeHtml(s: string): string {
|
|
166
|
-
return s
|
|
167
|
-
.replace(/&/g, "&")
|
|
168
|
-
.replace(/</g, "<")
|
|
169
|
-
.replace(/>/g, ">");
|
|
166
|
+
return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
170
167
|
}
|
|
@@ -45,8 +45,6 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
|
|
|
45
45
|
|
|
46
46
|
/** Open draft-stream handles keyed by chat:thread:replyId. */
|
|
47
47
|
activeDraftStreams: Map<string, Stream>
|
|
48
|
-
/** Mirror map: same keys → parse mode. */
|
|
49
|
-
activeDraftParseModes: Map<string, 'HTML' | 'MarkdownV2' | undefined>
|
|
50
48
|
|
|
51
49
|
/** Persist-side reaction registry (per-agent on-disk state). */
|
|
52
50
|
clearActiveReactions: () => void
|
|
@@ -85,7 +83,6 @@ export function flushOnAgentDisconnect<
|
|
|
85
83
|
activeTurnStartedAt,
|
|
86
84
|
claudeBusyKeys,
|
|
87
85
|
activeDraftStreams,
|
|
88
|
-
activeDraftParseModes,
|
|
89
86
|
clearActiveReactions,
|
|
90
87
|
disposeProgressDriver,
|
|
91
88
|
onDanglingTurnsSwept,
|
|
@@ -177,7 +174,6 @@ export function flushOnAgentDisconnect<
|
|
|
177
174
|
for (const [key, stream] of activeDraftStreams.entries()) {
|
|
178
175
|
if (!stream.isFinal()) void stream.finalize().catch(() => {})
|
|
179
176
|
activeDraftStreams.delete(key)
|
|
180
|
-
activeDraftParseModes.delete(key)
|
|
181
177
|
}
|
|
182
178
|
|
|
183
179
|
return true
|
|
@@ -95,17 +95,17 @@ export interface EffortCommandReply {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const PERSIST_NOTE =
|
|
98
|
-
'
|
|
98
|
+
'_Session-only — reverts to the configured default on restart. To change the default, set \`thinking_effort:\` in switchroom.yaml and restart._'
|
|
99
99
|
|
|
100
|
-
const LEVELS_INLINE = EFFORT_LEVELS.map(l =>
|
|
100
|
+
const LEVELS_INLINE = EFFORT_LEVELS.map(l => `\`${l}\``).join(' · ')
|
|
101
101
|
|
|
102
102
|
function helpText(deps: EffortCommandDeps, reason?: string): EffortCommandReply {
|
|
103
103
|
const lines: string[] = []
|
|
104
104
|
if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
|
|
105
105
|
lines.push(
|
|
106
|
-
'
|
|
107
|
-
'
|
|
108
|
-
|
|
106
|
+
'**/effort** — show or switch the reasoning effort (faster→smarter)',
|
|
107
|
+
'\`/effort\` — show the configured effort + a tap menu',
|
|
108
|
+
`\`/effort <level>\` — switch the live session (${LEVELS_INLINE})`,
|
|
109
109
|
PERSIST_NOTE,
|
|
110
110
|
)
|
|
111
111
|
return { text: lines.join('\n'), html: true }
|
|
@@ -122,9 +122,9 @@ export async function handleEffortCommand(
|
|
|
122
122
|
const shown = configured && configured.length > 0 ? configured : 'low'
|
|
123
123
|
return {
|
|
124
124
|
text: [
|
|
125
|
-
|
|
126
|
-
`Configured default:
|
|
127
|
-
`Switch the live session: ${EFFORT_LEVELS.map(l =>
|
|
125
|
+
`**Effort — ${deps.escapeHtml(deps.getAgentName())}**`,
|
|
126
|
+
`Configured default: \`${deps.escapeHtml(shown)}\``,
|
|
127
|
+
`Switch the live session: ${EFFORT_LEVELS.map(l => `\`/effort ${l}\``).join(' · ')}`,
|
|
128
128
|
PERSIST_NOTE,
|
|
129
129
|
].join('\n'),
|
|
130
130
|
html: true,
|
|
@@ -136,7 +136,7 @@ export async function handleEffortCommand(
|
|
|
136
136
|
if (!isValidEffortArg(parsed.level)) {
|
|
137
137
|
return helpText(deps, `not a valid effort level: ${parsed.level}`)
|
|
138
138
|
}
|
|
139
|
-
const verbHtml =
|
|
139
|
+
const verbHtml = `\`/effort ${deps.escapeHtml(parsed.level)}\``
|
|
140
140
|
let result: EffortApplyResult
|
|
141
141
|
try {
|
|
142
142
|
result = await deps.applyEffort(deps.getAgentName(), parsed.level)
|
|
@@ -153,17 +153,17 @@ export async function handleEffortCommand(
|
|
|
153
153
|
* say so honestly rather than just claiming success.
|
|
154
154
|
*/
|
|
155
155
|
function applyResultText(level: string, result: EffortApplyResult, deps: EffortCommandDeps): string {
|
|
156
|
-
const verbHtml =
|
|
156
|
+
const verbHtml = `\`/effort ${deps.escapeHtml(level)}\``
|
|
157
157
|
if (result.ok) {
|
|
158
158
|
const lines = [`✅ ${verbHtml} — ${deps.escapeHtml(result.output)}`]
|
|
159
159
|
if (result.confirmed) {
|
|
160
|
-
lines.push('
|
|
160
|
+
lines.push('_Switched mid-conversation — your next turn re-reads the cached history (slower, one time)._')
|
|
161
161
|
}
|
|
162
162
|
lines.push(PERSIST_NOTE)
|
|
163
163
|
return lines.join('\n')
|
|
164
164
|
}
|
|
165
165
|
if (result.reason === 'session_missing') {
|
|
166
|
-
return '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove
|
|
166
|
+
return '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove \`experimental.legacy_pty: true\` if set.'
|
|
167
167
|
}
|
|
168
168
|
if (result.reason === 'confirm_failed') {
|
|
169
169
|
const wedged = result.wedged
|
|
@@ -172,7 +172,7 @@ function applyResultText(level: string, result: EffortApplyResult, deps: EffortC
|
|
|
172
172
|
return `❌ ${verbHtml} — couldn't confirm the switch.${wedged}`
|
|
173
173
|
}
|
|
174
174
|
// apply_unverified
|
|
175
|
-
return `❌ ${verbHtml} — sent, but couldn't confirm it applied. The agent may be mid-turn; check
|
|
175
|
+
return `❌ ${verbHtml} — sent, but couldn't confirm it applied. The agent may be mid-turn; check \`/inject /status\`.`
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// ---------------------------------------------------------------------------
|
|
@@ -220,8 +220,8 @@ export function buildEffortMenu(deps: EffortCommandDeps, highlight?: string): Ef
|
|
|
220
220
|
const live = highlight ?? configured
|
|
221
221
|
return {
|
|
222
222
|
text: [
|
|
223
|
-
|
|
224
|
-
`Default:
|
|
223
|
+
`**Effort — ${deps.escapeHtml(deps.getAgentName())}**`,
|
|
224
|
+
`Default: \`${deps.escapeHtml(configured)}\` · faster → smarter: ${LEVELS_INLINE}`,
|
|
225
225
|
'Tap to switch the live session:',
|
|
226
226
|
PERSIST_NOTE,
|
|
227
227
|
].join('\n'),
|
|
@@ -260,8 +260,8 @@ export async function handleEffortMenuCallback(
|
|
|
260
260
|
const result = await deps.applyEffort(deps.getAgentName(), level)
|
|
261
261
|
if (result.ok) {
|
|
262
262
|
banner = result.confirmed
|
|
263
|
-
? `✅ Effort →
|
|
264
|
-
: `✅ Effort →
|
|
263
|
+
? `✅ Effort → \`${deps.escapeHtml(level)}\` (mid-conversation: next turn re-reads history)`
|
|
264
|
+
: `✅ Effort → \`${deps.escapeHtml(level)}\` for this session`
|
|
265
265
|
selected = level
|
|
266
266
|
} else if (result.reason === 'session_missing') {
|
|
267
267
|
banner = '❌ tmux session not found — is the agent running under the supervisor?'
|
|
@@ -42,12 +42,18 @@ function fakeCtx(userId = 12345): { ctx: Context; spy: FakeCtx } {
|
|
|
42
42
|
});
|
|
43
43
|
return { message_id: 1 };
|
|
44
44
|
},
|
|
45
|
-
editMessageText: async (
|
|
45
|
+
editMessageText: async (
|
|
46
|
+
text: string | { markdown: string },
|
|
47
|
+
opts?: { reply_markup?: { inline_keyboard?: unknown[][] } },
|
|
48
|
+
) => {
|
|
46
49
|
const rows = (opts?.reply_markup?.inline_keyboard ?? []) as Array<
|
|
47
50
|
Array<{ text: string }>
|
|
48
51
|
>;
|
|
52
|
+
// #2669: the rich path passes `{ markdown }`; normalize to the body
|
|
53
|
+
// string so the substring assertions keep working.
|
|
54
|
+
const body = typeof text === "object" && text != null ? text.markdown : text;
|
|
49
55
|
spy.edits.push({
|
|
50
|
-
text,
|
|
56
|
+
text: body,
|
|
51
57
|
keyboardRows: opts?.reply_markup
|
|
52
58
|
? rows.map((r) => r.map((b) => b.text))
|
|
53
59
|
: undefined,
|