switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. 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}$/, {
@@ -20685,7 +20687,23 @@ var AUDIT_ROOT = join2(homedir2(), ".switchroom", "audit");
20685
20687
  var import_handlebars = __toESM(require_lib(), 1);
20686
20688
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync5, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync as mkdirSync2, realpathSync } from "node:fs";
20687
20689
  import { resolve as resolve4, join as join3, sep as pathSep } from "node:path";
20688
- var PROFILES_ROOT = resolve4(import.meta.dirname, "../../profiles");
20690
+ function resolveProfilesRoot() {
20691
+ const envOverride = process.env.SWITCHROOM_PROFILES_ROOT?.trim();
20692
+ if (envOverride) {
20693
+ return resolve4(envOverride);
20694
+ }
20695
+ const candidates = [
20696
+ resolve4(import.meta.dirname, "../../profiles"),
20697
+ resolve4(import.meta.dirname, "profiles")
20698
+ ];
20699
+ for (const candidate of candidates) {
20700
+ if (existsSync5(candidate)) {
20701
+ return candidate;
20702
+ }
20703
+ }
20704
+ return candidates[0];
20705
+ }
20706
+ var PROFILES_ROOT = resolveProfilesRoot();
20689
20707
  import_handlebars.default.registerHelper("json", (value) => {
20690
20708
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
20691
20709
  });
@@ -26645,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26645
26663
  import { dirname as dirname4, join as join7 } from "node:path";
26646
26664
 
26647
26665
  // src/build-info.ts
26648
- var VERSION = "0.18.31";
26666
+ var VERSION = "0.18.33";
26649
26667
 
26650
26668
  // src/cli/resolve-version.ts
26651
26669
  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}$/, {
@@ -19070,7 +19072,23 @@ var AUDIT_ROOT = join(homedir2(), ".switchroom", "audit");
19070
19072
  var import_handlebars = __toESM(require_lib(), 1);
19071
19073
  import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync, realpathSync } from "node:fs";
19072
19074
  import { resolve as resolve4, join as join2, sep as pathSep } from "node:path";
19073
- var PROFILES_ROOT = resolve4(import.meta.dirname, "../../profiles");
19075
+ function resolveProfilesRoot() {
19076
+ const envOverride = process.env.SWITCHROOM_PROFILES_ROOT?.trim();
19077
+ if (envOverride) {
19078
+ return resolve4(envOverride);
19079
+ }
19080
+ const candidates = [
19081
+ resolve4(import.meta.dirname, "../../profiles"),
19082
+ resolve4(import.meta.dirname, "profiles")
19083
+ ];
19084
+ for (const candidate of candidates) {
19085
+ if (existsSync4(candidate)) {
19086
+ return candidate;
19087
+ }
19088
+ }
19089
+ return candidates[0];
19090
+ }
19091
+ var PROFILES_ROOT = resolveProfilesRoot();
19074
19092
  import_handlebars.default.registerHelper("json", (value) => {
19075
19093
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
19076
19094
  });
@@ -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}$/, {
@@ -18778,7 +18780,23 @@ var AUDIT_ROOT = join(homedir2(), ".switchroom", "audit");
18778
18780
  var import_handlebars = __toESM(require_lib(), 1);
18779
18781
  import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync, realpathSync } from "node:fs";
18780
18782
  import { resolve as resolve4, join as join2, sep as pathSep } from "node:path";
18781
- var PROFILES_ROOT = resolve4(import.meta.dirname, "../../profiles");
18783
+ function resolveProfilesRoot() {
18784
+ const envOverride = process.env.SWITCHROOM_PROFILES_ROOT?.trim();
18785
+ if (envOverride) {
18786
+ return resolve4(envOverride);
18787
+ }
18788
+ const candidates = [
18789
+ resolve4(import.meta.dirname, "../../profiles"),
18790
+ resolve4(import.meta.dirname, "profiles")
18791
+ ];
18792
+ for (const candidate of candidates) {
18793
+ if (existsSync4(candidate)) {
18794
+ return candidate;
18795
+ }
18796
+ }
18797
+ return candidates[0];
18798
+ }
18799
+ var PROFILES_ROOT = resolveProfilesRoot();
18782
18800
  import_handlebars.default.registerHelper("json", (value) => {
18783
18801
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
18784
18802
  });
@@ -23427,6 +23445,23 @@ class VaultBroker {
23427
23445
  socket.write(encodeResponse(errorResponse("DENIED", aclResult.reason)));
23428
23446
  return;
23429
23447
  }
