switchroom 0.11.1 → 0.12.1

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.
Files changed (77) hide show
  1. package/README.md +32 -16
  2. package/dist/agent-scheduler/index.js +216 -97
  3. package/dist/auth-broker/index.js +176 -97
  4. package/dist/cli/drive-write-pretool.mjs +26 -11
  5. package/dist/cli/skill-validate-pretool.mjs +7209 -0
  6. package/dist/cli/switchroom.js +45571 -42642
  7. package/dist/cli/ui/index.html +1281 -0
  8. package/dist/host-control/main.js +3628 -309
  9. package/dist/vault/approvals/kernel-server.js +207 -98
  10. package/dist/vault/broker/server.js +249 -119
  11. package/examples/personal-google-workspace-mcp/README.md +8 -3
  12. package/examples/switchroom.yaml +91 -42
  13. package/package.json +4 -3
  14. package/profiles/_base/start.sh.hbs +76 -36
  15. package/profiles/_shared/agent-self-service.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +4 -2
  17. package/skills/file-bug/SKILL.md +6 -4
  18. package/skills/skill-creator/SKILL.md +52 -0
  19. package/skills/switchroom-cli/SKILL.md +20 -4
  20. package/skills/switchroom-install/SKILL.md +3 -3
  21. package/telegram-plugin/auth-snapshot-format.ts +9 -9
  22. package/telegram-plugin/card-format.ts +3 -3
  23. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  24. package/telegram-plugin/dist/gateway/gateway.js +853 -414
  25. package/telegram-plugin/dist/server.js +162 -161
  26. package/telegram-plugin/format.ts +71 -0
  27. package/telegram-plugin/gateway/access-validator.test.ts +8 -8
  28. package/telegram-plugin/gateway/access-validator.ts +1 -1
  29. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  30. package/telegram-plugin/gateway/approval-card.ts +1 -1
  31. package/telegram-plugin/gateway/auth-command.ts +2 -2
  32. package/telegram-plugin/gateway/boot-card.ts +40 -3
  33. package/telegram-plugin/gateway/boot-probes.ts +114 -30
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  35. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  36. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  37. package/telegram-plugin/gateway/gateway.ts +265 -22
  38. package/telegram-plugin/gateway/update-announce.ts +167 -0
  39. package/telegram-plugin/quota-check.ts +0 -195
  40. package/telegram-plugin/recent-outbound-dedup.ts +1 -1
  41. package/telegram-plugin/registry/turns-schema.ts +1 -1
  42. package/telegram-plugin/retry-api-call.ts +24 -0
  43. package/telegram-plugin/server.ts +8 -5
  44. package/telegram-plugin/tests/auth-add-flow.test.ts +32 -3
  45. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  46. package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
  48. package/telegram-plugin/tests/boot-probes.test.ts +90 -2
  49. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  50. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
  51. package/telegram-plugin/tests/fleet-state.test.ts +3 -2
  52. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  53. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  54. package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
  55. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
  56. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
  57. package/telegram-plugin/tests/secret-detect.test.ts +8 -8
  58. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  59. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  60. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
  61. package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
  62. package/telegram-plugin/welcome-text.ts +1 -8
  63. package/profiles/default/CLAUDE.md +0 -192
  64. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  65. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  66. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  67. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  68. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  69. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  70. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  71. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  72. package/telegram-plugin/first-paint.ts +0 -225
  73. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  74. package/telegram-plugin/server.js +0 -41795
  75. package/telegram-plugin/tests/html-balanced.ts +0 -63
  76. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  77. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Update-flow PR C — boot-card surfacing for MCP-originated updates.
