switchroom 0.18.15 → 0.18.17

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 (40) hide show
  1. package/dist/agent-scheduler/index.js +3 -0
  2. package/dist/auth-broker/index.js +432 -10
  3. package/dist/cli/notion-write-pretool.mjs +3 -0
  4. package/dist/cli/switchroom.js +50 -1
  5. package/dist/host-control/main.js +4 -1
  6. package/dist/vault/approvals/kernel-server.js +3 -0
  7. package/dist/vault/broker/server.js +3 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/dist/gateway/gateway.js +386 -259
  11. package/telegram-plugin/draft-stream.ts +78 -3
  12. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
  13. package/telegram-plugin/gateway/effort-command.ts +9 -7
  14. package/telegram-plugin/gateway/gateway.ts +265 -220
  15. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  16. package/telegram-plugin/gateway/model-command.ts +96 -18
  17. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  18. package/telegram-plugin/gateway/session-model-file.ts +38 -172
  19. package/telegram-plugin/litellm-local-notice.ts +189 -0
  20. package/telegram-plugin/quota-watch.ts +16 -4
  21. package/telegram-plugin/runtime-metrics.ts +16 -0
  22. package/telegram-plugin/send-gate-degraded.test.ts +9 -7
  23. package/telegram-plugin/send-gate.ts +34 -4
  24. package/telegram-plugin/stream-controller.ts +143 -20
  25. package/telegram-plugin/stream-reply-handler.ts +12 -2
  26. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  27. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  28. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  29. package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
  30. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  31. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  32. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  33. package/telegram-plugin/tests/model-command.test.ts +84 -1
  34. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  35. package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
  36. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  37. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  38. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  39. package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
  40. package/telegram-plugin/worker-activity-feed.ts +83 -8
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.18.15", COMMIT_SHA = "2fa611a1";
2123
+ var VERSION = "0.18.17", COMMIT_SHA = "372a4968";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -13843,6 +13843,9 @@ var init_schema = __esm(() => {
13843
13843
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
13844
13844
  max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 \u2014 a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
13845
13845
  }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
13846
+ litellm_notice: exports_external.object({
13847
+ window_ms: exports_external.number().int().positive().optional().describe("Per-agent cooldown window (ms) for the litellm-local 429 notice. " + "When the agent trips the LiteLLM proxy's OWN tpm_limit/rpm_limit " + "cap (a `litellm-local` classified 429 \u2014 see docs/auth.md \u00a7 " + "LiteLLM-proxy-local 429s), the gateway posts ONE calm notice " + "naming the fleet token limiter, then counts further hits silently " + "for this long; the first notice after the window expires says " + "how many were absorbed. Default 900000 (15 min). Invalid values " + "fall back to the default.")
13848
+ }).optional().describe("Debounce tuning for the litellm-local throttle notice \u2014 the calm " + "'fleet token limiter engaged' message posted when the LiteLLM " + "proxy's own rate cap trips (never an Anthropic account limit). " + "Cascades from defaults.channels.telegram.litellm_notice."),
13846
13849
  interrupt: exports_external.object({
13847
13850
  safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
13848
13851
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short \u2014 the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
@@ -28995,6 +28998,9 @@ function buildAccessJson2(agentConfig, telegramConfig, resolvedTopicId, userId)
28995
28998
  if (typeof tg?.coalesce?.max_attachments === "number") {
28996
28999
  access.coalesceMaxAttachments = tg.coalesce.max_attachments;
28997
29000
  }
29001
+ if (typeof tg?.litellm_notice?.window_ms === "number") {
29002
+ access.litellmNoticeWindowMs = tg.litellm_notice.window_ms;
29003
+ }
28998
29004
  if (typeof tg?.interrupt?.safe_boundary === "boolean") {
28999
29005
  access.interruptSafeBoundary = tg.interrupt.safe_boundary;
29000
29006
  }
@@ -69654,6 +69660,36 @@ function printConsumersTable(state) {
69654
69660
  console.log(` ${c.name.padEnd(10)} ${acct} ${status}`);
69655
69661
  }
69656
69662
  }
69663
+ function usageHeadroomCell(t) {
69664
+ const noColor = (s) => s;
69665
+ if (t == null)
69666
+ return { plain: "no data", color: source_default.gray };
69667
+ if (t.headroomPct == null)
69668
+ return { plain: "unknown", color: source_default.gray };
69669
+ const left = Math.round(t.headroomPct);
69670
+ const peak = t.peakUtilizationPct == null ? "?" : `${Math.round(t.peakUtilizationPct)}`;
69671
+ const base = `${left}% left (peak ${peak}%, n=${t.observations})`;
69672
+ if (t.walled)
69673
+ return { plain: `WALLED - ${base}`, color: source_default.red };
69674
+ if (t.refilled)
69675
+ return { plain: `refilled - ${base}`, color: source_default.green };
69676
+ if (left <= 10)
69677
+ return { plain: base, color: source_default.yellow };
69678
+ return { plain: base, color: noColor };
69679
+ }
69680
+ function printUsageTable(state) {
69681
+ console.log(source_default.bold(" ACCOUNT PREMIUM (Fable) HEADROOM STANDARD HEADROOM"));
69682
+ const WIDTH = 44;
69683
+ for (const a of state.accounts) {
69684
+ const marker = a.label === state.active ? source_default.green("*") : " ";
69685
+ const label = a.label.padEnd(32);
69686
+ const premium = usageHeadroomCell(a.usage_ledger?.premium ?? null);
69687
+ const standard = usageHeadroomCell(a.usage_ledger?.standard ?? null);
69688
+ const premiumCol = premium.color(premium.plain.padEnd(WIDTH));
69689
+ const standardCol = standard.color(standard.plain);
69690
+ console.log(` ${marker} ${label} ${premiumCol} ${standardCol}`);
69691
+ }
69692
+ }
69657
69693
  function printAgentDetail(state, agent) {
69658
69694
  console.log();
69659
69695
  console.log(source_default.bold(` ${agent.name}`));
@@ -69832,6 +69868,19 @@ function registerAuthCommand(program3) {
69832
69868
  printAccountsTable(state);
69833
69869
  console.log();
69834
69870
  }));
69871
+ auth.command("usage").description("Per-account premium (Fable) + standard usage headroom, from the durable ledger").option("--json", "Output the raw per-account usage_ledger summaries as JSON").action(withConfigError(async (opts) => {
69872
+ const state = await brokerCall((client) => client.listState());
69873
+ if (opts.json) {
69874
+ console.log(JSON.stringify(state.accounts.map((a) => ({ label: a.label, usage_ledger: a.usage_ledger ?? null })), null, 2));
69875
+ return;
69876
+ }
69877
+ console.log();
69878
+ printUsageTable(state);
69879
+ console.log();
69880
+ console.log(source_default.gray(" Premium = flagship (Fable) 7d_oi tier; standard = 5h/7d. Passive: harvested from probe headers, no extra requests."));
69881
+ console.log(source_default.gray(" 'X% left' is current (refill-aware); 'peak' is the max over retained history (~48h) and may predate the latest reset."));
69882
+ console.log();
69883
+ }));
69835
69884
  function pinAuthActiveInYaml(label, quiet = false) {
69836
69885
  try {
69837
69886
  const yamlPath = getConfigPath(program3);
@@ -18861,6 +18861,9 @@ var TelegramChannelSchema = exports_external.object({
18861
18861
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
18862
18862
  max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
18863
18863
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
18864
+ litellm_notice: exports_external.object({
18865
+ window_ms: exports_external.number().int().positive().optional().describe("Per-agent cooldown window (ms) for the litellm-local 429 notice. " + "When the agent trips the LiteLLM proxy's OWN tpm_limit/rpm_limit " + "cap (a `litellm-local` classified 429 — see docs/auth.md § " + "LiteLLM-proxy-local 429s), the gateway posts ONE calm notice " + "naming the fleet token limiter, then counts further hits silently " + "for this long; the first notice after the window expires says " + "how many were absorbed. Default 900000 (15 min). Invalid values " + "fall back to the default.")
18866
+ }).optional().describe("Debounce tuning for the litellm-local throttle notice — the calm " + "'fleet token limiter engaged' message posted when the LiteLLM " + "proxy's own rate cap trips (never an Anthropic account limit). " + "Cascades from defaults.channels.telegram.litellm_notice."),
18864
18867
  interrupt: exports_external.object({
18865
18868
  safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
18866
18869
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
@@ -26589,7 +26592,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26589
26592
  import { dirname as dirname4, join as join7 } from "node:path";
26590
26593
 
26591
26594
  // src/build-info.ts
26592
- var VERSION = "0.18.15";
26595
+ var VERSION = "0.18.17";
26593
26596
 
26594
26597
  // src/cli/resolve-version.ts
26595
26598
  function readPackageVersion() {
@@ -4281,6 +4281,9 @@ var init_schema = __esm(() => {
4281
4281
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
4282
4282
  max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
4283
4283
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
4284
+ litellm_notice: exports_external.object({
4285
+ window_ms: exports_external.number().int().positive().optional().describe("Per-agent cooldown window (ms) for the litellm-local 429 notice. " + "When the agent trips the LiteLLM proxy's OWN tpm_limit/rpm_limit " + "cap (a `litellm-local` classified 429 — see docs/auth.md § " + "LiteLLM-proxy-local 429s), the gateway posts ONE calm notice " + "naming the fleet token limiter, then counts further hits silently " + "for this long; the first notice after the window expires says " + "how many were absorbed. Default 900000 (15 min). Invalid values " + "fall back to the default.")
4286
+ }).optional().describe("Debounce tuning for the litellm-local throttle notice — the calm " + "'fleet token limiter engaged' message posted when the LiteLLM " + "proxy's own rate cap trips (never an Anthropic account limit). " + "Cascades from defaults.channels.telegram.litellm_notice."),
4284
4287
  interrupt: exports_external.object({
4285
4288
  safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
4286
4289
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
@@ -4281,6 +4281,9 @@ var init_schema = __esm(() => {
4281
4281
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
4282
4282
  max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
4283
4283
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
4284
+ litellm_notice: exports_external.object({
4285
+ window_ms: exports_external.number().int().positive().optional().describe("Per-agent cooldown window (ms) for the litellm-local 429 notice. " + "When the agent trips the LiteLLM proxy's OWN tpm_limit/rpm_limit " + "cap (a `litellm-local` classified 429 — see docs/auth.md § " + "LiteLLM-proxy-local 429s), the gateway posts ONE calm notice " + "naming the fleet token limiter, then counts further hits silently " + "for this long; the first notice after the window expires says " + "how many were absorbed. Default 900000 (15 min). Invalid values " + "fall back to the default.")
4286
+ }).optional().describe("Debounce tuning for the litellm-local throttle notice — the calm " + "'fleet token limiter engaged' message posted when the LiteLLM " + "proxy's own rate cap trips (never an Anthropic account limit). " + "Cascades from defaults.channels.telegram.litellm_notice."),
4284
4287
  interrupt: exports_external.object({
4285
4288
  safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
4286
4289
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
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.15",
4
+ "version": "0.18.17",
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": {
@@ -1130,23 +1130,28 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
1130
1130
  unset sr_ll_key sr_ll_ok sr_ll_unreachable
1131
1131
  fi
1132
1132
 
1133
- # --- Session model resolution (durable .session-model + .relaunch-model-intent) ---
1133
+ # --- Session model resolution (consume-once .session-model carrier) ---
1134
1134
  #
1135
- # Contract: reference/rfcs/session-model-stickiness.md (#3039 revision). A
1136
- # positively-confirmed `/model X` switch persists as
1137
- # `{{agentDir}}/.session-model` (one-line JSON written by the gateway) and is
1138
- # honored on EVERY boot — deploy, watchdog bounce, raw `docker restart`,
1139
- # host reboot, crash. It is cleared only by:
1135
+ # Contract: reference/rfcs/session-model-stickiness.md §0.1 (rev 4, operator
1136
+ # decision 2026-07-12 SESSION-SCOPED, superseding rev 3 keep-by-default). A
1137
+ # `/model` override lasts only for the current session:
1140
1138
  #
1141
- # - explicit user action (`/model default` deletes it live; a fresh
1142
- # (<10 min) explicit "revert" `.relaunch-model-intent` reverts at boot)
1143
- # - invalidation: corrupt/malformed file, or the configured yaml `model:`
1144
- # changed since the switch
1139
+ # - Claude switches apply LIVE in-session (claude's native picker) and write
1140
+ # NO carrier — the explicit `claude --model {{{modelQ}}}` this script execs
1141
+ # reverts them for free on the next boot.
1142
+ # - sr-* / sr→Claude switches and a queued /model persisted at shutdown need
1143
+ # a relaunch to take effect, so the gateway writes `{{agentDir}}/.session-model`
1144
+ # (one-line JSON) IMMEDIATELY before that relaunch. This block APPLIES the
1145
+ # carrier on the single boot that reads it and then DELETES it (consume-once).
1146
+ # Any SUBSEQUENT restart — deploy, `/restart`, `/new`, watchdog recovery,
1147
+ # crash, raw `docker restart`, host reboot — finds no carrier and boots the
1148
+ # configured default. That deletion is the marker distinguishing the
1149
+ # model-apply relaunch from every later restart.
1145
1150
  #
1146
- # Every clearing path writes `.session-model-alert`, which the gateway
1147
- # relays to the operator chat once at boot — an override is never dropped
1148
- # silently. Default (no intent / stale / corrupt intent) is KEEP (operator
1149
- # decision 2026-07-11, superseding the earlier revert-by-default).
1151
+ # `/model default` deletes the carrier live. Invalidation paths (corrupt file,
1152
+ # configured yaml `model:` changed) also delete + write `.session-model-alert`,
1153
+ # which the gateway relays to the operator chat once at boot. The normal apply
1154
+ # path is silent here — the gateway already acked the `/model` in chat.
1150
1155
  #
1151
1156
  # NB {{{modelQ}}} is already shell-single-quoted by the scaffold (it renders as
1152
1157
  # a quoted token, e.g. 'claude-sonnet-5'), so it is assigned BARE here — never
@@ -1154,63 +1159,42 @@ fi
1154
1159
  # the value and break `claude --model`.
1155
1160
  _EFFECTIVE_MODEL={{{modelQ}}}
1156
1161
  # Record the RESOLVED configured default (raw, unquoted) every boot, before
1157
- # override resolution. The gateway copies this into the override's
1162
+ # override resolution. The gateway copies this into the carrier's
1158
1163
  # `configuredDefaultAtWrite`, so both sides of the invalidation compare below
1159
1164
  # come from the same resolver. Overwrite, not consumed.
1160
1165
  printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.configured-default-model" 2>/dev/null || true
1161
1166
 
1162
- # Migration shim (one release, RFC §7): a leftover one-shot
1163
- # `.session-model-override` carrier means an OLD gateway wrote it immediately
1164
- # before this very bounce it is the newest user intent and WINS over any
1165
- # `.session-model` on disk. Convert (overwrite) + consume, and apply THIS
1166
- # boot (the carrier's presence is itself the keep signal).
1167
- _sm_keep=""
1167
+ # Rev-4 hygiene (#3184 review LOW-2): remove the files retired with the
1168
+ # keep/revert intent subsystem and the crashloop self-heal. Nothing reads them
1169
+ # anymore; without this rm they would linger in the bind-mounted state dir
1170
+ # forever after rollout. Idempotent a cheap no-op once clean.
1171
+ rm -f "{{agentDir}}/.relaunch-model-intent" "{{agentDir}}/.session-model-boot-attempts" "{{agentDir}}/.session-model-kept-notified"
1172
+
1173
+ # Migration shim (one release): a leftover one-shot `.session-model-override`
1174
+ # carrier from a pre-consume-once gateway means an OLD gateway wrote it
1175
+ # immediately before this very bounce — convert it to `.session-model` so the
1176
+ # block below applies + consumes it this boot (same one-shot semantics).
1168
1177
  if [ -f "{{agentDir}}/.session-model-override" ]; then
1169
1178
  _mig="$(tr -d '[:space:]' < "{{agentDir}}/.session-model-override" 2>/dev/null || true)"
1170
1179
  rm -f "{{agentDir}}/.session-model-override"
1171
1180
  if printf '%s' "$_mig" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1172
1181
  printf '{"model":"%s","configuredDefaultAtWrite":"%s","ts":%s}\n' "$_mig" "$_EFFECTIVE_MODEL" "$(( $(date +%s) * 1000 ))" > "{{agentDir}}/.session-model" 2>/dev/null || true
1173
- _sm_keep="1"
1174
- echo "session-model: migrated legacy one-shot carrier '$_mig' to durable .session-model (applying this boot)" >&2
1182
+ echo "session-model: migrated legacy one-shot carrier '$_mig' to .session-model (applying + consuming this boot)" >&2
1175
1183
  else
1176
1184
  echo "session-model: ignoring malformed legacy .session-model-override (failed shape gate)" >&2
1177
1185
  fi
1178
1186
  unset _mig
1179
1187
  fi
1180
1188
 
1181
- # One-shot intent: consume unconditionally, honor only a fresh "keep".
1182
- # Freshness clock is the EMBEDDED ts (ms), never file mtime — same clock the
1183
- # gateway writes with.
1184
- _sm_reason=""
1185
- _sm_revert=""
1186
- if [ -f "{{agentDir}}/.relaunch-model-intent" ]; then
1187
- _int_raw="$(cat "{{agentDir}}/.relaunch-model-intent" 2>/dev/null || true)"
1188
- rm -f "{{agentDir}}/.relaunch-model-intent"
1189
- _int="$(printf '%s' "$_int_raw" | sed -n 's/.*"intent"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p')"
1190
- _int_ts="$(printf '%s' "$_int_raw" | sed -n 's/.*"ts"[[:space:]]*:[[:space:]]*\([0-9]\{1,\}\).*/\1/p')"
1191
- _sm_reason="$(printf '%s' "$_int_raw" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1192
- # #3039: boot default is KEEP. Only a FRESH explicit "revert" intent
1193
- # (stamped by an explicit user/gateway revert path) clears the override.
1194
- # A stale or corrupt intent counts as no intent → keep.
1195
- if [ "$_int" = "revert" ] && [ -n "$_int_ts" ] && [ $(( $(date +%s) * 1000 - _int_ts )) -lt 600000 ]; then
1196
- _sm_revert="1"
1197
- fi
1198
- unset _int_raw _int _int_ts
1199
- fi
1200
-
1201
1189
  if [ -f "{{agentDir}}/.session-model" ]; then
1202
1190
  _smf="$(cat "{{agentDir}}/.session-model" 2>/dev/null || true)"
1203
1191
  _sm_model="$(printf '%s' "$_smf" | sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1204
1192
  _sm_cfg="$(printf '%s' "$_smf" | sed -n 's/.*"configuredDefaultAtWrite"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1205
- _sm_ts="$(printf '%s' "$_smf" | sed -n 's/.*"ts"[[:space:]]*:[[:space:]]*\([0-9]\{1,\}\).*/\1/p')"
1206
- if [ -n "$_sm_revert" ]; then
1207
- # Explicit fresh revert intent the ONLY boot path that clears a valid
1208
- # override by design (#3039). Everything else keeps.
1209
- rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-kept-notified"
1210
- _sm_why="${_sm_reason:-explicit revert intent}"
1211
- echo "session-model: reverting to configured default '$_EFFECTIVE_MODEL' — $_sm_why (session override '$_sm_model' cleared)" >&2
1212
- printf 'Session model override `%s` was cleared as requested (%s) — this relaunch booted the configured default `%s`. Re-issue /model %s to switch back.\n' "$_sm_model" "$_sm_why" "$_EFFECTIVE_MODEL" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1213
- unset _sm_why
1193
+ # CONSUME-ONCE: delete the carrier now, before any apply/exec. The carrier
1194
+ # governs exactly this one boot; a bad token can therefore crash at most one
1195
+ # boot (the next boot has no carrier and reverts), so no crashloop self-heal
1196
+ # is needed. Every branch below has already removed the file.
1197
+ rm -f "{{agentDir}}/.session-model"
1214
1198
  # Shape gate — kept BYTE-IDENTICAL with MODEL_ARG_RE in
1215
1199
  # telegram-plugin/gateway/model-command.ts. `/` is allowed for
1216
1200
  # OpenRouter-style `sr-vendor/model` ids; it is not a shell metachar inside
@@ -1219,116 +1203,69 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1219
1203
  # extraction above emits one match per matching LINE, and `grep -Eq`
1220
1204
  # passes if ANY line matches — a multiline value must never reach
1221
1205
  # `claude --model` (parity with parseSessionModel's single-string check).
1222
- 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
1223
- # Invalid carrier (#3039): fall back to the configured default AND tell
1224
- # the operator once — never a silent stderr-only drop.
1225
- rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-kept-notified"
1206
+ if [ "$(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
1207
+ # Invalid carrier: fall back to the configured default AND tell the
1208
+ # operator once — never a silent stderr-only drop.
1226
1209
  echo "session-model: ignoring malformed .session-model (failed shape gate) — using configured default '$_EFFECTIVE_MODEL'" >&2
1227
1210
  printf 'Your saved session model override could not be read (invalid or corrupt), so the agent booted on its configured default `%s`. Re-issue /model <name> if you want a different model.\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1228
1211
  elif [ "$_sm_cfg" != "$_EFFECTIVE_MODEL" ]; then
1229
- # switchroom.yaml `model:` changed since the switch the override is
1230
- # against a default that no longer exists (it may name a retired model).
1231
- # Invalidate + announce once (#3039 invalid-carrier fallback).
1232
- rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-kept-notified"
1233
- echo "session-model: configured default changed ('$_sm_cfg''$_EFFECTIVE_MODEL') clearing session override '$_sm_model'" >&2
1234
- 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
1212
+ # switchroom.yaml `model:` changed between the switch and this apply-boot
1213
+ # the carrier is against a default that no longer exists. Invalidate +
1214
+ # announce once.
1215
+ echo "session-model: configured default changed ('$_sm_cfg' → '$_EFFECTIVE_MODEL') — dropping session override '$_sm_model'" >&2
1216
+ printf 'The configured default model changed (`%s``%s`), so your session override to `%s` was not applied — 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
1217
+ elif [ "${_sm_model#sr-}" != "$_sm_model" ] && [ -z "$_LITELLM_OK" ]; then
1218
+ # sr-* + LiteLLM unreachable at the apply-boot: booting the override would
1219
+ # 4xx against Anthropic, and consume-once means we can't retain it for a
1220
+ # later relaunch. Boot the configured default and tell the operator to
1221
+ # re-issue once the proxy is back.
1222
+ echo "session-model: LiteLLM proxy unreachable at boot — NOT applying sr-* override '$_sm_model'; booting configured default '$_EFFECTIVE_MODEL' (re-issue /model when the proxy is back)" >&2
1223
+ printf 'LiteLLM proxy was unreachable at boot, so the session model override `%s` was not applied — the agent booted on its configured default `%s`. Re-issue /model %s once the proxy is reachable.\n' "$_sm_model" "$_EFFECTIVE_MODEL" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1235
1224
  else
1236
- case "$_sm_model" in
1237
- sr-*)
1238
- if [ -z "$_LITELLM_OK" ]; then
1239
- # sr-* + LiteLLM unreachable: booting the override would 4xx against
1240
- # Anthropic. Boot the configured default but RETAIN the durable file
1241
- # — it re-applies on the next keep relaunch once the proxy is back.
1242
- 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
1243
- 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
1244
- else
1245
- _EFFECTIVE_MODEL="$_sm_model"
1246
- fi
1247
- ;;
1248
- *)
1249
- _EFFECTIVE_MODEL="$_sm_model"
1250
- ;;
1251
- esac
1252
- if [ "$_EFFECTIVE_MODEL" = "$_sm_model" ]; then
1253
- echo "session-model: keeping session override '$_sm_model' across this relaunch${_sm_reason:+ ($_sm_reason)}" >&2
1254
- # #3042 item 4: notify the chat ONCE per kept value — a watchdog bounce
1255
- # loop must not storm the operator with identical "kept" alerts. The
1256
- # sentinel is cleared on every path that clears the override.
1257
- if [ "$(cat "{{agentDir}}/.session-model-kept-notified" 2>/dev/null || true)" != "$_sm_model" ]; then
1258
- printf 'Session model override `%s` kept across this relaunch%s. It persists across restarts and deploys; /model default clears it.\n' "$_sm_model" "${_sm_reason:+ ($_sm_reason)}" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1259
- printf '%s\n' "$_sm_model" > "{{agentDir}}/.session-model-kept-notified" 2>/dev/null || true
1260
- fi
1261
- fi
1225
+ # Apply the carrier for THIS session. No boot alert — the gateway already
1226
+ # acked the /model switch in chat, and the override reverts on next restart.
1227
+ _EFFECTIVE_MODEL="$_sm_model"
1228
+ echo "session-model: applying session override '$_sm_model' this boot (consume-once reverts on next restart)" >&2
1262
1229
  fi
1263
- unset _smf _sm_model _sm_cfg _sm_ts
1230
+ unset _smf _sm_model _sm_cfg
1264
1231
  fi
1265
- unset _sm_keep _sm_reason _sm_revert
1266
1232
 
1267
- # --- Override crashloop self-heal (#3042 review blocker 2b) ---
1233
+ # --- Session effort resolution (consume-once .session-effort carrier, #3186) ---
1268
1234
  #
1269
- # Under keep-by-default an invalid-but-shape-valid persisted model (a typo
1270
- # persisted at shutdown, or a confirmed model later retired) would make every
1271
- # boot exec `claude --model <bad>` and die with the gateway dead recovery
1272
- # would need a host shell. Detect the loop from inside: when an override is
1273
- # ACTIVE, stamp a boot-attempt counter just before exec; a healthy run makes
1274
- # the next boot arrive much later (stamp stale counter resets), while a
1275
- # fast crashloop re-enters here within seconds (docker backoff caps ≈1 min).
1276
- # Three consecutive fast boots with the same override → clear the carrier,
1277
- # boot the configured default, and alert the chat once.
1278
- _SM_CFG_RECORDED="$(cat "{{agentDir}}/.configured-default-model" 2>/dev/null | tr -d '[:space:]' || true)"
1279
- if [ -n "$_SM_CFG_RECORDED" ] && [ "$_EFFECTIVE_MODEL" != "$_SM_CFG_RECORDED" ] && [ -f "{{agentDir}}/.session-model" ]; then
1280
- _bl_now="$(date +%s)"
1281
- _bl_cnt=0
1282
- _bl_prev=0
1283
- if [ -f "{{agentDir}}/.session-model-boot-attempts" ]; then
1284
- read -r _bl_cnt _bl_prev < "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null || true
1285
- fi
1286
- case "$_bl_cnt" in (''|*[!0-9]*) _bl_cnt=0;; esac
1287
- case "$_bl_prev" in (''|*[!0-9]*) _bl_prev=0;; esac
1288
- if [ $(( _bl_now - _bl_prev )) -lt 150 ]; then _bl_cnt=$(( _bl_cnt + 1 )); else _bl_cnt=1; fi
1289
- if [ "$_bl_cnt" -ge 3 ]; then
1290
- echo "session-model: override '$_EFFECTIVE_MODEL' appears to be crashlooping the session ($_bl_cnt fast boots) — clearing it, booting configured default '$_SM_CFG_RECORDED'" >&2
1291
- printf 'Session model override `%s` was cleared automatically: the agent failed to stay up %s boots in a row with it active (the model may be invalid or retired). Booting the configured default `%s`. Re-issue /model <name> if you want a different model.\n' "$_EFFECTIVE_MODEL" "$_bl_cnt" "$_SM_CFG_RECORDED" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1292
- rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-boot-attempts" "{{agentDir}}/.session-model-kept-notified"
1293
- _EFFECTIVE_MODEL="$_SM_CFG_RECORDED"
1294
- else
1295
- printf '%s %s\n' "$_bl_cnt" "$_bl_now" > "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null || true
1296
- fi
1297
- unset _bl_now _bl_cnt _bl_prev
1298
- else
1299
- # No active override this boot — a stale counter must not bite a future one.
1300
- rm -f "{{agentDir}}/.session-model-boot-attempts"
1301
- fi
1302
- unset _SM_CFG_RECORDED
1303
-
1304
- # --- Session effort resolution (durable .session-effort, #3039) ---
1235
+ # The `/effort` sibling of the block above, session-scoped like /model
1236
+ # (rev 4). A live `/effort <level>` applies in-session via the applyEffort
1237
+ # driver and writes NO carrier the explicit `--effort <configured>` below
1238
+ # reverts it on the next boot. The ONLY boot-applied case is a queued
1239
+ # mid-turn `/effort` the gateway persisted at shutdown: this block APPLIES
1240
+ # that carrier on the single boot that reads it and then DELETES it
1241
+ # (consume-once), so any subsequent restart reverts to the configured
1242
+ # `thinking_effort`. Invalidation (corrupt file / configured default
1243
+ # changed) appends to `.session-model-alert` so the gateway's boot relay
1244
+ # tells the operator once.
1305
1245
  #
1306
- # The `/effort` sibling of the block above. A positively-confirmed `/effort`
1307
- # apply persists `{{agentDir}}/.session-effort` (one-line JSON
1308
- # {"level","configuredDefaultAtWrite","ts"} written by the gateway). It is
1309
- # honored on every boot and cleared only by `/effort default` (live delete)
1310
- # or invalidation here (corrupt file / configured `thinking_effort:` changed)
1311
- # — each invalidation appends to `.session-model-alert` so the gateway's
1312
- # boot relay tells the operator once.
1246
+ # NB: the CONFIGURED default resolution is untouched (#3186 constraint)
1247
+ # the fleet `thinking_effort: low` pin (see #1978 /
1248
+ # src/config/thinking-effort-risk.ts) resolves exactly as before.
1313
1249
  _EFFECTIVE_EFFORT={{#if thinkingEffort}}'{{thinkingEffort}}'{{else}}''{{/if}}
1314
1250
  if [ -f "{{agentDir}}/.session-effort" ]; then
1315
1251
  _sef="$(cat "{{agentDir}}/.session-effort" 2>/dev/null || true)"
1316
1252
  _se_level="$(printf '%s' "$_sef" | sed -n 's/.*"level"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1317
1253
  _se_cfg="$(printf '%s' "$_sef" | sed -n 's/.*"configuredDefaultAtWrite"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1254
+ # CONSUME-ONCE: delete the carrier now, before any apply/exec — it governs
1255
+ # exactly this one boot (parity with the .session-model block above).
1256
+ rm -f "{{agentDir}}/.session-effort"
1318
1257
  # Allowlist gate — kept in sync with EFFORT_LEVELS in
1319
1258
  # telegram-plugin/gateway/effort-command.ts. The level is passed verbatim
1320
1259
  # to `claude --effort`.
1321
1260
  if [ "$(printf '%s' "$_se_level" | wc -l)" -ne 0 ] || ! printf '%s' "$_se_level" | grep -Eq '^(low|medium|high|xhigh|max)$'; then
1322
- rm -f "{{agentDir}}/.session-effort"
1323
1261
  echo "session-effort: ignoring malformed .session-effort (failed allowlist gate) — using configured default '${_EFFECTIVE_EFFORT:-<unset>}'" >&2
1324
1262
  printf 'Your saved session effort override could not be read (invalid or corrupt), so the agent booted on its configured default effort. Re-issue /effort <level> if you want a different one.\n' >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1325
1263
  elif [ "$_se_cfg" != "$_EFFECTIVE_EFFORT" ]; then
1326
- rm -f "{{agentDir}}/.session-effort"
1327
- echo "session-effort: configured thinking_effort changed ('$_se_cfg' → '${_EFFECTIVE_EFFORT:-<unset>}') clearing session override '$_se_level'" >&2
1328
- printf 'The configured default effort changed (`%s` → `%s`), so your session effort override `%s` was cleared — the agent booted on the new configured default. Re-issue /effort %s if you still want it.\n' "${_se_cfg:-<unset>}" "${_EFFECTIVE_EFFORT:-<unset>}" "$_se_level" "$_se_level" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1264
+ echo "session-effort: configured thinking_effort changed ('$_se_cfg' → '${_EFFECTIVE_EFFORT:-<unset>}') — dropping session override '$_se_level'" >&2
1265
+ printf 'The configured default effort changed (`%s``%s`), so your session effort override `%s` was not applied — the agent booted on the new configured default. Re-issue /effort %s if you still want it.\n' "${_se_cfg:-<unset>}" "${_EFFECTIVE_EFFORT:-<unset>}" "$_se_level" "$_se_level" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1329
1266
  else
1330
1267
  _EFFECTIVE_EFFORT="$_se_level"
1331
- echo "session-effort: keeping session effort override '$_se_level' across this relaunch" >&2
1268
+ echo "session-effort: applying session effort override '$_se_level' this boot (consume-once — reverts on next restart)" >&2
1332
1269
  fi
1333
1270
  unset _sef _se_level _se_cfg
1334
1271
  fi
@@ -1337,6 +1274,11 @@ if [ -n "$_EFFECTIVE_EFFORT" ]; then
1337
1274
  else
1338
1275
  _EFFORT_ARG=""
1339
1276
  fi
1277
+ # Record the EFFECTIVE launched effort so the gateway can re-hydrate its
1278
+ # in-memory session-effort state after a queued-apply boot, keeping the
1279
+ # /effort menu highlight honest. Overwrite (not consumed) — the effort
1280
+ # sibling of `.active-session-model` below.
1281
+ printf '%s\n' "$_EFFECTIVE_EFFORT" > "{{agentDir}}/.active-session-effort" 2>/dev/null || true
1340
1282
 
1341
1283
  # sr-* passthrough→router repoint — ONE post-resolution gate covering BOTH the
1342
1284
  # /model override path AND the configured-default path (`model: sr-*` in