switchroom 0.19.35 → 0.19.36

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 (29) hide show
  1. package/dist/cli/skill-validate-pretool.mjs +15 -2
  2. package/dist/cli/switchroom.js +672 -441
  3. package/dist/host-control/main.js +149 -3
  4. package/package.json +4 -2
  5. package/profiles/_base/start.sh.hbs +37 -12
  6. package/telegram-plugin/dist/gateway/gateway.js +311 -93
  7. package/telegram-plugin/format.ts +70 -15
  8. package/telegram-plugin/gateway/gateway.ts +22 -29
  9. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  10. package/telegram-plugin/gateway/model-command.ts +34 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  12. package/telegram-plugin/operator-events.ts +40 -16
  13. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  14. package/telegram-plugin/secret-detect/index.ts +24 -1
  15. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  16. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  17. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  18. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  19. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  20. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  21. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  22. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  23. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  25. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  26. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  27. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  28. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
@@ -21311,7 +21311,7 @@ function allocateAgentUid(name) {
21311
21311
  }
21312
21312
 
21313
21313
  // src/build-info.ts
21314
- var VERSION = "0.19.35";
21314
+ var VERSION = "0.19.36";
21315
21315
 
21316
21316
  // src/setup/hindsight-recall-tunables.ts
21317
21317
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
@@ -27290,16 +27290,22 @@ var STRUCTURED_PATTERNS = [
27290
27290
  },
27291
27291
  {
27292
27292
  rule_id: "bearer_auth_header",
27293
- regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/g,
27293
+ regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/gi,
27294
27294
  captureIndex: 1,
27295
27295
  slugHint: "bearer_token"
27296
27296
  },
27297
27297
  {
27298
27298
  rule_id: "bearer_loose",
27299
- regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
27299
+ regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/gi,
27300
27300
  captureIndex: 1,
27301
27301
  slugHint: "bearer_token"
27302
27302
  },