3
+ *
4
+ * When an agent (e.g. klanker) runs `mcp__hostd__update_apply` the work
5
+ * happens hostd-side, the agent itself restarts at the end, and the
6
+ * resulting boot card for THIS agent (the one that just restarted via
7
+ * the redeploy) needs to surface the outcome — success or failure with a
8
+ * recovery hint — so the operator sees what happened without trawling
9
+ * the audit log.
10
+ *
11
+ * Implementation: on boot, scan ~/.switchroom/host-control-audit.log for
12
+ * the most recent `phase: "terminal"` `update_apply` row within a recent
13
+ * window. Dedupe via an atomic O_EXCL marker so a respawn within the
14
+ * window doesn't re-announce. Render a single line that's appended to
15
+ * the existing boot-card body.
16
+ *
17
+ * Pure & test-friendly — `readLastTerminalUpdateAudit` accepts an
18
+ * injectable file-reader, `renderUpdateOutcomeLine` is a pure function,
19
+ * and `claimUpdateAnnouncement` accepts an injectable claim-dir + clock.
20
+ */
21
+
22
+ import { existsSync, mkdirSync, openSync, closeSync, readFileSync } from 'node:fs'
23
+ import { join } from 'node:path'
24
+ import { homedir } from 'node:os'
25
+ import { readAndFilter, defaultAuditLogPath, type AuditEntry } from '../../src/host-control/audit-reader.js'
26
+
27
+ /** Default lookback window: 10 minutes is enough to catch the boot that
28
+ * follows a normal update_apply but small enough that an audit row from
29
+ * yesterday's run can't re-trigger an announcement after a long
30
+ * outage. */
31
+ export const DEFAULT_LOOKBACK_MS = 10 * 60 * 1000
32
+
33
+ export interface ReadOpts {
34
+ /** Read the file system. Override in tests. */
35
+ readFile?: (path: string) => string
36
+ /** Exists check. Override in tests. */
37
+ exists?: (path: string) => boolean
38
+ /** Override the audit-log path (defaults to ~/.switchroom/host-control-audit.log). */
39
+ auditLogPath?: string
40
+ /** Wall-clock for the lookback comparison. */
41
+ now?: number
42
+ /** Lookback window in ms. Defaults to {@link DEFAULT_LOOKBACK_MS}. */
43
+ lookbackMs?: number
44
+ }
45
+
46
+ /**
47
+ * Read the audit log and return the most-recent `update_apply` terminal
48
+ * row within the lookback window, or null if none. We deliberately do
49
+ * not filter by caller — any update_apply outcome is interesting to the
50
+ * person watching this chat regardless of which agent ran the verb.
51
+ */
52
+ export function readLastTerminalUpdateAudit(opts: ReadOpts = {}): AuditEntry | null {
53
+ const path = opts.auditLogPath ?? defaultAuditLogPath()
54
+ const exists = opts.exists ?? existsSync
55
+ const readFile = opts.readFile ?? ((p: string) => readFileSync(p, 'utf-8'))
56
+ if (!exists(path)) return null
57
+ let raw: string
58
+ try {
59
+ raw = readFile(path)
60
+ } catch {
61
+ return null
62
+ }
63
+ // Pull recent update_apply rows, then trim to terminal-phase within window.
64
+ const recent = readAndFilter(raw, { op: 'update_apply' }, 200)
65
+ const now = opts.now ?? Date.now()
66
+ const since = now - (opts.lookbackMs ?? DEFAULT_LOOKBACK_MS)
67
+ let best: AuditEntry | null = null
68
+ for (const e of recent) {
69
+ if (e.phase !== 'terminal') continue
70
+ const ts = Date.parse(e.ts)
71
+ if (Number.isNaN(ts)) continue
72
+ if (ts < since) continue
73
+ if (best == null || Date.parse(best.ts) < ts) best = e
74
+ }
75
+ return best
76
+ }
77
+
78
+ const RECOVERY_HINTS: Record<string, string> = {
79
+ binary:
80
+ 'curl https://switchroom.ai/install.sh | sh && switchroom update',
81
+ source:
82
+ 'cd ~/code/switchroom && git pull && bun install && bun run build && switchroom update',
83
+ 'source-unlinked':
84
+ 'cd ~/code/switchroom && bun link && switchroom update # ensures binary is in PATH first',
85
+ docker:
86
+ 'docker compose -p switchroom pull && docker compose -p switchroom up -d',
87
+ unknown:
88
+ 'Cannot auto-detect install type. Run `switchroom apply` to refresh ~/.switchroom/install-type.json, then retry.',
89
+ }
90
+
91
+ function recoveryHint(installType: string | undefined): string {
92
+ if (!installType) return RECOVERY_HINTS.unknown
93
+ return RECOVERY_HINTS[installType] ?? RECOVERY_HINTS.unknown
94
+ }
95
+
96
+ function shortSha(s: string): string {
97
+ return s.replace(/^sha256:/, '').slice(0, 12)
98
+ }
99
+
100
+ /**
101
+ * Pure renderer. Returns the single line (HTML-safe — plain ASCII)
102
+ * to append to the boot card body. `null` means nothing to surface
103
+ * (entry too stale, schema invalid, etc.).
104
+ */
105
+ export function renderUpdateOutcomeLine(entry: AuditEntry): string {
106
+ const success = entry.exit_code === 0 && entry.result !== 'error' && entry.result !== 'denied'
107
+ if (success) {
108
+ const channel = entry.channel ? `channel:${entry.channel}` : entry.pin ? `pin:${entry.pin}` : 'channel:?'
109
+ let shaStr = ''
110
+ if (entry.resolved_sha) {
111
+ const firstSha = Object.values(entry.resolved_sha)[0]
112
+ if (firstSha) shaStr = `, sha:${shortSha(firstSha)}`
113
+ }
114
+ return `✅ update completed (${channel}${shaStr})`
115
+ }
116
+ const stderrTail = (entry.stderr_tail ?? entry.error ?? '').slice(-400)
117
+ const opStep = entry.op
118
+ const hint = recoveryHint(entry.install_context?.install_type)
119
+ // Single line is reader-friendly when short; multi-line when stderr is present.
120
+ const lines = [`❌ update failed at ${opStep}: ${stderrTail || '(no stderr captured)'}`, ` ↳ Recovery: ${hint}`]
121
+ return lines.join('\n')
122
+ }
123
+
124
+ export interface ClaimOpts {
125
+ /** Override state-dir base (default: $TELEGRAM_STATE_DIR or ~/.switchroom/<agent>/telegram). */
126
+ stateDir?: string
127
+ }
128
+
129
+ /**
130
+ * Atomic claim via `O_CREAT|O_EXCL` — returns true if THIS process is
131
+ * the first to announce this request_id. Idempotent across respawns
132
+ * within the same state-dir. We deliberately don't clean up old
133
+ * markers — they're tiny and bounded by the lookback window above.
134
+ */
135
+ export function claimUpdateAnnouncement(requestId: string, opts: ClaimOpts = {}): boolean {
136
+ const stateDir = opts.stateDir ?? process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.switchroom')
137
+ const dir = join(stateDir, 'update-announced')
138
+ try {
139
+ mkdirSync(dir, { recursive: true })
140
+ } catch {
141
+ return false
142
+ }
143
+ const safeId = requestId.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 200)
144
+ const path = join(dir, safeId)
145
+ try {
146
+ // O_CREAT | O_EXCL — fails with EEXIST if another boot already
147
+ // claimed this request_id.
148
+ const fd = openSync(path, 'wx')
149
+ closeSync(fd)
150
+ return true
151
+ } catch {
152
+ return false
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Combined entry-point used by the gateway boot path: read + claim +
158
+ * render. Returns the line to append (already escaped for HTML — plain
159
+ * ASCII text — caller embeds with no further processing) or null when
160
+ * there's nothing to surface OR another boot already claimed the row.
161
+ */
162
+ export function maybeRenderUpdateAnnouncement(opts: ReadOpts & ClaimOpts = {}): string | null {
163
+ const entry = readLastTerminalUpdateAudit(opts)
164
+ if (!entry) return null
165
+ if (!claimUpdateAnnouncement(entry.request_id, opts)) return null
166
+ return renderUpdateOutcomeLine(entry)
167
+ }
@@ -256,198 +256,3 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
256
256
  return lines.join("\n");
257
257
  }
