switchroom 0.18.28 → 0.18.30

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 (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.18.28",
4
+ "version": "0.18.30",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -24,9 +24,9 @@
24
24
  "build": "node scripts/build.mjs",
25
25
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
26
26
  "pretest": "npm run build",
27
- "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.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/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.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/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
27
+ "test": "vitest run && bun test telegram-plugin/tests/catch-all-forwarded-history.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.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/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.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/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
28
28
  "test:vitest": "vitest run",
29
- "test:bun": "bun test src/vault/grants.test.ts src/vault/grants-db.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/server-admin-only-keys.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/litellm/provision-apply-e2e.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/approval-origin.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/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.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/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
29
+ "test:bun": "bun test telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.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/server-admin-only-keys.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/litellm/provision-apply-e2e.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/approval-origin.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/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.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/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
31
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs",
32
32
  "lint:tsc": "tsc --noEmit",
@@ -1148,18 +1148,19 @@ fi
1148
1148
 
1149
1149
  # --- Session model resolution (consume-once .session-model carrier) ---
1150
1150
  #
1151
- # Contract: reference/rfcs/session-model-stickiness.md §0.1 (rev 4, operator
1152
- # decision 2026-07-12SESSION-SCOPED, superseding rev 3 keep-by-default). A
1153
- # `/model` override lasts only for the current session:
1151
+ # Contract: reference/rfcs/session-model-stickiness.md §0.05 (rev 5, operator
1152
+ # decision 2026-07-16DETERMINISTIC SWITCH, superseding rev 4 §0.1's "live
1153
+ # Claude switches write no carrier"). A `/model` override lasts only for the
1154
+ # current session:
1154
1155
  #
1155
- # - Claude switches apply LIVE in-session (claude's native picker) and write
1156
- # NO carrier the explicit `claude --model {{{modelQ}}}` this script execs
1157
- # reverts them for free on the next boot.
1158
- # - sr-* / sr→Claude switches and a queued /model persisted at shutdown need
1159
- # a relaunch to take effect, so the gateway writes `{{agentDir}}/.session-model`
1160
- # (one-line JSON) IMMEDIATELY before that relaunch. This block APPLIES the
1161
- # carrier on the single boot that reads it and then DELETES it (consume-once).
1162
- # Any SUBSEQUENT restart — deploy, `/restart`, `/new`, watchdog recovery,
1156
+ # - EVERY `/model` switch Claude→Claude, Claude→sr-*, sr-*→Claude, the
1157
+ # Fable/alias button, and the picker SELECT writes `{{agentDir}}/.session-model`
1158
+ # (one-line JSON) IMMEDIATELY before the relaunch that applies it. The
1159
+ # inject-into-tmux + terminal-scrape path is RETIRED: it silently no-op'd
1160
+ # then optimistically lied to /status. This block APPLIES the carrier on the
1161
+ # single boot that reads it and then DELETES it (consume-once), and the
1162
+ # `exec claude --model <token>` below cannot silently no-op.
1163
+ # - Any SUBSEQUENT restart — deploy, `/restart`, `/new`, watchdog recovery,
1163
1164
  # crash, raw `docker restart`, host reboot — finds no carrier and boots the
1164
1165
  # configured default. That deletion is the marker distinguishing the
1165
1166
  # model-apply relaunch from every later restart.
@@ -1181,10 +1182,17 @@ _EFFECTIVE_MODEL={{{modelQ}}}
1181
1182
  printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.configured-default-model" 2>/dev/null || true
1182
1183
 
1183
1184
  # Rev-4 hygiene (#3184 review LOW-2): remove the files retired with the
1184
- # keep/revert intent subsystem and the crashloop self-heal. Nothing reads them
1185
- # anymore; without this rm they would linger in the bind-mounted state dir
1186
- # forever after rollout. Idempotent — a cheap no-op once clean.
1187
- rm -f "{{agentDir}}/.relaunch-model-intent" "{{agentDir}}/.session-model-boot-attempts" "{{agentDir}}/.session-model-kept-notified"
1185
+ # keep/revert intent subsystem. Nothing reads them anymore; without this rm they
1186
+ # would linger in the bind-mounted state dir forever after rollout. Idempotent —
1187
+ # a cheap no-op once clean.
1188
+ rm -f "{{agentDir}}/.relaunch-model-intent" "{{agentDir}}/.session-model-kept-notified"
1189
+ # `.session-model-boot-attempts` is REVIVED as LIVE state (#3284 bounded-retry
1190
+ # counter, see the resolution block below): the gateway clears it on a healthy
1191
+ # boot, and start.sh gives up + reverts once it exceeds the attempt bound. It is
1192
+ # meaningless without a carrier to belong to, so sweep it as stale ONLY when no
1193
+ # `.session-model` carrier is present (rollout leftover / completed apply / a
1194
+ # prior giveup that didn't get to delete it).
1195
+ [ -f "{{agentDir}}/.session-model" ] || rm -f "{{agentDir}}/.session-model-boot-attempts"
1188
1196
 
1189
1197
  # Migration shim (one release): a leftover one-shot `.session-model-override`
1190
1198
  # carrier from a pre-consume-once gateway means an OLD gateway wrote it
@@ -1206,11 +1214,34 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1206
1214
  _smf="$(cat "{{agentDir}}/.session-model" 2>/dev/null || true)"
1207
1215
  _sm_model="$(printf '%s' "$_smf" | sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1208
1216
  _sm_cfg="$(printf '%s' "$_smf" | sed -n 's/.*"configuredDefaultAtWrite"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1209
- # CONSUME-ONCE: delete the carrier now, before any apply/exec. The carrier
1210
- # governs exactly this one boot; a bad token can therefore crash at most one
1211
- # boot (the next boot has no carrier and reverts), so no crashloop self-heal
1212
- # is needed. Every branch below has already removed the file.
1213
- rm -f "{{agentDir}}/.session-model"
1217
+ # BOUNDED-RETRY CONSUME (#3284) — the carrier is NO LONGER deleted here before
1218
+ # the apply/exec. It used to be (pure consume-once), but a boot that reads the
1219
+ # carrier may WEDGE before its gateway acquires the boot lock (the
1220
+ # boot.lock_stale_recovered_boot_mismatch race, e.g. a proxy-only `fable`
1221
+ # apply-boot) and never become the surviving healthy session. Deleting before
1222
+ # apply handed the requested model to that loser: the retry boot found no
1223
+ # carrier and silently reverted to the configured default. Instead:
1224
+ #
1225
+ # - the GATEWAY deletes the carrier + the attempt counter once it ACQUIRES
1226
+ # the boot lock (boot.lock_acquired in gateway.ts — the healthy-boot signal
1227
+ # a wedged boot cannot fake), so a GOOD token survives a transient wedge
1228
+ # and RE-APPLIES on the retry boot instead of reverting;
1229
+ # - a persisted attempt counter (`.session-model-boot-attempts`) BOUNDS the
1230
+ # retries: a token that never reaches a healthy gateway (a genuinely BAD
1231
+ # token that crashes claude before lock-acquire) is given up after
1232
+ # _SM_MAX_ATTEMPTS boots — deleted, reverted, and alerted — so a bad token
1233
+ # can NEVER produce an unbounded wedge→retry→wedge crashloop. The original
1234
+ # "a bad token crashes at most one boot then reverts" guarantee becomes "at
1235
+ # most _SM_MAX_ATTEMPTS boots then reverts", still a hard finite bound.
1236
+ #
1237
+ # Every INVALIDATION / GIVEUP branch below deletes BOTH the carrier and the
1238
+ # counter; only the APPLY branch keeps the carrier (for the gateway to clear)
1239
+ # and persists the incremented counter. The delete-before-apply that used to
1240
+ # sit here is GONE by design.
1241
+ _SM_MAX_ATTEMPTS=3
1242
+ _sm_attempts="$(cat "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null | tr -dc '0-9' || true)"
1243
+ [ -z "$_sm_attempts" ] && _sm_attempts=0
1244
+ _sm_attempts=$(( _sm_attempts + 1 ))
1214
1245
  # Shape gate — kept BYTE-IDENTICAL with MODEL_ARG_RE in
