switchroom 0.18.6 → 0.18.8

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 (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -21495,6 +21495,7 @@ var SessionContinuitySchema = exports_external.object({
21495
21495
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
21496
21496
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
21497
21497
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
21498
+ boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
21498
21499
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
21499
21500
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
21500
21501
  }).optional();
@@ -28737,7 +28738,7 @@ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:f
28737
28738
  import { dirname as dirname4, join as join6 } from "node:path";
28738
28739
 
28739
28740
  // src/build-info.ts
28740
- var VERSION = "0.18.6";
28741
+ var VERSION = "0.18.8";
28741
28742
 
28742
28743
  // src/cli/resolve-version.ts
28743
28744
  function readPackageVersion() {
@@ -4212,6 +4212,7 @@ var init_schema = __esm(() => {
4212
4212
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
4213
4213
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
4214
4214
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
4215
+ boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
4215
4216
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
4216
4217
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
4217
4218
  }).optional();
@@ -4212,6 +4212,7 @@ var init_schema = __esm(() => {
4212
4212
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
4213
4213
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
4214
4214
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
4215
+ boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
4215
4216
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
4216
4217
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
4217
4218
  }).optional();
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.6",
4
+ "version": "0.18.8",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -24,9 +24,9 @@
24
24
  "build": "node scripts/build.mjs",
25
25
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
26
26
  "pretest": "npm run build",
27
- "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
27
+ "test": "vitest run && bun test telegram-plugin/tests/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/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
28
28
  "test:vitest": "vitest run",
29
- "test:bun": "bun test src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/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 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/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",
32
32
  "lint:tsc": "tsc --noEmit",
@@ -87,36 +87,75 @@ CRON_APPEND_PROMPT="You are the cheap background cron worker for {{name}}. You r
87
87
  # claude's project key matches the pre-seeded trust state (.claude-cron).
88
88
  cd "{{agentDir}}" || exit 1
89
89
 
90
- # LiteLLM routing for the cron session — mirrors start.sh's boot block.
90
+ # LiteLLM routing for the cron session — ports start.sh's boot contract.
91
91
  # IMPORTANT: the vault key is provisioned under the BASE agent name ({{name}}),
92
92
  # NOT the cron identity ({{name}}-cron). Use the Handlebars template var so the
93
- # lookup is a compile-time literal — no fragile runtime suffix-stripping.
94
- # Attribution headers carry $SWITCHROOM_AGENT_NAME (= {{name}}-cron) so LiteLLM
95
- # can distinguish cron spend from main-session spend per agent.
96
- # FAIL-OPEN: missing key OR unreachable proxy strip routing env, fall back to
97
- # direct OAuth. An outage must never take the cron session dark.
93
+ # lookup is a compile-time literal — no fragile runtime suffix-stripping. The
94
+ # attribution headers use the SAME compile-time-literal form ({{name}}-cron,
95
+ # identical to $SWITCHROOM_AGENT_NAME here line 69 exports it) so this block
96
+ # refers to the agent one consistent way instead of mixing a template literal
97
+ # for the key with a runtime env var for the tags. Cron spend is attributed to
98
+ # {{name}}-cron so LiteLLM distinguishes it from main-session spend per agent.
99
+ #
100
+ # Two boot failure modes, handled DIFFERENTLY (ported 2026-07 from start.sh so
101
+ # cron no longer fails OPEN on a transient blip and silently runs UNTRACKED
102
+ # direct OAuth for the fire — the product invariant is all model traffic stays
103
+ # proxy-routed for cost/token tracking):
104
+ # - MISSING KEY → fail-open: strip routing, fall back to direct OAuth, LOUD
105
+ # log. Without a key there is no metered route to reach — correct here.
106
+ # - PROXY UNREACHABLE with key in hand → do NOT strip. Keep routing pointed at
107
+ # the proxy AND export the auth header so the session authenticates the
108
+ # moment the proxy recovers (the socat forwarder reconnects per-connection),
109
+ # LOUD log. Never a silent untracked direct-OAuth fallback.
110
+ # Retry budget is DELIBERATELY short — 3 attempts, worst-case ~25s (3×5s curl
111
+ # timeouts + 2×5s sleeps) — vs start.sh's 120s: a cron fire is
112
+ # latency-sensitive and short-lived, and unreachable now KEEPS routing anyway,
113
+ # so a long boot-probe would only add dead latency for no gain.
98
114
  if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; then
99
115
  sr_ll_key="$(switchroom vault get "litellm/{{name}}/api-key" 2>/dev/null || true)"
100
116
  sr_ll_ok=""
117
+ sr_ll_unreachable=""
101
118
  if [ -z "$sr_ll_key" ]; then
102
- echo "litellm: no virtual key for cron '{{name}}' — falling back to direct OAuth (no tracking/guardrail)" >&2
103
- elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ] \
104
- && ! curl -fsS -m 5 -o /dev/null "${SWITCHROOM_LITELLM_BASE:-${ANTHROPIC_BASE_URL%/anthropic}}/health/liveliness" 2>/dev/null; then
105
- echo "litellm: proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} falling back to direct OAuth (no tracking/guardrail this session)" >&2
119
+ echo "litellm(cron): no virtual key for '{{name}}' — falling back to direct OAuth (untracked, unguarded)" >&2
120
+ elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ]; then
121
+ # Bounded retry probe short budget for cron latency (3 attempts,
122
+ # worst-case ~25s: 3×5s curl timeouts + 2×5s sleeps).
123
+ sr_ll_url="${SWITCHROOM_LITELLM_BASE:-${ANTHROPIC_BASE_URL%/anthropic}}/health/liveliness"
124
+ sr_ll_try=0
125
+ sr_ll_up=""
126
+ while :; do
127
+ if curl -fsS -m 5 -o /dev/null "$sr_ll_url" 2>/dev/null; then sr_ll_up="1"; break; fi
128
+ sr_ll_try=$(( sr_ll_try + 1 ))
129
+ [ "$sr_ll_try" -ge 3 ] && break
130
+ sleep 5
131
+ done
132
+ if [ -z "$sr_ll_up" ]; then
133
+ # Proxy unreachable but the key is present. Do NOT strip: keep routing so
134
+ # the cron session self-heals once the proxy is back.
135
+ sr_ll_unreachable="1"
136
+ echo "litellm(cron): proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} after 3 retries — routing LEFT IN PLACE so it self-heals; NOT falling back to untracked direct Anthropic OAuth." >&2
137
+ else
138
+ sr_ll_ok="1"
139
+ fi
140
+ unset sr_ll_url sr_ll_try sr_ll_up
106
141
  else
