switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -19265,7 +19265,8 @@ var MemoryBackendConfigSchema = exports_external.object({
19265
19265
  llm_model: exports_external.string().optional().describe("LiteLLM model name for Hindsight's LLM ops (retain/reflect/" + "consolidation) when the top-level `litellm.enabled` carve-out is " + "on. Defaults to a cheap OpenRouter model (routed via LiteLLM's " + "model-mapped path, not the Anthropic OAuth pass-through) to keep " + "background memory-op cost off the Claude subscription quota. Set " + "to a `claude-*` model name to route it back through the OAuth " + "pass-through instead. Has no effect when litellm is disabled."),
19266
19266
  api_key: exports_external.string().optional().describe("API key or vault reference for embedding provider"),
19267
19267
  docker_service: exports_external.boolean().default(true).describe("Whether to include Hindsight in docker-compose"),
19268
- url: exports_external.string().url("Hindsight URL must be a valid URL (no shell-special characters)").optional().describe("Hindsight MCP endpoint URL (e.g., http://localhost:18888/mcp/). Defaults to http://localhost:8888/mcp/.")
19268
+ url: exports_external.string().url("Hindsight URL must be a valid URL (no shell-special characters)").optional().describe("Hindsight MCP endpoint URL (e.g., http://localhost:18888/mcp/). Defaults to http://localhost:8888/mcp/."),
19269
+ mcp_transport: exports_external.enum(["shim", "http"]).optional().describe("How agents reach Hindsight's MCP surface. Default (unset or " + "'shim'): the lazy-connect stdio proxy `switchroom " + "hindsight-mcp-shim`, which always registers at session start " + "even when the hindsight container is down and reconnects " + "per-call. 'http' is the escape hatch that restores the legacy " + "direct Streamable-HTTP entry (session-fatal if the backend is " + "down when the agent session boots).")
19269
19270
  }).optional()
19270
19271
  });
