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.
- package/dist/auth-broker/index.js +104 -11
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +524 -293
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +97 -255
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- 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
|
-
|
|
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
|
|
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 {
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.19.
|
|
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
|
-
|
|
30162
|
-
|
|
30163
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
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
|
-
|
|
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
|