107
142
  sr_ll_ok="1"
108
143
  fi
109
- if [ -n "$sr_ll_ok" ]; then
144
+ if [ -n "$sr_ll_ok" ] || [ -n "$sr_ll_unreachable" ]; then
145
+ # Key fetched successfully — export the auth header REGARDLESS of the probe
146
+ # outcome so the virtual key reaches the process env. On unreachable this is
147
+ # what lets the session authenticate the moment the proxy recovers instead
148
+ # of 401-ing (the same self-heal fix as start.sh's inner/outer blocks).
110
149
  export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: Bearer $sr_ll_key
111
- x-litellm-customer-id: $SWITCHROOM_AGENT_NAME
112
- x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
150
+ x-litellm-customer-id: {{name}}-cron
151
+ x-litellm-tags: agent:{{name}}-cron,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
113
152
  export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
114
153
  else
115
- # Fail-open: drop every routing var so the claude CLI talks to Anthropic
116
- # directly on its OAuth credential (subscription path), unproxied.
154
+ # Missing-key fail-open: drop every routing var so the claude CLI talks to
155
+ # Anthropic directly on its OAuth credential (subscription path), unproxied.
117
156
  unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM SWITCHROOM_LITELLM_BASE
118
157
  fi
119
- unset sr_ll_key sr_ll_ok
158
+ unset sr_ll_key sr_ll_ok sr_ll_unreachable
120
159
  fi
121
160
 
122
161
  # Create the cron tmux session DETACHED (-d), not in attach mode. This is a
@@ -46,6 +46,17 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
46
46
  # compose env and the inner-pass export. Pinned by the scaffold-order test.
