switchroom 0.19.3 → 0.19.5

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 (32) hide show
  1. package/dist/auth-broker/index.js +104 -11
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +524 -293
  8. package/telegram-plugin/gateway/command-format.ts +253 -0
  9. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  10. package/telegram-plugin/gateway/gateway.ts +97 -255
  11. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  12. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  13. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  14. package/telegram-plugin/gateway/stream-render.ts +18 -1
  15. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  16. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  17. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  18. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  19. package/telegram-plugin/render/line-start-guard.ts +76 -4
  20. package/telegram-plugin/rich-send.ts +8 -1
  21. package/telegram-plugin/tests/command-format.test.ts +212 -0
  22. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  23. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  24. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  25. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  26. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  27. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  29. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  30. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  31. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  32. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -18839,6 +18839,27 @@ function parseQuotaHeaders(headers, now = new Date) {
18839
18839
  }
18840
18840
  };
18841
18841
  }
18842
+ function extractApiErrorMessage(bodyText) {
18843
+ if (!bodyText || bodyText.trim().length === 0)
18844
+ return null;
18845
+ try {
18846
+ const parsed = JSON.parse(bodyText);
18847
+ const msg = parsed?.error?.message;
18848
+ if (typeof msg === "string" && msg.trim().length > 0)
18849
+ return msg.trim();
18850
+ } catch {}
18851
+ return bodyText.trim().slice(0, 500);
18852
+ }
18853
+ function isEntitlementDisabledMessage(message) {
18854
+ if (!message)
18855
+ return false;
18856
+ const m = message.toLowerCase();
18857
+ if (!m.includes("disabled"))
18858
+ return false;
18859
+ const mentionsCode = m.includes("claude code") || m.includes("claude subscription");
18860
+ const mentionsOrgOrSub = m.includes("organization") || m.includes("subscription") || m.includes(" org ");
18861
+ return mentionsCode && mentionsOrgOrSub;
18862
+ }
18842
18863
  async function fetchQuota(opts) {
18843
18864
  const token = opts.accessToken?.trim();
18844
18865
  if (!token || token.length === 0) {
@@ -18874,13 +18895,28 @@ async function fetchQuota(opts) {
18874
18895
  }
18875
18896
  return { ok: false, reason: `quota probe network error: ${msg}` };
18876
18897
  }
18877
- clearTimeout(timeout);
18878
18898
  const parsed = parseQuotaHeaders(resp.headers);
18879
- if (parsed.ok)
18899
+ if (parsed.ok) {
18900
+ clearTimeout(timeout);
18880
18901
  return parsed;
18902
+ }
18881
18903
  if (!resp.ok) {
18882
- return { ok: false, reason: `HTTP ${resp.status} from Anthropic (${parsed.reason})` };
18904
+ let bodyText = "";
18905
+ try {
18906
+ bodyText = await resp.text();
18907
+ } catch {}
18908
+ clearTimeout(timeout);
18909
+ const apiErrorMessage = extractApiErrorMessage(bodyText);
18910
+ const entitlement = resp.status === 403 && isEntitlementDisabledMessage(apiErrorMessage);
18911
+ return {
18912
+ ok: false,
18913
+ reason: `HTTP ${resp.status} from Anthropic (${parsed.reason})`,
18914
+ httpStatus: resp.status,
18915
+ ...apiErrorMessage ? { apiErrorMessage } : {},
18916
+ failureKind: entitlement ? "entitlement_blocked" : "other"
18917
+ };
18883
18918
  }
18919
+ clearTimeout(timeout);
18884
18920
  return parsed;
18885
18921
  }
18886
18922
 
@@ -18912,7 +18948,9 @@ function snapshotClearlyHealthy(s) {
18912
18948
  return s.fiveHourUtilizationPct < HEALTHY_CLEAR_PCT && s.sevenDayUtilizationPct < HEALTHY_CLEAR_PCT;
18913
18949
  }
