switchroom 0.19.4 → 0.19.6

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 (43) hide show
  1. package/dist/auth-broker/index.js +7 -3
  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 +585 -302
  8. package/telegram-plugin/flushed-turn-supersede.ts +43 -7
  9. package/telegram-plugin/gateway/command-format.ts +253 -0
  10. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  11. package/telegram-plugin/gateway/gateway.ts +128 -259
  12. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  13. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  14. package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
  15. package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
  16. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  17. package/telegram-plugin/gateway/stream-render.ts +18 -1
  18. package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  20. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  21. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  22. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  23. package/telegram-plugin/render/line-start-guard.ts +76 -4
  24. package/telegram-plugin/reply-owner-resolve.ts +43 -7
  25. package/telegram-plugin/rich-send.ts +8 -1
  26. package/telegram-plugin/tests/command-format.test.ts +212 -0
  27. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
  28. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  29. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  30. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  31. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
  32. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  33. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  34. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
  37. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  38. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  39. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  40. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  41. package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
  42. package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
  43. 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 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.4", COMMIT_SHA = "7cdf5428";
2123
+ var VERSION = "0.19.6", COMMIT_SHA = "ae57dd83";
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.4";
26666
+ var VERSION = "0.19.6";
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",
4
+ "version": "0.19.6",
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