switchroom 0.18.32 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -20687,7 +20687,23 @@ var AUDIT_ROOT = join2(homedir2(), ".switchroom", "audit");
20687
20687
  var import_handlebars = __toESM(require_lib(), 1);
20688
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";
20689
20689
  import { resolve as resolve4, join as join3, sep as pathSep } from "node:path";
20690
- 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();
20691
20707
  import_handlebars.default.registerHelper("json", (value) => {
20692
20708
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
20693
20709
  });
@@ -26647,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26647
26663
  import { dirname as dirname4, join as join7 } from "node:path";
26648
26664
 
26649
26665
  // src/build-info.ts
26650
- var VERSION = "0.18.32";
26666
+ var VERSION = "0.19.0";
26651
26667
 
26652
26668
  // src/cli/resolve-version.ts
26653
26669
  function readPackageVersion() {
@@ -19072,7 +19072,23 @@ var AUDIT_ROOT = join(homedir2(), ".switchroom", "audit");
19072
19072
  var import_handlebars = __toESM(require_lib(), 1);
19073
19073
  import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync, realpathSync } from "node:fs";
19074
19074
  import { resolve as resolve4, join as join2, sep as pathSep } from "node:path";
19075
- 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();
19076
19092
  import_handlebars.default.registerHelper("json", (value) => {
19077
19093
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
19078
19094
  });
@@ -18780,7 +18780,23 @@ var AUDIT_ROOT = join(homedir2(), ".switchroom", "audit");
18780
18780
  var import_handlebars = __toESM(require_lib(), 1);
18781
18781
  import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync, realpathSync } from "node:fs";
18782
18782
  import { resolve as resolve4, join as join2, sep as pathSep } from "node:path";
18783
- 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();
18784
18800
  import_handlebars.default.registerHelper("json", (value) => {
18785
18801
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
18786
18802
  });
@@ -23429,6 +23445,23 @@ class VaultBroker {
23429
23445
  socket.write(encodeResponse(errorResponse("DENIED", aclResult.reason)));
23430
23446
  return;
23431
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
+ }
23432
23465
  }
23433
23466
  const existing = this.secrets[req.key];
23434
23467
  if (existing === undefined && writeGrantId === null && !passphraseAttested) {
@@ -23458,7 +23491,16 @@ class VaultBroker {
23458
23491
  return;
23459
23492
  }
23460
23493
  const previousEntry = existing;
23461
- 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;
23462
23504
  try {
23463
23505
  saveVault(this.passphrase, this.vaultPath, this.secrets);
23464
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.32",
4
+ "version": "0.19.0",
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,7 +26,7 @@
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
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",
@@ -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=""
@@ -1096,7 +1103,20 @@ fi
1096
1103
  # unreachable at boot, or claude would 4xx an unknown model against Anthropic.
1097
1104
  # Empty by default (proxy not confirmed live); set to "1" only on a successful
1098
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.
1099
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:-}}"
1100
1120
  if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; then
1101
1121
  sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
1102
1122
  sr_ll_ok=""
@@ -1112,10 +1132,14 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1112
1132
  # co-boots, the litellm proxy's heavy Python app is often not yet healthy
1113
1133
  # in the first few seconds. A single one-shot probe fails-open and takes
1114
1134
  # this agent dark (untracked) for the WHOLE session. Poll the liveliness
1115
- # 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
1116
1138
  # it is STILL unreachable after that whole window.
1117
1139
  sr_ll_url="${SWITCHROOM_LITELLM_BASE:-${ANTHROPIC_BASE_URL%/anthropic}}/health/liveliness"
1118
- 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 ))
1119
1143
  sr_ll_up=""
1120
1144
  while :; do
1121
1145
  if curl -fsS -m 5 -o /dev/null "$sr_ll_url" 2>/dev/null; then sr_ll_up="1"; break; fi
@@ -1127,8 +1151,9 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1127
1151
  # leave it pointed at litellm so the fleet self-heals once the proxy is
1128
1152
  # back (the socat forwarder at 127.0.0.1:4010 reconnects per-connection).
1129
1153
  sr_ll_unreachable="1"
1154
+ _LITELLM_UNREACHABLE="1"
1130
1155
  echo "==================== litellm UNREACHABLE ====================" >&2
1131
- 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
1132
1157
  echo "WARNING: Claude traffic will FAIL until it recovers — routing LEFT IN PLACE" >&2
1133
1158
  echo "WARNING: so it self-heals (socat forwarder reconnects per-connection)." >&2
1134
1159
  echo "WARNING: NOT falling back to untracked direct Anthropic OAuth." >&2
@@ -1136,7 +1161,7 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1136
1161
  else
1137
1162
  sr_ll_ok="1"
1138
1163
  fi
1139
- unset sr_ll_url sr_ll_deadline sr_ll_up
1164
+ unset sr_ll_url sr_ll_budget sr_ll_deadline sr_ll_up
1140
1165
  else
1141
1166
  sr_ll_ok="1"
1142
1167
  fi
@@ -1419,22 +1444,37 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1419
1444
  unset _smf _sm_model _sm_cfg _sm_attempts _SM_MAX_ATTEMPTS
1420
1445
  fi
1421
1446
 
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.
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).
1432
1468
  case "$_EFFECTIVE_MODEL" in
1433
1469
  fable|claude-fable-5)
1434
1470
  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 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
1437
- _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
1438
1478
  fi
1439
1479
  ;;
1440
1480
  esac
@@ -1518,9 +1558,16 @@ printf '%s\n' "$_EFFECTIVE_EFFORT" > "{{agentDir}}/.active-session-effort" 2>/de
1518
1558
  # /v1/models (both `fable` and `claude-fable-5` served). Other Claude sessions
1519
1559
  # (opus/sonnet/haiku/default) keep the passthrough — it dodges the Opus SSE
1520
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).
1521
1568
  case "$_EFFECTIVE_MODEL" in
1522
1569
  sr-*|fable|claude-fable-5)
1523
- if [ -n "$_LITELLM_OK" ] && [ -n "$SWITCHROOM_LITELLM_BASE" ]; then
1570
+ if { [ -n "$_LITELLM_OK" ] || [ -n "$_LITELLM_UNREACHABLE" ]; } && [ -n "$SWITCHROOM_LITELLM_BASE" ]; then
1524
1571
  export ANTHROPIC_BASE_URL="$SWITCHROOM_LITELLM_BASE"
1525
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
1526
1573
  fi
@@ -1531,6 +1578,46 @@ esac
1531
1578
  # welcome card honest. Overwrite (not consumed) — the gateway reads it at boot.
1532
1579
  printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.active-session-model" 2>/dev/null || true
1533
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
+
1534
1621
  {{#if useSwitchroomPlugin}}
1535
1622
  if [ -n "$APPEND_PROMPT" ]; then
1536
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}}