258
258
 
259
- /* ── Account-level quota probe + short-lived cache ───────────────────── */
260
-
261
- /**
262
- * Resolve an account's OAuth access token from
263
- * `~/.switchroom/accounts/<label>/credentials.json` (new account model
264
- * — see `reference/share-auth-across-the-fleet.md`). Returns null when
265
- * the file is missing, malformed, or has no accessToken — caller
266
- * surfaces a graceful "missing credentials" badge.
267
- *
268
- * Exported for unit testing; production callers go through
269
- * {@link fetchAccountQuota}.
270
- */
271
- export function readAccountAccessToken(
272
- label: string,
273
- home: string = (process.env.HOME ?? "/root"),
274
- ): string | null {
275
- const credPath = join(home, ".switchroom", "accounts", label, "credentials.json");
276
- if (!existsSync(credPath)) return null;
277
- try {
278
- const raw = readFileSync(credPath, "utf-8");
279
- const parsed = JSON.parse(raw) as {
280
- claudeAiOauth?: { accessToken?: string };
281
- };
282
- const token = parsed.claudeAiOauth?.accessToken?.trim();
283
- return token && token.length > 0 ? token : null;
284
- } catch {
285
- return null;
286
- }
287
- }
288
-
289
- /**
290
- * Cache key per account label. The cached entry holds the result and
291
- * the wall-clock timestamp it was fetched at, so the dashboard tap
292
- * pattern (refresh-on-tap) doesn't trigger a fresh API call within the
293
- * TTL window. Quota numbers don't move within a few seconds anyway.
294
- */
295
- type AccountQuotaCacheEntry = {
296
- fetchedAt: number;
297
- result: QuotaResult;
298
- };
299
-
300
- /** TTL for the per-account quota cache — controls when
301
- * `prefetchAccountQuotaIfStale` re-probes Anthropic and when
302
- * `fetchAccountQuota`'s cache-bypass kicks in. 5 min: quota numbers
303
- * don't move within a few minutes for human-scale usage; the
304
- * prefetch fires on every dashboard render so the cache stays fresh
305
- * whenever the operator interacts. The dashboard's sync read
306
- * (`getCachedAccountQuota`) returns last-known data regardless of
307
- * staleness — see that function's docstring for why. */
308
- export const ACCOUNT_QUOTA_CACHE_TTL_MS = 5 * 60_000;
309
-
310
- const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
311
-
312
- /**
313
- * Fetch quota for a global account by label. Wraps {@link fetchQuota}
314
- * with token-resolution (`~/.switchroom/accounts/<label>/credentials.json`)
315
- * and a short-lived in-process cache so repeat dashboard taps within
316
- * the TTL don't re-hit the Anthropic API.
317
- *
318
- * Pass `force: true` to bypass the cache (used when the user
319
- * explicitly taps "📊 Full quota" — they expect a live read).
320
- */
321
- export async function fetchAccountQuota(
322
- label: string,
323
- opts: {
324
- home?: string;
325
- force?: boolean;
326
- now?: () => number;
327
- fetchImpl?: typeof fetch;
328
- timeoutMs?: number;
329
- } = {},
330
- ): Promise<QuotaResult> {
331
- const now = opts.now?.() ?? Date.now();
332
- if (!opts.force) {
333
- const cached = accountQuotaCache.get(label);
334
- if (cached && now - cached.fetchedAt < ACCOUNT_QUOTA_CACHE_TTL_MS) {
335
- return cached.result;
336
- }
337
- }
338
-
339
- const token = readAccountAccessToken(label, opts.home);
340
- if (!token) {
341
- const result: QuotaResult = {
342
- ok: false,
343
- reason: "no credentials.json or accessToken for account",
344
- };
345
- accountQuotaCache.set(label, { fetchedAt: now, result });
346
- return result;
347
- }
348
-
349
- const result = await fetchQuota({
350
- accessToken: token,
351
- fetchImpl: opts.fetchImpl,
352
- timeoutMs: opts.timeoutMs,
353
- });
354
- accountQuotaCache.set(label, { fetchedAt: now, result });
355
- // Note: pre-RFC-H this also persisted to disk via writeAccountQuota
356
- // (#708) so a gateway restart could re-hydrate without an API call.
357
- // Post-RFC-H the broker holds canonical quota state and answers
358
- // via `list-state`, so the gateway's in-process cache is enough.
359
- return result;
360
- }
361
-
362
- /**
363
- * Re-hydrate the in-process account-quota cache from on-disk
364
- * snapshots written by previous gateway lifetimes (issue #708).
365
- * Called once at gateway boot so the boot card and the first /auth
366
- * tap have data instantly — no need to wait for the background
367
- * prefetch tick.
368
- *
369
- * Safe to call repeatedly: each label is set to the disk snapshot's
370
- * `capturedAt` timestamp so a fresher live probe still wins on
371
- * `now - fetchedAt < TTL` comparisons. When the disk snapshot is
372
- * older than the TTL, the cache entry is still seeded — the background
373
- * prefetch will replace it on the next tap.
374
- */
375
- export function hydrateAccountQuotaCacheFromDisk(
376
- _labels: ReadonlyArray<string>,
377
- _home?: string,
378
- ): void {
379
- // No-op post-RFC-H. The disk-snapshot store this function used to
380
- // re-hydrate from (per-account quota.json files under
381
- // ~/.switchroom/accounts/<label>/) is gone — switchroom-auth-broker
382
- // now owns canonical quota state. Boot-time hydration is the
383
- // broker's `list-state` call instead. Signature preserved so
384
- // existing call sites continue to compile while we phase them out.
385
- }
386
-
387
- /** Test/utility helper — wipe the per-account quota cache. The
388
- * gateway calls this on auth-account-level mutations (account add,
389
- * account rm, account rename, refresh-accounts tick) so a stale
390
- * pre-rename label doesn't survive into the dashboard. */
391
- export function clearAccountQuotaCache(label?: string): void {
392
- if (label == null) {
393
- accountQuotaCache.clear();
394
- return;
395
- }
396
- accountQuotaCache.delete(label);
397
- }
398
-
399
- /**
400
- * Sync read of the account quota cache. Returns whatever's cached for
401
- * this label — `null` only when there's NO entry at all. Stale-but-
402
- * present cache entries are returned on purpose:
403
- *
404
- * - The dashboard renders sync; awaiting a fresh probe would block
405
- * the user-visible message (and a probe can stall on Anthropic
406
- * latency or network).
407
- * - Showing yesterday's number is dramatically better UX than
408
- * showing nothing — quota changes slowly enough that "the cached
409
- * value" is almost always close to truth.
410
- * - The background prefetch (`prefetchAccountQuotaIfStale`) keeps
411
- * the cache fresh across renders. Within the 5-min TTL it
412
- * no-ops; past the TTL it kicks off a fresh probe whose result
413
- * is visible on the operator's NEXT render (refresh tap or
414
- * auto-refresh after an action).
415
- *
416
- * Pre-v0.6.11 this function treated stale entries as a miss, which
417
- * meant the boot-warmed cache vanished after 30s and the operator
418
- * saw empty quota rows on the first /auth tap of any day after the
419
- * gateway restart. That's the bug this docstring exists to keep
420
- * fixed.
421
- */
422
- export function getCachedAccountQuota(
423
- _label: string,
424
- _now: number = Date.now(),
425
- ): QuotaResult | null {
426
- // Note the unused params — we keep the signature stable for callers
427
- // that pass `now` (test helpers) even though we no longer use it.
428
- const cached = accountQuotaCache.get(_label);
429
- if (!cached) return null;
430
- return cached.result;
431
- }
432
-
433
- /**
434
- * Fire-and-forget background prefetch — kicks off
435
- * `fetchAccountQuota` if the cache is cold/stale and discards the
436
- * promise. Safe to call on every dashboard render: the cache TTL
437
- * keeps the API call rate bounded to ~1 per account per 30s
438
- * regardless of how many times the user taps /auth.
439
- *
440
- * Errors are swallowed (the next tap re-tries via the cache miss
441
- * path); the dashboard's empty quota row is the user-visible
442
- * "didn't probe yet" signal.
443
- */
444
- export function prefetchAccountQuotaIfStale(
445
- label: string,
446
- opts: { home?: string; now?: () => number; fetchImpl?: typeof fetch } = {},
447
- ): void {
448
- const now = opts.now?.() ?? Date.now();
449
- const cached = accountQuotaCache.get(label);
450
- if (cached && now - cached.fetchedAt < ACCOUNT_QUOTA_CACHE_TTL_MS) return;
451
- // Don't await — background warm.
452
- void fetchAccountQuota(label, opts).catch(() => {});
453
- }
@@ -14,7 +14,7 @@
14
14
  * stream_reply lands as a SECOND message with the same content