19271
19272
  var VaultConfigSchema = exports_external.object({
@@ -19308,7 +19309,8 @@ var HostControlConfigSchema = exports_external.object({
19308
19309
  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.")
19309
19310
  });
19310
19311
  var WebServiceConfigSchema = exports_external.object({
19311
- managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
19312
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers."),
19313
+ port: exports_external.number().int().min(1).max(65535).default(8080).describe("Host loopback port the web server listens on (127.0.0.1:<port>). " + "Default: 8080. Set this when another service owns 8080 on the " + "host (e.g. UniFi OS Server) — `switchroom webd install` and the " + "`switchroom update` refresh-web step both honor it, so a custom " + "port survives updates instead of being silently reverted to " + "8080 by the regenerated compose file. Remember to point the " + "host-side consumers (cloudflared tunnel, `tailscale serve`) at " + "the same port.")
19312
19314
  });
19313
19315
  var FleetHealthConfigSchema = exports_external.object({
19314
19316
  owner_agent: exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
@@ -26645,7 +26647,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26645
26647
  import { dirname as dirname4, join as join7 } from "node:path";
26646
26648
 
26647
26649
  // src/build-info.ts
26648
- var VERSION = "0.18.30";
26650
+ var VERSION = "0.18.32";
26649
26651
 
26650
26652
  // src/cli/resolve-version.ts
26651
26653
  function readPackageVersion() {
@@ -4685,7 +4685,8 @@ var init_schema = __esm(() => {
4685
4685
  llm_model: exports_external.string().optional().describe("LiteLLM model name for Hindsight's LLM ops (retain/reflect/" + "consolidation) when the top-level `litellm.enabled` carve-out is " + "on. Defaults to a cheap OpenRouter model (routed via LiteLLM's " + "model-mapped path, not the Anthropic OAuth pass-through) to keep " + "background memory-op cost off the Claude subscription quota. Set " + "to a `claude-*` model name to route it back through the OAuth " + "pass-through instead. Has no effect when litellm is disabled."),
4686
4686
  api_key: exports_external.string().optional().describe("API key or vault reference for embedding provider"),
4687
4687
  docker_service: exports_external.boolean().default(true).describe("Whether to include Hindsight in docker-compose"),
4688
- url: exports_external.string().url("Hindsight URL must be a valid URL (no shell-special characters)").optional().describe("Hindsight MCP endpoint URL (e.g., http://localhost:18888/mcp/). Defaults to http://localhost:8888/mcp/.")
4688
+ url: exports_external.string().url("Hindsight URL must be a valid URL (no shell-special characters)").optional().describe("Hindsight MCP endpoint URL (e.g., http://localhost:18888/mcp/). Defaults to http://localhost:8888/mcp/."),
4689
+ mcp_transport: exports_external.enum(["shim", "http"]).optional().describe("How agents reach Hindsight's MCP surface. Default (unset or " + "'shim'): the lazy-connect stdio proxy `switchroom " + "hindsight-mcp-shim`, which always registers at session start " + "even when the hindsight container is down and reconnects " + "per-call. 'http' is the escape hatch that restores the legacy " + "direct Streamable-HTTP entry (session-fatal if the backend is " + "down when the agent session boots).")
4689
4690
  }).optional()
4690
4691
  });
4691
4692
  VaultConfigSchema = exports_external.object({
@@ -4728,7 +4729,8 @@ var init_schema = __esm(() => {
4728
4729
  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.")
4729
4730
  });
4730
4731
  WebServiceConfigSchema = exports_external.object({
4731
- managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
4732
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers."),
4733
+ port: exports_external.number().int().min(1).max(65535).default(8080).describe("Host loopback port the web server listens on (127.0.0.1:<port>). " + "Default: 8080. Set this when another service owns 8080 on the " + "host (e.g. UniFi OS Server) — `switchroom webd install` and the " + "`switchroom update` refresh-web step both honor it, so a custom " + "port survives updates instead of being silently reverted to " + "8080 by the regenerated compose file. Remember to point the " + "host-side consumers (cloudflared tunnel, `tailscale serve`) at " + "the same port.")
4732
4734
  });
4733
4735
  FleetHealthConfigSchema = exports_external.object({
4734
4736
  owner_agent: exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
@@ -4685,7 +4685,8 @@ var init_schema = __esm(() => {
4685
4685
  llm_model: exports_external.string().optional().describe("LiteLLM model name for Hindsight's LLM ops (retain/reflect/" + "consolidation) when the top-level `litellm.enabled` carve-out is " + "on. Defaults to a cheap OpenRouter model (routed via LiteLLM's " + "model-mapped path, not the Anthropic OAuth pass-through) to keep " + "background memory-op cost off the Claude subscription quota. Set " + "to a `claude-*` model name to route it back through the OAuth " + "pass-through instead. Has no effect when litellm is disabled."),
4686
4686
  api_key: exports_external.string().optional().describe("API key or vault reference for embedding provider"),
4687
4687
  docker_service: exports_external.boolean().default(true).describe("Whether to include Hindsight in docker-compose"),
4688
- url: exports_external.string().url("Hindsight URL must be a valid URL (no shell-special characters)").optional().describe("Hindsight MCP endpoint URL (e.g., http://localhost:18888/mcp/). Defaults to http://localhost:8888/mcp/.")
4688
+ url: exports_external.string().url("Hindsight URL must be a valid URL (no shell-special characters)").optional().describe("Hindsight MCP endpoint URL (e.g., http://localhost:18888/mcp/). Defaults to http://localhost:8888/mcp/."),
4689
+ mcp_transport: exports_external.enum(["shim", "http"]).optional().describe("How agents reach Hindsight's MCP surface. Default (unset or " + "'shim'): the lazy-connect stdio proxy `switchroom " + "hindsight-mcp-shim`, which always registers at session start " + "even when the hindsight container is down and reconnects " + "per-call. 'http' is the escape hatch that restores the legacy " + "direct Streamable-HTTP entry (session-fatal if the backend is " + "down when the agent session boots).")
4689
4690
  }).optional()
4690
4691
  });
4691
4692
  VaultConfigSchema = exports_external.object({
@@ -4728,7 +4729,8 @@ var init_schema = __esm(() => {
4728
4729
  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.")
4729
4730
  });
4730
4731
  WebServiceConfigSchema = exports_external.object({
4731
- managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
4732
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers."),
4733
+ port: exports_external.number().int().min(1).max(65535).default(8080).describe("Host loopback port the web server listens on (127.0.0.1:<port>). " + "Default: 8080. Set this when another service owns 8080 on the " + "host (e.g. UniFi OS Server) — `switchroom webd install` and the " + "`switchroom update` refresh-web step both honor it, so a custom " + "port survives updates instead of being silently reverted to " + "8080 by the regenerated compose file. Remember to point the " + "host-side consumers (cloudflared tunnel, `tailscale serve`) at " + "the same port.")
4732
4734
  });
4733
4735
  FleetHealthConfigSchema = exports_external.object({
4734
4736
  owner_agent: exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
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.30",
4
+ "version": "0.18.32",
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": {
@@ -28,7 +28,7 @@
28
28
  "test:vitest": "vitest run",
29
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
- "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",
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 && node scripts/check-gateway-line-ratchet.mjs",
32
32
  "lint:tsc": "tsc --noEmit",
33
33
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
34
34
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
@@ -36,6 +36,7 @@
36
36
  "lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
37
37
  "lint:web-subscription-honest": "node scripts/check-web-subscription-honest.mjs",
38
38
  "lint:no-broadcast-delivery": "node scripts/check-no-broadcast-delivery.mjs",
39
+ "lint:gateway-line-ratchet": "node scripts/check-gateway-line-ratchet.mjs",
39
40
  "prepublishOnly": "npm run build && npm run lint && npm test"
40
41
  },
41
42
  "dependencies": {
@@ -602,16 +602,54 @@ export HINDSIGHT_SENDER_BANKS_JSON={{{hindsightSenderBanksJsonQ}}}
602
602
  {{#if hindsightTopicFilterMode}}
603
603
  export HINDSIGHT_TOPIC_FILTER_MODE={{hindsightTopicFilterMode}}
604
604
  {{/if}}
605
- # Wait for Hindsight API to be reachable before launching Claude, otherwise
606
- # the MCP server connection fails at startup with "1 MCP server failed".
605
+ # Give Claude Code a longer MCP server-startup budget (ms). The default can
606
+ # race a Hindsight restart window; the boot gate below usually absorbs it,
607
+ # but this keeps the in-session connect attempt itself patient too.
608
+ # Documented Claude Code env var (MCP server startup timeout).
609
+ export MCP_TIMEOUT="${MCP_TIMEOUT:-30000}"
610
+ # Wait for Hindsight MCP to be READY before launching Claude, otherwise the
611
+ # MCP server connection fails at startup with "1 MCP server failed" and the
612
+ # session boots toolless (incident 2026-07-18: clerk booted toolless despite
613
+ # a 30s bare-GET gate — the endpoint returned HTTP 200 mid-restart while a
614
+ # real MCP initialize still failed). So: probe with an actual JSON-RPC
615
+ # initialize POST and require a JSON-RPC result, not just HTTP 200.
616
+ # Bounded at 120s of real wall-clock time (each probe costs up to ~3s of
617
+ # curl timeout + 1s sleep, so an iteration count would overshoot — measure
618
+ # elapsed seconds against the deadline instead; HINDSIGHT_WAIT holds real
619
+ # elapsed seconds). On timeout we log a warning and boot anyway (degraded
620
+ # beats never-boots — the plugin reconnects once Hindsight recovers).
607
621
  HINDSIGHT_WAIT=0
608
- while [ $HINDSIGHT_WAIT -lt 30 ]; do
609
- if curl -sf -o /dev/null --max-time 2 {{{hindsightApiBaseUrlQ}}}/mcp/; then
622
+ HINDSIGHT_READY=0
623
+ _hs_start=$(date +%s)
624
+ while [ $HINDSIGHT_WAIT -lt 120 ]; do
625
+ _hs_resp=$(curl -sf --max-time 3 -i \
626
+ -X POST {{{hindsightApiBaseUrlQ}}}/mcp/ \
627
+ -H "Content-Type: application/json" \
628
+ -H "Accept: application/json, text/event-stream" \
629
+ -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"switchroom-boot-gate","version":"1.0"}}}' \
630
+ 2>/dev/null)
631
+ # Match on serverInfo — only present in a successful initialize result;
632
+ # a JSON-RPC *error* body could legitimately contain the substring "result".
633
+ if printf '%s\n' "$_hs_resp" | grep -q '"serverInfo"'; then
634
+ HINDSIGHT_READY=1
635
+ # Best-effort cleanup of the probe's server-side MCP session (one would
636
+ # leak per boot otherwise). Header may be absent on stateless servers.
637
+ _hs_sid=$(printf '%s\n' "$_hs_resp" | tr -d '\r' | sed -n 's/^[Mm][Cc][Pp]-[Ss]ession-[Ii][Dd]: *//p' | head -n 1)
638
+ if [ -n "$_hs_sid" ]; then
639
+ curl -s -o /dev/null --max-time 2 -X DELETE {{{hindsightApiBaseUrlQ}}}/mcp/ \
640
+ -H "Mcp-Session-Id: $_hs_sid" 2>/dev/null || true
641
+ fi
642
+ HINDSIGHT_WAIT=$(( $(date +%s) - _hs_start ))
610
643
  break
611
644
  fi
612
645
  sleep 1
613
- HINDSIGHT_WAIT=$((HINDSIGHT_WAIT + 1))
646
+ HINDSIGHT_WAIT=$(( $(date +%s) - _hs_start ))
614
647
  done
648
+ if [ "$HINDSIGHT_READY" = "1" ]; then
649
+ echo "start.sh: hindsight boot gate — MCP initialize OK after ${HINDSIGHT_WAIT}s" >&2
650
+ else
651
+ echo "start.sh: WARNING hindsight boot gate TIMED OUT after ${HINDSIGHT_WAIT}s — MCP initialize never returned a JSON-RPC result; launching claude anyway (memory tools may be unavailable this session)" >&2
652
+ fi
615
653
  {{/if}}
616
654
  {{#if userEnvQuoted}}
617
655
  # User-declared env vars from switchroom.yaml (agent + defaults merged).
@@ -1170,15 +1208,92 @@ fi
1170
1208
  # which the gateway relays to the operator chat once at boot. The normal apply
1171
1209
  # path is silent here — the gateway already acked the `/model` in chat.
1172
1210
  #
1211
+ # --- Live configured-default resolution (Defect B / drift fix) ---
1212
+ #
1213
+ # The apply-time bake ({{{modelQ}}}) is only the LAST-DITCH fallback: editing
1214
+ # `model:` in switchroom.yaml and restarting must take effect WITHOUT a full
1215
+ # `switchroom apply`. The launcher asks the SAME resolver the gateway's
1216
+ # /status uses (`switchroom agent effective-model`, which applies
1217
+ # resolveMainModel server-side) against the live mounted yaml — one resolver,
1218
+ # never re-implemented in shell, so what-you-see and what-runs cannot split.
1219
+ #
1220
+ # Fallback chain, never silent past step 1:
1221
+ # 1. live read of $SWITCHROOM_CONFIG (one bounded retry — a host-side
1222
+ # in-place rewrite can present a torn file for a moment);
1223
+ # 2. `.configured-default-model` — the last-known-good live value this
1224
+ # launcher recorded on every prior boot (read BEFORE it is overwritten);
1225
+ # 3. the apply-time bake.
1226
+ # Steps 2/3 append to `.session-model-alert` so the gateway tells the
1227
+ # operator once at boot. No SWITCHROOM_CONFIG mount at all means there is
1228
+ # nothing to read live — the bake IS the truth there (stderr note only; an
1229
+ # operator alert every boot forever would be noise with no action).
1230
+ #
1231
+ # The yaml file bind mount re-resolves by PATH at container start, so a
1232
+ # host-side atomic-rename write is always picked up by the NEXT boot — the
1233
+ # path this block runs on. Only mid-life reads by long-running processes can
1234
+ # see a stale inode; that pre-existing edge is out of scope here (tracked as
1235
+ # a follow-up on the drift fix).
1236
+ #
1173
1237
  # NB {{{modelQ}}} is already shell-single-quoted by the scaffold (it renders as
1174
1238
  # a quoted token, e.g. 'claude-sonnet-5'), so it is assigned BARE here — never
1175
1239
  # inside additional double quotes, which would embed the literal quote chars in
1176
1240
  # the value and break `claude --model`.
1177
- _EFFECTIVE_MODEL={{{modelQ}}}
1241
+ _BAKED_MODEL={{{modelQ}}}
1242
+ _LKG_MODEL="$(cat "{{agentDir}}/.configured-default-model" 2>/dev/null | tr -d '[:space:]' || true)"
1243
+ _EFFECTIVE_MODEL=""
1244
+ if [ -n "$SWITCHROOM_CONFIG" ] && [ -f "$SWITCHROOM_CONFIG" ] && command -v switchroom >/dev/null 2>&1; then
1245
+ _lm_live=""
1246
+ for _lm_try in 1 2; do
1247
+ _lm_live="$(timeout 20 switchroom --config "$SWITCHROOM_CONFIG" agent effective-model "{{name}}" 2>/dev/null | tail -n 1 | tr -d '[:space:]')" || _lm_live=""
1248
+ # Shape gate — same regex as the .session-model carrier below (byte-kept
1249
+ # with MODEL_ARG_RE); the tr above already guarantees single-line.
1250
+ if [ -n "$_lm_live" ] && printf '%s' "$_lm_live" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1251
+ break
1252
+ fi
1253
+ _lm_live=""
1254
+ if [ "$_lm_try" -eq 1 ]; then sleep 1; fi
1255
+ done
1256
+ if [ -n "$_lm_live" ]; then
1257
+ # Routing class (Claude passthrough vs sr-* router root) is baked into
1258
+ # compose at render time, so a Claude<->sr-* class flip CANNOT be applied
1259
+ # by a bare restart — half-applying would run the new model against the
1260
+ # old routing. Detect the flip, keep the baked model, and tell the
1261
+ # operator to run a real apply.
1262
+ case "$_BAKED_MODEL" in sr-*) _lm_baked_class=router ;; *) _lm_baked_class=claude ;; esac
1263
+ case "$_lm_live" in sr-*) _lm_live_class=router ;; *) _lm_live_class=claude ;; esac
1264
+ if [ "$_lm_live_class" != "$_lm_baked_class" ]; then
1265
+ echo "session-model: configured model class changed ('$_BAKED_MODEL' -> '$_lm_live', $_lm_baked_class -> $_lm_live_class) — routing is render-time, NOT applying; run 'switchroom apply' then restart" >&2
1266
+ printf 'The configured model changed from `%s` to `%s`, which switches routing class — a restart alone cannot apply that. The agent booted on `%s`. Run `switchroom apply` on the host, then restart this agent.\n' "$_BAKED_MODEL" "$_lm_live" "$_BAKED_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1267
+ _EFFECTIVE_MODEL="$_BAKED_MODEL"
1268
+ else
1269
+ _EFFECTIVE_MODEL="$_lm_live"
1270
+ if [ "$_lm_live" != "$_BAKED_MODEL" ]; then
1271
+ echo "session-model: live configured model '$_lm_live' differs from apply-time bake '$_BAKED_MODEL' — using the live value (switchroom.yaml is the source of truth)" >&2
1272
+ fi
1273
+ fi
1274
+ elif [ -n "$_LKG_MODEL" ] && printf '%s' "$_LKG_MODEL" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1275
+ # Live read failed (torn write mid-read, yaml schema error, CLI wedge).
1276
+ # Last-known-good beats the bake: it was live truth on a prior boot,
1277
+ # while the bake can predate any number of yaml edits. Loud either way —
1278
+ # a silent fallback here would resurrect Defect B as an intermittent flap.
1279
+ _EFFECTIVE_MODEL="$_LKG_MODEL"
1280
+ echo "session-model: live config read FAILED — using last-known-good configured model '$_LKG_MODEL' (apply-time bake: '$_BAKED_MODEL')" >&2
1281
+ printf 'switchroom.yaml could not be read at boot, so the agent booted on the last-known-good configured model `%s`. If you just changed `model:`, check the yaml for errors and restart the agent.\n' "$_LKG_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1282
+ else
1283
+ _EFFECTIVE_MODEL="$_BAKED_MODEL"
1284
+ echo "session-model: live config read FAILED and no last-known-good value — using apply-time baked model '$_BAKED_MODEL'" >&2
1285
+ printf 'switchroom.yaml could not be read at boot and no prior boot recorded a model, so the agent booted on the apply-time default `%s`. If you just changed `model:`, check the yaml for errors and restart the agent.\n' "$_BAKED_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1286
+ fi
1287
+ unset _lm_live _lm_try _lm_baked_class _lm_live_class
1288
+ else
1289
+ _EFFECTIVE_MODEL="$_BAKED_MODEL"
1290
+ fi
1291
+ unset _BAKED_MODEL _LKG_MODEL
1178
1292
  # Record the RESOLVED configured default (raw, unquoted) every boot, before
1179
1293
  # override resolution. The gateway copies this into the carrier's
1180
1294
  # `configuredDefaultAtWrite`, so both sides of the invalidation compare below
1181
- # come from the same resolver. Overwrite, not consumed.
1295
+ # come from the same resolver. Overwrite, not consumed — and doubling as the
1296
+ # last-known-good live value the fallback chain above reads on the NEXT boot.
1182
1297
  printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.configured-default-model" 2>/dev/null || true
1183
1298
 
1184
1299
  # Rev-4 hygiene (#3184 review LOW-2): remove the files retired with the
@@ -1304,6 +1419,26 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1304
1419
  unset _smf _sm_model _sm_cfg _sm_attempts _SM_MAX_ATTEMPTS
1305
1420
  fi
1306
1421
 
1422
+ # Configured-default LiteLLM guard. A proxy-only *configured* model with
1423
+ # LiteLLM unreachable at boot would exec against the /anthropic passthrough,
1424
+ # where `fable`/`claude-fable-5` is a retired codename and EVERY call 4xxs —
1425
+ # a total outage. The carrier block above already drops proxy-only OVERRIDES
1426
+ # on a down proxy; this extends the same guard to the configured default,
1427
+ # which the live-config read above makes a mainline path (pinning `model:
1428
+ # fable` in yaml is now the normal way to run Fable). Claude agents have a
1429
+ # safe same-class fallback (opus); sr-* configured defaults keep today's
1430
+ # loud-degraded behavior — there is no Claude fallback for them and the
1431
+ # repoint case below already leaves their routing to self-heal.
1432
+ case "$_EFFECTIVE_MODEL" in
1433
+ fable|claude-fable-5)
1434
+ if [ -z "$_LITELLM_OK" ]; then
1435
+ echo "session-model: configured model '$_EFFECTIVE_MODEL' is proxy-only but LiteLLM is unreachable at boot — booting 'opus' until the proxy recovers" >&2
1436
+ printf 'LiteLLM was unreachable at boot, so the configured model `%s` (proxy-only) could not be used — the agent booted on `opus` instead. Restart the agent once the proxy is back to return to `%s`.\n' "$_EFFECTIVE_MODEL" "$_EFFECTIVE_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1437
+ _EFFECTIVE_MODEL="opus"
1438
+ fi
1439
+ ;;
1440
+ esac
1441
+
1307
1442
  # --- Session effort resolution (consume-once .session-effort carrier, #3186) ---
1308
1443
  #
1309
1444
  # The `/effort` sibling of the block above, session-scoped like /model