47
47
  export SWITCHROOM_AGENT_NAME="{{name}}"
48
48
 
49
+ # Boot-resume policy (session_continuity.boot_resume; default 'in-flight').
50
+ # The gateway's boot-resume block reads SWITCHROOM_BOOT_RESUME to decide
51
+ # whether a genuinely in-flight turn is auto-resumed across a deliberate
52
+ # restart ('in-flight' — default), always ('always'), or downgraded to a
53
+ # passive notice ('never'). MUST be exported here, before the gateway fork
54
+ # below, or the daemon never sees it and every agent defaults silently — same
55
+ # start.sh env-fork landmine as the channels.telegram knobs. The later
56
+ # SWITCHROOM_RESUME_MODE block is set AFTER the fork (it drives the inner
57
+ # `claude` --continue path, not the gateway), so this needs its own hoist.
58
+ export SWITCHROOM_BOOT_RESUME="{{#if bootResumeMode}}{{{bootResumeMode}}}{{else}}in-flight{{/if}}"
59
+
49
60
  # Gateway-consumed env MUST be exported HERE, before the gateway fork
50
61
  # below. The gateway daemon reads channels.telegram.* knobs (and any
51
62
  # agent env) from process.env at startup — e.g. SWITCHROOM_TG_STREAM_
@@ -120,17 +131,19 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
120
131
  else
121
132
  sr_ll_ok="1"
122
133
  fi
123
- if [ -n "$sr_ll_ok" ]; then
134
+ if [ -n "$sr_ll_ok" ] || [ -n "$sr_ll_unreachable" ]; then
135
+ # Key fetched successfully — export the proxy auth header REGARDLESS of the
136
+ # reachability probe outcome. The header is what carries the virtual key
137
+ # into the process env; without it the gateway's sr-* discovery
138
+ # (gateway.ts discoverSrModels) is dead and, once the proxy recovers,
139
+ # every request 401s until a manual restart. Since routing is LEFT IN
140
+ # PLACE on unreachable (below/above), the header must be too so the
141
+ # session actually self-heals when litellm returns. The probe result gates
142
+ # only log wording + inner-pass _LITELLM_OK, never the header.
124
143
  export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: Bearer $sr_ll_key
125
144
  x-litellm-customer-id: $SWITCHROOM_AGENT_NAME
126
145
  x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
127
146
  export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
128
- elif [ -n "$sr_ll_unreachable" ]; then
129
- # Proxy unreachable at boot: routing intentionally LEFT IN PLACE (loud
130
- # warning already emitted) so it survives into the inner pass and
131
- # self-heals once litellm returns. NO unset — the permanent strip here
132
- # was what made the inner self-heal fix inert on docker.
133
- :
134
147
  else
135
148
  # Missing-key fail-open: gateway talks direct OAuth, unproxied.
136
149
  unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM SWITCHROOM_LITELLM_BASE
@@ -967,8 +980,8 @@ fi
967
980
  # per-session cost/issue tracking is handled out-of-band by correlating
968
981
  # LiteLLM's request log against switchroom's turn ledger, never by mutating the
969
982
  # claude protocol.
970
- # _LITELLM_OK is read by the session-model-override block below: an sr-*
971
- # (LiteLLM/OpenRouter) session override must be DROPPED when the proxy is
983
+ # _LITELLM_OK is read by the session-model resolution block below: an sr-*
984
+ # (LiteLLM/OpenRouter) session override must NOT be applied when the proxy is
972
985
  # unreachable at boot, or claude would 4xx an unknown model against Anthropic.
973
986
  # Empty by default (proxy not confirmed live); set to "1" only on a successful
974
987
  # liveliness probe. Deliberately NOT unset with the sr_ll_* scratch vars.
@@ -1016,8 +1029,15 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1016
1029
  else
1017
1030
  sr_ll_ok="1"
1018
1031
  fi
