switchroom 0.11.0 → 0.12.0

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 (62) hide show
  1. package/README.md +7 -6
  2. package/dist/agent-scheduler/index.js +218 -99
  3. package/dist/auth-broker/index.js +300 -99
  4. package/dist/cli/drive-write-pretool.mjs +45 -12
  5. package/dist/cli/switchroom.js +44972 -42457
  6. package/dist/cli/ui/index.html +1281 -0
  7. package/dist/host-control/main.js +3630 -311
  8. package/dist/vault/approvals/kernel-server.js +209 -100
  9. package/dist/vault/broker/server.js +220 -99
  10. package/examples/personal-google-workspace-mcp/README.md +8 -3
  11. package/examples/switchroom.yaml +91 -42
  12. package/package.json +2 -2
  13. package/profiles/_base/start.sh.hbs +76 -36
  14. package/profiles/default/CLAUDE.md.hbs +4 -2
  15. package/skills/file-bug/SKILL.md +6 -4
  16. package/skills/switchroom-cli/SKILL.md +20 -4
  17. package/skills/switchroom-install/SKILL.md +3 -3
  18. package/telegram-plugin/auth-snapshot-format.ts +4 -4
  19. package/telegram-plugin/auto-fallback-fleet.ts +4 -4
  20. package/telegram-plugin/card-format.ts +3 -3
  21. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  22. package/telegram-plugin/dist/gateway/gateway.js +1029 -628
  23. package/telegram-plugin/dist/server.js +162 -161
  24. package/telegram-plugin/format.ts +71 -0
  25. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  26. package/telegram-plugin/gateway/approval-card.ts +1 -1
  27. package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
  28. package/telegram-plugin/gateway/auth-command.ts +12 -2
  29. package/telegram-plugin/gateway/boot-card.ts +40 -3
  30. package/telegram-plugin/gateway/boot-probes.ts +71 -27
  31. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  32. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  33. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  34. package/telegram-plugin/gateway/gateway.ts +244 -46
  35. package/telegram-plugin/gateway/hostd-dispatch.ts +10 -2
  36. package/telegram-plugin/gateway/update-announce.ts +167 -0
  37. package/telegram-plugin/quota-check.ts +0 -195
  38. package/telegram-plugin/retry-api-call.ts +24 -0
  39. package/telegram-plugin/server.ts +8 -5
  40. package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
  41. package/telegram-plugin/tests/boot-probes.test.ts +53 -0
  42. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  43. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  44. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  45. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  46. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  47. package/telegram-plugin/welcome-text.ts +1 -8
  48. package/profiles/default/CLAUDE.md +0 -192
  49. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  51. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  53. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  54. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  55. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  56. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  57. package/telegram-plugin/first-paint.ts +0 -225
  58. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  59. package/telegram-plugin/server.js +0 -41795
  60. package/telegram-plugin/tests/html-balanced.ts +0 -63
  61. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  62. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -6,9 +6,14 @@ tools to **your own Claude Code session on the host** (not to switchroom
6
6
  agents).
7
7
 
8
8
  It is intentionally **separate from the agent-side feature.** Agents get
9
- Workspace access via `switchroom auth google connect <agent>` (RFC G
10
- §4.5, available after Phase 3 lands). This example is for the
11
- operator's pair-design loop with their own host-side `claude`.
9
+ Workspace access via `switchroom auth google connect`
10
+ `switchroom auth google account add` (RFC G §4.5, shipped) see
11
+ [`docs/google-workspace.md`](../../docs/google-workspace.md) for the
12
+ fleet setup. **Do not reuse this example's OAuth client for the
13
+ fleet:** different trust posture (approval-kernel-mediated vs.
14
+ single-identity), and switchroom expects its own client. This example
15
+ is only for the operator's pair-design loop with their own host-side
16
+ `claude`.
12
17
 
13
18
  > **Why two paths?** Agents run inside switchroom containers with
14
19
  > approval-kernel-mediated tool access; the per-agent OAuth posture is
@@ -5,8 +5,22 @@
5
5
  # 2. profiles: → named presets agents opt into via `extends:`
6
6
  # 3. agents: → per-agent overrides (only express differences)
7
7
  #