1215
1246
  # telegram-plugin/gateway/model-command.ts. `/` is allowed for
1216
1247
  # OpenRouter-style `sr-vendor/model` ids; it is not a shell metachar inside
@@ -1221,29 +1252,56 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1221
1252
  # `claude --model` (parity with parseSessionModel's single-string check).
1222
1253
  if [ "$(printf '%s' "$_sm_model" | wc -c)" -eq 0 ] || [ "$(printf '%s' "$_sm_model" | wc -l)" -ne 0 ] || ! printf '%s' "$_sm_model" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1223
1254
  # Invalid carrier: fall back to the configured default AND tell the
1224
- # operator once — never a silent stderr-only drop.
1255
+ # operator once — never a silent stderr-only drop. Terminal → drop the
1256
+ # carrier + counter (a corrupt token is never worth retrying).
1257
+ rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-boot-attempts"
1225
1258
  echo "session-model: ignoring malformed .session-model (failed shape gate) — using configured default '$_EFFECTIVE_MODEL'" >&2
1226
1259
  printf 'Your saved session model override could not be read (invalid or corrupt), so the agent booted on its configured default `%s`. Re-issue /model <name> if you want a different model.\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1227
1260
  elif [ "$_sm_cfg" != "$_EFFECTIVE_MODEL" ]; then