18914
18950
  function accountEligibility(opts) {
18915
- const { mark, snapshot, now, allowOverage = false } = opts;
18951
+ const { mark, snapshot, now, allowOverage = false, entitlementBlocked = false } = opts;
18952
+ if (entitlementBlocked)
18953
+ return "blocked";
18916
18954
  if (snapshotFresh(snapshot, now) && snapshotWalled(snapshot) && !overageLiftsWall(snapshot, allowOverage)) {
18917
18955
  return "blocked";
18918
18956
  }
@@ -19343,16 +19381,20 @@ async function fetchAndSummarizeExternalSpend(opts) {
19343
19381
  },
19344
19382
  signal: ac.signal
19345
19383
  });
19346
- if (!res.ok)
19384
+ if (!res.ok) {
19385
+ console.warn(`external-spend: LiteLLM /spend/logs returned HTTP ${res.status} — ` + `External /usage row will be blank (check master key / proxy)`);
19347
19386
  return null;
19387
+ }
19348
19388
  let body;
19349
19389
  try {
19350
19390
  body = await res.json();
19351
- } catch {
19391
+ } catch (err) {
19392
+ console.warn(`external-spend: failed to parse /spend/logs JSON: ${err?.message ?? err}`);
19352
19393
  return null;
19353
19394
  }
19354
19395
  return summarizeExternalSpend(normalizeSpendLogRows(body), now);
19355
- } catch {
19396
+ } catch (err) {
19397
+ console.warn(`external-spend: LiteLLM /spend/logs fetch failed: ${err?.message ?? err}`);
19356
19398
  return null;
19357
19399
  } finally {
19358
19400
  clearTimeout(timer);
@@ -21170,9 +21212,51 @@ class AuthBroker {
21170
21212
  mark: this.exhaustionMarkOf(account),
21171
21213
  snapshot: this.lastQuotaCache[account],
21172
21214
  now: this.now(),
21173
- allowOverage: this.isOverageAllowed(account)
21215
+ allowOverage: this.isOverageAllowed(account),
21216
+ entitlementBlocked: this.isAccountEntitlementBlocked(account)
21174
21217
  });
21175
21218
  }
21219
+ isAccountEntitlementBlocked(account) {
21220
+ return this.quota[account]?.entitlement_blocked === true;
21221
+ }
21222
+ markEntitlementBlocked(label, result) {
21223
+ if (result.ok || result.failureKind !== "entitlement_blocked")
21224
+ return;
21225
+ if (this.quota[label]?.entitlement_blocked === true)
21226
+ return;
21227
+ this.quota[label] = {
21228
+ ...this.quota[label],
21229
+ entitlement_blocked: true,
21230
+ entitlement_blocked_at: this.now(),
21231
+ ...result.apiErrorMessage ? { entitlement_blocked_reason: result.apiErrorMessage } : {}
21232
+ };
21233
+ this.persistQuota();
21234
+ this.audit({
21235
+ op: "mark-exhausted",
21236
+ identity: { kind: "operator" },
21237
+ account: label,
21238
+ accountKind: "claude",
21239
+ ok: true,
21240
+ reason: "entitlement-403"
21241
+ });
21242
+ process.stdout.write(`auth-broker: ${label} returned entitlement-403 (Claude Code access disabled) — marked entitlement_blocked${result.apiErrorMessage ? ` (${result.apiErrorMessage})` : ""}
21243
+ `);
21244
+ }
21245
+ clearEntitlementBlocked(label) {
21246
+ const entry = this.quota[label];
21247
+ if (!entry?.entitlement_blocked)
21248
+ return;
21249
+ const {
21250
+ entitlement_blocked,
21251
+ entitlement_blocked_at,
21252
+ entitlement_blocked_reason,
21253
+ ...rest
21254
+ } = entry;
21255
+ this.quota[label] = rest;
21256
+ this.persistQuota();
21257
+ process.stdout.write(`auth-broker: live probe of ${label} succeeded — cleared entitlement_blocked mark
21258
+ `);
21259
+ }
21176
21260
  premiumWallMarkOf(account) {
21177
21261
  const q = this.quota[account];
21178
21262
  if (!q || q.premium_walled_until === undefined)
@@ -21329,7 +21413,8 @@ class AuthBroker {
21329
21413
  mark: this.exhaustionMarkOf(account),
21330
21414
  snapshot,
21331
21415
  now,
21332
- allowOverage: true
21416
+ allowOverage: true,
21417
+ entitlementBlocked: this.isAccountEntitlementBlocked(account)
21333
21418
  }) === "eligible";