8
- # Each agent gets its own Telegram topic in a forum group.
9
- # Create bots via @BotFather: /newbot for each agent.
8
+ # ┌─ ONE TELEGRAM BOT PER AGENT NON-NEGOTIABLE ───────────────────┐
9
+ # Every agent MUST have its own Telegram bot + its own token.
10
+ # │ Two agents sharing a token both long-poll getUpdates and │
11
+ # │ Telegram 409-Conflicts them in a loop — neither one replies. │
12
+ # │ │
13
+ # │ For each agent: create a separate @BotFather bot (`/newbot`), │
14
+ # │ vault its token under its own key │
15
+ # │ (`switchroom vault set telegram-<agent>-bot-token`), and give │
16
+ # │ the agent its own `bot_token: "vault:telegram-<agent>-bot- │
17
+ # │ token"`. This file ships ONE active agent (`assistant`); every │
18
+ # │ extra agent below is commented out WITH its own bot_token — │
19
+ # │ uncomment one only after you've minted its bot + vaulted its │
20
+ # │ token, then `switchroom apply` (no need to re-run setup). │
21
+ # └─────────────────────────────────────────────────────────────────┘
22
+ #
23
+ # See docs/botfather-walkthrough.md for the ~3-min-per-bot steps.
10
24
 
11
25
  switchroom:
12
26
  version: 1
@@ -14,6 +28,12 @@ switchroom:
14
28
  skills_dir: ~/.switchroom/skills # shared skill pool (symlinked per agent)
15
29
 
16
30
  telegram:
31
+ # Single-agent fallback ONLY. This is the bot the one shipped agent
32
+ # (`assistant`) uses — `switchroom setup` stores your first
33
+ # BotFather token in the vault under `telegram-bot-token`. The
34
+ # moment you run more than one agent, this global token is NOT
35
+ # enough: give EACH agent its own `bot_token:` (see the agents
36
+ # section). Never let two agents resolve to the same token.
17
37
  bot_token: "vault:telegram-bot-token"
18
38
  # DM-only sentinel; v0.7+ defaults to per-agent DM-pair topology.
19
39
  # Legacy forum-mode installs keep a real chat id here.
@@ -155,52 +175,80 @@ profiles:
155
175
 
156
176
  # --- Agents ---
157
177
  # Minimal per-agent declarations. Everything else inherited.
178
+ #
179
+ # This file ships exactly ONE active agent so a fresh `switchroom
180
+ # setup` → `apply` → `up` brings up a single, working bot (the
181
+ # `assistant` below uses the single global telegram.bot_token that
182
+ # setup vaulted). Every other agent is a commented-out TEMPLATE.
183
+ #
184
+ # To add any agent: (1) @BotFather `/newbot` → a NEW bot just for it;
185
+ # (2) `switchroom vault set telegram-<agent>-bot-token` (paste that
186
+ # bot's token); (3) uncomment its block below — note each already
187
+ # carries its own `bot_token: "vault:telegram-<agent>-bot-token"`;
188
+ # (4) `switchroom apply`. Do NOT re-run `switchroom setup` for this —
189
+ # `apply` reads the vaulted per-agent token directly.
190
+ #
191
+ # Sharing one token across agents is the single most common
192
+ # multi-agent install failure: both bots long-poll getUpdates and
193
+ # Telegram 409-Conflicts them forever. One bot per agent, always.
158
194
  agents:
159
- coach:
160
- topic_name: "Fitness"
161
- topic_emoji: "🏋️"
162
- extends: advisor # inherits from inline profile above
163
- soul:
164
- name: Coach
165
- style: motivational, direct # overrides advisor.soul.style
166
- memory:
167
- collection: fitness
168
- schedule:
169
- - cron: "0 8 * * *"
170
- prompt: "Good morning check-in: ask about sleep, energy, and plans for today"
171
- - cron: "0 20 * * 0"
172
- prompt: "Weekly review: summarize this week's activity and progress"
173
-
174
- dev:
175
- topic_name: "Code"
176
- topic_emoji: "💻"
177
- extends: coder # inherits from inline profile above
178
- model: claude-opus-4-7 # override defaults.model for this agent
179
- memory:
180
- collection: coding
181
- cli_args: ["--effort", "high"] # escape hatch: extra exec claude flags
182
-
183
195
  assistant:
184
196
  topic_name: "General"
185
197
  topic_emoji: "💬"
186
198
  memory:
187
199
  collection: general
200
+ # Uses the global telegram.bot_token above (the one `switchroom
201
+ # setup` vaulted as `telegram-bot-token`). This is the ONLY agent
202
+ # allowed to rely on the global token — because it's the only one
203
+ # shipped active. Give every agent you add its own bot_token.
188
204
  # No `extends:` → uses the "default" filesystem profile (profiles/default/)
189
205
  # No tool/model overrides → inherits everything from defaults:
190
206
 