1228
1261
  # switchroom.yaml `model:` changed between the switch and this apply-boot →
1229
1262
  # the carrier is against a default that no longer exists. Invalidate +
1230
- # announce once.
1263
+ # announce once. Terminal → drop the carrier + counter.
1264
+ rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-boot-attempts"
1231
1265
  echo "session-model: configured default changed ('$_sm_cfg' → '$_EFFECTIVE_MODEL') — dropping session override '$_sm_model'" >&2
1232
1266
  printf 'The configured default model changed (`%s` → `%s`), so your session override to `%s` was not applied — the agent booted on the new configured default. Re-issue /model %s if you still want it.\n' "$_sm_cfg" "$_EFFECTIVE_MODEL" "$_sm_model" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1233
- elif [ "${_sm_model#sr-}" != "$_sm_model" ] && [ -z "$_LITELLM_OK" ]; then
1234
- # sr-* + LiteLLM unreachable at the apply-boot: booting the override would
1235
- # 4xx against Anthropic, and consume-once means we can't retain it for a
1236
- # later relaunch. Boot the configured default and tell the operator to
1237
- # re-issue once the proxy is back.
1238
- echo "session-model: LiteLLM proxy unreachable at boot NOT applying sr-* override '$_sm_model'; booting configured default '$_EFFECTIVE_MODEL' (re-issue /model when the proxy is back)" >&2
1267
+ elif { [ "${_sm_model#sr-}" != "$_sm_model" ] || [ "$_sm_model" = fable ] || [ "$_sm_model" = claude-fable-5 ]; } && [ -z "$_LITELLM_OK" ]; then
1268
+ # Proxy-only override + LiteLLM unreachable at the apply-boot: booting the
1269
+ # override would 4xx (sr-* against Anthropic; `fable`/`claude-fable-5` is a
1270
+ # retired codename direct-to-Anthropic and ONLY resolves via the LiteLLM
1271
+ # router see the repoint case below). The litellm probe already waited out
1272
+ # its full 120s window before we got here, so "still unreachable" is a real
1273
+ # down-state, not the transient boot-lock wedge the bounded-retry counter
1274
+ # covers — drop the carrier + counter (terminal) and tell the operator to
1275
+ # re-issue once the proxy is back, rather than looping against a dead proxy.
1276
+ # (Rev 5: Fable is now reachable via the carrier relaunch — the old
1277
+ # live-inject path never launched it — so it needs the same down-guard as sr-*.)
1278
+ rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-boot-attempts"
1279
+ echo "session-model: LiteLLM proxy unreachable at boot — NOT applying proxy-only override '$_sm_model'; booting configured default '$_EFFECTIVE_MODEL' (re-issue /model when the proxy is back)" >&2
1239
1280
  printf 'LiteLLM proxy was unreachable at boot, so the session model override `%s` was not applied — the agent booted on its configured default `%s`. Re-issue /model %s once the proxy is reachable.\n' "$_sm_model" "$_EFFECTIVE_MODEL" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1281