1019
- if [ -n "$sr_ll_ok" ]; then
1020
- _LITELLM_OK="1"
1032
+ if [ -n "$sr_ll_ok" ] || [ -n "$sr_ll_unreachable" ]; then
1033
+ # Key fetched successfully — export the proxy auth header REGARDLESS of the
1034
+ # reachability probe outcome. Routing is LEFT IN PLACE on unreachable so the
1035
+ # session self-heals when litellm returns; the header MUST survive with it,
1036
+ # otherwise every request 401s once the proxy recovers (the "self-heals"
1037
+ # promise below was inert without this) and gateway sr-* discovery stays
1038
+ # dead. The probe outcome gates ONLY the _LITELLM_OK flag (sr-* override
1039
+ # drop) and the log wording above — never the header.
1040
+ #
1021
1041
  # Newline-separated Name: Value pairs (claude CLI ANTHROPIC_CUSTOM_HEADERS format).
1022
1042
  # Tags: agent:<name> for per-agent spend tracking; profile:<profile> for
1023
1043
  # fleet-level cost breakdown by role. Per-turn tags (cron vs telegram) are
@@ -1031,13 +1051,12 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
1031
1051
  # models configured in the proxy appear in the /model picker and can be
1032
1052
  # selected via --model. Without this, the CLI only knows its bundled list.
1033
1053
  export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
1034
- elif [ -n "$sr_ll_unreachable" ]; then
1035
- # Proxy unreachable at boot: routing intentionally LEFT IN PLACE (loud
1036
- # warning already emitted above) so it self-heals when litellm returns.
1037
- # _LITELLM_OK stays empty so any sr-* session model override is still
1038
- # dropped below (claude would 4xx an unknown model against a down endpoint).
1039
- # NO unset here — that permanent strip was the bug this branch fixes.
1040
- :
1054
+ if [ -n "$sr_ll_ok" ]; then
1055
+ # Probe succeeded: proxy is live, so honor any sr-* session model override
1056
+ # below. On unreachable, _LITELLM_OK stays empty so the override is dropped
1057
+ # (claude would 4xx an unknown model against a down endpoint).
1058
+ _LITELLM_OK="1"
1059
+ fi
1041
1060
  else
1042
1061
  # Missing key fail-open: drop every routing var so the claude CLI talks to
1043
1062
  # Anthropic directly on its OAuth credential (subscription path), unproxied.
@@ -1046,54 +1065,131 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
1046
1065
  unset sr_ll_key sr_ll_ok sr_ll_unreachable
1047
1066
  fi
1048
1067
 
1049
- # --- Session-only model override (carrier: .session-model-override) ---
1068
+ # --- Session model resolution (durable .session-model + .relaunch-model-intent) ---
1069
+ #
1070
+ # Contract: reference/rfcs/session-model-stickiness.md. A positively-
1071
+ # confirmed `/model X` switch persists as `{{agentDir}}/.session-model`
1072
+ # (one-line JSON written by the gateway). Whether THIS boot applies it is
1073
+ # decided by the one-shot `.relaunch-model-intent` file, stamped by the
1074
+ # gateway immediately before every switchroom-managed bounce:
1050
1075
  #
1051
- # The Telegram `/model <sr-*>` switch can't use claude's in-REPL `/model`
1052
- # picker for non-Anthropic (LiteLLM/OpenRouter `sr-*`) models the CLI's
1053
- # native picker rejects unknown ids ("Model not found"). Instead the gateway
1054
- # writes the chosen model token to `.session-model-override` and gracefully
1055
- # restarts the agent; THIS boot picks it up and launches `claude --model
1056
- # <token>` directly (no picker validation at launch LiteLLM routes it).
1076
+ # fresh (<10 min by embedded ts) "keep" intent → apply the override
1077
+ # anything else (revert / absent / stale / corrupt) delete the override,
1078
+ # boot the configured default, and (when an override was actually
1079
+ # removed) write a `.session-model-alert` notice naming why.
1080
+ #
1081
+ # Default is REVERT (operator decision 2026-07): a crash, OOM, raw
1082
+ # `docker restart`, host reboot, or deploy writes no intent and therefore
1083
+ # reverts to the yaml model. Watchdog/recovery bounces run gateway code and
1084
+ # stamp "keep" before SIGTERM, so a confirmed switch survives them.
1057
1085
  #