191
- exec:
192
- topic_name: "Executive"
193
- topic_emoji: "📋"
194
- extends: advisor
195
- soul:
196
- name: Friday
197
- style: efficient, proactive, anticipates needs
198
- skills: [daily-briefing, meeting-prep]
199
- memory:
200
- collection: executive
201
- schedule:
202
- - cron: "0 7 * * 1-5"
203
- prompt: "Daily briefing: summarize today's calendar, pending tasks, and priorities"
207
+ # ── Additional agents — each needs its OWN BotFather bot ──────────
208
+ # Before uncommenting any block: create its bot, then
209
+ # switchroom vault set telegram-coach-bot-token # (etc.)
210
+ # The `bot_token:` line in each block points at that per-agent key.
211
+
212
+ # coach:
213
+ # topic_name: "Fitness"
214
+ # topic_emoji: "🏋️"
215
+ # bot_token: "vault:telegram-coach-bot-token" # its own bot
216
+ # extends: advisor # inherits from inline profile above
217
+ # soul:
218
+ # name: Coach
219
+ # style: motivational, direct # overrides advisor.soul.style
220
+ # memory:
221
+ # collection: fitness
222
+ # schedule:
223
+ # - cron: "0 8 * * *"
224
+ # prompt: "Good morning check-in: ask about sleep, energy, and plans for today"
225
+ # - cron: "0 20 * * 0"
226
+ # prompt: "Weekly review: summarize this week's activity and progress"
227
+
228
+ # dev:
229
+ # topic_name: "Code"
230
+ # topic_emoji: "💻"
231
+ # bot_token: "vault:telegram-dev-bot-token" # its own bot
232
+ # extends: coder # inherits from inline profile above
233
+ # model: claude-opus-4-7 # override defaults.model for this agent
234
+ # memory:
235
+ # collection: coding
236
+ # cli_args: ["--effort", "high"] # escape hatch: extra exec claude flags
237
+
238
+ # exec:
239
+ # topic_name: "Executive"
240
+ # topic_emoji: "📋"
241
+ # bot_token: "vault:telegram-exec-bot-token" # its own bot
242
+ # extends: advisor
243
+ # soul:
244
+ # name: Friday
245
+ # style: efficient, proactive, anticipates needs
246
+ # skills: [daily-briefing, meeting-prep]
247
+ # memory:
248
+ # collection: executive
249
+ # schedule:
250
+ # - cron: "0 7 * * 1-5"
251
+ # prompt: "Daily briefing: summarize today's calendar, pending tasks, and priorities"
204
252
 
205
253
  # Example admin agent — its gateway intercepts fleet-management slash
206
254
  # commands (/agents, /restart, /update, /logs, etc.) and runs them
@@ -209,12 +257,13 @@ agents:
209
257
  # on every agent regardless of admin status. See the three-tier
210
258
  # command model in docs/architecture.md.
211
259
  #
212
- # Uncomment after creating a second BotFather bot and adding its
213
- # token to the vault (`switchroom vault set telegram-admin-bot-token`).
260
+ # Like every agent it needs its OWN bot create a separate BotFather
261
+ # bot and `switchroom vault set telegram-admin-bot-token` before
262
+ # uncommenting.
214
263
  # admin:
215
264
  # topic_name: "Admin"
216
265
  # topic_emoji: "🛠️"
217
- # bot_token: "vault:telegram-admin-bot-token"
266
+ # bot_token: "vault:telegram-admin-bot-token" # its own bot
218
267
  # admin: true
219
268
  # system_prompt_append: |
220
269
  # You are the fleet admin agent. Always respond concisely.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "dev": "bun bin/switchroom.ts",
22
22
  "build": "node scripts/build.mjs",
23
23
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
24
+ "pretest": "npm run build",
24
25
  "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts",
25
26
  "test:vitest": "vitest run",
26
27
  "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts",
@@ -53,7 +54,6 @@
53
54
  "@types/bun": "^1.3.11",
54
55
  "@types/node": "^22.0.0",
55
56
  "@vitest/coverage-v8": "3.2.4",
56
- "buildkite-test-collector": "^1.9.5",
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^3.2.4"
59
59
  },
@@ -32,66 +32,88 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
32
32
  # same path the rest of start.sh + the MCP sidecar expects.
33
33
  export TELEGRAM_STATE_DIR="{{agentDir}}/telegram"
34
34
 