+ elif [ "$_sm_attempts" -gt "$_SM_MAX_ATTEMPTS" ]; then
1282
+ # CRASHLOOP BOUND (#3284). This carrier has now been read on more than
1283
+ # _SM_MAX_ATTEMPTS boots WITHOUT the gateway ever reaching boot.lock_acquired
1284
+ # to clear it — i.e. every apply-boot wedged/crashed before a healthy
1285
+ # gateway. That is the signature of a genuinely bad token (one that will
1286
+ # never boot), NOT the transient boot-lock race we retry for. Give up:
1287
+ # drop the carrier + counter, revert to the configured default, and alert.
1288
+ # This is the hard finite bound that guarantees no unbounded crashloop.
1289
+ rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-boot-attempts"
1290
+ echo "session-model: override '$_sm_model' did not reach a healthy boot after $_SM_MAX_ATTEMPTS attempts — reverting to configured default '$_EFFECTIVE_MODEL'" >&2
1291
+ printf 'Your session model override `%s` could not boot successfully after %s attempts, so the agent reverted to its configured default `%s`. Re-issue /model %s to try again.\n' "$_sm_model" "$_SM_MAX_ATTEMPTS" "$_EFFECTIVE_MODEL" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1240
1292
  else
1241
- # Apply the carrier for THIS session. No boot alert the gateway already
1242
- # acked the /model switch in chat, and the override reverts on next restart.
1293
+ # Apply the carrier for THIS session and KEEP it: the gateway deletes the
1294
+ # carrier + counter once it acquires the boot lock (healthy boot), so a
1295
+ # transient wedge before that RE-APPLIES on the retry boot instead of
1296
+ # reverting. Persist the incremented attempt counter so the crashloop bound
1297
+ # above can fire if this token keeps wedging. No boot alert — the gateway
1298
+ # already acked the /model switch in chat, and the override reverts on the
1299
+ # next restart (once the gateway has cleared the carrier).
1300
+ printf '%s\n' "$_sm_attempts" > "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null || true
1243
1301
  _EFFECTIVE_MODEL="$_sm_model"
1244
- echo "session-model: applying session override '$_sm_model' this boot (consume-once — reverts on next restart)" >&2
1302
+ echo "session-model: applying session override '$_sm_model' this boot (attempt $_sm_attempts/$_SM_MAX_ATTEMPTS cleared by the gateway on a healthy boot, reverts on next restart)" >&2
1245
1303
  fi
1246
- unset _smf _sm_model _sm_cfg
1304
+ unset _smf _sm_model _sm_cfg _sm_attempts _SM_MAX_ATTEMPTS
1247
1305
  fi
1248
1306
 
1249
1307
  # --- Session effort resolution (consume-once .session-effort carrier, #3186) ---
@@ -1312,11 +1370,24 @@ printf '%s\n' "$_EFFECTIVE_EFFORT" > "{{agentDir}}/.active-session-effort" 2>/de
1312
1370
  # non-Claude default at the router root) and run degraded until litellm returns —
1313
1371
  # there is NO Claude default to fall back to for a persistent sr-* agent, and
1314
1372
  # that (loud-warned, non-fatal) degraded state is the correct behavior.
