switchroom 0.19.4 → 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 +7 -3
- 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
|
@@ -19381,16 +19381,20 @@ async function fetchAndSummarizeExternalSpend(opts) {
|
|
|
19381
19381
|
},
|
|
19382
19382
|
signal: ac.signal
|
|
19383
19383
|
});
|
|
19384
|
-
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)`);
|
|
19385
19386
|
return null;
|
|
19387
|
+
}
|
|
19386
19388
|
let body;
|
|
19387
19389
|
try {
|
|
19388
19390
|
body = await res.json();
|
|
19389
|
-
} catch {
|
|
19391
|
+
} catch (err) {
|
|
19392
|
+
console.warn(`external-spend: failed to parse /spend/logs JSON: ${err?.message ?? err}`);
|
|
19390
19393
|
return null;
|
|
19391
19394
|
}
|
|
19392
19395
|
return summarizeExternalSpend(normalizeSpendLogRows(body), now);
|
|
19393
|
-
} catch {
|
|
19396
|
+
} catch (err) {
|
|
19397
|
+
console.warn(`external-spend: LiteLLM /spend/logs fetch failed: ${err?.message ?? err}`);
|
|
19394
19398
|
return null;
|
|
19395
19399
|
} finally {
|
|
19396
19400
|
clearTimeout(timer);
|
|
@@ -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
|