15
15
  * (raw markdown, since reply tools don't always render HTML).
16
16
  *
17
- * Smoking-gun evidence: klanker chat 8248703757, msgs 5025 + 5027,
17
+ * Smoking-gun evidence: klanker chat 12345, msgs 5025 + 5027,
18
18
  * 11s apart. msg=5025 had `<b>...</b>` (turn-flush + markdownToHtml).
19
19
  * msg=5027 had `**...**` (the raw markdown reply tool's payload).
20
20
  * Same content, different formatting, two messages.
@@ -12,7 +12,7 @@
12
12
  * Schema (one table):
13
13
  *
14
14
  * turns
15
- * turn_key TEXT PK -- e.g. "8248703757:11"
15
+ * turn_key TEXT PK -- e.g. "12345:11"
16
16
  * chat_id TEXT NOT NULL
17
17
  * thread_id TEXT -- nullable: forum topics only
18
18
  * started_at INTEGER NOT NULL -- unix ms
@@ -250,3 +250,27 @@ export async function retryWithThreadFallback<T>(
250
250
  throw err
251
251
  }
252
252
  }
253
+
254
+ /**
255
+ * True when Telegram rejected a message because it couldn't parse the
256
+ * HTML/entities we sent — our prevention (markdownToHtml +
257
+ * sanitizeForTelegram + splitHtmlChunks) let something malformed
258
+ * through anyway. These 400s are deliberately NOT swallowed or retried
259
+ * by `retryApiCall` (only not-modified / not-found / thread-not-found
260
+ * are) — they surface to the caller, which recovers by resending the
261
+ * chunk as plain text (parse_mode unset). Same "caller-level fallback"
262
+ * shape as the THREAD_NOT_FOUND contract above.
263
+ */
264
+ export function isHtmlParseRejectError(err: unknown): boolean {
265
+ if (!(err instanceof GrammyError) || err.error_code !== 400) return false
266
+ const d = (err.description || '').toLowerCase()
267
+ return (
268
+ d.includes("can't parse entities") ||
269
+ d.includes('can’t parse entities') ||
270
+ d.includes('unsupported start tag') ||
271
+ d.includes('unclosed start tag') ||
272
+ d.includes("can't find end of the entity") ||
273
+ // covers both "expected end tag" and "unexpected end tag"
274
+ d.includes('expected end tag')
275
+ )
276
+ }
@@ -159,13 +159,16 @@ installPluginLogger()
159
159
  // missing-feature bugs (no /issues card, no quota notifications, no
160
160
  // graceful failover). A clean error is more honest.
161
161
  //
162
- // To install the gateway: `switchroom setup` provisions the
163
- // `switchroom-telegram-gateway` systemd unit. Inspect with
164
- // `systemctl --user status switchroom-telegram-gateway`.
162
+ // In v0.7+ the gateway is an in-container sidecar started by start.sh
163
+ // (no systemd unit). On a Docker host inspect/recover with
164
+ // `docker logs switchroom-<agent>` / `switchroom agent restart <agent>`;
165
+ // on a legacy non-docker install it's the gateway systemd user unit.
166
+ const recover = process.env.SWITCHROOM_RUNTIME === 'docker'
167
+ ? 'check `docker logs switchroom-<agent>` then `switchroom agent restart <agent>`'
168
+ : 'run `switchroom setup` to install the gateway, or check `systemctl --user status switchroom-telegram-gateway`'
165
169
  process.stderr.write(
166
170
  `telegram channel: no gateway socket at ${_gatewaySocket}. ` +
167
- `Run \`switchroom setup\` to install the gateway daemon, or check ` +
168
- `\`systemctl --user status switchroom-telegram-gateway\`. Exiting sidecar.\n`,
171
+ `The gateway sidecar is not running ${recover}. Exiting sidecar.\n`,
169
172
  )
170
173
  process.exit(1)
171
174
  }