1373
+ #
1374
+ # Rev 5 (F2): `fable`/`claude-fable-5` ALSO needs the router root, not the
1375
+ # passthrough. The `fable` alias resolves in the CLI to `claude-fable-5`
1376
+ # (ANTHROPIC_DEFAULT_FABLE_MODEL above), a RETIRED codename that 4xxs
1377
+ # direct-to-Anthropic; it ONLY resolves via the LiteLLM router (maps
1378
+ # fable/claude-fable-5 -> anthropic/claude-fable-5, forwarding the OAuth). The
1379
+ # old Fable path was a live inject that silently no-op'd, so Fable never actually
1380
+ # launched via `--model`; routing the Fable button through the carrier relaunch
1381
+ # newly exposes this, so the repoint case must include it or a fable carrier
1382
+ # 4xxs even with LiteLLM UP. Verified against the live litellm config + router
1383
+ # /v1/models (both `fable` and `claude-fable-5` served). Other Claude sessions
1384
+ # (opus/sonnet/haiku/default) keep the passthrough — it dodges the Opus SSE
1385
+ # re-chunk stall; Fable trades that for being routable at all.
1315
1386
  case "$_EFFECTIVE_MODEL" in
1316
- sr-*)
1387
+ sr-*|fable|claude-fable-5)
1317
1388
  if [ -n "$_LITELLM_OK" ] && [ -n "$SWITCHROOM_LITELLM_BASE" ]; then
1318
1389
  export ANTHROPIC_BASE_URL="$SWITCHROOM_LITELLM_BASE"
1319
- echo "session-model: effective model '$_EFFECTIVE_MODEL' is sr-* — routing via LiteLLM model router '$SWITCHROOM_LITELLM_BASE' (off /anthropic passthrough) so it reaches OpenRouter" >&2
1390
+ echo "session-model: effective model '$_EFFECTIVE_MODEL' is proxy-only (sr-*/fable) — routing via LiteLLM model router '$SWITCHROOM_LITELLM_BASE' (off /anthropic passthrough)" >&2
1320
1391
  fi
1321
1392
  ;;
1322
1393
  esac
