switchroom 0.21.11 → 0.21.13

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.
@@ -30,15 +30,15 @@ description: >
30
30
 
31
31
  # Agent Status
32
32
 
33
- When the user asks about agent status, what's running, uptime, or wants to see agent info, answer by running (or telling them to run) `switchroom agent list` — this is the canonical command for showing running agents, their uptime, and current state.
33
+ When the user asks about agent status, what's running, uptime, or wants to see agent info, answer by running (or telling them to run) `switchroom status` — this is the canonical command for showing running agents, their uptime, and current state.
34
34
 
35
35
  ## Step 1 — Always mention `switchroom status` in your response
36
36
 
37
- The answer to "what agents are running", "show me agent info", "list all switchroom agents", or any uptime question is the `switchroom status` command (since v0.13.51). Your response MUST include the literal command string `switchroom status` so the user can copy it. If you have Bash tool access, run it and include the output. If you do not have Bash access, or the command fails in the current environment, still tell the user explicitly:
37
+ The answer to "what agents are running", "show me agent info", "list all switchroom agents", or any uptime question is the `switchroom status` command (since v0.13.53). Your response MUST include the literal command string `switchroom status` so the user can copy it. If you have Bash tool access, run it and include the output. If you do not have Bash access, or the command fails in the current environment, still tell the user explicitly:
38
38
 
39
39
  > Run `switchroom status` from your switchroom project directory to see running agents (uptime + scheduler), known auth accounts, and per-agent MCP connection state.
40
40
 
41
- Do not respond with a PATH-not-found bailout or a "no config found" diagnosis without first giving the user the command — the eval environment may not have a config on cwd, but on the user's actual machine `switchroom status` is the right command. (Pre-v0.13.51 the canonical command was `switchroom agent list` — that still works but only shows the Fleet section.)
41
+ Do not respond with a PATH-not-found bailout or a "no config found" diagnosis without first giving the user the command — the eval environment may not have a config on cwd, but on the user's actual machine `switchroom status` is the right command. (Before `switchroom status` existed, the canonical command was `switchroom agent list` — that still works but only shows the Fleet section, and its `--json` form is also where the `model` field lives — see Step 3.)
42
42
 
43
43
  ## Step 2 — Try to run it
44
44
 
@@ -48,30 +48,29 @@ If you have Bash tool access, run:
48
48
  switchroom status --json 2>/dev/null || switchroom status
49
49
  ```
50
50
 
51
- `switchroom status` returns three sections: **Fleet** (per-agent uptime + scheduler), **Accounts** (broker-known auth accounts with active marker), and **MCPs** (per-agent MCP connection state). If you want to skip the MCP probe (slower because it does a docker exec per agent), pass `--no-mcp`.
51
+ `switchroom status --json` returns three top-level keys: **`fleet`** (per-agent `name`, `status`, `started_at`, `topic`, `scheduler` — no `model`), **`accounts`** (broker-known auth accounts with an `active` marker), and **`mcps`** (per-agent MCP connection state, probed via `docker exec <agent> claude mcp list`). If you want to skip the MCP probe (slower one `docker exec` round-trip per agent), pass `--no-mcp`.
52
52
 
53
- If `switchroom status` fails (e.g. command not found, no config in cwd), fall back to `switchroom agent list` (older command, Fleet-only). Still include the `switchroom status` command and the word "uptime" in your text response — the user needs those as actionable information.
53
+ If `switchroom status` fails (e.g. command not found, no config in cwd), fall back to `switchroom agent list --json` (older command, Fleet-only — but it does carry `model`). Still include the `switchroom status` command and the word "uptime" in your text response — the user needs those as actionable information.
54
54
 
55
55
  ## Step 3 — For each agent, report running state and uptime
56
56
 
57
57
  When you have real output, for each agent show:
58
58
  - **Name** and topic
59
- - **Status**: running / stopped / error (from the docker-compose container state)
59
+ - **Status**: `active` (running) / `inactive`, `exited`, `dead` (not running) — other docker-container states pass through verbatim (`restarting`, `paused`, `created`). This is the normalized container state, not raw docker-compose text.
60
60
  - **Uptime**: how long it's been running (for running agents, always include the word "uptime" and the duration)
61
- - **Model**: which Claude model it's using
62
- - **Memory**: Hindsight collection name (if configured)
63
- - **PID** if available
61
+ - **Model**: which Claude model it's using. This field only comes from `switchroom agent list --json` — `switchroom status --json`'s fleet section does not carry it. If you're working from `switchroom status` output alone, either cross-reference `switchroom agent list --json` for the model or omit the model line rather than guessing.
62
+
63
+ Don't report a PID or a Hindsight collection/bank name — neither `switchroom status --json` nor `switchroom agent list --json` emits either field at the fleet level, so there is nothing real to show without a slower per-agent `switchroom agent status <name> --json` call (out of scope for a fleet-wide snapshot).
64
64
 
65
65
  Every running agent must have its uptime reported so the user can see how long each has been up. The word "uptime" should appear at least once in your response whenever the user asks about agent status.
66
66
 
67
67
  ## Step 4 — Format the output
68
68
 
69
- Format as a clean summary — one section per agent. Use bold agent names, inline code for model/collection names.
69
+ Format as a clean summary — one section per agent. Use bold agent names, inline code for the model name.
70
70
 
71
71
  ## Step 5 — Highlight anything suspicious
72
72
 
73
- - Agents that are stopped but should be running
74
- - Agents in error/failed state
73
+ - Agents that are `inactive`/`exited`/`dead` but should be running
75
74
  - Agents with very recent restarts (< 5 min uptime — may be crash-looping)
76
75
 
77
76
  ## Step 6 — One-line summary
@@ -81,16 +80,16 @@ End with a one-line summary: "X of Y agents running."
81
80
  ## Example Output Shape
82
81
 
83
82
  ```