23448
+ const existingForScope = this.secrets[req.key];
23449
+ if (existingForScope !== undefined) {
23450
+ const writeScope = checkEntryScope(existingForScope.scope, agentName);
23451
+ if (!writeScope.allow) {
23452
+ this.auditLogger.write({
23453
+ ts: new Date().toISOString(),
23454
+ op: "put",
23455
+ key: req.key,
23456
+ caller: auditCaller,
23457
+ pid: auditPid,
23458
+ cgroup: auditCgroup,
23459
+ result: `denied:${writeScope.reason}`
23460
+ });
23461
+ socket.write(encodeResponse(errorResponse("DENIED", writeScope.reason)));
23462
+ return;
23463
+ }
23464
+ }
23430
23465
  }
23431
23466
  const existing = this.secrets[req.key];
23432
23467
  if (existing === undefined && writeGrantId === null && !passphraseAttested) {
@@ -23456,7 +23491,16 @@ class VaultBroker {
23456
23491
  return;
23457
23492
  }
23458
23493
  const previousEntry = existing;
23459
- this.secrets[req.key] = req.entry;
23494
+ let nextEntry = req.entry;
23495
+ if (existing !== undefined && !passphraseAttested) {
23496
+ const preservedFormat = "format" in existing && existing.format !== undefined ? { format: existing.format } : {};
23497
+ nextEntry = {
23498
+ ...req.entry,
23499
+ ...preservedFormat,
23500
+ ...existing.scope !== undefined ? { scope: existing.scope } : {}
23501
+ };
23502
+ }
23503
+ this.secrets[req.key] = nextEntry;
23460
23504
  try {
23461
23505
  saveVault(this.passphrase, this.vaultPath, this.secrets);
23462
23506
  } catch (err) {
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.31",
4
+ "version": "0.18.33",
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": {
@@ -26,9 +26,9 @@
26
26
  "pretest": "npm run build",
27
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 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",
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-scope-persist.test.ts src/vault/broker/server-tokenless-scope.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": {
@@ -96,6 +96,13 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
96
96
  #
97
97
  # SWITCHROOM_AGENT_NAME is injected by compose env (compose.ts:1816)
98
98
  # so it is available here, before the inner-pass export at line ~320.
99
+ #
100
+ # Capture whether LiteLLM routing was configured at compose time BEFORE the
101
+ # missing-key fail-open below can strip SWITCHROOM_LITELLM. The routing-mode
102
+ # observability block (inner pass, pre-exec) reads this to distinguish
103
+ # "litellm never configured" (declared intent IS direct-oauth — no
104
+ # divergence) from "configured but stripped at boot" (real divergence).
105
+ export SWITCHROOM_LITELLM_DECLARED="${SWITCHROOM_LITELLM_DECLARED:-${SWITCHROOM_LITELLM:-}}"
99
106
  if [ -n "${SWITCHROOM_LITELLM:-}" ] && [ -z "$ANTHROPIC_CUSTOM_HEADERS" ] && command -v switchroom >/dev/null 2>&1; then
100
107
  sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
101
108
  sr_ll_ok=""
@@ -602,16 +609,54 @@ export HINDSIGHT_SENDER_BANKS_JSON={{{hindsightSenderBanksJsonQ}}}
602
609
  {{#if hindsightTopicFilterMode}}
603
610
  export HINDSIGHT_TOPIC_FILTER_MODE={{hindsightTopicFilterMode}}
604
611
  {{/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".
612
+ # Give Claude Code a longer MCP server-startup budget (ms). The default can
613
+ # race a Hindsight restart window; the boot gate below usually absorbs it,
614
+ # but this keeps the in-session connect attempt itself patient too.
615
+ # Documented Claude Code env var (MCP server startup timeout).
616
+ export MCP_TIMEOUT="${MCP_TIMEOUT:-30000}"
617
+ # Wait for Hindsight MCP to be READY before launching Claude, otherwise the
618
+ # MCP server connection fails at startup with "1 MCP server failed" and the
619
+ # session boots toolless (incident 2026-07-18: clerk booted toolless despite
620
+ # a 30s bare-GET gate — the endpoint returned HTTP 200 mid-restart while a
621
+ # real MCP initialize still failed). So: probe with an actual JSON-RPC
622
+ # initialize POST and require a JSON-RPC result, not just HTTP 200.
623
+ # Bounded at 120s of real wall-clock time (each probe costs up to ~3s of
624
+ # curl timeout + 1s sleep, so an iteration count would overshoot — measure
625
+ # elapsed seconds against the deadline instead; HINDSIGHT_WAIT holds real
626
+ # elapsed seconds). On timeout we log a warning and boot anyway (degraded
627
+ # beats never-boots — the plugin reconnects once Hindsight recovers).
607
628
  HINDSIGHT_WAIT=0
608
- while [ $HINDSIGHT_WAIT -lt 30 ]; do
609
- if curl -sf -o /dev/null --max-time 2 {{{hindsightApiBaseUrlQ}}}/mcp/; then
629
+ HINDSIGHT_READY=0
630
+ _hs_start=$(date +%s)
631
+ while [ $HINDSIGHT_WAIT -lt 120 ]; do
632
+ _hs_resp=$(curl -sf --max-time 3 -i \
633
+ -X POST {{{hindsightApiBaseUrlQ}}}/mcp/ \
634
+ -H "Content-Type: application/json" \
635
+ -H "Accept: application/json, text/event-stream" \
636
+ -d '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"switchroom-boot-gate","version":"1.0"}}}' \
637
+ 2>/dev/null)
638
+ # Match on serverInfo — only present in a successful initialize result;
639
+ # a JSON-RPC *error* body could legitimately contain the substring "result".
640
+ if printf '%s\n' "$_hs_resp" | grep -q '"serverInfo"'; then
641
+ HINDSIGHT_READY=1
642
+ # Best-effort cleanup of the probe's server-side MCP session (one would
643
+ # leak per boot otherwise). Header may be absent on stateless servers.
644
+ _hs_sid=$(printf '%s\n' "$_hs_resp" | tr -d '\r' | sed -n 's/^[Mm][Cc][Pp]-[Ss]ession-[Ii][Dd]: *//p' | head -n 1)
645
+ if [ -n "$_hs_sid" ]; then
646
+ curl -s -o /dev/null --max-time 2 -X DELETE {{{hindsightApiBaseUrlQ}}}/mcp/ \
647
+ -H "Mcp-Session-Id: $_hs_sid" 2>/dev/null || true
648
+ fi
649
+ HINDSIGHT_WAIT=$(( $(date +%s) - _hs_start ))
610
650
  break
611
651
  fi
612
652
  sleep 1
613
- HINDSIGHT_WAIT=$((HINDSIGHT_WAIT + 1))
653
+ HINDSIGHT_WAIT=$(( $(date +%s) - _hs_start ))
614
654
  done
655
+ if [ "$HINDSIGHT_READY" = "1" ]; then
656
+ echo "start.sh: hindsight boot gate — MCP initialize OK after ${HINDSIGHT_WAIT}s" >&2
657
+ else
658
+ 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
659
+ fi
615
660
  {{/if}}
616
661
  {{#if userEnvQuoted}}
617
662
  # User-declared env vars from switchroom.yaml (agent + defaults merged).
@@ -1058,7 +1103,20 @@ fi
1058
1103
  # unreachable at boot, or claude would 4xx an unknown model against Anthropic.
1059
1104
  # Empty by default (proxy not confirmed live); set to "1" only on a successful
1060
1105
  # liveliness probe. Deliberately NOT unset with the sr_ll_* scratch vars.
1106
+ #
1107
+ # _LITELLM_UNREACHABLE distinguishes WHY _LITELLM_OK is empty (routing-cause
1108
+ # split, 2026-07-17 boot-race incident): "1" means the key was present and the
1109
+ # probe failed — routing was LEFT IN PLACE pointing at the proxy, so a
1110
+ # proxy-only model (fable/sr-*) can boot loud-degraded and self-heal per
1111
+ # request. Empty means either the key is MISSING (routing stripped — a
1112
+ # proxy-only model genuinely cannot work and must demote) or LiteLLM is not
1113
+ # configured at all (same conclusion). Deliberately NOT unset either.
1061
1114
  _LITELLM_OK=""
1115
+ _LITELLM_UNREACHABLE=""
1116
+ # Idempotent mirror of the outer-pass capture (first setter wins) — on the
1117
+ # non-docker path only this inner block runs, so capture here too, before the
1118
+ # missing-key fail-open below can strip SWITCHROOM_LITELLM.
1119
+ export SWITCHROOM_LITELLM_DECLARED="${SWITCHROOM_LITELLM_DECLARED:-${SWITCHROOM_LITELLM:-}}"
1062
1120
  if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; then
1063
1121
  sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
1064
1122
  sr_ll_ok=""
@@ -1074,10 +1132,14 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1074
1132
  # co-boots, the litellm proxy's heavy Python app is often not yet healthy
1075
1133
  # in the first few seconds. A single one-shot probe fails-open and takes
1076
1134
  # this agent dark (untracked) for the WHOLE session. Poll the liveliness
1077
- # endpoint every ~3s for up to 120s (hard budget) and only fall open if
1135
+ # endpoint every ~3s for up to SWITCHROOM_LITELLM_BOOT_BUDGET seconds
1136
+ # (default 240 — the INNER budget only; the outer/gateway-pass probe stays
1137
+ # at 120s so gateway boot latency is not doubled) and only fall open if
1078
1138
  # it is STILL unreachable after that whole window.
1079
1139
  sr_ll_url="${SWITCHROOM_LITELLM_BASE:-${ANTHROPIC_BASE_URL%/anthropic}}/health/liveliness"
1080
- sr_ll_deadline=$(( $(date +%s) + 120 ))
1140
+ sr_ll_budget="${SWITCHROOM_LITELLM_BOOT_BUDGET:-240}"
1141
+ case "$sr_ll_budget" in *[!0-9]*|"") sr_ll_budget=240 ;; esac
1142
+ sr_ll_deadline=$(( $(date +%s) + sr_ll_budget ))
1081
1143
  sr_ll_up=""
1082
1144
  while :; do
1083
1145
  if curl -fsS -m 5 -o /dev/null "$sr_ll_url" 2>/dev/null; then sr_ll_up="1"; break; fi
@@ -1089,8 +1151,9 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1089
1151
  # leave it pointed at litellm so the fleet self-heals once the proxy is
1090
1152
  # back (the socat forwarder at 127.0.0.1:4010 reconnects per-connection).
1091
1153
  sr_ll_unreachable="1"
1154
+ _LITELLM_UNREACHABLE="1"
1092
1155
  echo "==================== litellm UNREACHABLE ====================" >&2
1093
- echo "WARNING: litellm proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} after 120s of retries;" >&2
1156
+ echo "WARNING: litellm proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} after ${sr_ll_budget}s of retries;" >&2
1094
1157
  echo "WARNING: Claude traffic will FAIL until it recovers — routing LEFT IN PLACE" >&2
1095
1158
  echo "WARNING: so it self-heals (socat forwarder reconnects per-connection)." >&2
1096
1159
  echo "WARNING: NOT falling back to untracked direct Anthropic OAuth." >&2
@@ -1098,7 +1161,7 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1098
1161
  else
1099
1162
  sr_ll_ok="1"
1100
1163
  fi
1101
- unset sr_ll_url sr_ll_deadline sr_ll_up
1164
+ unset sr_ll_url sr_ll_budget sr_ll_deadline sr_ll_up
1102
1165
  else
1103
1166
  sr_ll_ok="1"
1104
1167
  fi
@@ -1381,22 +1444,37 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1381
1444
  unset _smf _sm_model _sm_cfg _sm_attempts _SM_MAX_ATTEMPTS
1382
1445
  fi
1383
1446
 
1384
- # Configured-default LiteLLM guard. A proxy-only *configured* model with
1385
- # LiteLLM unreachable at boot would exec against the /anthropic passthrough,
1386
- # where `fable`/`claude-fable-5` is a retired codename and EVERY call 4xxs
1387
- # a total outage. The carrier block above already drops proxy-only OVERRIDES
1388
- # on a down proxy; this extends the same guard to the configured default,
1389
- # which the live-config read above makes a mainline path (pinning `model:
1390
- # fable` in yaml is now the normal way to run Fable). Claude agents have a
1391
- # safe same-class fallback (opus); sr-* configured defaults keep today's
1392
- # loud-degraded behavior — there is no Claude fallback for them and the
1393
- # repoint case below already leaves their routing to self-heal.
1447
+ # Configured-default LiteLLM guard, SPLIT BY CAUSE (2026-07-17 boot-race
1448
+ # incident: a slow LiteLLM co-boot demoted a configured-fable agent to opus on
1449
+ # the passthrough silent divergence that persisted for the whole session).
1450
+ #
1451
+ # - MISSING KEY / LiteLLM not configured (_LITELLM_UNREACHABLE empty):
1452
+ # routing was STRIPPED (direct Anthropic OAuth), where `fable`/
1453
+ # `claude-fable-5` is a retired codename and EVERY call 4xxs a total
1454
+ # outage. Claude agents have a safe same-class fallback: demote to opus,
1455
+ # loudly (stderr + .session-model-alert).
1456
+ # - PROXY UNREACHABLE, key present (_LITELLM_UNREACHABLE="1"): routing was
1457
+ # LEFT IN PLACE pointing at the proxy. Do NOT demote — boot the declared
1458
+ # fable model against the LiteLLM router root (the repoint case below
1459
+ # includes the unreachable path) and run loud-degraded, the same policy as
1460
+ # sr-* configured defaults: requests fail until the proxy heals, then work
1461
+ # with ZERO divergence and no restart needed (per-request recovery — see
1462
+ # the repoint comment below).
1463
+ #
1464
+ # The carrier block above already drops proxy-only OVERRIDES on a down proxy;
1465
+ # this guard covers only the configured default, which the live-config read
1466
+ # above makes a mainline path (pinning `model: fable` in yaml is the normal
1467
+ # way to run Fable).
1394
1468
  case "$_EFFECTIVE_MODEL" in
1395
1469
  fable|claude-fable-5)
1396
1470
  if [ -z "$_LITELLM_OK" ]; then
1397
- echo "session-model: configured model '$_EFFECTIVE_MODEL' is proxy-only but LiteLLM is unreachable at boot — booting 'opus' until the proxy recovers" >&2
1398
- printf 'LiteLLM was unreachable at boot, so the configured model `%s` (proxy-only) could not be usedthe 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
1399
- _EFFECTIVE_MODEL="opus"
1471
+ if [ -n "$_LITELLM_UNREACHABLE" ]; then
1472
+ echo "session-model: configured model '$_EFFECTIVE_MODEL' is proxy-only and LiteLLM is unreachable at boot booting '$_EFFECTIVE_MODEL' anyway against the LiteLLM router (loud-degraded; requests fail until the proxy recovers, then self-heal per-request). NOT demoting to opus." >&2
1473
+ else
1474
+ echo "session-model: configured model '$_EFFECTIVE_MODEL' is proxy-only but no LiteLLM virtual key is available (routing stripped to direct Anthropic, where it would 4xx every call) — booting 'opus' instead" >&2
1475
+ printf 'No LiteLLM virtual key was available at boot (routing stripped to direct Anthropic), so the configured model `%s` (proxy-only) cannot work — the agent booted on `opus` instead. Run `switchroom apply` on the host (it provisions the LiteLLM key), then restart this agent to return to `%s`.\n' "$_EFFECTIVE_MODEL" "$_EFFECTIVE_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1476
+ _EFFECTIVE_MODEL="opus"
1477
+ fi
1400
1478
  fi
1401
1479
  ;;
1402
1480
  esac
@@ -1480,9 +1558,16 @@ printf '%s\n' "$_EFFECTIVE_EFFORT" > "{{agentDir}}/.active-session-effort" 2>/de
1480
1558
  # /v1/models (both `fable` and `claude-fable-5` served). Other Claude sessions
1481
1559
  # (opus/sonnet/haiku/default) keep the passthrough — it dodges the Opus SSE
1482
1560
  # re-chunk stall; Fable trades that for being routable at all.
1561
+ # Repoint fires when the proxy is LIVE (_LITELLM_OK) OR when it is unreachable
1562
+ # with the key present (_LITELLM_UNREACHABLE — routing left in place, cause
1563
+ # split above): a configured fable that survives the unreachable guard must
1564
+ # land on the ROUTER ROOT, not the model-agnostic passthrough, or it would
1565
+ # keep 4xxing even after the proxy heals. Missing-key boots never get here
1566
+ # with a proxy-only model (the guard above demoted fable to opus, and sr-*
1567
+ # had its routing stripped anyway).
1483
1568
  case "$_EFFECTIVE_MODEL" in
1484
1569
  sr-*|fable|claude-fable-5)
1485
- if [ -n "$_LITELLM_OK" ] && [ -n "$SWITCHROOM_LITELLM_BASE" ]; then
1570
+ if { [ -n "$_LITELLM_OK" ] || [ -n "$_LITELLM_UNREACHABLE" ]; } && [ -n "$SWITCHROOM_LITELLM_BASE" ]; then
1486
1571
  export ANTHROPIC_BASE_URL="$SWITCHROOM_LITELLM_BASE"
1487
1572
  echo "session-model: effective model '$_EFFECTIVE_MODEL' is proxy-only (sr-*/fable) — routing via LiteLLM model router '$SWITCHROOM_LITELLM_BASE' (off /anthropic passthrough)" >&2
1488
1573
  fi
@@ -1493,6 +1578,46 @@ esac
1493
1578
  # welcome card honest. Overwrite (not consumed) — the gateway reads it at boot.
1494
1579
  printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.active-session-model" 2>/dev/null || true
1495
1580
 
1581
+ # --- Routing-mode observability (.routing-mode, 2026-07-17 boot-race incident) ---
1582
+ #
1583
+ # Deterministic record of WHERE this boot actually landed, written immediately
1584
+ # before exec claude (overwrite-per-boot, one line). Consumers: the gateway
1585
+ # boot card's "routing" row and any operator shell. _DECLARED_ROUTING is baked
1586
+ # at render time by declaredRoutingMode() — the POST-REPOINT mode this boot is
1587
+ # meant to land on (fable/claude-fable-5/sr-* declare router-root because the
1588
+ # repoint case below moves them there; only non-fable Claude models declare
1589
+ # passthrough) — the apply-time INTENT the landed mode is compared against.
1590
+ # Divergence (landed ≠ declared) appends a consume-once .session-model-alert
1591
+ # line the gateway relays to the operator, but ONLY when the (landed, declared)
1592
+ # PAIR differs from the PREVIOUS boot's pair — a persistently degraded agent
1593
+ # alerts once, not on every restart, yet a declared-only flip (operator changes
1594
+ # the configured model class while the agent stays stuck on the same wrong
1595
+ # landed mode) still re-alerts.
1596
+ _DECLARED_ROUTING={{{declaredRoutingQ}}}
1597
+ # When LiteLLM was never configured for this agent (no SWITCHROOM_LITELLM at
1598
+ # boot entry, captured before any strip), compose injects NO routing env — the
1599
+ # declared intent genuinely IS direct-oauth, not the model-class bake. This
1600
+ # keeps non-LiteLLM fleets from false-alerting divergence on every boot.
1601
+ if [ -z "${SWITCHROOM_LITELLM_DECLARED:-}" ]; then _DECLARED_ROUTING="direct-oauth"; fi
1602
+ case "${ANTHROPIC_BASE_URL:-}" in
1603
+ "") _ROUTING_MODE="direct-oauth" ;;
1604
+ */anthropic) _ROUTING_MODE="passthrough" ;;
1605
+ *) _ROUTING_MODE="router-root" ;;
1606
+ esac
1607
+ _prev_routing_mode="$(sed -n 's/^mode=\([^ ]*\).*/\1/p' "{{agentDir}}/.routing-mode" 2>/dev/null | head -n 1 || true)"
1608
+ _prev_declared_routing="$(sed -n 's/.* declared=\([^ ]*\).*/\1/p' "{{agentDir}}/.routing-mode" 2>/dev/null | head -n 1 || true)"
1609
+ _routing_line="mode=$_ROUTING_MODE base=${ANTHROPIC_BASE_URL:-direct} model=$_EFFECTIVE_MODEL litellm_ok=${_LITELLM_OK:-0} declared=$_DECLARED_ROUTING ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1610
+ printf '%s\n' "$_routing_line" > "{{agentDir}}/.routing-mode" 2>/dev/null || true
1611
+ echo "routing: $_routing_line" >&2
1612
+ # Dedup on the (landed, declared) PAIR, not landed alone: a persistently
1613
+ # degraded agent alerts once, but if the operator flips the declared class
1614
+ # while the agent stays stuck on the same wrong landed mode, the pair changes
1615
+ # and we re-alert (LOW-2).
1616
+ if [ "$_ROUTING_MODE" != "$_DECLARED_ROUTING" ] && { [ "$_ROUTING_MODE" != "$_prev_routing_mode" ] || [ "$_DECLARED_ROUTING" != "$_prev_declared_routing" ]; }; then
1617
+ printf 'Routing divergence: this agent landed on `%s` (base `%s`, model `%s`) but its declared routing is `%s`. Check `%s/.routing-mode` and the boot log for the cause (LiteLLM key/provision or proxy reachability).\n' "$_ROUTING_MODE" "${ANTHROPIC_BASE_URL:-direct}" "$_EFFECTIVE_MODEL" "$_DECLARED_ROUTING" "{{agentDir}}" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1618
+ fi
1619
+ unset _prev_routing_mode _prev_declared_routing _routing_line
1620
+
1496
1621
  {{#if useSwitchroomPlugin}}
1497
1622
  if [ -n "$APPEND_PROMPT" ]; then
1498
1623
  exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG --model "$_EFFECTIVE_MODEL" $_EFFORT_ARG{{#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}}