@@ -22970,6 +22970,65 @@ import {
22970
22970
  import { homedir as homedir2 } from "os";
22971
22971
  import { basename, join as join3 } from "path";
22972
22972
 
22973
+ // text-voice-scrub.ts
22974
+ var NULL = "\x00";
22975
+ var FENCE_PH = `${NULL}VS_FENCE`;
22976
+ var INLINE_PH = `${NULL}VS_INLINE`;
22977
+ var HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
22978
+ var HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
22979
+ var URL_PH = `${NULL}VS_URL`;
22980
+
22981
+ // model-unavailable.ts
22982
+ var transientUpstreamSignals = [
22983
+ "not your usage limit",
22984
+ "not your account",
22985
+ "not your account's",
22986
+ "temporarily limiting requests",
22987
+ "temporarily rate",
22988
+ "server is temporarily",
22989
+ "would exceed your account\u2019s rate limit",
22990
+ "would exceed your account's rate limit"
22991
+ ];
22992
+ function isTransientUpstreamSignal(text) {
22993
+ if (typeof text !== "string" || text.length === 0)
22994
+ return false;
22995
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
22996
+ const lower = sample.toLowerCase();
22997
+ return transientUpstreamSignals.some((s) => lower.includes(s));
22998
+ }
22999
+ var litellmProxyLocal429Signals = [
23000
+ "deployment over user-defined ratelimit",
23001
+ "model rate limit exceeded. tpm limit",
23002
+ "model rate limit exceeded. rpm limit",
23003
+ "deployment over defined rpm limit",
23004
+ "no deployments available for selected model",
23005
+ "litellm rate limit handler",
23006
+ "crossed tpm / rpm",
23007
+ "max parallel request limit reached"
23008
+ ];
23009
+ var litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
23010
+ function isLitellmProxyLocal429(text) {
23011
+ if (typeof text !== "string" || text.length === 0)
23012
+ return false;
23013
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
23014
+ const lower = sample.toLowerCase();
23015
+ if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
23016
+ return true;
23017
+ return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
23018
+ }
23019
+ function isLitellmProxyAuthMisconfig(text) {
23020
+ if (typeof text !== "string" || text.length === 0)
23021
+ return false;
23022
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
23023
+ const lower = sample.toLowerCase();
23024
+ if (lower.includes("x-api-key header is required"))
23025
+ return true;
23026
+ const isAuthErr = lower.includes("authentication_error") || lower.includes("authenticationerror");
23027
+ if (!isAuthErr)
23028
+ return false;
23029
+ return lower.includes("fallback") && lower.includes("x-api-key");
23030
+ }
23031
+
22973
23032
  // operator-events.ts
22974
23033
  function classifyClaudeError(raw) {
22975
23034
  try {
@@ -22987,6 +23046,12 @@ function classifyInner(raw) {
22987
23046
  const message = extractString(obj, "message") ?? extractString(getNestedObj(obj, "error"), "message") ?? (typeof raw === "string" ? raw : "") ?? "";
22988
23047
  const status = extractNumber(obj, "status") ?? extractNumber(obj, "statusCode") ?? extractNumber(obj, "status_code") ?? null;
22989
23048
  const sdkCode = extractString(obj, "error_code") ?? "";
23049
+ if (isLitellmProxyAuthMisconfig(`${errorType}
23050
+ ${errorCode}
23051
+ ${sdkCode}
23052
+ ${message}`)) {
23053
+ return "proxy-misconfig";
23054
+ }
22990
23055
  if (errorType === "authentication_error" || errorCode === "authentication_error" || sdkCode === "authentication_error" || message.toLowerCase().includes("authentication_error")) {
22991
23056
  const msg = message.toLowerCase();
22992
23057
  if (msg.includes("expired") || msg.includes("refresh")) {
@@ -23034,53 +23099,12 @@ function getNestedObj(obj, key) {
23034
23099
  }
23035
23100
  var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
23036
23101
  var cooldownMap = new Map;
23037
-
23038
- // text-voice-scrub.ts
23039
- var NULL = "\x00";
23040
- var FENCE_PH = `${NULL}VS_FENCE`;
23041
- var INLINE_PH = `${NULL}VS_INLINE`;
23042
- var HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
23043
- var HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
23044
- var URL_PH = `${NULL}VS_URL`;
23045
-
23046
- // model-unavailable.ts
23047
- var transientUpstreamSignals = [
23048
- "not your usage limit",
23049
- "not your account",
23050
- "not your account's",
23051
- "temporarily limiting requests",
23052
- "temporarily rate",
23053
- "server is temporarily",
23054
- "would exceed your account\u2019s rate limit",
23055
- "would exceed your account's rate limit"
23056
- ];
23057
- function isTransientUpstreamSignal(text) {
23058
- if (typeof text !== "string" || text.length === 0)
23059
- return false;
23060
- const sample = text.length > 16384 ? text.slice(0, 16384) : text;
23061
- const lower = sample.toLowerCase();
23062
- return transientUpstreamSignals.some((s) => lower.includes(s));
23063
- }
23064
- var litellmProxyLocal429Signals = [
23065
- "deployment over user-defined ratelimit",
23066
- "model rate limit exceeded. tpm limit",
23067
- "model rate limit exceeded. rpm limit",
23068
- "deployment over defined rpm limit",
23069
- "no deployments available for selected model",
23070
- "litellm rate limit handler",
23071
- "crossed tpm / rpm",
23072
- "max parallel request limit reached"
23073
- ];
23074
- var litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
23075
- function isLitellmProxyLocal429(text) {
23076
- if (typeof text !== "string" || text.length === 0)
23077
- return false;
23078
- const sample = text.length > 16384 ? text.slice(0, 16384) : text;
23079
- const lower = sample.toLowerCase();
23080
- if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
23081
- return true;
23082
- return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
23083
- }
23102
+ var OPERATOR_ACTIONABLE_KINDS = new Set([
23103
+ "credentials-expired",
23104
+ "credentials-invalid",
23105
+ "credit-exhausted",
23106
+ "proxy-misconfig"
23107
+ ]);
23084
23108
 
23085
23109
  // tool-label-sidecar.ts
23086
23110
  import { existsSync as existsSync2, readFileSync, statSync as statSync2 } from "node:fs";