1058
- # One-shot + session-only: the carrier is consumed (rm) on this one boot
1059
- # regardless of validity, so the next restart reverts to the configured
1060
- # default `{{{modelQ}}}`. Mirrors the `.force-fresh-session` marker's
1061
- # gateway↔start.sh round-trip. See telegram-plugin/gateway/model-command.ts.
1062
1086
  # NB {{{modelQ}}} is already shell-single-quoted by the scaffold (it renders as
1063
1087
  # a quoted token, e.g. 'claude-sonnet-5'), so it is assigned BARE here — never
1064
1088
  # inside additional double quotes, which would embed the literal quote chars in
1065
1089
  # the value and break `claude --model`.
1066
1090
  _EFFECTIVE_MODEL={{{modelQ}}}
1091
+ # Record the RESOLVED configured default (raw, unquoted) every boot, before
1092
+ # override resolution. The gateway copies this into the override's
1093
+ # `configuredDefaultAtWrite`, so both sides of the invalidation compare below
1094
+ # come from the same resolver. Overwrite, not consumed.
1095
+ printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.configured-default-model" 2>/dev/null || true
1096
+
1097
+ # Migration shim (one release, RFC §7): a leftover one-shot
1098
+ # `.session-model-override` carrier means an OLD gateway wrote it immediately
1099
+ # before this very bounce — it is the newest user intent and WINS over any
1100
+ # `.session-model` on disk. Convert (overwrite) + consume, and apply THIS
1101
+ # boot (the carrier's presence is itself the keep signal).
1102
+ _sm_keep=""
1067
1103
  if [ -f "{{agentDir}}/.session-model-override" ]; then
1068
- _override="$(tr -d '[:space:]' < "{{agentDir}}/.session-model-override" 2>/dev/null || true)"
1104
+ _mig="$(tr -d '[:space:]' < "{{agentDir}}/.session-model-override" 2>/dev/null || true)"
1069
1105
  rm -f "{{agentDir}}/.session-model-override"