35
- # Tiny in-process supervisor: runs cmd in a respawn loop. Caps at
36
- # 10 restarts in 60s before giving up protects against tight
37
- # crash loops that would otherwise burn CPU and obscure the root
38
- # cause in logs. The sidecar's own structured logging is written
35
+ # Tiny in-process supervisor: runs cmd in a respawn loop with
36
+ # exponential backoff (1→2→4…→60s cap) and NEVER permanently gives
37
+ # up. Rationale (RFC J / install-validation 2026-05-17): the
38
+ # gateway's hardest dependency is the vault-broker, which gets
39
+ # recreated+relocked by a routine `switchroom apply`. The old
40
+ # "10 restarts in 60s → give up forever" turned that transient
41
+ # outage into a dead agent until a human recreated the container —
42
+ # a direct violation of the always-on outcome. Backoff bounds CPU
43
+ # during an outage; indefinite retry means the agent self-heals
44
+ # within one backoff cycle the moment the broker is back. The ONLY
45
+ # non-retry path is EX_CONFIG=78 (genuine permanent misconfig).
46
+ # The sidecar's own structured logging is written
39
47
  # directly to its log file; this wrapper only handles process
40
48
  # lifecycle. Ampersand-backgrounded by callers below.
41
49
  _switchroom_supervise() {
42
50
  local _name="$1"; local _logfile="$2"; shift 2
43
- local _restarts=0
44
- local _window_start=$SECONDS
51
+ local _cap=60
52
+ local _delay=1
53
+ local _attempt=0
45
54
  while true; do
55
+ local _start=$SECONDS
46
56
  "$@" >> "$_logfile" 2>&1
47
57
  local _exit=$?
48
- local _now=$SECONDS
58
+ local _ran=$((SECONDS - _start))
49
59
  # Exit 78 = sysexits EX_CONFIG, the "permanent config error, do
50
60
  # not restart" sentinel. The gateway uses this on a 401 from
51
61
  # Telegram (#1076 — revoked / wrong-typed bot token). Restarting
52
- # would just re-hit the same 401, burn the 10-in-60 s budget,
53
- # and leave the agent silently dead. The supervisor instead
54
- # records the quarantine in the log and stops — the host CLI
55
- # (`switchroom doctor`, `switchroom agent restart`) reads the
56
- # quarantine marker at <TELEGRAM_STATE_DIR>/quarantine.json and
57
- # surfaces it to the operator.
62
+ # just re-hits the same 401, so we quarantine and stop — the
63
+ # host CLI (`switchroom doctor`, `switchroom agent restart`)
64
+ # reads the marker at <TELEGRAM_STATE_DIR>/quarantine.json and
65
+ # surfaces it. This is the ONLY non-retry path: a transient
66
+ # dependency (vault-broker locked/recreating RFC J) must never
67
+ # be terminal for an always-on agent.
58
68
  if [ $_exit -eq 78 ]; then
59
69
  echo "[supervise] $_name exit 78 (EX_CONFIG) — quarantined, not restarting. Operator action required." >> "$_logfile"
60
70
  return 0
61
71
  fi
62
- if [ $((_now - _window_start)) -ge 60 ]; then
63
- _restarts=0
64
- _window_start=$_now
72
+ # A run that stayed up for at least the backoff cap means the
73
+ # dependency was healthy and this exit is a fresh transient
74
+ # blip — reset backoff so recovery latency is minimal. Only
75
+ # consecutive fast failures escalate the delay.
76
+ if [ $_ran -ge $_cap ]; then
77
+ _delay=1
78
+ _attempt=0
65
79
  fi
66
- _restarts=$((_restarts + 1))
67
- echo "[supervise] $_name exited (status=$_exit, restart=$_restarts in $((_now - _window_start))s window)" >> "$_logfile"
68
- if [ $_restarts -ge 10 ]; then
69
- echo "[supervise] $_name hit 10 restarts in <60s — giving up" >> "$_logfile"
70
- return 1
71
- fi
72
- sleep 1
80
+ _attempt=$((_attempt + 1))
81
+ echo "[supervise] $_name exited (status=$_exit, ran=${_ran}s, attempt=$_attempt) — retrying in ${_delay}s (transient deps self-heal; never gives up)" >> "$_logfile"
82
+ sleep $_delay
83
+ _delay=$((_delay * 2))
84
+ [ $_delay -gt $_cap ] && _delay=$_cap
73
85
  done
74
86
  }
75
87
 
76
88
  # 1) Gateway daemon — the long-running Telegram bot client.
89
+ # Honors channels.telegram.enabled (PR A schema, PR C wiring).
90
+ # When the operator sets enabled:false, skip the gateway sidecar
91
+ # entirely so the agent boots without bot-token requirements —
92
+ # used by the CI smoke-test harness and any offline-dev setup.
93
+ # Default behavior preserved: when the var is unset/empty, treat
94
+ # as enabled (no operator action required for existing agents).
77
95
  # Polls Telegram, writes gateway.sock for the in-claude MCP