27303
+ {
27304
+ rule_id: "basic_auth_header",
27305
+ regex: /Authorization\s*[:=]\s*Basic\s+([A-Za-z0-9+/=]{8,})/gi,
27306
+ captureIndex: 1,
27307
+ slugHint: "basic_auth"
27308
+ },
27303
27309
  {
27304
27310
  rule_id: "pem_private_key",
27305
27311
  regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
@@ -27358,9 +27364,108 @@ function shannonEntropy(s) {
27358
27364
  return h;
27359
27365
  }
27360
27366
 
27367
+ // telegram-plugin/secret-detect/inert-values.ts
27368
+ var INERT_VALUE_RE = [
27369
+ /^\[REDACTED(?::[A-Za-z0-9_]+)?\]$/i,
27370
+ /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/,
27371
+ /^\{\{[^\n\r\u2028\u2029]*\}\}$/,
27372
+ /^<[^<>\n\r\u2028\u2029]*>$/,
27373
+ /^%[A-Za-z_][A-Za-z0-9_]*%$/,
27374
+ /^vault:[A-Za-z0-9_./-]*$/i,
27375
+ /^[*x•.]+$/i,
27376
+ /^(?:process\.env|import\.meta\.env|os\.environ)(?:\.[A-Za-z_$][A-Za-z0-9_$]*|\[["']?[A-Za-z_$][A-Za-z0-9_$]*["']?\])*$/,
27377
+ /^(?:changeme|change-me|replaceme|replace-me|placeholder|todo|tbd|yourkey|your-key|yourpassword|your-password|yoursecret|your-secret|yourtoken|your-token)(?:[-_][A-Za-z0-9-]+)?$/i,
27378
+ /^[a-z]+(?: [a-z]+){2,}$/
27379
+ ];
27380
+ var CREDENTIAL_RUN_RE = /[A-Za-z0-9]{12,}/g;
27381
+ var MIXED_CLASS_RUN_MIN = 12;
27382
+ var SINGLE_CLASS_RUN_MIN = 16;
27383
+ function runIsCredentialShaped(run) {
27384
+ let classes = 0;
27385
+ if (/[a-z]/.test(run))
27386
+ classes++;
27387
+ if (/[A-Z]/.test(run))
27388
+ classes++;
27389
+ if (/[0-9]/.test(run))
27390
+ classes++;
27391
+ return run.length >= (classes >= 2 ? MIXED_CLASS_RUN_MIN : SINGLE_CLASS_RUN_MIN);
27392
+ }
27393
+ function hasCredentialShapedRun(value) {
27394
+ for (const run of value.match(CREDENTIAL_RUN_RE) ?? []) {
27395
+ if (runIsCredentialShaped(run))
27396
+ return true;
27397
+ }
27398
+ return false;
27399
+ }
27400
+ function isInertValue(value) {
27401
+ if (!INERT_VALUE_RE.some((re) => re.test(value)))
27402
+ return false;
27403
+ return !hasCredentialShapedRun(value);
27404
+ }
27405
+ var INERT_GATED_RULES = new Set([
27406
+ "env_key_value",
27407
+ "json_secret_field",
27408
+ "cli_flag"
27409
+ ]);
27410
+ var TRAILING_PUNCT_RE = /[.,;:!?)\]}]+$/;
27411
+ function stripTrailingPunctuation(value) {
27412
+ return value.replace(TRAILING_PUNCT_RE, "");
27413
+ }
27414
+
27361
27415
  // telegram-plugin/secret-detect/kv-scanner.ts
27362
27416
  var KV_RE = /\b([A-Za-z_][A-Za-z0-9_-]*(?:password|passwd|token|secret|key|api[_-]?key))\s*[:=]\s*["']?([^\s"'\\]{8,})["']?/gi;
27363
27417
  var KV_ENTROPY_THRESHOLD = 4;
27418
+ var MEMORABLE_PW_RE = /\b([A-Za-z0-9_-]*(?:password|passwd|passphrase|pwd))\b\s*(?:[:=]\s*|\s+is\s+)(["']?)([^\s"']{8,64})\2/gi;
27419
+ var MEMORABLE_PW_RULE_ID = "memorable_password";
27420
+ var MEMORABLE_PW_MIN_CLASSES = 2;
27421
+ function charClassCount(value) {
27422
+ let n = 0;
27423
+ if (/[a-z]/.test(value))
27424
+ n++;
27425
+ if (/[A-Z]/.test(value))
27426
+ n++;
27427
+ if (/[0-9]/.test(value))
27428
+ n++;
27429
+ if (/[^A-Za-z0-9]/.test(value))
27430
+ n++;
27431
+ return n;
27432
+ }
27433
+ function looksLikeMemorablePassword(value) {
27434
+ if (isInertValue(value))
27435
+ return false;
27436
+ const core = stripTrailingPunctuation(value);
27437
+ if (core.length < 8 || core.length > 64)
27438
+ return false;
27439
+ if (isInertValue(core))
27440
+ return false;
27441
+ if (new Set(core).size < 4)
27442
+ return false;
27443
+ return charClassCount(core) >= MEMORABLE_PW_MIN_CLASSES;
27444
+ }
27445
+ function scanMemorablePasswords(text) {
27446
+ const hits = [];
27447
+ MEMORABLE_PW_RE.lastIndex = 0;
27448
+ let m;
27449
+ while ((m = MEMORABLE_PW_RE.exec(text)) !== null) {
27450
+ const keyName = m[1];
27451
+ const value = m[3];
27452
+ if (!value || !looksLikeMemorablePassword(value))
27453
+ continue;
27454
+ const valueOffsetInMatch = m[0].indexOf(value, keyName.length);
27455
+ if (valueOffsetInMatch < 0)
27456
+ continue;
27457
+ const start = m.index + valueOffsetInMatch;
27458
+ hits.push({
27459
+ rule_id: MEMORABLE_PW_RULE_ID,
27460
+ start,
27461
+ end: start + value.length,
27462
+ matched_text: value,
27463
+ key_name: keyName,
27464
+ confidence: "ambiguous"
27465
+ });
27466
+ }
27467
+ return hits;
27468
+ }
27364
27469
  function scanKeyValue(text) {
27365
27470
  const hits = [];
27366
27471
  KV_RE.lastIndex = 0;
@@ -27369,6 +27474,8 @@ function scanKeyValue(text) {
27369
27474
  const [, keyName, value] = m;
27370
27475
  if (!value)
27371
27476
  continue;
27477
+ if (isInertValue(value))
27478
+ continue;
27372
27479
  const h = shannonEntropy(value);
27373
27480
  if (h < KV_ENTROPY_THRESHOLD)
27374
27481
  continue;
@@ -27389,6 +27496,37 @@ function scanKeyValue(text) {
27389
27496
  return hits;
27390
27497
  }
27391
27498
 
27499
+ // telegram-plugin/secret-detect/db-uri.ts
27500
+ var DB_URI_RE = /\b([a-zA-Z][a-zA-Z0-9+.-]+):\/\/([^\s:/@]+):([^\s/?#]+)@([^\s/@?#]+)/g;
27501
+ var DB_URI_RULE_ID = "db_uri_password";
27502
+ function isInertPassword(value) {
27503
+ if (value.startsWith("[REDACTED"))
27504
+ return true;
27505
+ return /^[*x]+$/i.test(value);
27506
+ }
27507
+ function scanDbUris(text) {
27508
+ const hits = [];
27509
+ DB_URI_RE.lastIndex = 0;
27510
+ let m;
27511
+ while ((m = DB_URI_RE.exec(text)) !== null) {
27512
+ const scheme = m[1];
27513
+ const user = m[2];
27514
+ const password = m[3];
27515
+ if (isInertPassword(password))
27516
+ continue;
27517
+ const start = m.index + scheme.length + 3 + user.length + 1;
27518
+ hits.push({
27519
+ rule_id: DB_URI_RULE_ID,
27520
+ start,
27521
+ end: start + password.length,
27522
+ matched_text: password,
27523
+ key_name: `${scheme}_password`,
27524
+ confidence: "ambiguous"
27525
+ });
27526
+ }
27527
+ return hits;
27528
+ }
27529
+
27392
27530
  // telegram-plugin/secret-detect/generic-entropy.ts
27393
27531
  var CANDIDATE_RE = /[A-Za-z0-9]{28,}/g;
27394
27532
  var GENERIC_MIN_DISTINCT = 18;
@@ -27562,6 +27700,8 @@ function detectSecrets(text) {
27562
27700
  const globalStart = win.offset + matchStart;
27563
27701
  const globalEnd = globalStart + cap.length;
27564
27702
  const keyName = p.rule_id === "env_key_value" ? m[1] : undefined;
27703
+ if (INERT_GATED_RULES.has(p.rule_id) && isInertValue(cap))
27704
+ continue;
27565
27705
  if (p.rule_id === "env_key_value") {
27566
27706
  const ENV_KV_MIN_LEN = 12;
27567
27707
  const ENV_KV_MIN_ENTROPY = 3.5;
@@ -27584,6 +27724,12 @@ function detectSecrets(text) {
27584
27724
  for (const h of kvHits) {
27585
27725
  raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
27586
27726
  }
27727
+ for (const h of scanDbUris(win.text)) {
27728
+ raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
27729
+ }
27730
+ for (const h of scanMemorablePasswords(win.text)) {
27731
+ raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
27732
+ }
27587
27733
  const genHits = scanGenericSecrets(win.text);
27588
27734
  for (const h of genHits) {
27589
27735
  raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset });
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.19.35",
4
+ "version": "0.19.36",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -28,8 +28,10 @@
28
28
  "test:vitest": "vitest run",
29
29
  "test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts 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/self-approval-bypass.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-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-agent-attribution-trailers.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-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts",
32
32
  "lint:tsc": "tsc --noEmit",
33
+ "lint:hindsight-write-redaction": "node scripts/check-hindsight-write-redaction.mjs",
34
+ "lint:secret-pattern-parity": "bun scripts/check-secret-pattern-parity.ts",
33
35
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
34
36
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
35
37
  "lint:bun-test-imports": "node scripts/check-bun-test-imports.mjs",
@@ -390,6 +390,10 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
390
390
  # ephemeral). Refresh-or-remove every boot so a stale snapshot can never
391
391
  # re-apply a consumed override on a later restart.
392
392
  rm -f "{{agentDir}}/.session-model.boot-snapshot" "{{agentDir}}/.session-model-boot-attempts.boot-snapshot"
393
+ # Resolution runs later, after the bounded LiteLLM probe. Remove the prior
394
+ # boot's completion barrier before forking the gateway so it cannot consume
395
+ # stale active-model/effort/alert files while this boot is still resolving.
396
+ rm -f "{{agentDir}}/.session-model-resolved"
393
397
  if [ -f "{{agentDir}}/.session-model" ]; then
394
398
  # A failed copy (ENOSPC, perms) must not be a SILENT drop — the resolution
395
399
  # block still falls back to the live carrier, but the gateway may consume
@@ -1954,6 +1958,13 @@ rm -f "{{agentDir}}/.session-model.boot-snapshot" "{{agentDir}}/.session-model-b
1954
1958
  # this guard covers only the configured default, which the live-config read
1955
1959
  # above makes a mainline path (pinning `model: fable` in yaml is the normal
1956
1960
  # way to run Fable).
1961
+ #
1962
+ # Snapshot the resolved model BEFORE this guard can rewrite it. _DECLARED_
1963
+ # ROUTING (below) must reflect this boot's actual INTENT — the resolved
1964
+ # override or configured default — not a post-demotion landing, or the exact
1965
+ # divergence this file exists to catch (missing key silently demoting
1966
+ # fable → opus) becomes invisible to the check built to catch it.
1967
+ _DECLARED_MODEL="$_EFFECTIVE_MODEL"
1957
1968
  case "$_EFFECTIVE_MODEL" in
1958
1969
  fable|claude-fable-5)
1959
1970
  if [ -z "$_LITELLM_OK" ]; then
@@ -2080,23 +2091,27 @@ export SWITCHROOM_SESSION_MODEL="$_EFFECTIVE_MODEL"
2080
2091
  #
2081
2092
  # Deterministic record of WHERE this boot actually landed, written immediately
2082
2093
  # before exec claude (overwrite-per-boot, one line). Consumers: the gateway
2083
- # boot card's "routing" row and any operator shell. _DECLARED_ROUTING is baked
2084
- # at render time by declaredRoutingMode() — the POST-REPOINT mode this boot is
2085
- # meant to land on (fable/claude-fable-5/sr-* declare router-root because the
2086
- # repoint case below moves them there; only non-fable Claude models declare
2087
- # passthrough) — the apply-time INTENT the landed mode is compared against.
2094
+ # boot card's "routing" row and any operator shell. _DECLARED_ROUTING is derived
2095
+ # from $_DECLARED_MODEL (the resolved override/default snapshotted BEFORE the
2096
+ # missing-key opus demotion above), so a legitimate session override is
2097
+ # compared against its own POST-REPOINT intent rather than the configured
2098
+ # default's render-time model class — and a missing-key demotion still reads
2099
+ # as a genuine divergence instead of chasing its own landed model.
2088
2100
  # Divergence (landed ≠ declared) appends a consume-once .session-model-alert
2089
2101
  # line the gateway relays to the operator, but ONLY when the (landed, declared)
2090
2102
  # PAIR differs from the PREVIOUS boot's pair — a persistently degraded agent
2091
2103
  # alerts once, not on every restart, yet a declared-only flip (operator changes
2092
2104
  # the configured model class while the agent stays stuck on the same wrong
2093
2105
  # landed mode) still re-alerts.
2094
- _DECLARED_ROUTING={{{declaredRoutingQ}}}
2095
- # When LiteLLM was never configured for this agent (no SWITCHROOM_LITELLM at
2096
- # boot entry, captured before any strip), compose injects NO routing env — the
2097
- # declared intent genuinely IS direct-oauth, not the model-class bake. This
2098
- # keeps non-LiteLLM fleets from false-alerting divergence on every boot.
2099
- if [ -z "${SWITCHROOM_LITELLM_DECLARED:-}" ]; then _DECLARED_ROUTING="direct-oauth"; fi
2106
+ if [ -z "${SWITCHROOM_LITELLM_DECLARED:-}" ]; then
2107
+ # No LiteLLM routing was configured for this agent at boot entry.
2108
+ _DECLARED_ROUTING="direct-oauth"
2109
+ else
2110
+ case "$_DECLARED_MODEL" in
2111
+ sr-*|fable|claude-fable-5) _DECLARED_ROUTING="router-root" ;;
2112
+ *) _DECLARED_ROUTING="passthrough" ;;
2113
+ esac
2114
+ fi
2100
2115
  case "${ANTHROPIC_BASE_URL:-}" in
2101
2116
  "") _ROUTING_MODE="direct-oauth" ;;
2102
2117
  */anthropic) _ROUTING_MODE="passthrough" ;;
@@ -2114,7 +2129,17 @@ echo "routing: $_routing_line" >&2
2114
2129
  if [ "$_ROUTING_MODE" != "$_DECLARED_ROUTING" ] && { [ "$_ROUTING_MODE" != "$_prev_routing_mode" ] || [ "$_DECLARED_ROUTING" != "$_prev_declared_routing" ]; }; then
2115
2130
  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
2116
2131
  fi
2117
- unset _prev_routing_mode _prev_declared_routing _routing_line
2132
+ unset _prev_routing_mode _prev_declared_routing _routing_line _DECLARED_MODEL
2133
+
2134
+ # Publish completion only after every boot-resolution output is final: active
2135
+ # model, active effort, routing mode, and any alert append. Atomic rename keeps
2136
+ # the gateway from observing a partially-written barrier.
2137
+ _session_model_resolved_tmp="{{agentDir}}/.session-model-resolved.tmp.$$"
2138
+ if printf '%s\n' "$$" > "$_session_model_resolved_tmp" 2>/dev/null; then
2139
+ mv -f "$_session_model_resolved_tmp" "{{agentDir}}/.session-model-resolved" 2>/dev/null || true
2140
+ fi
2141
+ rm -f "$_session_model_resolved_tmp"
2142
+ unset _session_model_resolved_tmp
2118
2143
 
2119
2144
  {{#if useSwitchroomPlugin}}
2120
2145
  if [ -n "$APPEND_PROMPT" ]; then