1070
- # Shape gate kept aligned with MODEL_ARG_RE in telegram-plugin/gateway/model-command.ts.
1071
- # `/` is allowed for OpenRouter-style `sr-vendor/model` ids; it is not a shell
1072
- # metachar inside the double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage below.
1073
- if printf '%s' "$_override" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1074
- _EFFECTIVE_MODEL="$_override"
1075
- # LiteLLM-down guard for an sr-* OVERRIDE only (operator decision 2026-07):
1076
- # with the proxy unreachable at boot there IS a Claude configured default to
1077
- # fall back to, so DROP the override rather than boot on an unreachable sr-*
1078
- # model that would 4xx against Anthropic — log LOUDLY and write an alert
1079
- # sentinel the gateway turns into a Telegram message. (The passthrough→router
1080
- # repoint for a LIVE sr-* override OR configured default — is done once,
1081
- # post-resolution, below.)
1082
- case "$_override" in
1106
+ if printf '%s' "$_mig" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1107
+ printf '{"model":"%s","configuredDefaultAtWrite":"%s","ts":%s}\n' "$_mig" "$_EFFECTIVE_MODEL" "$(( $(date +%s) * 1000 ))" > "{{agentDir}}/.session-model" 2>/dev/null || true
1108
+ _sm_keep="1"
1109
+ echo "session-model: migrated legacy one-shot carrier '$_mig' to durable .session-model (applying this boot)" >&2
1110
+ else
1111
+ echo "session-model: ignoring malformed legacy .session-model-override (failed shape gate)" >&2
1112
+ fi
1113
+ unset _mig
1114
+ fi
1115
+
1116
+ # One-shot intent: consume unconditionally, honor only a fresh "keep".
1117
+ # Freshness clock is the EMBEDDED ts (ms), never file mtime — same clock the
1118
+ # gateway writes with.
1119
+ _sm_reason=""
1120
+ if [ -f "{{agentDir}}/.relaunch-model-intent" ]; then
1121
+ _int_raw="$(cat "{{agentDir}}/.relaunch-model-intent" 2>/dev/null || true)"
1122
+ rm -f "{{agentDir}}/.relaunch-model-intent"
1123
+ _int="$(printf '%s' "$_int_raw" | sed -n 's/.*"intent"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p')"
1124
+ _int_ts="$(printf '%s' "$_int_raw" | sed -n 's/.*"ts"[[:space:]]*:[[:space:]]*\([0-9]\{1,\}\).*/\1/p')"
1125
+ _sm_reason="$(printf '%s' "$_int_raw" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1126
+ if [ "$_int" = "keep" ] && [ -n "$_int_ts" ] && [ $(( $(date +%s) * 1000 - _int_ts )) -lt 600000 ]; then
1127
+ _sm_keep="1"
1128
+ fi
1129
+ unset _int_raw _int _int_ts
1130
+ fi
1131
+
1132
+ if [ -f "{{agentDir}}/.session-model" ]; then
1133
+ _smf="$(cat "{{agentDir}}/.session-model" 2>/dev/null || true)"
1134
+ _sm_model="$(printf '%s' "$_smf" | sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1135
+ _sm_cfg="$(printf '%s' "$_smf" | sed -n 's/.*"configuredDefaultAtWrite"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1136
+ _sm_ts="$(printf '%s' "$_smf" | sed -n 's/.*"ts"[[:space:]]*:[[:space:]]*\([0-9]\{1,\}\).*/\1/p')"
1137
+ if [ -z "$_sm_keep" ]; then
1138
+ # No fresh keep intent → deliberate restart, crash, external container
1139
+ # restart, or deploy. Revert to the configured default and say so.
1140
+ rm -f "{{agentDir}}/.session-model"
1141
+ _sm_why="${_sm_reason:-no keep intent — crash, external container restart, or deploy}"
1142
+ echo "session-model: reverting to configured default '$_EFFECTIVE_MODEL' — $_sm_why (session override '$_sm_model' cleared)" >&2
1143
+ printf 'Session model override `%s` was cleared — this relaunch reverted to the configured default `%s` (%s). Re-issue /model %s to switch back.\n' "$_sm_model" "$_EFFECTIVE_MODEL" "$_sm_why" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1144
+ unset _sm_why
1145
+ # Shape gate — kept BYTE-IDENTICAL with MODEL_ARG_RE in
1146
+ # telegram-plugin/gateway/model-command.ts. `/` is allowed for
1147
+ # OpenRouter-style `sr-vendor/model` ids; it is not a shell metachar inside
1148
+ # the double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage below.
1149
+ # The wc -c/-l check rejects EMPTY and MULTILINE values first: the sed
1150
+ # extraction above emits one match per matching LINE, and `grep -Eq`
1151
+ # passes if ANY line matches — a multiline value must never reach
1152
+ # `claude --model` (parity with parseSessionModel's single-string check).
1153
+ elif [ "$(printf '%s' "$_sm_model" | wc -c)" -eq 0 ] || [ "$(printf '%s' "$_sm_model" | wc -l)" -ne 0 ] || ! printf '%s' "$_sm_model" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1154
+ rm -f "{{agentDir}}/.session-model"
1155
+ echo "session-model: ignoring malformed .session-model (failed shape gate) — using configured default '$_EFFECTIVE_MODEL'" >&2
1156
+ elif [ "$_sm_cfg" != "$_EFFECTIVE_MODEL" ]; then
1157
+ # switchroom.yaml `model:` changed since the switch → the override is
1158
+ # against a default that no longer exists. Invalidate + announce.
1159
+ rm -f "{{agentDir}}/.session-model"
1160
+ echo "session-model: configured default changed ('$_sm_cfg' → '$_EFFECTIVE_MODEL') — clearing session override '$_sm_model'" >&2
1161
+ printf 'The configured default model changed (`%s` → `%s`), so your session override to `%s` was cleared — the agent booted on the new configured default. Re-issue /model %s if you still want it.\n' "$_sm_cfg" "$_EFFECTIVE_MODEL" "$_sm_model" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1162
+ elif [ -n "$_sm_ts" ] && [ $(( $(date +%s) * 1000 - _sm_ts )) -gt 604800000 ]; then
1163
+ # 7-day staleness bound (belt-and-braces vs version-rollback resurrection
1164
+ # and long-forgotten overrides).
1165
+ rm -f "{{agentDir}}/.session-model"
1166
+ echo "session-model: session override '$_sm_model' is older than 7 days — expiring it, using configured default '$_EFFECTIVE_MODEL'" >&2
1167
+ printf 'Session model override `%s` expired (older than 7 days) — the agent booted on its configured default `%s`. Re-issue /model %s if you still want it.\n' "$_sm_model" "$_EFFECTIVE_MODEL" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1168
+ else
1169
+ case "$_sm_model" in
1083
1170
  sr-*)