@@ -26,10 +26,39 @@
26
26
  */
27
27
 
28
28
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
29
- import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
29
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
30
30
  import { tmpdir } from 'node:os'
31
31
  import { join } from 'node:path'
32
32
 
33
+ /**
34
+ * Pick an exec-allowed temp root. Some containers (and this one) mount
35
+ * /tmp with `noexec`, which breaks the subprocess fixtures that spawn
36
+ * a small node script as a stand-in for `claude setup-token`. When the
37
+ * default tmpdir is noexec, fall back to a project-local `.test-tmp/`
38
+ * which inherits the project mount's exec bits.
39
+ */
40
+ function execAllowedTmpdir(): string {
41
+ const def = tmpdir()
42
+ try {
43
+ // Read /proc/mounts and check whether the directory's mount has noexec.
44
+ const mounts = readFileSync('/proc/mounts', 'utf8')
45
+ const noexec = mounts.split('\n').some((line) => {
46
+ const parts = line.split(' ')
47
+ if (parts.length < 4) return false
48
+ const [, mountPoint, , opts] = parts
49
+ return mountPoint === def && opts.split(',').includes('noexec')
50
+ })
51
+ if (!noexec) return def
52
+ } catch {
53
+ return def
54
+ }
55
+ const fallback = join(process.cwd(), '.test-tmp')
56
+ mkdirSync(fallback, { recursive: true })
57
+ return fallback
58
+ }
59
+
60
+ const EXEC_TMPDIR = execAllowedTmpdir()
61
+
33
62
  import {
34
63
  parseAuthCommand,
35
64
  handleAuthCommand,
@@ -51,7 +80,7 @@ import {
51
80
  let workspace: string
52
81
 
53
82
  beforeEach(() => {
54
- workspace = mkdtempSync(join(tmpdir(), 'auth-add-flow-test-'))
83
+ workspace = mkdtempSync(join(EXEC_TMPDIR, 'auth-add-flow-test-'))
55
84
  pendingAuthAddFlows.clear()
56
85
  })
57
86
 
@@ -98,7 +127,7 @@ function fakeClaudeBinary(opts: {
98
127
  const creds = {
99
128
  claudeAiOauth: {
100
129
  accessToken: ${JSON.stringify(token)},
101
- refreshToken: 'sk-ant-ort01-test-refresh',
130
+ refreshToken: ${JSON.stringify(['sk-ant-', 'ort01-test-refresh'].join(''))},
102
131
  expiresAt: Date.now() + 8 * 3600_000,
103
132
  scopes: ['user:inference'],
104
133
  subscriptionType: 'max',
@@ -45,14 +45,14 @@ function qOk(part: Partial<QuotaUtilization>): QuotaResult {
45
45
  const NOW_MS = new Date('2026-05-15T00:53:00Z').getTime();
46
46
 
47
47
  const FIXTURE_STATE: ListStateData = {
48
- active: 'pixsoul@x',
49
- fallback_order: ['ken@x', 'me@x', 'pixsoul@x'],
48
+ active: 'you@x',
49
+ fallback_order: ['ken@x', 'me@x', 'you@x'],
50
50
  accounts: [
51
51
  { label: 'ken@x', exhausted: false },
52
52
  { label: 'me@x', exhausted: false },
53
- { label: 'pixsoul@x', exhausted: false },
53
+ { label: 'you@x', exhausted: false },
54
54
  ],
55
- agents: [{ name: 'carrie', account: 'pixsoul@x', override: null }],
55
+ agents: [{ name: 'carrie', account: 'you@x', override: null }],
56
56
  consumers: [],
57
57
  };
58
58
 
@@ -135,7 +135,7 @@ describe('renderAuthSnapshotFormat2', () => {
135
135
  // fixture. If the formatter changes shape, update these expectations.
136
136
  const fixtureSnaps: AccountSnapshot[] = [
137
137
  snap({
138
- label: 'ken.thompson@outlook.com.au',
138
+ label: 'alice@example.com',
139
139
  isActive: false,
140
140
  quota: quota({
141
141
  fiveHourUtilizationPct: 0,
@@ -146,7 +146,7 @@ describe('renderAuthSnapshotFormat2', () => {
146
146
  }),
147
147
  }),
148
148
  snap({
149
- label: 'me@kenthompson.com.au',
149
+ label: 'bob@example.com',
150
150
  isActive: false,
151
151
  quota: quota({
152
152
  fiveHourUtilizationPct: 0,
@@ -157,7 +157,7 @@ describe('renderAuthSnapshotFormat2', () => {
157
157
  }),
158
158
  }),
159
159
  snap({
160
- label: 'pixsoul@gmail.com',
160
+ label: 'you@example.com',
161
161
  isActive: true,
162
162
  quota: quota({
163
163
  fiveHourUtilizationPct: 8,
@@ -181,18 +181,18 @@ describe('renderAuthSnapshotFormat2', () => {
181
181
 
182
182
  it('marks the active account with ●', () => {
183
183
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
184
- expect(out).toMatch(/●\s*<code>pixsoul@gmail\.com<\/code>/);
184
+ expect(out).toMatch(/●\s*<code>you@example\.com<\/code>/);
185
185
  });
186
186
 
187
187
  it('shows "back …" for blocked accounts with binding-window word', () => {
188
188
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
189
- // me@kenthompson is blocked on 7d, recovers Sun
190
- expect(out).toMatch(/me@kenthompson\.com\.au[\s\S]*back .* 7-day cap/);
189
+ // bob@example is blocked on 7d, recovers Sun
190
+ expect(out).toMatch(/bob@example\.com[\s\S]*back .* 7-day cap/);
191
191
  });
192
192
 
193
193
  it('puts the imminent window first on healthy/throttling rows', () => {
194
194
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
195
- // pixsoul: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
195
+ // you: 5h reset is in 7m, 7d reset is in 2d. 5h should come first.
196
196
  const pixRow = out.split('\n').find((l) => l.includes('5h refills') && l.includes('7d resets'));
197
197
  expect(pixRow).toBeDefined();
198
198
  expect(pixRow!.indexOf('5h refills')).toBeLessThan(pixRow!.indexOf('7d resets'));
@@ -245,7 +245,7 @@ describe('renderFallbackAnnouncement', () => {
245
245
  representativeClaim: 'five_hour',
246
246
  });
247
247
 
248
- const PIXSOUL_HEALTHY = quota({
248
+ const YOU_HEALTHY = quota({
249
249
  fiveHourUtilizationPct: 8,
250
250
  sevenDayUtilizationPct: 20,
251
251
  fiveHourResetAt: new Date('2026-05-15T01:00:00Z'),
@@ -256,8 +256,8 @@ describe('renderFallbackAnnouncement', () => {
256
256
  const out5 = renderFallbackAnnouncement({
257
257
  oldLabel: 'ken@x',
258
258
  oldQuota: KEN_5H_BLOWN,
259
- newLabel: 'pixsoul@x',
260
- newQuota: PIXSOUL_HEALTHY,
259
+ newLabel: 'you@x',
260
+ newQuota: YOU_HEALTHY,
261
261
  triggerAgent: 'carrie',
262
262
  now: NOW,
263
263
  tz: 'UTC',
@@ -272,8 +272,8 @@ describe('renderFallbackAnnouncement', () => {
272
272
  sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
273
273
  representativeClaim: 'seven_day',
274
274
  }),
275
- newLabel: 'pixsoul@x',
276
- newQuota: PIXSOUL_HEALTHY,
275
+ newLabel: 'you@x',
276
+ newQuota: YOU_HEALTHY,
277
277
  triggerAgent: 'clerk',
278
278
  now: NOW,
279
279
  tz: 'UTC',
@@ -285,8 +285,8 @@ describe('renderFallbackAnnouncement', () => {
285
285
  const out = renderFallbackAnnouncement({
286
286
  oldLabel: 'ken@x',
287
287
  oldQuota: KEN_5H_BLOWN,
288
- newLabel: 'pixsoul@x',
289
- newQuota: PIXSOUL_HEALTHY,
288
+ newLabel: 'you@x',
289
+ newQuota: YOU_HEALTHY,
290
290
  triggerAgent: 'carrie',
291
291
  now: NOW,
292
292
  tz: 'UTC',
@@ -299,8 +299,8 @@ describe('renderFallbackAnnouncement', () => {
299
299
  const happy = renderFallbackAnnouncement({
300
300
  oldLabel: 'ken@x',
301
301
  oldQuota: KEN_5H_BLOWN,
302
- newLabel: 'pixsoul@x',
303
- newQuota: PIXSOUL_HEALTHY,
302
+ newLabel: 'you@x',
303
+ newQuota: YOU_HEALTHY,
304
304
  triggerAgent: 'carrie',
305
305
  now: NOW,
306
306
  tz: 'UTC',
@@ -310,7 +310,7 @@ describe('renderFallbackAnnouncement', () => {
310
310
  const tight = renderFallbackAnnouncement({
311
311
  oldLabel: 'ken@x',
312
312
  oldQuota: KEN_5H_BLOWN,
313
- newLabel: 'pixsoul@x',
313
+ newLabel: 'you@x',
314
314
  newQuota: quota({ fiveHourUtilizationPct: 85 }),
315
315
  triggerAgent: 'carrie',
316
316
  now: NOW,