78
96
  # sidecar to bridge through. Mirrors the v0.6 sibling
79
97
  # switchroom-<name>-gateway.service unit. Talks to the broker
80
98
  # over SWITCHROOM_VAULT_BROKER_SOCK (set by compose) for the bot
81
99
  # token. Failure modes: vault locked → gateway boots, fails to
82
- # fetch token, exits non-zero, supervisor respawns; bot token
83
- # invalid 401 from Telegram, gateway exits, same loop. The
84
- # cap avoids an infinite vault-locked respawn storm.
100
+ # fetch token, exits non-zero, supervisor backs off and keeps
101
+ # retrying it recovers on its own the moment the broker
102
+ # unlocks (no human, no container recreate); bot token invalid
103
+ # → 401 → gateway exits 78 → quarantined (operator action).
85
104
  _gateway_bundle=/opt/switchroom/telegram-plugin/dist/gateway/gateway.js
86
- if [ -f "$_gateway_bundle" ] && command -v bun >/dev/null 2>&1; then
105
+ _telegram_enabled={{#if telegramEnabledFlag}}{{telegramEnabledFlag}}{{else}}true{{/if}}
106
+ if [ "$_telegram_enabled" = "true" ] && [ -f "$_gateway_bundle" ] && command -v bun >/dev/null 2>&1; then
87
107
  _switchroom_supervise gateway /var/log/switchroom/gateway-supervisor.log \
88
108
  bun "$_gateway_bundle" &
109
+ elif [ "$_telegram_enabled" != "true" ]; then
110
+ echo "[start.sh] channels.telegram.enabled=false — skipping gateway sidecar" >&2
89
111
  fi
90
112
 
91
113
  # 2) autoaccept-poll — first-run TUI prompt dispatcher. Single-shot
92
114
  # by design (exits cleanly after idle-timeout once prompts have
93
- # fired); supervisor's restart cap means a flaky autoaccept won't
94
- # masquerade as a tight loop.
115
+ # fired); the supervisor's exponential backoff keeps a flaky
116
+ # autoaccept from busy-looping.
95
117
  if [ -f /opt/switchroom/autoaccept-poll.js ] && command -v bun >/dev/null 2>&1; then
96
118
  _switchroom_supervise autoaccept /var/log/switchroom/autoaccept.log \
97
119
  bun /opt/switchroom/autoaccept-poll.js "{{name}}" &
@@ -375,8 +397,8 @@ fi
375
397
  # --- Wake audit sentinel ---
376
398
  #
377
399
  # Every boot drops a `.wake-audit-pending` sentinel into the telegram
378
- # state dir. The agent's CLAUDE.md (`telegram-style.md.hbs` "Wake
379
- # audit" section) instructs it to detect this file at the start of
400
+ # state dir. The agent's wake-audit protocol
401
+ # (`skills/switchroom-runtime/SKILL.md`) instructs it to detect this file at the start of
380
402
  # its first turn after boot, run a three-signal check (owed reply /
381
403
  # orphan sub-agents / open todos), surface findings to the user, and
382
404
  # `rm -f` the file. This complements the SWITCHROOM_PENDING_TURN env
@@ -394,7 +416,7 @@ fi
394
416
  # level dedup (so the agent doesn't re-fire the same "owed reply"
395
417
  # audit twice on the same user message after a respawn) lives in the
396
418
  # agent's audit logic via `.wake-audit-last-completed`, not here. See
397
- # the "Conversation-aware dedup" block in telegram-style.md.hbs.
419
+ # the "Conversation-aware dedup" block in skills/switchroom-runtime/SKILL.md.
398
420
  mkdir -p "$TELEGRAM_STATE_DIR" 2>/dev/null || true
399
421
  : > "$TELEGRAM_STATE_DIR/.wake-audit-pending" 2>/dev/null || true
400
422
 
@@ -514,16 +536,34 @@ if [ -x "{{repoRoot}}/bin/boot-self-test.sh" ]; then
514
536
  fi
515
537
  {{/if}}
516
538
 
539
+ # --- Security-hooks plugin integrity check (sec WS8-F1 / #1416) ---
540
+ # The image-baked, agent-unstrippable tool-safety plugin is loaded via
541
+ # --plugin-dir below. If the image copy is missing/empty (bad build,
542
+ # tampering, downgrade) we DON'T fail boot — Claude Code unions
543
+ # plugin-dir hooks with the settings.json copy, which still protects —
544
+ # but we surface it loudly to stderr so the gap is visible instead of
545
+ # silently relying on the strippable fallback.
546
+ SR_SECPLUGIN="{{securityPluginDir}}"
547
+ for f in \
548
+ "$SR_SECPLUGIN/.claude-plugin/plugin.json" \
549
+ "$SR_SECPLUGIN/hooks/hooks.json" \
550
+ "$SR_SECPLUGIN/hooks/secret-guard-pretool.mjs" \
551
+ "$SR_SECPLUGIN/hooks/drive-write-pretool.mjs"; do
552
+ if [ ! -s "$f" ]; then
553
+ echo "WARNING sec WS8-F1 (#1416): security-hooks plugin artifact missing or empty: $f — tool-safety is running on the settings.json fallback only (strippable). Rebuild/redeploy the agent image." >&2
554
+ fi
555
+ done
556
+
517
557
  {{#if useSwitchroomPlugin}}
518
558
  if [ -n "$APPEND_PROMPT" ]; then
519
- exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
559
+ exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
520
560
  else
521
- exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
561
+ exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
522
562
  fi
523
563
  {{else}}
524
564
  if [ -n "$APPEND_PROMPT" ]; then
525
- exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
565
+ exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
526
566
  else
527
- exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
567
+ exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}}{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
528
568
  fi
529
569
  {{/if}}
@@ -122,6 +122,8 @@ A config-summary greeting card is sent automatically by the SessionStart hook
122
122
  ## Admin surface
123
123
 
124
124
  You're `admin: true`. Fleet operations live on the `hostd` MCP server: `agent_restart` / `agent_start` / `agent_stop` (lifecycle of any peer), `agent_logs` (peer container logs), `agent_exec` (read-only inspection inside any peer — argv[0] must be on the safe-command allowlist), `update_check` / `update_apply`. Treat these like a root shell on the host: confirm intent before destructive actions, refuse if unsure who's asking.
125
+
126
+ Only `update_check` (a read-only dry-run) runs immediately. Every mutating / host verb — `update_apply`, `agent_exec`, `agent_restart` / `agent_start` / `agent_stop`, `agent_logs` — pauses for an **operator approval card in Telegram before it executes**: a human must tap approve. This is deliberate (you are a prompt-injectable process; the human in the loop is the safety boundary, not your own judgement). Expect the call to block until approved or denied; if denied, don't retry — relay the denial and stop.
125
127
  {{else}}
126
128
  ## Admin operations
127
129
 
@@ -137,7 +139,7 @@ Use your available tools when appropriate. If you lack the right tool for a task
137
139
 
138
140
  {{#if schedule}}
139
141
  ## Scheduled Tasks
140
- You have scheduled tasks configured. These run independently as one-shot `claude -p` calls on a schedule that fires across reboots. They don't use your session or context, they fire on their own (typically Sonnet for cost efficiency) and send output directly to Telegram.
142
+ You have scheduled tasks configured. At fire time an in-container scheduler sidecar injects a synthesized inbound turn into **your running session** — a scheduled task arrives as an ordinary turn tagged `<channel source="cron">`, using your normal session, context, and model, and it shows up in your transcript and Hindsight memory like any other turn (it is *not* an isolated one-shot `claude -p` process). They survive reboots via the container restart policy plus an at-least-once boot replay.
141
143
 
142
- You don't need to manage them. If the user asks about scheduled tasks, explain that they run automatically and are configured in switchroom.yaml under `schedule:`.
144
+ You don't need to manage them. If the user asks about scheduled tasks, explain that they fire into your session automatically and are configured in switchroom.yaml under `schedule:`.
143
145
  {{/if}}
@@ -74,11 +74,13 @@ Switchroom's standard log map (resolve `<agent>` from the user or from `SWITCHRO
74
74
  |---|---|---|
75
75
  | Gateway events | `~/.switchroom/agents/<agent>/telegram/gateway.log` | Inbound/outbound messages, IPC, progress card, watcher, classifier output |
76
76
  | Claude stdout/stderr | `~/.switchroom/agents/<agent>/service.log` | The agent's own session output, tool calls, errors |
77
- | Systemd lifecycle | `journalctl --user -u switchroom-agent-<agent>` | Boot/restart/crash, exit codes |
78
- | Cron lifecycle | `journalctl --user -u switchroom-agent-<agent>-cron` | Scheduled-task firings |
79
- | Vault broker | `journalctl --user -u switchroom-vault-broker` | Audit log, ACL gates |
77
+ | Container lifecycle | `docker logs switchroom-<agent>` | Boot/restart/crash, exit codes |
78
+ | Cron firings | `docker logs switchroom-<agent>` (lines prefixed `agent-scheduler:`) | Scheduled-task firings (in-container sidecar since Phase 4) |
79
+ | Vault broker | `docker logs switchroom-vault-broker` | Audit log, ACL gates |
80
80
 
81
- For each relevant source: extract the slice that brackets the symptom window. Use `awk '/<start-ts>/,/<end-ts>/'` or `journalctl --since "10 min ago"`. Do **not** paste raw multi-MB dumps; cap each excerpt at the lines that actually matter and signpost what was clipped.
81
+ (v0.7+ agents run in Docker there is no systemd/`journalctl` in-container; logs are `docker logs`. Only a legacy non-docker install would use `journalctl --user -u switchroom-…`.)
82
+
83
+ For each relevant source: extract the slice that brackets the symptom window. Use `docker logs --since 10m switchroom-<agent>` or pipe through `awk '/<start-ts>/,/<end-ts>/'`. Do **not** paste raw multi-MB dumps; cap each excerpt at the lines that actually matter and signpost what was clipped.
82
84
 
83
85
  If the gateway.log doesn't have what you need, check whether `progress-card.log`, `bridge.log`, or `subagent-watcher.log` are configured separately on this agent (some setups split).
84
86
 
@@ -234,16 +234,32 @@ List cron jobs and scheduled tasks.
234
234
 
235
235
  ### Step 1 — Show live timers
236
236
 
237
- Cron timers in v0.7+ run inside the per-agent scheduler container. Inspect
238
- its log to see fired jobs:
237
+ Since Phase 4 (#893) cron runs **in-container** as the `agent-scheduler`
238
+ sidecar inside each agent the old `switchroom-<agent>-scheduler` /
239
+ `switchroom-cron` singleton container no longer exists. Inspect fired
240
+ jobs in the agent's own log; scheduler lines are prefixed
241
+ `agent-scheduler:`:
239
242
 
240
243
  ```bash
241
- docker compose -p switchroom -f ~/.switchroom/compose/docker-compose.yml logs switchroom-<agent>-scheduler --tail 100
244
+ docker logs --tail 100 switchroom-<agent> 2>&1 | grep agent-scheduler:
242
245
  ```
243
246
 
244
247
  ### Step 2 — Show declared schedule entries
245
248
 
246
- From `switchroom.yaml`, the `schedule:` array under each agent specifies `cron` + `prompt` + optional `model`. Read the relevant agent block and enumerate the entries with their next-fire times.
249
+ From `switchroom.yaml`, the `schedule:` array under each agent specifies `cron` + `prompt`. (A `model:` field may appear but is **ignored** — since the v0.8 cron-fold-in the fire runs in the agent's existing session and uses the agent's configured model, not a per-task one. Don't tell the user a per-task model is honoured.) Cron expressions are evaluated in the agent's resolved timezone (the `switchroom.timezone` / per-agent `timezone` cascade), not hard-coded UTC. Read the relevant agent block and enumerate the entries with their next-fire times in that zone.
250
+
251
+ ### Step 3 — A schedule change is NOT live until the agent restarts
252
+
253
+ The in-container `agent-scheduler` reads its entries **once at boot**. Editing the `schedule:` array in `switchroom.yaml`, or adding/removing an entry via the agent-config `schedule add` / `schedule remove` tools, writes the change to disk but does **not** register it in the running scheduler. The same is true for `skill_install` and `.mcp.json` changes — claude loads skills and MCP servers at process start.
254
+
255
+ So whenever you (or the user) change a schedule, skill, or MCP config:
256
+
257
+ - The `schedule add` / `skill_install` tool result includes `restart_required: true` and a `restart_hint`. **Surface it.** Tell the user plainly that the change is on disk but won't take effect until `switchroom agent restart <name>` (or `switchroom restart <name>` for the reconcile+restart path).
258
+ - Never report a just-added schedule or skill as already active. It is staged, not live.
259
+
260
+ ### Step 4 — Missed runs while the agent was offline
261
+
262
+ If the user asks whether scheduled runs were missed during downtime: the scheduler replays fires missed within the last ~30 min on boot, but runs older than that window are **not** re-run (cron is not a queue). It is not silent about this — on boot it emits a one-time notice listing every schedule that had a skipped run, delivered as a normal turn and recorded in `agent-scheduler:` log lines / `/state/agent/scheduler.jsonl`. Check those to answer honestly; never claim a run happened if the skip notice says it was dropped.
247
263
 
248
264
  ---
249
265
 
@@ -107,7 +107,7 @@ This is the default. One OAuth flow per Anthropic account, then every agent in t
107
107
  ## What not to do
108
108
 
109
109
  - **Do not** run `switchroom setup` non-interactively or pipe input to it — it's designed for a human.
110
- - **Do not** edit `~/.switchroom/vault.enc` or any file under `~/.switchroom/` directly. Use the CLI.
111
- - **Do not** run `docker build` on the operator's host. The 5 fleet images are published on GHCR; `switchroom apply` writes a compose file that pulls them.
112
- - **Do not** suggest the legacy `switchroom up` / `switchroom init` / `switchroom update` verbsthey were removed in v0.7. The current flow is `switchroom apply && docker compose pull && docker compose up -d`.
110
+ - **Do not** edit the vault (`~/.switchroom/vault/`) or any file under `~/.switchroom/` directly. Use the CLI.
111
+ - **Do not** run `docker build` on the operator's host. The fleet images are published on GHCR; `switchroom apply` writes a compose file that pulls them.
112
+ - **Do not** suggest the legacy `switchroom up` / `switchroom init` verbs — they were removed. NOTE: `switchroom update` is **current and canonical** it is the one-shot upgrade path (pull images + apply + recreate + doctor); recommend it for "how do I update". A fresh install/redeploy is `switchroom apply && docker compose pull && docker compose up -d`.
113
113
  - **Do not** reinstall over an existing install without asking. If the user wants a clean slate, have them run `switchroom uninstall` first (or confirm they want to blow away `~/.switchroom/`).
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Format 2 — health-grouped /auth snapshot + causal auto-fallback
3
3
  * announcement. Pure functions; the gateway handles the live-API probe
4
- * (via `fetchAccountQuota({force: true})`) and the broker `listState`,
4
+ * (via the broker `probe-quota` op, #1336) and the broker `listState`,
5
5
  * then hands shaped data to these formatters.
6
6
  *
7
7
  * JTBD this module serves:
@@ -588,9 +588,9 @@ function escapeHtml(s: string): string {
588
588
  * results (same length, same order), return the AccountSnapshot[] the
589
589
  * formatters need.
590
590
  *
591
- * The gateway calls this after running `Promise.all(accounts.map(a =>
592
- * fetchAccountQuota(a.label, {force: true})))` — both arrays are
593
- * caller-provided, this is just a zip + classify.
591
+ * The gateway calls this after probing quota via the broker
592
+ * `probe-quota` op (#1336) — both arrays are caller-provided, this
593
+ * is just a zip + classify.
594
594
  */
595
595
  export function buildSnapshotsFromState(
596
596
  state: ListStateData,
@@ -18,8 +18,8 @@
18
18
  *
19
19
  * What this module does:
20
20
  *
21
- * 1. Probe live quota for every account in parallel
22
- * (`fetchAccountQuota({force: true})`) so we pick the best
21
+ * 1. Probe live quota for every account in parallel via the
22
+ * broker (`client.probeQuota(...)`, #1336) so we pick the best
23
23
  * target with current data, not stale broker disk-cache.
24
24
  * 2. Skip blocked accounts entirely; pick the lowest-utilization
25
25
  * healthy candidate (or, if none, the lowest throttling one).
@@ -79,8 +79,8 @@ export interface FleetFallbackDeps {
79
79
  * is testable without spinning up a UDS. */
80
80
  state: ListStateData;
81
81
  /** Parallel array of live quota probes, same order as `state.accounts`.
82
- * Use `Promise.all(state.accounts.map(a =>
83
- * fetchAccountQuota(a.label, {force: true})))`. */
82
+ * Get via `client.probeQuota(state.accounts.map(a => a.label))`
83
+ * and map the response back to per-account results (#1336). */
84
84
  quotas: QuotaResult[];
85
85
  /** Broker `setActive` invoker. Returns the result for logging. */
86
86
  setActive: (label: string) => Promise<{ active: string; fanned: string[] }>;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Shared formatters for Telegram status cards.
3
3
  *
4
- * Both the main progress card (`progress-card.ts`) and the pinned worker
5
- * card (`subagent-watcher.ts`) emit HTML to Telegram; before issue #94
6
- * each module had its own private copies of these helpers with subtly
4
+ * Both the main progress card (rendered via `stream-reply-handler.ts`)
5
+ * and the pinned worker card (`subagent-watcher.ts`) emit HTML to
6
+ * Telegram; before issue #94 each had its own private copies with subtly
7
7
  * different conventions:
8
8
  *
9
9
  * - `formatDuration(500)` → progress-card returned `500ms`, watcher