1084
1171
  if [ -z "$_LITELLM_OK" ]; then
1085
- _EFFECTIVE_MODEL={{{modelQ}}}
1086
- echo "session-model: LiteLLM proxy unreachable at boot — DROPPING sr-* override '$_override', booting on configured default '$_EFFECTIVE_MODEL' instead (would otherwise 4xx against Anthropic)" >&2
1087
- echo "session-model: the requested sr-* model is UNAVAILABLE this session; re-issue /model $_override once LiteLLM is reachable" >&2
1088
- printf 'LiteLLM proxy was unreachable at boot, so the session-only model switch to `%s` was dropped — the agent booted on its configured default `%s` instead. Re-issue /model %s once LiteLLM is reachable.\n' "$_override" "$_EFFECTIVE_MODEL" "$_override" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1172
+ # sr-* + LiteLLM unreachable: booting the override would 4xx against
1173
+ # Anthropic. Boot the configured default but RETAIN the durable file
1174
+ # it re-applies on the next keep relaunch once the proxy is back.
1175
+ echo "session-model: LiteLLM proxy unreachable at boot NOT applying sr-* override '$_sm_model' this boot; booting configured default '$_EFFECTIVE_MODEL' (override retained, re-applies next relaunch)" >&2
1176
+ printf 'LiteLLM proxy was unreachable at boot, so the session model override `%s` was not applied — the agent booted on its configured default `%s`. The override is retained and will re-apply on the next switchroom-managed relaunch once LiteLLM is reachable; /model default drops it.\n' "$_sm_model" "$_EFFECTIVE_MODEL" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1177
+ else
1178
+ _EFFECTIVE_MODEL="$_sm_model"
1089
1179
  fi
1090
1180
  ;;
1181
+ *)
1182
+ _EFFECTIVE_MODEL="$_sm_model"
1183
+ ;;
1091
1184
  esac
1092
- else
1093
- echo "session-model: ignoring malformed .session-model-override (failed shape gate) using configured default '$_EFFECTIVE_MODEL'" >&2
1185
+ if [ "$_EFFECTIVE_MODEL" = "$_sm_model" ]; then
1186
+ echo "session-model: keeping session override '$_sm_model' across this switchroom-managed relaunch${_sm_reason:+ ($_sm_reason)}" >&2
1187
+ printf 'Session model override `%s` kept across this relaunch%s. It reverts on /restart, agent restart, crash, or an external container restart; /model default clears it now.\n' "$_sm_model" "${_sm_reason:+ ($_sm_reason)}" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1188
+ fi
1094
1189
  fi
1095
- unset _override
1190
+ unset _smf _sm_model _sm_cfg _sm_ts
1096
1191
  fi
1192
+ unset _sm_keep _sm_reason
1097
1193
 
1098
1194
  # sr-* passthrough→router repoint — ONE post-resolution gate covering BOTH the
1099
1195
  # /model override path AND the configured-default path (`model: sr-*` in
@@ -105,7 +105,7 @@ By default, every restart starts a **fresh `claude` session** — the in-flight
105
105
  - **`resume_interrupted`** (operator restart / SIGTERM / crash): pick the work back up and carry it to completion. Briefly tell the user you're resuming and roughly how long ago it was interrupted — then just do it. Do NOT ask whether to resume.
106
106
  - **`resume_watchdog_timeout`** (hang-watchdog killed it after no progress): do NOT silently resume — it may hang the same way. Tell the user plainly that your last turn was killed after N minutes of no progress, roughly what it was doing, and ask whether to retry or take a different angle. Report only the honest cause; don't invent a deeper root cause.