21334
21419
  }
21335
21420
  accountEligibilityOf(account) {
@@ -21339,7 +21424,8 @@ class AuthBroker {
21339
21424
  mark: this.exhaustionMarkOf(account),
21340
21425
  snapshot,
21341
21426
  now: this.now(),
21342
- allowOverage
21427
+ allowOverage,
21428
+ entitlementBlocked: this.isAccountEntitlementBlocked(account)
21343
21429
  });
21344
21430
  if (verdict === "eligible" && allowOverage && snapshot && (snapshot.fiveHourUtilizationPct >= WALL_PCT || snapshot.sevenDayUtilizationPct >= WALL_PCT)) {
21345
21431
  process.stdout.write(`auth-broker: ${account} is past the utilization wall but eligible via allow_overage — Anthropic overage billing active (5h=${snapshot.fiveHourUtilizationPct.toFixed(1)}%, 7d=${snapshot.sevenDayUtilizationPct.toFixed(1)}%)
@@ -21356,6 +21442,8 @@ class AuthBroker {
21356
21442
  const result = await this.probeQuotaSingleFlight(account, token);
21357
21443
  if (result.ok)
21358
21444
  this.cacheQuotaSnapshot(account, result);
21445
+ else
21446
+ this.markEntitlementBlocked(account, result);
21359
21447
  } catch {}
21360
21448
  }
21361
21449
  async nextHealthyAccountLive(current, order) {
@@ -21455,7 +21543,6 @@ class AuthBroker {
21455
21543
  expiresAt: creds?.claudeAiOauth?.expiresAt,
21456
21544
  exhausted,
21457
21545
  in_service: inService.has(label),
21458
- entitlement_blocked: false,
21459
21546
  exhausted_until: q?.exhausted_until,
21460
21547
  throttled_until: q?.throttled_until,
21461
21548
  threshold_violations: this.thresholdViolations[label] ?? 0,
@@ -21465,6 +21552,7 @@ class AuthBroker {
21465
21552
  premium_walled_until: q?.premium_walled_until,
21466
21553
  premium_wall_bucket: q?.premium_wall_bucket,
21467
21554
  last_tier_quota: this.lastTierQuotaCache[label] ?? null,
21555
+ entitlement_blocked: q?.entitlement_blocked ?? false,
21468
21556
  usage_ledger: summarizeAccountUsage(this.usageLedger, label, this.now())
21469
21557
  };
21470
21558
  });
@@ -21539,6 +21627,7 @@ class AuthBroker {
21539
21627
  this.cacheQuotaSnapshot(label, result);
21540
21628
  return { label, result, served: "live" };
21541
21629
  }
21630
+ this.markEntitlementBlocked(label, result);
21542
21631
  if (cached) {
21543
21632
  return { label, result: cachedSnapshotToResult(cached), served: "cache", capturedAt: cached.capturedAt };
21544
21633
  }
@@ -21560,6 +21649,7 @@ class AuthBroker {
21560
21649
  cacheQuotaSnapshot(label, result) {
21561
21650
  if (!result.ok)
21562
21651
  return;
21652
+ this.clearEntitlementBlocked(label);
21563
21653
  const snapshot = {
21564
21654
  fiveHourUtilizationPct: result.data.fiveHourUtilizationPct,
21565
21655
  sevenDayUtilizationPct: result.data.sevenDayUtilizationPct,
@@ -21589,6 +21679,8 @@ class AuthBroker {
21589
21679
  const canarySet = this.premiumCanarySet();
21590
21680
  const canaryEnabled = process.env.SWITCHROOM_DISABLE_MODEL_TIER_PROBE !== "1";
21591
21681
  for (const label of listAccounts(this.home)) {
21682
+ if (this.isAccountEntitlementBlocked(label))
21683
+ continue;
21592
21684
  const creds = readAccountCredentials(label, this.home);
21593
21685
  const token = creds?.claudeAiOauth?.accessToken;
21594
21686
  if (!token)
@@ -21601,6 +21693,7 @@ class AuthBroker {
21601
21693
  continue;
21602
21694
  }
21603
21695
  this.cacheQuotaSnapshot(label, result);
21696
+ this.markEntitlementBlocked(label, result);
21604
21697
  probed.push({ label, result });
21605
21698
  if (canaryEnabled && canarySet.has(label)) {
21606
21699
  let tierResult;
@@ -490,6 +490,7 @@ async function runWedgeWatchdog(opts) {
490
490
  let confirmModalPresent = 0;
491
491
  let permissionPromptPresent = 0;
492
492
  let manifestStallPresent = 0;
493
+ let lastManifestKey = null;
493
494
  let confirmCooldownUntil = 0;
494
495
  let permissionCooldownUntil = 0;
495
496
  while (polls < maxPolls) {
@@ -505,11 +506,16 @@ async function runWedgeWatchdog(opts) {
505
506
  const isPermissionPrompt = !isRateLimitMenu && !!text && permissionPromptSignature !== null && permissionPromptSignature.test(text);
506
507
  const isConfirmModal = !isRateLimitMenu && !isPermissionPrompt && !!text && confirmModalSignature !== null && confirmModalSignature.test(text);
507
508
  const isBlockingModal = !isRateLimitMenu && !isPermissionPrompt && !isConfirmModal && !!text && signature.test(text) && !deferToPrompts.some((p) => p.match.test(text));
508
- const isManifestStall = !!text && manifestStallSignature !== null && manifestStallSignature.test(text) && STOP_HOOK_ERROR_SIGNATURE.test(text);
509
+ const manifestSignatureHit = !!text && manifestStallSignature !== null && manifestStallSignature.test(text);
510
+ const manifestKey = manifestSignatureHit ? stabilityKey(text) : null;
511
+ const stopHookPresent = !!text && STOP_HOOK_ERROR_SIGNATURE.test(text);
512
+ const manifestNoProgress = manifestSignatureHit && (stopHookPresent || manifestKey === lastManifestKey);
513
+ lastManifestKey = manifestKey;
514
+ const isManifestStall = manifestNoProgress;
509
515
  if (isManifestStall) {
510
516
  manifestStallPresent++;
511
517
  if (manifestStallPresent >= manifestStallPolls) {
512
- const detail = `Manifesting + stop-hook-error present ${manifestStallPresent} polls ` + `(~${Math.round(manifestStallPresent * pollIntervalMs / 1000)}s) with no progress`;
518
+ const detail = `Manifesting ${stopHookPresent ? "+ stop-hook-error " : "pane byte-stable "}` + `present ${manifestStallPresent} polls ` + `(~${Math.round(manifestStallPresent * pollIntervalMs / 1000)}s) with no progress`;
513
519
  console.error(`[wedge-watchdog] ${opts.agentName}: manifest-stall wedge \u2014 ${detail}; ` + (requestRestart ? "escalating to kill + handoff restart" : "no requestRestart wired \u2014 logging only"));
514
520
  if (requestRestart) {
515
521
  try {
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.3", COMMIT_SHA = "41896be4";
2123
+ var VERSION = "0.19.5", COMMIT_SHA = "c5feaef8";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -29899,6 +29899,15 @@ function normalizeBindMountPath(p) {
29899
29899
  out = out.slice(0, -1);
29900
29900
  return out;
29901
29901
  }
29902
+ function isLoopbackHttpBase(url) {
29903
+ try {
29904
+ const u = new URL(url.includes("://") ? url : `http://${url}`);
29905
+ const h = (u.hostname || "").toLowerCase();
29906
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
29907
+ } catch {
29908
+ return false;
29909
+ }
29910
+ }
29902
29911
  function resolveConfigMountSource(switchroomConfigPath, homePrefix) {
29903
29912
  if (!switchroomConfigPath)
29904
29913
  return;
@@ -30154,13 +30163,17 @@ function generateCompose(opts) {
30154
30163
  }
30155
30164
  lines.push(` SWITCHROOM_AUTH_BROKER_STATE_DIR: /state/auth-broker`);
30156
30165
  let authBrokerNeedsHostGateway = false;
30166
+ let authBrokerNeedsHostNetwork = false;
30157
30167
  {
30158
30168
  const llBase = config.litellm?.base_url;
30159
30169
  if (typeof llBase === "string" && llBase.trim()) {
30160
30170
  const raw = llBase.trim().replace(/\/+$/, "");
30161
- const bridged = raw.replace(/^http:\/\/127\.0\.0\.1(?=[:/]|$)/i, "http://host.docker.internal").replace(/^http:\/\/localhost(?=[:/]|$)/i, "http://host.docker.internal").replace(/^https:\/\/127\.0\.0\.1(?=[:/]|$)/i, "https://host.docker.internal").replace(/^https:\/\/localhost(?=[:/]|$)/i, "https://host.docker.internal");
30162
- lines.push(` SWITCHROOM_LITELLM_BASE: ${JSON.stringify(bridged)}`);
30163
- authBrokerNeedsHostGateway = true;
30171
+ lines.push(` SWITCHROOM_LITELLM_BASE: ${JSON.stringify(raw)}`);
30172
+ if (isLoopbackHttpBase(raw)) {
30173
+ authBrokerNeedsHostNetwork = true;
30174
+ } else {
30175
+ authBrokerNeedsHostGateway = true;
30176
+ }
30164
30177
  }
30165
30178
  }
30166
30179
  lines.push(` SWITCHROOM_ACCOUNTS_DIR: /state/accounts`);
@@ -30168,7 +30181,9 @@ function generateCompose(opts) {
30168
30181
  if (opts.operatorUid !== undefined) {
30169
30182
  lines.push(` SWITCHROOM_AUTH_BROKER_OPERATOR_UID: "${opts.operatorUid}"`);
30170
30183
  }
30171
- if (authBrokerNeedsHostGateway) {
30184
+ if (authBrokerNeedsHostNetwork) {
30185
+ lines.push(` network_mode: host`);
30186
+ } else if (authBrokerNeedsHostGateway) {
30172
30187
  lines.push(` extra_hosts:`);
30173
30188
  lines.push(` - "host.docker.internal:host-gateway"`);
30174
30189
  }
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26663
26663
  import { dirname as dirname4, join as join7 } from "node:path";
26664
26664
 
26665
26665
  // src/build-info.ts
26666
- var VERSION = "0.19.3";
26666
+ var VERSION = "0.19.5";
26667
26667
 
26668
26668
  // src/cli/resolve-version.ts
26669
26669
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.3",
4
+ "version": "0.19.5",
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": {
@@ -325,6 +325,32 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
325
325
  # retrying — it recovers on its own the moment the broker
326
326
  # unlocks (no human, no container recreate); bot token invalid
327
327
  # → 401 → gateway exits 78 → quarantined (operator action).
328
+ # --- Session-model carrier PRE-FORK SNAPSHOT (gateway-consume boot race) ---
329
+ # The gateway forked just below consumes `.session-model` (+ the bounded-retry
330
+ # attempt counter) the moment it acquires the boot lock (#3284 healthy-boot
331
+ # consume, gateway.ts). But the session-model RESOLUTION block runs much later
332
+ # in the INNER pass — behind the LiteLLM probe's up-to-120s wait — so the
333
+ # gateway reliably won the race and the carrier vanished before resolution
334
+ # ever read it: a `/model <target>` differing from the configured default was
335
+ # silently dropped and the boot reverted (the overlord `/model fable`
336
+ # NOT-APPLIED incident, 2026-07). Snapshot the carrier + counter HERE, before
337
+ # the fork, into `.boot-snapshot` siblings the gateway never touches; the
338
+ # resolution block prefers the snapshot and deletes it after use (per-boot
339
+ # ephemeral). Refresh-or-remove every boot so a stale snapshot can never
340
+ # re-apply a consumed override on a later restart.
341
+ rm -f "{{agentDir}}/.session-model.boot-snapshot" "{{agentDir}}/.session-model-boot-attempts.boot-snapshot"
342
+ if [ -f "{{agentDir}}/.session-model" ]; then
343
+ # A failed copy (ENOSPC, perms) must not be a SILENT drop — the resolution
344
+ # block still falls back to the live carrier, but the gateway may consume
345
+ # that first, so warn loudly enough to diagnose a reverted override.
346
+ cp -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model.boot-snapshot" 2>/dev/null \
347
+ || echo "session-model: WARNING — failed to snapshot .session-model before the gateway fork; if the gateway consumes the carrier first, this boot's /model override may silently revert to the configured default" >&2
348
+ if [ -f "{{agentDir}}/.session-model-boot-attempts" ]; then
349
+ cp -f "{{agentDir}}/.session-model-boot-attempts" "{{agentDir}}/.session-model-boot-attempts.boot-snapshot" 2>/dev/null \
350
+ || echo "session-model: WARNING — failed to snapshot .session-model-boot-attempts before the gateway fork (retry-budget count may be lost this boot)" >&2
351
+ fi
352
+ fi
353
+
328
354
  _gateway_bundle=/opt/switchroom/telegram-plugin/dist/gateway/gateway.js
329
355
  _telegram_enabled={{#if telegramEnabledFlag}}{{telegramEnabledFlag}}{{else}}true{{/if}}
330
356
  if [ "$_telegram_enabled" = "true" ] && [ -f "$_gateway_bundle" ] && command -v bun >/dev/null 2>&1; then
@@ -1333,7 +1359,11 @@ rm -f "{{agentDir}}/.relaunch-model-intent" "{{agentDir}}/.session-model-kept-no
1333
1359
  # meaningless without a carrier to belong to, so sweep it as stale ONLY when no
1334
1360
  # `.session-model` carrier is present (rollout leftover / completed apply / a
1335
1361
  # prior giveup that didn't get to delete it).
1336
- [ -f "{{agentDir}}/.session-model" ] || rm -f "{{agentDir}}/.session-model-boot-attempts"
1362
+ # (Gateway-consume race fix: the outer pass snapshotted the carrier BEFORE the
1363
+ # gateway fork — the gateway may already have consumed the live carrier by now
1364
+ # on a healthy boot, so the snapshot is an equally valid "a carrier existed
1365
+ # this boot" signal.)
1366
+ { [ -f "{{agentDir}}/.session-model" ] || [ -f "{{agentDir}}/.session-model.boot-snapshot" ]; } || rm -f "{{agentDir}}/.session-model-boot-attempts"
1337
1367
 
1338
1368
  # Migration shim (one release): a leftover one-shot `.session-model-override`
1339
1369
  # carrier from a pre-consume-once gateway means an OLD gateway wrote it
@@ -1344,6 +1374,18 @@ if [ -f "{{agentDir}}/.session-model-override" ]; then
1344
1374
  rm -f "{{agentDir}}/.session-model-override"
1345
1375
  if printf '%s' "$_mig" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1346
1376
  printf '{"model":"%s","configuredDefaultAtWrite":"%s","ts":%s}\n' "$_mig" "$_EFFECTIVE_MODEL" "$(( $(date +%s) * 1000 ))" > "{{agentDir}}/.session-model" 2>/dev/null || true
1377
+ # Also refresh the pre-fork snapshot: this write happened AFTER the outer
1378
+ # pass's snapshot point, and the resolution below prefers the snapshot. A
1379
+ # newer legacy intent must win over any stale pre-fork copy (and survive
1380
+ # an early gateway consume of the live file).
1381
+ #
1382
+ # KNOWN one-release quirk (accepted): this live write also lands AFTER the
1383
+ # gateway's boot-lock consume already fired, so on a healthy boot nothing
1384
+ # deletes it until the NEXT boot's gateway consume — the migrated legacy
1385
+ # override therefore applies for up to TWO boots instead of one. Shim-only
1386
+ # (the mainline gateway writes `.session-model` directly, pre-restart, so
1387
+ # it is snapshotted and consumed normally); the shim dies next release.
1388
+ cp -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model.boot-snapshot" 2>/dev/null || true
1347
1389
  echo "session-model: migrated legacy one-shot carrier '$_mig' to .session-model (applying + consuming this boot)" >&2
1348
1390
  else
1349
1391
  echo "session-model: ignoring malformed legacy .session-model-override (failed shape gate)" >&2
@@ -1351,8 +1393,19 @@ if [ -f "{{agentDir}}/.session-model-override" ]; then
1351
1393
  unset _mig
1352
1394
  fi
1353
1395
 
1354
- if [ -f "{{agentDir}}/.session-model" ]; then
1355
- _smf="$(cat "{{agentDir}}/.session-model" 2>/dev/null || true)"
1396
+ # Prefer the PRE-FORK SNAPSHOT over the live carrier: on a healthy boot the
1397
+ # gateway (forked long before this block runs) has typically ALREADY consumed
1398
+ # the live `.session-model` by now — reading only the live file silently
1399
+ # reverted every `/model <target>` that differed from the configured default
1400
+ # (the overlord `/model fable` NOT-APPLIED incident). The snapshot was taken
1401
+ # before the gateway fork so it is race-free; fall back to the live file when
1402
+ # no snapshot exists (non-docker runtimes / block run standalone in tests).
1403
+ if [ -f "{{agentDir}}/.session-model.boot-snapshot" ] || [ -f "{{agentDir}}/.session-model" ]; then
1404
+ if [ -f "{{agentDir}}/.session-model.boot-snapshot" ]; then
1405
+ _smf="$(cat "{{agentDir}}/.session-model.boot-snapshot" 2>/dev/null || true)"
1406
+ else
1407
+ _smf="$(cat "{{agentDir}}/.session-model" 2>/dev/null || true)"
1408
+ fi
1356
1409
  _sm_model="$(printf '%s' "$_smf" | sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1357
1410
  _sm_cfg="$(printf '%s' "$_smf" | sed -n 's/.*"configuredDefaultAtWrite"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
1358
1411
  # BOUNDED-RETRY CONSUME (#3284) — the carrier is NO LONGER deleted here before
@@ -1380,7 +1433,13 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1380
1433
  # and persists the incremented counter. The delete-before-apply that used to
1381
1434
  # sit here is GONE by design.
1382
1435
  _SM_MAX_ATTEMPTS=3
1383
- _sm_attempts="$(cat "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null | tr -dc '0-9' || true)"
1436
+ # Counter read prefers the pre-fork snapshot for the same race reason (the
1437
+ # gateway deletes the live counter alongside the carrier on healthy consume).
1438
+ if [ -f "{{agentDir}}/.session-model-boot-attempts.boot-snapshot" ]; then
1439
+ _sm_attempts="$(cat "{{agentDir}}/.session-model-boot-attempts.boot-snapshot" 2>/dev/null | tr -dc '0-9' || true)"
1440
+ else
1441
+ _sm_attempts="$(cat "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null | tr -dc '0-9' || true)"
1442
+ fi
1384
1443
  [ -z "$_sm_attempts" ] && _sm_attempts=0
1385
1444
  _sm_attempts=$(( _sm_attempts + 1 ))
1386
1445
  # Shape gate — kept BYTE-IDENTICAL with MODEL_ARG_RE in
@@ -1444,6 +1503,10 @@ if [ -f "{{agentDir}}/.session-model" ]; then
1444
1503
  fi
1445
1504
  unset _smf _sm_model _sm_cfg _sm_attempts _SM_MAX_ATTEMPTS
1446
1505
  fi
1506
+ # The snapshot is strictly per-boot: consumed here regardless of which branch
1507
+ # fired, so it can never re-apply an already-consumed override on a later boot
1508
+ # (the outer pass also refresh-or-removes it, belt and braces).
1509
+ rm -f "{{agentDir}}/.session-model.boot-snapshot" "{{agentDir}}/.session-model-boot-attempts.boot-snapshot"
1447
1510
 
1448
1511
  # Configured-default LiteLLM guard, SPLIT BY CAUSE (2026-07-17 boot-race
1449
1512
  # incident: a slow LiteLLM co-boot demoted a configured-fable agent to opus on