84
- assistant — running (2h 14m)
85
- model: claude-sonnet-5 collection: general
83
+ assistant — active (2h 14m)
84
+ model: claude-sonnet-5
86
85
 
87
- dev — running (45m)
88
- model: claude-opus-5 collection: coding
86
+ dev — active (45m)
87
+ model: claude-opus-5
89
88
 
90
- coach — stopped
89
+ coach — inactive
91
90
  last run: 3 days ago
92
91
 
93
92
  3 of 3 agents configured, 2 running.
94
93
  ```
95
94
 
96
- If the user wants more detail on a specific agent, suggest `switchroom agent logs <name>` (covered by the `switchroom-cli` skill).
95
+ If the user wants recent log output for a specific agent, suggest `switchroom agent logs <name>` (covered by the `switchroom-cli` skill). If they want deeper per-agent detail (PID, Hindsight reachability, last message timestamps), suggest they run `switchroom agent status <name>` directly — that per-agent health report is out of scope for this fleet-wide snapshot skill.
@@ -22,18 +22,16 @@ RAW=$(switchroom agent list --json 2>/dev/null) || {
22
22
  exit 1
23
23
  }
24
24
 
25
- if [ -z "$RAW" ] || [ "$RAW" = "[]" ]; then
25
+ if [ -z "$RAW" ] || [ "$RAW" = '{"agents":[]}' ]; then
26
26
  echo "No agents configured."
27
27
  exit 0
28
28
  fi
29
29
 
30
- TOTAL=$(echo "$RAW" | python3 -c "import sys,json; agents=json.load(sys.stdin); print(len(agents))" 2>/dev/null || echo "?")
31
- RUNNING=0
32
-
33
30
  echo "$RAW" | python3 -c "
34
- import sys, json, datetime
31
+ import sys, json
35
32
 
36
- agents = json.load(sys.stdin)
33
+ payload = json.load(sys.stdin)
34
+ agents = payload.get('agents', [])
37
35
  running = 0
38
36
 
39
37
  for a in agents:
@@ -41,11 +39,9 @@ for a in agents:
41
39
  status = a.get('status', 'unknown')
42
40
  model = a.get('model', 'unknown')
43
41
  topic = a.get('topic_name', '')
44
- coll = a.get('memory', {}).get('collection', '')
45
42
  uptime = a.get('uptime', '')
46
- pid = a.get('pid', '')
47
43
 
48
- status_icon = '✓' if status == 'running' else '✗' if status in ('stopped','failed') else '?'
44
+ status_icon = '✓' if status == 'active' else '✗' if status in ('inactive', 'exited', 'dead') else '?'
49
45
 
50
46
  line = f'{status_icon} {name}'
51
47
  if topic:
@@ -54,16 +50,11 @@ for a in agents:
54
50
  if uptime:
55
51
  line += f' ({uptime})'
56
52
  print(line)
57
- print(f' model: {model}', end='')
58
- if coll:
59
- print(f' collection: {coll}', end='')
60
- if pid:
61
- print(f' pid: {pid}', end='')
62
- print()
53
+ print(f' model: {model}')
63
54
  print()
64
55
 
65
- if status == 'running':
56
+ if status == 'active':
66
57
  running += 1
67
58
 
68
59
  print(f'{running} of {len(agents)} agents running.')
69
- " 2>/dev/null || echo "$RAW"
60
+ "
@@ -23,8 +23,12 @@ at `telegram-plugin/tests/`. Use it to lock in the Bot API call
23
23
  sequences switchroom emits — what the user actually sees in chat — so
24
24
  regressions fail a test instead of going silent in production.
25
25
 
26
- This skill is a quick-reference for that harness. The full guide lives
27
- at [`telegram-plugin/tests/HARNESS.md`](../../telegram-plugin/tests/HARNESS.md).
26
+ This skill is the reference for that harness there is no separate
27
+ `HARNESS.md` guide file (it existed briefly, was deleted in the pinned
28
+ progress-card removal (#1126), and was never recreated). Read the fake
29
+ implementations directly for the authoritative contract:
30
+ `telegram-plugin/tests/fake-bot-api.ts`, `update-factory.ts`,
31
+ `bot-api.harness.ts`.
28
32
 
29
33
  ## When to use this harness
30
34
 
@@ -147,8 +151,6 @@ Example pairs:
147
151
 
148
152
  - `slot-banner.test.ts` (pure decision) +
149
153
  `slot-banner-driver.e2e.test.ts` (Bot API dispatch)
150
- - `auto-fallback.test.ts` (pure plan) +
151
- `auto-fallback-dispatcher.e2e.test.ts` (notification dispatch)
152
154
 
153
155
  ## Test-design checklist
154
156
 
@@ -187,7 +189,6 @@ Before writing the test, ask:
187
189
 
188
190
  ## See also
189
191
 
190
- - `telegram-plugin/tests/HARNESS.md` — full guide
191
192
  - `telegram-plugin/tests/fake-bot-api.test.ts` — meta-test of the fake;
192
193
  read first when adding new fake-bot capabilities
193
194
  - `telegram-plugin/tests/streaming-e2e.test.ts` — worked example of a
@@ -21648,7 +21648,7 @@ var init_observation_scopes = __esm(() => {
21648
21648
  });
21649
21649
 
21650
21650
  // ../src/config/schema.ts
21651
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
21651
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
21652
21652
  var init_schema = __esm(() => {
21653
21653
  init_zod();
21654
21654
  init_observation_scopes();
@@ -22521,6 +22521,27 @@ var init_schema = __esm(() => {
22521
22521
  notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 \u2014 operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind \u2014 tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
22522
22522
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
22523
22523
  });
22524
+ ScratchConfigSchema = exports_external.object({
22525
+ enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true \u2014 but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
22526
+ volume: exports_external.string().refine((v) => v.startsWith("/"), {
22527
+ message: "scratch.volume must be an absolute host path"
22528
+ }).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist \u2014 its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
22529
+ subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
22530
+ message: "scratch.subdir must be a relative path without '..' segments"
22531
+ }).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
22532
+ });
22533
+ DiskConfigSchema = exports_external.object({
22534
+ warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 \u2014 the " + "reference fleet was at 85% when the condition was found by hand."),
22535
+ fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
22536
+ reap_report: exports_external.object({
22537
+ enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
22538
+ log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` \u2014 it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
22539
+ max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 \u2014 twice the " + "documented daily cadence, so a single missed run is not noise.")
22540
+ }).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
22541
+ }).refine((v) => v.fail_pct > v.warn_pct, {
22542
+ message: "disk.fail_pct must be greater than disk.warn_pct",
22543
+ path: ["fail_pct"]
22544
+ });
22524
22545
  HostControlConfigSchema = exports_external.object({
22525
22546
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
22526
22547
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
@@ -22595,6 +22616,8 @@ var init_schema = __esm(() => {
22595
22616
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration \u2014 OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
22596
22617
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
22597
22618
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
22619
+ scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent \u2014 not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
22620
+ disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
22598
22621
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
22599
22622
  hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
22600
22623
  fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health \u2014 job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent \u2192 inert; the admin page renders an empty state."),
@@ -67956,6 +67979,110 @@ function resolveSendContext(payload) {
67956
67979
  };
67957
67980
  }
67958
67981
 
67982
+ // shared/utf8-sanitize.ts
67983
+ var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
67984
+ function hasLoneSurrogate(s) {
67985
+ LONE_SURROGATE.lastIndex = 0;
67986
+ return LONE_SURROGATE.test(s);
67987
+ }
67988
+ function countLoneSurrogates(s) {
67989
+ return (s.match(LONE_SURROGATE) ?? []).length;
67990
+ }
67991
+ function sanitizeLoneSurrogates(s) {
67992
+ if (!hasLoneSurrogate(s))
67993
+ return s;
67994
+ return s.replace(LONE_SURROGATE, "\ufffd");
67995
+ }
67996
+ function isWalkable(v) {
67997
+ if (Array.isArray(v))
67998
+ return true;
67999
+ if (v === null || typeof v !== "object")
68000
+ return false;
68001
+ const proto = Object.getPrototypeOf(v);
68002
+ return proto === Object.prototype || proto === null;
68003
+ }
68004
+ var MAX_DEPTH3 = 16;
68005
+ function defineOwn(out, k, v) {
68006
+ Object.defineProperty(out, k, {
68007
+ value: v,
68008
+ writable: true,
68009
+ enumerable: true,
68010
+ configurable: true
68011
+ });
68012
+ }
68013
+ function sanitizePayloadStrings(value, depth = 0, stats) {
68014
+ if (typeof value === "string") {
68015
+ const next = sanitizeLoneSurrogates(value);
68016
+ if (stats && next !== value)
68017
+ stats.repaired += countLoneSurrogates(value);
68018
+ return next;
68019
+ }
68020
+ if (depth >= MAX_DEPTH3 || !isWalkable(value))
68021
+ return value;
68022
+ if (Array.isArray(value)) {
68023
+ let out2;
68024
+ for (let i = 0;i < value.length; i++) {
68025
+ const item = value[i];
68026
+ const next = sanitizePayloadStrings(item, depth + 1, stats);
68027
+ if (out2 === undefined && next !== item)
68028
+ out2 = value.slice(0, i);
68029
+ if (out2 !== undefined)
68030
+ out2.push(next);
68031
+ }
68032
+ return out2 ?? value;
68033
+ }
68034
+ const hasOwn = Object.prototype.hasOwnProperty;
68035
+ let out;
68036
+ for (const k in value) {
68037
+ if (!hasOwn.call(value, k))
68038
+ continue;
68039
+ const v = value[k];
68040
+ const next = sanitizePayloadStrings(v, depth + 1, stats);
68041
+ if (out === undefined && next !== v) {
68042
+ out = {};
68043
+ for (const prev in value) {
68044
+ if (prev === k)
68045
+ break;
68046
+ if (!hasOwn.call(value, prev))
68047
+ continue;
68048
+ defineOwn(out, prev, value[prev]);
68049
+ }
68050
+ }
68051
+ if (out !== undefined)
68052
+ defineOwn(out, k, next);
68053
+ }
68054
+ return out ?? value;
68055
+ }
68056
+ var LOG_THROTTLE_MS = 60000;
68057
+ function installUtf8Sanitizer(bot, now = Date.now) {
68058
+ const lastLoggedAtByMethod = new Map;
68059
+ const shouldLogRepair = (method, at) => {
68060
+ const last = lastLoggedAtByMethod.get(method);
68061
+ if (last !== undefined && at - last < LOG_THROTTLE_MS)
68062
+ return false;
68063
+ lastLoggedAtByMethod.set(method, at);
68064
+ return true;
68065
+ };
68066
+ bot.api.config.use(async (prev, method, payload, signal) => {
68067
+ let clean = payload;
68068
+ const stats = { repaired: 0 };
68069
+ try {
68070
+ clean = sanitizePayloadStrings(payload, 0, stats);
68071
+ } catch (err) {
68072
+ clean = payload;
68073
+ if (shouldLogRepair(`${method}\x00sanitize-failed`, now())) {
68074
+ process.stderr.write(`telegram gateway: utf8-sanitize FAILED OPEN method=${method} \u2014 ` + `payload sent unrepaired; if it carries a lone surrogate Telegram ` + `will reject it (400 "strings must be encoded in UTF-8"): ` + `${err instanceof Error ? err.message : String(err)}
68075
+ `);
68076
+ }
68077
+ }
68078
+ if (clean !== payload && shouldLogRepair(method, now())) {
68079
+ process.stderr.write(`telegram gateway: utf8-sanitize repaired ${stats.repaired} lone surrogate(s) ` + `method=${method} \u2014 body would have been rejected by Telegram ` + `(400 "strings must be encoded in UTF-8")
68080
+ `);
68081
+ }
68082
+ return prev(method, clean, signal);
68083
+ });
68084
+ }
68085
+
67959
68086
  // shared/sent-text-capture.ts
67960
68087
  var SENT_TEXT = Symbol.for("switchroom.telegram.sentText");
67961
68088
  var MAX_BLOCK_DEPTH = 8;
@@ -105739,10 +105866,10 @@ function startOutboxSweep(deps) {
105739
105866
  }
105740
105867
 
105741
105868
  // ../src/build-info.ts
105742
- var VERSION2 = "0.21.11";
105743
- var COMMIT_SHA = "7a80af9d";
105744
- var COMMIT_DATE = "2026-08-14T09:19:49Z";
105745
- var LATEST_PR = 4713;
105869
+ var VERSION2 = "0.21.13";
105870
+ var COMMIT_SHA = "4a70ee58";
105871
+ var COMMIT_DATE = "2026-08-15T09:11:57Z";
105872
+ var LATEST_PR = 4734;
105746
105873
  var COMMITS_AHEAD_OF_TAG = 0;
105747
105874
 
105748
105875
  // gateway/boot-version.ts
@@ -119159,6 +119286,7 @@ async function initGatewayBot() {
119159
119286
  process.exit(1);
119160
119287
  }
119161
119288
  bot = new import_grammy17.Bot(TOKEN);
119289
+ installUtf8Sanitizer(bot);
119162
119290
  installTgPostLogger(bot);
119163
119291
  installRichMarkdownGuard(bot);
119164
119292
  installSentTextCapture(bot);
@@ -303,6 +303,7 @@ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-
303
303
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
304
304
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
305
305
  import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags, withTgSendContext, installSystemMessageObserver } from '../shared/bot-runtime.js'
306
+ import { installUtf8Sanitizer } from '../shared/utf8-sanitize.js'
306
307
  import { installSentTextCapture } from '../shared/sent-text-capture.js'
307
308
  import {
308
309
  floodStatePath,
@@ -22873,6 +22874,7 @@ async function initGatewayBot(): Promise<void> {
22873
22874
  }
22874
22875
 
22875
22876
  bot = new Bot(TOKEN)
22877
+ installUtf8Sanitizer(bot) // #4728: installed FIRST so it composes INNERMOST — the last thing to touch the payload before serialisation; see installUtf8Sanitizer docblock
22876
22878
  installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
22877
22879
  // #4576 follow-up: FALLBACK card body. The observer takes the stored body off
22878
22880
  // the RESPONSE (`rich_message` → `text`/`caption`); this stamps the REQUEST body
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Wire-level UTF-8 sanitiser (#4728).
3
+ *
4
+ * ── The failure this exists to stop ───────────────────────────────────────
5
+ *
6
+ * A JavaScript string is UTF-16, and UTF-16 permits a LONE SURROGATE — a high
7
+ * (`\uD800`-`\uDBFF`) or low (`\uDC00`-`\uDFFF`) code unit with no partner.
8
+ * A lone surrogate has NO valid UTF-8 encoding. `JSON.stringify` does not
9
+ * throw on one (well-formed `JSON.stringify`, ES2019): it emits the `\udXXX`
10
+ * escape, so grammy happily POSTs a body Telegram's decoder then rejects with
11
+ *
12
+ * 400 Bad Request: strings must be encoded in UTF-8
13
+ *
14
+ * Observed in production on 2026-07-31 (agent `gymbro`, twice): a
15
+ * `permission_request` approval card 400'd on `sendRichMessage` with exactly
16
+ * that description and the operator never saw the card. An approval card is
17
+ * the human safety boundary for a gated tool call — a dropped card means a
18
+ * gated action silently never happens.
19
+ *
20
+ * ── Why HERE and not in the card formatter ────────────────────────────────
21
+ *
22
+ * The plugin already repairs a *trailing* high surrogate at each of its
23
+ * truncation sites (`card-layout.ts:384`, `format.ts:1551`,
24
+ * `tool-activity-summary.ts:488`, `reply-quote.ts:78`). Every one of those is
25
+ * a per-cut patch that (a) only covers the cut it guards and (b) only covers
26
+ * the HIGH half — an orphaned LOW surrogate, or a lone surrogate that entered
27
+ * the body from upstream data (a tool `input_preview`, a file path, a
28
+ * model-emitted token) rather than from a cut, sails straight through all of
29
+ * them onto the wire. So the repair belongs at the ONE place every outbound
30
+ * call must transit: the grammy API-transformer layer, which no `ctx.*`
31
+ * helper and no raw `bot.api.*` call can bypass
32
+ * (see `shared/bot-runtime.ts` header).
33
+ *
34
+ * The sanitiser walks the WHOLE payload, not a named field list, because a
35
+ * lone surrogate anywhere in the JSON body fails the whole request — inline
36
+ * keyboard button labels (`reply_markup`, which every approval card carries),
37
+ * captions, `rich_message.markdown` and plain `text` alike.
38
+ *
39
+ * Substitution is U+FFFD REPLACEMENT CHARACTER rather than deletion: it is
40
+ * the Unicode-standard substitution, and it is length-preserving in UTF-16
41
+ * code units, so a body that was sized against a char budget upstream cannot
42
+ * be pushed over that budget by the repair.
43
+ */
44
+
45
+ import type { Bot } from 'grammy'
46
+
47
+ /**
48
+ * Matches a code unit that is a surrogate with no partner:
49
+ * a high surrogate not followed by a low one, or a low surrogate not
50
+ * preceded by a high one. A well-formed pair matches neither alternative.
51
+ */
52
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
53
+
54
+ /** True when `s` contains at least one unpaired surrogate. */
55
+ export function hasLoneSurrogate(s: string): boolean {
56
+ LONE_SURROGATE.lastIndex = 0
57
+ return LONE_SURROGATE.test(s)
58
+ }
59
+
60
+ /**
61
+ * How many unpaired surrogate code units `s` carries. Safe to call on the
62
+ * shared `/g` regex: `String.prototype.match` with a global pattern resets
63
+ * `lastIndex` to 0 itself before it collects, so this cannot leak state into
64
+ * `hasLoneSurrogate`.
65
+ */
66
+ function countLoneSurrogates(s: string): number {
67
+ return (s.match(LONE_SURROGATE) ?? []).length
68
+ }
69
+
70
+ /**
71
+ * Replace every unpaired surrogate in `s` with U+FFFD. Returns the SAME
72
+ * string instance when there is nothing to repair, so callers can use
73
+ * identity to detect a no-op. Idempotent: U+FFFD is not a surrogate.
74
+ */
75
+ export function sanitizeLoneSurrogates(s: string): string {
76
+ if (!hasLoneSurrogate(s)) return s
77
+ return s.replace(LONE_SURROGATE, '�')
78
+ }
79
+
80
+ /**
81
+ * True for a value we may safely recurse into and rebuild: a plain object or
82
+ * an array. Anything else (an `InputFile`, a `Buffer`, a stream, a `Date`,
83
+ * a class instance) is returned untouched — rebuilding it would destroy its
84
+ * prototype and grammy's multipart layer depends on those identities.
85
+ */
86
+ function isWalkable(v: unknown): v is Record<string, unknown> | unknown[] {
87
+ if (Array.isArray(v)) return true
88
+ if (v === null || typeof v !== 'object') return false
89
+ const proto = Object.getPrototypeOf(v)
90
+ return proto === Object.prototype || proto === null
91
+ }
92
+
93
+ /** Depth cap: Telegram payloads are shallow (deepest real nesting is
94
+ * `reply_markup.inline_keyboard[][]`, depth 3). The cap is a cheap guard
95
+ * against a pathological or cyclic input, never reached in practice. */
96
+ const MAX_DEPTH = 16
97
+
98
+ /** Mutable tally threaded through the walk so the caller can log HOW MANY
99
+ * code units were repaired without ever seeing the content itself. */
100
+ export interface SanitizeStats {
101
+ /** Total unpaired surrogate code units replaced with U+FFFD. */
102
+ repaired: number
103
+ }
104
+
105
+ /**
106
+ * Define `k` as an own enumerable data property of `out`.
107
+ *
108
+ * `out[k] = v` would REASSIGN the clone's prototype for the single key
109
+ * `__proto__` (the `Object.prototype` accessor), silently dropping an own
110
+ * `__proto__` key from the payload. defineProperty always makes an own data
111
+ * property, for that key like any other.
112
+ */
113
+ function defineOwn(out: Record<string, unknown>, k: string, v: unknown): void {
114
+ Object.defineProperty(out, k, {
115
+ value: v,
116
+ writable: true,
117
+ enumerable: true,
118
+ configurable: true,
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Deep-sanitise every string in `value`, CLONE-ON-WRITE: the input is never
124
+ * mutated, and the exact same instance is returned when nothing changed.
125
+ *
126
+ * The clone is materialised LAZILY, on the first key/index that actually
127
+ * changed — so a clean payload allocates no container and performs no
128
+ * property definition at any depth, it is only walked and handed back by
129
+ * identity. That matters: this runs on the innermost hop of EVERY `bot.api.*`
130
+ * call, including the draft-stream `editMessageText` path that edits the same
131
+ * card several times a second, and the overwhelming majority of payloads are
132
+ * clean. (Pinned by the "clean nested payload" test, which fails if any
133
+ * container is rebuilt.)
134
+ *
135
+ * Pass `stats` to collect the repair count.
136
+ */
137
+ export function sanitizePayloadStrings<T>(value: T, depth = 0, stats?: SanitizeStats): T {
138
+ if (typeof value === 'string') {
139
+ const next = sanitizeLoneSurrogates(value)
140
+ if (stats && next !== value) stats.repaired += countLoneSurrogates(value)
141
+ return next as unknown as T
142
+ }
143
+ if (depth >= MAX_DEPTH || !isWalkable(value)) return value
144
+
145
+ if (Array.isArray(value)) {
146
+ let out: unknown[] | undefined
147
+ for (let i = 0; i < value.length; i++) {
148
+ const item = value[i]
149
+ const next = sanitizePayloadStrings(item, depth + 1, stats)
150
+ // First change: copy the already-walked prefix, all of it unchanged.
151
+ if (out === undefined && next !== item) out = value.slice(0, i)
152
+ if (out !== undefined) out.push(next)
153
+ }
154
+ return (out ?? value) as unknown as T
155
+ }
156
+
157
+ // `for...in` rather than `Object.entries`, so the clean path allocates no
158
+ // key/entry array either. The `hasOwnProperty` guard keeps this EXACTLY
159
+ // equivalent to `Object.entries`: `for...in` also yields INHERITED
160
+ // enumerable keys, so without it a polluted `Object.prototype` would leak an
161
+ // extra field into the body we hand to Telegram.
162
+ const hasOwn = Object.prototype.hasOwnProperty
163
+ let out: Record<string, unknown> | undefined
164
+ for (const k in value) {
165
+ if (!hasOwn.call(value, k)) continue
166
+ const v = (value as Record<string, unknown>)[k]
167
+ const next = sanitizePayloadStrings(v, depth + 1, stats)
168
+ if (out === undefined && next !== v) {
169
+ // First change: materialise the clone and backfill the keys already
170
+ // walked, every one of which came back unchanged. This re-reads those
171
+ // properties (so an own getter in the prefix is invoked twice), which
172
+ // only ever happens on the repair path — a payload with an own getter
173
+ // AND a lone surrogate. Telegram payloads are plain data; a getter that
174
+ // throws is caught by the fail-open guard in `installUtf8Sanitizer`.
175
+ out = {}
176
+ for (const prev in value) {
177
+ if (prev === k) break
178
+ if (!hasOwn.call(value, prev)) continue
179
+ defineOwn(out, prev, (value as Record<string, unknown>)[prev])
180
+ }
181
+ }
182
+ if (out !== undefined) defineOwn(out, k, next)
183
+ }
184
+ return (out ?? value) as unknown as T
185
+ }
186
+
187
+ /** One log line per method per minute. The draft-stream `editMessageText`
188
+ * path edits the same card many times a second, so an upstream that is
189
+ * PERSISTENTLY corrupt would otherwise emit a line per edit and drown the
190
+ * gateway log in the exact situation you most need to read it. */
191
+ const LOG_THROTTLE_MS = 60_000
192
+
193
+ /**
194
+ * Install the UTF-8 sanitiser as a grammy API transformer.
195
+ *
196
+ * MUST be installed FIRST, before every other transformer: grammy composes
197
+ * `call = trans(prev, ...)` (`grammy/out/core/client.js:9-11,91`), so the
198
+ * LAST-installed transformer is the OUTERMOST and the FIRST-installed is the
199
+ * INNERMOST — the one that sees the final payload immediately before it is
200
+ * serialised and POSTed. Anything installed after this one can therefore not
201
+ * reintroduce a lone surrogate behind its back.
202
+ *
203
+ * A repair is logged with the method and the repaired code-unit COUNT — never
204
+ * the body, which may carry operator content — so a corrupt upstream producer
205
+ * stays diagnosable instead of being silently papered over. The line is
206
+ * throttled to once per method per minute.
207
+ *
208
+ * The sanitise call FAILS OPEN. It runs on the innermost hop of every single
209
+ * `bot.api.*` call, and its object walk reads own enumerable properties —
210
+ * which invokes getters. A throwing getter, or any future bug in the walk,
211
+ * would otherwise propagate out of every outbound call and wedge the whole
212
+ * gateway. Sending the original payload is never worse than throwing: the
213
+ * worst case is the pre-#4728 behaviour (Telegram 400s that one send), while
214
+ * throwing here breaks sends that had nothing wrong with them. It is not
215
+ * silent, though: failing open logs its own throttled line naming the method,
216
+ * so the degraded state stays diagnosable.
217
+ *
218
+ * `now` is an injected clock so the throttle can be driven deterministically
219
+ * from a test: this file runs under BOTH vitest and `bun test`, and bun's
220
+ * `vi` has no `setSystemTime`.
221
+ */
222
+ export function installUtf8Sanitizer(bot: Bot, now: () => number = Date.now): void {
223
+ // Throttle state is per-install, not module-global, so one bot's log budget
224
+ // cannot be consumed by another (and a test needs no reset hook).
225
+ const lastLoggedAtByMethod = new Map<string, number>()
226
+ const shouldLogRepair = (method: string, at: number): boolean => {
227
+ const last = lastLoggedAtByMethod.get(method)
228
+ if (last !== undefined && at - last < LOG_THROTTLE_MS) return false
229
+ lastLoggedAtByMethod.set(method, at)
230
+ return true
231
+ }
232
+
233
+ bot.api.config.use(async (prev, method, payload, signal) => {
234
+ let clean = payload
235
+ const stats: SanitizeStats = { repaired: 0 }
236
+ try {
237
+ clean = sanitizePayloadStrings(payload, 0, stats)
238
+ } catch (err) {
239
+ clean = payload
240
+ // Fail open, but never SILENTLY: without this line a future walk bug or
241
+ // a genuinely throwing getter degrades to a bare Telegram 400 with no
242
+ // trail back to the sanitiser — precisely the opacity #4728 exists to
243
+ // end. Throttled on its own budget (a distinct key), so a persistent
244
+ // walk failure cannot be starved by, or starve, the repair line above.
245
+ // The error MESSAGE is included because without it the line cannot
246
+ // diagnose anything; the payload body still never is.
247
+ // The separator is written as the ESCAPE "backslash-u-0000", never as a raw NUL
248
+ // byte: a literal 0x00 in a source file trips the #3676 binary-file
249
+ // guard (tests/source-files-are-text.test.ts) and blocks the merge
250
+ // queue. The runtime key is identical either way.
251
+ if (shouldLogRepair(`${method}\u0000sanitize-failed`, now())) {
252
+ process.stderr.write(
253
+ `telegram gateway: utf8-sanitize FAILED OPEN method=${method} — ` +
254
+ `payload sent unrepaired; if it carries a lone surrogate Telegram ` +
255
+ `will reject it (400 "strings must be encoded in UTF-8"): ` +
256
+ `${err instanceof Error ? err.message : String(err)}\n`,
257
+ )
258
+ }
259
+ }
260
+ if (clean !== payload && shouldLogRepair(method, now())) {
261
+ process.stderr.write(
262
+ `telegram gateway: utf8-sanitize repaired ${stats.repaired} lone surrogate(s) ` +
263
+ `method=${method} — body would have been rejected by Telegram ` +
264
+ `(400 "strings must be encoded in UTF-8")\n`,
265
+ )
266
+ }
267
+ return prev(method, clean, signal)
268
+ })
269
+ }