107
107
  The one-shot `SWITCHROOM_PENDING_*` env vars are passive forensic context for the wake-audit / "why did you restart" protocols — not the resume trigger.
108
- - **`.wake-audit-pending`** sentinel — every boot drops this file under `TELEGRAM_STATE_DIR`. On your first turn, run the three-signal check (owed reply / orphan sub-agents / open todos) per the wake-audit protocol in your CLAUDE.md, then `rm -f` the sentinel.
108
+ - **`.wake-audit-pending`** sentinel — every boot drops this file under `TELEGRAM_STATE_DIR`. On your first turn, run the three-signal check (owed reply / orphan sub-agents / open todos) per the wake-audit protocol in the `switchroom-runtime` skill (`skills/switchroom-runtime/SKILL.md`), then `rm -f` the sentinel.
109
109
 
110
110
  A config-summary greeting card is sent automatically by the SessionStart hook — you don't need to announce yourself. If your context feels thin (after compaction or any fresh session), proactively recall from Hindsight before proceeding.
111
111
 
@@ -178,4 +178,6 @@ This is **the persistent-shell wedge.** Claude Code keeps a single `bash` subpro
178
178
 
179
179
  **Triggering causes to avoid.** The wedge most often follows: (a) a long `npm test` / `bun test` run, (b) any command that was `!`-interrupted mid-flight, (c) heredoc-style commands the shell's stdin couldn't fully consume. Prevention: dispatch heavy test suites to a worker sub-agent (so the wedge dies with the worker) rather than running them in your own session, and use `run_in_background: true` for long jobs.
180
180
 
181
+ **Mid-flight responsiveness — never block your turn on a long foreground watch.** A blocking foreground command (`gh pr checks --watch`, `sleep`-and-poll loops, a long `docker build`) pins your whole turn: a user message arriving mid-watch waits behind it, sometimes for minutes. The gateway posts a deterministic "⏳ Queued — currently inside `<tool>`" ack on your behalf (#2995), but the ack is a mitigation, not a license. Run long watches as background tasks (`run_in_background: true`, then poll `BashOutput` between other work) so mid-flight questions get real answers in seconds.
182
+
181
183
  A sentinel file at `$TELEGRAM_STATE_DIR/wedge-detected.json` records the most recent wedge detection. Operators can `cat` it for forensic timestamps; you don't normally need to read it yourself.
@@ -23132,6 +23132,20 @@ function createToolLabelSidecar(opts) {
23132
23132
  };
23133
23133
  }
23134
23134
 
23135
+ // model-label.ts
23136
+ function isModelSentinel(model) {
23137
+ if (typeof model !== "string")
23138
+ return true;
23139
+ const m = model.trim();
23140
+ if (m.length === 0)
23141
+ return true;
23142
+ if (m.startsWith("<"))
23143
+ return true;
23144
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(m))
23145
+ return true;
23146
+ return false;
23147
+ }
23148
+
23135
23149
  // session-tail.ts
23136
23150
  function isMultiAgentEnabled(env = process.env) {
23137
23151
  return env.PROGRESS_CARD_MULTI_AGENT !== "0";
@@ -23263,6 +23277,10 @@ function projectTranscriptLine(line) {
23263
23277
  if (!Array.isArray(content))
23264
23278
  return [];
23265
23279
  const events = [];
23280
+ const mainModel = message?.model;
23281
+ if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
23282
+ events.push({ kind: "model", model: mainModel });
23283
+ }
23266
23284
  const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({ kind: "text", text, blockIndex, lastInMessage }));
23267
23285
  content.forEach((c, i) => {
23268
23286
  const ct = c.type;
@@ -23365,6 +23383,10 @@ function projectSubagentLine(line, agentId, state) {
23365
23383
  if (!Array.isArray(content))
23366
23384
  return [];
23367
23385
  const events = [];
23386
+ const subModel = message?.model;
23387
+ if (typeof subModel === "string" && !isModelSentinel(subModel)) {
23388
+ events.push({ kind: "sub_agent_model", agentId, model: subModel });
23389
+ }
23368
23390
  const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({
23369
23391
  kind: "sub_agent_text",
23370
23392
  agentId,