switchroom 0.19.30 → 0.19.32

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 (51) hide show
  1. package/dist/cli/switchroom.js +1121 -584
  2. package/dist/host-control/main.js +151 -73
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +3 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +1660 -677
  8. package/telegram-plugin/dist/server.js +3 -0
  9. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  10. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  11. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  12. package/telegram-plugin/gateway/gateway.ts +87 -88
  13. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  14. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  15. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  18. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  19. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  20. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  21. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  22. package/telegram-plugin/gateway/stream-render.ts +578 -321
  23. package/telegram-plugin/registry/turns-schema.ts +21 -1
  24. package/telegram-plugin/retry-api-call.ts +46 -21
  25. package/telegram-plugin/session-tail.ts +13 -0
  26. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  27. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  28. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  29. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  30. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  31. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  32. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  33. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  34. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  35. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  36. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  37. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  38. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  39. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  40. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  41. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  42. package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
  43. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  44. package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
  45. package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
  46. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
  47. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  48. package/telegram-plugin/tool-activity-summary.ts +104 -38
  49. package/telegram-plugin/worker-activity-feed.ts +33 -16
  50. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  51. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -19060,9 +19060,9 @@ var require_commander = __commonJS((exports2) => {
19060
19060
  });
19061
19061
 
19062
19062
  // src/host-control/main.ts
19063
- import { homedir as homedir7 } from "node:os";
19064
- import { existsSync as existsSync15, readdirSync as readdirSync6 } from "node:fs";
19065
- import { join as join13, resolve as resolve10 } from "node:path";
19063
+ import { homedir as homedir8 } from "node:os";
19064
+ import { existsSync as existsSync16, readdirSync as readdirSync7 } from "node:fs";
19065
+ import { join as join14, resolve as resolve10 } from "node:path";
19066
19066
 
19067
19067
  // src/config/loader.ts
19068
19068
  init_zod();
@@ -21210,7 +21210,7 @@ function allocateAgentUid(name) {
21210
21210
  }
21211
21211
 
21212
21212
  // src/build-info.ts
21213
- var VERSION = "0.19.30";
21213
+ var VERSION = "0.19.32";
21214
21214
 
21215
21215
  // src/setup/hindsight-recall-tunables.ts
21216
21216
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
@@ -21767,8 +21767,8 @@ import { createServer } from "node:net";
21767
21767
  import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
21768
21768
  import { mkdir as mkdir2, chmod, chown, unlink, appendFile } from "node:fs/promises";
21769
21769
  import {
21770
- readdirSync as readdirSync5,
21771
- existsSync as existsSync14,
21770
+ readdirSync as readdirSync6,
21771
+ existsSync as existsSync15,
21772
21772
  readFileSync as readFileSync11,
21773
21773
  writeFileSync as writeFileSync6,
21774
21774
  renameSync as renameSync5,
@@ -21781,7 +21781,7 @@ import {
21781
21781
  copyFileSync as copyFileSync3,
21782
21782
  unlinkSync as unlinkSync5
21783
21783
  } from "node:fs";
21784
- import { join as join12, dirname as dirname9, resolve as resolve9 } from "node:path";
21784
+ import { join as join13, dirname as dirname9, resolve as resolve9 } from "node:path";
21785
21785
  import { createHash as createHash6, randomUUID as randomUUID2, randomBytes } from "node:crypto";
21786
21786
 
21787
21787
  // src/host-control/protocol.ts
@@ -27739,7 +27739,51 @@ function recoverPinJournal(configPath, opts = {}) {
27739
27739
  }
27740
27740
 
27741
27741
  // src/cli/operator-uid.ts
27742
- function resolveOperatorUid() {
27742
+ import {
27743
+ chownSync,
27744
+ existsSync as existsSync12,
27745
+ lstatSync as lstatSync2,
27746
+ readdirSync as readdirSync5,
27747
+ realpathSync as realpathSync2,
27748
+ statSync as statSync7
27749
+ } from "node:fs";
27750
+ import { homedir as homedir6 } from "node:os";
27751
+ import { join as join9 } from "node:path";
27752
+ var CONTAINER_OPERATOR_HOME = "/host-home";
27753
+ var SWITCHROOM_HOME_MARKER = "switchroom.yaml";
27754
+ function operatorHomeCandidates(env, home2) {
27755
+ const roots = [env.SWITCHROOM_HOST_HOME?.trim(), home2, CONTAINER_OPERATOR_HOME];
27756
+ const out = [];
27757
+ for (const r of roots) {
27758
+ if (!r || r.length === 0)
27759
+ continue;
27760
+ const dir = join9(r, ".switchroom");
27761
+ if (!out.includes(dir))
27762
+ out.push(dir);
27763
+ }
27764
+ return out;
27765
+ }
27766
+ function resolveOperatorUidFromOwnership(deps = {}) {
27767
+ const env = deps.env ?? process.env;
27768
+ const home2 = deps.home ?? homedir6();
27769
+ const exists = deps.exists ?? ((p) => existsSync12(p));
27770
+ const statUid = deps.statUid ?? ((p) => {
27771
+ try {
27772
+ return statSync7(p).uid;
27773
+ } catch {
27774
+ return;
27775
+ }
27776
+ });
27777
+ for (const dir of operatorHomeCandidates(env, home2)) {
27778
+ if (!exists(join9(dir, SWITCHROOM_HOME_MARKER)))
27779
+ continue;
27780
+ const uid = statUid(dir);
27781
+ if (uid !== undefined && Number.isFinite(uid) && uid > 0)
27782
+ return uid;
27783
+ }
27784
+ return;
27785
+ }
27786
+ function resolveOperatorUid(ownershipDeps = {}) {
27743
27787
  const sudoUid = process.env.SUDO_UID;
27744
27788
  if (sudoUid !== undefined) {
27745
27789
  const parsed = parseInt(sudoUid, 10);
@@ -27757,20 +27801,20 @@ function resolveOperatorUid() {
27757
27801
  if (Number.isFinite(parsed) && parsed > 0)
27758
27802
  return parsed;
27759
27803
  }
27760
- return;
27804
+ return resolveOperatorUidFromOwnership(ownershipDeps);
27761
27805
  }
27762
27806
 
27763
27807
  // src/cli/rollout.ts
27764
27808
  init_atomic();
27765
27809
 
27766
27810
  // src/cli/resolve-version.ts
27767
- import { existsSync as existsSync12, readFileSync as readFileSync9 } from "node:fs";
27768
- import { dirname as dirname7, join as join9 } from "node:path";
27811
+ import { existsSync as existsSync13, readFileSync as readFileSync9 } from "node:fs";
27812
+ import { dirname as dirname7, join as join10 } from "node:path";
27769
27813
  function readPackageVersion() {
27770
27814
  let dir = import.meta.dirname;
27771
27815
  for (let i = 0;i < 12 && dir && dir !== "/"; i++) {
27772
- const p = join9(dir, "package.json");
27773
- if (existsSync12(p)) {
27816
+ const p = join10(dir, "package.json");
27817
+ if (existsSync13(p)) {
27774
27818
  try {
27775
27819
  const pkg = JSON.parse(readFileSync9(p, "utf-8"));
27776
27820
  if (pkg?.name === "switchroom" && typeof pkg.version === "string") {
@@ -27869,6 +27913,9 @@ function parseAuditLine(line) {
27869
27913
  entry.failed_step = o.failed_step;
27870
27914
  if (typeof o.failed_agent === "string")
27871
27915
  entry.failed_agent = o.failed_agent;
27916
+ if (Array.isArray(o.drifted) && o.drifted.every((x) => typeof x === "string")) {
27917
+ entry.drifted = o.drifted;
27918
+ }
27872
27919
  if (typeof o.agent === "string")
27873
27920
  entry.agent = o.agent;
27874
27921
  if (typeof o.n === "number")
@@ -27900,6 +27947,7 @@ function latestRolloutRowForRequest(raw, requestId) {
27900
27947
  var DOCKER_INSPECT_TIMEOUT_MS = 60 * 1000;
27901
27948
 
27902
27949
  // src/cli/rollout.ts
27950
+ var VERIFY_COMPONENTS_STEP = "verify-components";
27903
27951
  function isVersionAssertable(target) {
27904
27952
  return /^v?\d+\.\d+\.\d+$/.test(target.trim());
27905
27953
  }
@@ -27973,7 +28021,7 @@ var ROLLOUT_RUN_TIMEOUT_MS = envMs("SWITCHROOM_ROLLOUT_RUN_TIMEOUT_MS", 20 * 60
27973
28021
  var ROLLOUT_PROBE_TIMEOUT_MS = envMs("SWITCHROOM_ROLLOUT_PROBE_TIMEOUT_MS", 60 * 1000);
27974
28022
 
27975
28023
  // src/cli/write-compose.ts
27976
- import { chownSync } from "node:fs";
28024
+ import { chownSync as chownSync2 } from "node:fs";
27977
28025
  import { mkdir, readFile, writeFile, rename, copyFile } from "node:fs/promises";
27978
28026
  var AGENT_IMAGE_TAG_RE = /image:\s*\S*switchroom-agent:(\S+)/;
27979
28027
  var BACKUP_SUFFIX = ".bak";
@@ -28062,7 +28110,7 @@ async function restoreComposeBackup(composePath, opts = {}) {
28062
28110
  const operatorUid = resolveOperatorUid();
28063
28111
  if (operatorUid !== undefined && process.geteuid?.() === 0) {
28064
28112
  try {
28065
- chownSync(composePath, operatorUid, operatorUid);
28113
+ chownSync2(composePath, operatorUid, operatorUid);
28066
28114
  } catch {}
28067
28115
  }
28068
28116
  return { restored: true, imageTag: composeImageTag(content) };
@@ -28072,8 +28120,8 @@ async function restoreComposeBackup(composePath, opts = {}) {
28072
28120
  var import__2 = __toESM(require_commander(), 1);
28073
28121
 
28074
28122
  // src/cli/update.ts
28075
- import { join as join10, dirname as dirname8, resolve as resolve8 } from "node:path";
28076
- import { homedir as homedir6 } from "node:os";
28123
+ import { join as join11, dirname as dirname8, resolve as resolve8 } from "node:path";
28124
+ import { homedir as homedir7 } from "node:os";
28077
28125
 
28078
28126
  // src/agents/lifecycle.ts
28079
28127
  import { execFileSync, spawn, spawnSync } from "node:child_process";
@@ -28169,7 +28217,7 @@ function getAllAgentStatuses(config) {
28169
28217
 
28170
28218
  // src/cli/update.ts
28171
28219
  init_atomic();
28172
- var DEFAULT_COMPOSE_PATH = join10(homedir6(), ".switchroom", "compose", "docker-compose.yml");
28220
+ var DEFAULT_COMPOSE_PATH = join11(homedir7(), ".switchroom", "compose", "docker-compose.yml");
28173
28221
  var UPDATE_RESULT_SENTINEL = "SWITCHROOM_UPDATE_RESULT:";
28174
28222
  function parseUpdateResultLine(stdout) {
28175
28223
  const idx = stdout.lastIndexOf(UPDATE_RESULT_SENTINEL);
@@ -28192,9 +28240,9 @@ function parseUpdateResultLine(stdout) {
28192
28240
 
28193
28241
  // src/host-control/config-edit-validator.ts
28194
28242
  var import_yaml5 = __toESM(require_dist(), 1);
28195
- import { mkdtempSync, writeFileSync as writeFileSync5, rmSync as rmSync2, existsSync as existsSync13, readFileSync as readFileSync10 } from "node:fs";
28243
+ import { mkdtempSync, writeFileSync as writeFileSync5, rmSync as rmSync2, existsSync as existsSync14, readFileSync as readFileSync10 } from "node:fs";
28196
28244
  import { tmpdir } from "node:os";
28197
- import { join as join11, isAbsolute as isAbsolute3, normalize, basename as basename4 } from "node:path";
28245
+ import { join as join12, isAbsolute as isAbsolute3, normalize, basename as basename4 } from "node:path";
28198
28246
  import { spawnSync as spawnSync2 } from "node:child_process";
28199
28247
  import { isDeepStrictEqual } from "node:util";
28200
28248
  var MAX_PATCH_BYTES = 1024 * 1024;
@@ -28334,7 +28382,7 @@ function validateShape(unifiedDiff, targetPath) {
28334
28382
  return null;
28335
28383
  }
28336
28384
  function applyPatch(unifiedDiff, configPath, gitBin) {
28337
- if (!existsSync13(configPath)) {
28385
+ if (!existsSync14(configPath)) {
28338
28386
  return {
28339
28387
  ok: false,
28340
28388
  code: "E_PATCH_APPLY_FAILED",
@@ -28342,13 +28390,13 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
28342
28390
  };
28343
28391
  }
28344
28392
  const liveContent = readFileSync10(configPath, "utf8");
28345
- const scratchDir = mkdtempSync(join11(tmpdir(), "config-propose-edit-"));
28393
+ const scratchDir = mkdtempSync(join12(tmpdir(), "config-propose-edit-"));
28346
28394
  try {
28347
28395
  const targetBasename = configPath.split("/").pop() ?? "switchroom.yaml";
28348
- const scratchFile = join11(scratchDir, targetBasename);
28396
+ const scratchFile = join12(scratchDir, targetBasename);
28349
28397
  writeFileSync5(scratchFile, liveContent);
28350
28398
  const normalizedDiff = normalizeDiffHeadersToBasename(unifiedDiff, targetBasename);
28351
- const patchFile = join11(scratchDir, "proposal.patch");
28399
+ const patchFile = join12(scratchDir, "proposal.patch");
28352
28400
  writeFileSync5(patchFile, normalizedDiff);
28353
28401
  const bin = gitBin ?? "git";
28354
28402
  const baseArgs = [
@@ -28576,7 +28624,7 @@ function validateConfigEdit(opts) {
28576
28624
  return schemaErr;
28577
28625
  let beforeData = {};
28578
28626
  try {
28579
- const beforeRaw = existsSync13(opts.configPath) ? readFileSync10(opts.configPath, "utf8") : "";
28627
+ const beforeRaw = existsSync14(opts.configPath) ? readFileSync10(opts.configPath, "utf8") : "";
28580
28628
  const beforeDoc = import_yaml5.parseDocument(beforeRaw, { merge: false, strict: false });
28581
28629
  beforeData = beforeDoc.toJS();
28582
28630
  } catch {
@@ -28917,8 +28965,8 @@ function etaLine(s) {
28917
28965
  function deferredLines(target) {
28918
28966
  const bare = target.trim().replace(/^v/, "");
28919
28967
  return [
28920
- `**Deferred (still on the prior version run host-side):**`,
28921
- `- \`switchroom-web\` \`switchroom webd install --tag ${target}\``,
28968
+ `**Verified on ${target}** — every component this roll owned passed \`verify-components\`, so this is host convergence, not just the agents. Anything the roll was told to skip is named in its warnings.`,
28969
+ `**Still host-side (nothing in a roll can do these):**`,
28922
28970
  `- host operator CLI — \`sudo npm i -g switchroom@${bare}\``,
28923
28971
  `- hostd template regen (only if the release changed hostd mounts/env) — \`switchroom hostd install --tag ${target}\``
28924
28972
  ];
@@ -28948,6 +28996,21 @@ function renderWith(s, compact) {
28948
28996
  if (s.deferred !== false)
28949
28997
  parts2.push("", ...deferredLines(s.target));
28950
28998
  return parts2.join(`
28999
+ `);
29000
+ }
29001
+ if (s.terminal === "error" && s.failedStep === VERIFY_COMPONENTS_STEP) {
29002
+ const drifted = s.drifted ?? [];
29003
+ const parts2 = [headerLine(s, "⚠️")];
29004
+ let summary = `**INCOMPLETE** — ${rolledCount}${s.m !== undefined ? `/${s.m}` : ""} ` + `agent(s) reached ${s.target}`;
29005
+ if (elapsedMs !== undefined)
29006
+ summary += ` in ${formatDurationMs(elapsedMs)}`;
29007
+ summary += `, but the host did NOT fully converge.`;
29008
+ parts2.push(summary);
29009
+ parts2.push("", `**Still behind ${s.target}:** ` + (drifted.length > 0 ? drifted.map((d) => `\`${d}\``).join(", ") : "one or more components (see the roll's warnings)"));
29010
+ parts2.push("", `Re-running the roll will NOT fix this — the agents are already on ` + `target. Finish the stale component(s), then \`switchroom update --check\`.`);
29011
+ if (checklist.length > 0)
29012
+ parts2.push("", ...checklist);
29013
+ return parts2.join(`
28951
29014
  `);
28952
29015
  }
28953
29016
  if (s.terminal === "error") {
@@ -29078,9 +29141,9 @@ function resolveDigests(imageRefs) {
29078
29141
  return out;
29079
29142
  }
29080
29143
  function readCachedInstallType(bindRoot) {
29081
- const cacheDir = join12(bindRoot, ".switchroom");
29082
- const cachePath = join12(cacheDir, "install-type.json");
29083
- if (existsSync14(cachePath)) {
29144
+ const cacheDir = join13(bindRoot, ".switchroom");
29145
+ const cachePath = join13(cacheDir, "install-type.json");
29146
+ if (existsSync15(cachePath)) {
29084
29147
  try {
29085
29148
  const raw = readFileSync11(cachePath, "utf-8");
29086
29149
  const parsed = JSON.parse(raw);
@@ -29169,7 +29232,7 @@ class HostdServer {
29169
29232
  fleetMutationInFlight = null;
29170
29233
  lastAutoRolloutFailedPin = null;
29171
29234
  autoRolloutLatchPath() {
29172
- return join12(this.hostdDirPath(), AUTO_ROLLOUT_LATCH_FILENAME);
29235
+ return join13(this.hostdDirPath(), AUTO_ROLLOUT_LATCH_FILENAME);
29173
29236
  }
29174
29237
  readAutoRolloutLatch() {
29175
29238
  try {
@@ -29212,7 +29275,7 @@ class HostdServer {
29212
29275
  return resolveHostdConfigPath(this.opts.configPath);
29213
29276
  }
29214
29277
  fleetComposePath() {
29215
- return join12(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
29278
+ return join13(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
29216
29279
  }
29217
29280
  recoverRolloutPinJournal(context) {
29218
29281
  let note = null;
@@ -29251,7 +29314,7 @@ class HostdServer {
29251
29314
  return note;
29252
29315
  }
29253
29316
  async start() {
29254
- const hostdDir = join12(this.opts.homeDir, ".switchroom", "hostd");
29317
+ const hostdDir = join13(this.opts.homeDir, ".switchroom", "hostd");
29255
29318
  await mkdir2(hostdDir, { recursive: true });
29256
29319
  await chmod(hostdDir, 493).catch(() => {
29257
29320
  return;
@@ -29272,13 +29335,13 @@ class HostdServer {
29272
29335
  }
29273
29336
  try {
29274
29337
  for (const name of agentNames) {
29275
- const dir = join12(hostdDir, name);
29276
- const sockPath = join12(dir, "sock");
29338
+ const dir = join13(hostdDir, name);
29339
+ const sockPath = join13(dir, "sock");
29277
29340
  await mkdir2(dir, { recursive: true });
29278
29341
  await chmod(dir, 493).catch(() => {
29279
29342
  return;
29280
29343
  });
29281
- if (existsSync14(sockPath))
29344
+ if (existsSync15(sockPath))
29282
29345
  await unlink(sockPath).catch(() => {
29283
29346
  return;
29284
29347
  });
@@ -29309,13 +29372,13 @@ class HostdServer {
29309
29372
  process.stderr.write(`hostd: SWITCHROOM_HOSTD_OPERATOR_UID='${opUidStr}' is not a positive integer; skipping operator listener
29310
29373
  `);
29311
29374
  } else {
29312
- const dir = join12(hostdDir, "operator");
29313
- const sockPath = join12(dir, "sock");
29375
+ const dir = join13(hostdDir, "operator");
29376
+ const sockPath = join13(dir, "sock");
29314
29377
  await mkdir2(dir, { recursive: true });
29315
29378
  await chmod(dir, 493).catch(() => {
29316
29379
  return;
29317
29380
  });
29318
- if (existsSync14(sockPath))
29381
+ if (existsSync15(sockPath))
29319
29382
  await unlink(sockPath).catch(() => {
29320
29383
  return;
29321
29384
  });
@@ -29351,7 +29414,7 @@ class HostdServer {
29351
29414
  }
29352
29415
  async reconcileOrphanedFleetMutations() {
29353
29416
  const path3 = this.auditLogPath();
29354
- if (!existsSync14(path3))
29417
+ if (!existsSync15(path3))
29355
29418
  return;
29356
29419
  let raw;
29357
29420
  try {
@@ -29754,11 +29817,11 @@ class HostdServer {
29754
29817
  missingApplyAssets() {
29755
29818
  const root = this.opts.applyAssetsRoot ?? resolve9(import.meta.dirname, "../..");
29756
29819
  return [
29757
- join12(root, "profiles"),
29758
- join12(root, "profiles", "default"),
29759
- join12(root, "vendor", "hindsight-memory"),
29760
- join12(root, "skills")
29761
- ].filter((p) => !existsSync14(p));
29820
+ join13(root, "profiles"),
29821
+ join13(root, "profiles", "default"),
29822
+ join13(root, "vendor", "hindsight-memory"),
29823
+ join13(root, "skills")
29824
+ ].filter((p) => !existsSync15(p));
29762
29825
  }
29763
29826
  applyAssetPreflight(request_id, started) {
29764
29827
  const missing = this.missingApplyAssets();
@@ -30021,16 +30084,16 @@ class HostdServer {
30021
30084
  return entry;
30022
30085
  }
30023
30086
  hostdDirPath() {
30024
- return join12(this.opts.homeDir, ".switchroom", "hostd");
30087
+ return join13(this.opts.homeDir, ".switchroom", "hostd");
30025
30088
  }
30026
30089
  hostdDirHostPath() {
30027
30090
  const hostHome = this.opts.hostHomeDir ?? process.env.SWITCHROOM_HOST_HOME?.trim() ?? this.opts.homeDir;
30028
- return join12(hostHome, ".switchroom", "hostd");
30091
+ return join13(hostHome, ".switchroom", "hostd");
30029
30092
  }
30030
30093
  async beginSelfBump(req, caller, started) {
30031
30094
  const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
30032
- const composePath = join12(this.hostdDirPath(), "docker-compose.yml");
30033
- if (!existsSync14(composePath)) {
30095
+ const composePath = join13(this.hostdDirPath(), "docker-compose.yml");
30096
+ if (!existsSync15(composePath)) {
30034
30097
  return deniedResponse(req.request_id, `rollout: hostd's CLI (v${ownVersion}) is older than the target ` + `${req.args.pin} and needs a self-bump first, but its compose file ` + `is missing at ${composePath}. Run \`switchroom hostd install ` + `--tag ${req.args.pin}\` on the host, then re-run the roll. ` + `Nothing was changed.`, Date.now() - started);
30035
30098
  }
30036
30099
  const before = readFileSync11(composePath, "utf8");
@@ -30052,12 +30115,12 @@ class HostdServer {
30052
30115
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
30053
30116
  const bakPath = `${composePath}.bak-selfbump-${ts}`;
30054
30117
  try {
30055
- const baks = readdirSync5(this.hostdDirPath()).filter((f) => f.startsWith("docker-compose.yml.bak-selfbump-")).sort();
30118
+ const baks = readdirSync6(this.hostdDirPath()).filter((f) => f.startsWith("docker-compose.yml.bak-selfbump-")).sort();
30056
30119
  for (const f of baks.slice(0, Math.max(0, baks.length - 4))) {
30057
- unlinkSync5(join12(this.hostdDirPath(), f));
30120
+ unlinkSync5(join13(this.hostdDirPath(), f));
30058
30121
  }
30059
30122
  } catch {}
30060
- const markerPath = join12(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30123
+ const markerPath = join13(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30061
30124
  const marker = {
30062
30125
  v: 1,
30063
30126
  request_id: req.request_id,
@@ -30141,7 +30204,7 @@ class HostdServer {
30141
30204
  rollbackBump();
30142
30205
  entry.result = "error";
30143
30206
  entry.finished_at = Date.now();
30144
- entry.error = `self-bump helper failed (container exit ${Number.isFinite(code) ? code : "unknown"}). ` + `See ${join12(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)} on the host. ` + `Compose bump rolled back. Fallback: \`switchroom hostd install ` + `--tag ${req.args.pin}\` host-side, then re-run the roll.`;
30207
+ entry.error = `self-bump helper failed (container exit ${Number.isFinite(code) ? code : "unknown"}). ` + `See ${join13(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)} on the host. ` + `Compose bump rolled back. Fallback: \`switchroom hostd install ` + `--tag ${req.args.pin}\` host-side, then re-run the roll.`;
30145
30208
  if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
30146
30209
  this.fleetMutationInFlight = null;
30147
30210
  }
@@ -30165,8 +30228,8 @@ class HostdServer {
30165
30228
  };
30166
30229
  }
30167
30230
  async resumePendingSelfBumpRollout() {
30168
- const markerPath = join12(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30169
- if (!existsSync14(markerPath))
30231
+ const markerPath = join13(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30232
+ if (!existsSync15(markerPath))
30170
30233
  return;
30171
30234
  let raw;
30172
30235
  try {
@@ -30221,7 +30284,7 @@ class HostdServer {
30221
30284
  return;
30222
30285
  }
30223
30286
  if (needsSelfBump(ownVersion, marker.pin)) {
30224
- await failResume(`hostd is still on v${ownVersion} after the self-bump to ` + `${marker.pin} (helper failed? see ` + `${join12(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)}). ` + `Fallback: \`switchroom hostd install --tag ${marker.pin}\` ` + `host-side, then re-issue the rollout.`);
30287
+ await failResume(`hostd is still on v${ownVersion} after the self-bump to ` + `${marker.pin} (helper failed? see ` + `${join13(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)}). ` + `Fallback: \`switchroom hostd install --tag ${marker.pin}\` ` + `host-side, then re-issue the rollout.`);
30225
30288
  return;
30226
30289
  }
30227
30290
  process.stderr.write(`hostd: resuming rollout ${marker.request_id} → ${marker.pin} after ` + `self-bump (v${marker.prior_hostd_version} → v${ownVersion})
@@ -30741,8 +30804,8 @@ ${output.recovery.stderr}` : "";
30741
30804
  if (this.opts.imageRefsForDigests)
30742
30805
  return this.opts.imageRefsForDigests();
30743
30806
  try {
30744
- const composePath = join12(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
30745
- if (!existsSync14(composePath))
30807
+ const composePath = join13(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
30808
+ if (!existsSync15(composePath))
30746
30809
  return [];
30747
30810
  const r = spawnSync3("docker", [
30748
30811
  "compose",
@@ -30813,6 +30876,9 @@ ${output.recovery.stderr}` : "";
30813
30876
  entry.failed_step = parsed.failedStep;
30814
30877
  if (parsed.failedAgent)
30815
30878
  entry.failed_agent = parsed.failedAgent;
30879
+ if (parsed.drifted && parsed.drifted.length > 0) {
30880
+ entry.drifted = parsed.drifted;
30881
+ }
30816
30882
  if (parsed.got !== undefined)
30817
30883
  entry.got = parsed.got;
30818
30884
  if (parsed.timedOut)
@@ -30868,7 +30934,10 @@ ${pinNote}`.trim();
30868
30934
  });
30869
30935
  }
30870
30936
  async finishFailedAutoRollout(entry, priorPin) {
30871
- const notes = [
30937
+ const drifted = entry.drifted ?? [];
30938
+ const notes = entry.failed_step === "verify-components" ? [
30939
+ `Unattended auto-update roll to ${entry.pin ?? "?"} rolled ` + `${(entry.rolled ?? []).length} agent(s) but did NOT converge the ` + `whole host: ${drifted.join(", ") || "one or more components"} ` + `still behind. Re-running the roll will not fix this — run the ` + `per-component install named in the rollout warnings, then ` + `\`switchroom update --check\` to confirm.`
30940
+ ] : [
30872
30941
  `Unattended auto-update roll to ${entry.pin ?? "?"} FAILED at ` + `${entry.failed_step ?? "unknown step"}` + `${entry.failed_agent ? ` (agent ${entry.failed_agent})` : ""}. ` + `Unattended retries of this version are disabled; fix the cause, ` + `then roll manually (\`switchroom rollout --pin ${entry.pin ?? "vX.Y.Z"}\`).`
30873
30942
  ];
30874
30943
  try {
@@ -30891,6 +30960,10 @@ ${pinNote}`.trim();
30891
30960
  async recoverFailedAutoRollout(entry, priorPin) {
30892
30961
  const notes = [];
30893
30962
  const rolledCount = (entry.rolled ?? []).length;
30963
+ if (entry.failed_step === "verify-components") {
30964
+ notes.push(`No automatic rollback: the agent fleet reached ` + `${entry.pin ?? "the target"} and the pin is committed — the gap is ` + `${(entry.drifted ?? []).join(", ") || "a component"}, not the fleet. ` + `Rolling back would move working agents backwards; converge the ` + `named component(s) forward instead.`);
30965
+ return notes;
30966
+ }
30894
30967
  const beforeAnyAgent = entry.failed_step === "apply" || entry.failed_step === "restart-agent" && rolledCount === 0;
30895
30968
  if (!beforeAnyAgent) {
30896
30969
  notes.push(rolledCount > 0 ? `No automatic rollback: ${rolledCount} agent(s) already confirmed on ` + `${entry.pin ?? "the target"} past a green canary ` + `(${(entry.rolled ?? []).join(", ")}). The fleet is mixed — ` + `finish the roll or roll back manually.` : `No automatic rollback: the roll stopped at ` + `${entry.failed_step ?? "an unknown step"} before any change ` + `landed — nothing to revert. Verify host-side if unsure.`);
@@ -30937,7 +31010,8 @@ ${pinNote}`.trim();
30937
31010
  ...entry.finished_at !== null ? { elapsedMs: entry.finished_at - entry.started_at } : {},
30938
31011
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
30939
31012
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
30940
- ...entry.got !== undefined ? { got: entry.got } : {}
31013
+ ...entry.got !== undefined ? { got: entry.got } : {},
31014
+ ...entry.drifted && entry.drifted.length > 0 ? { drifted: entry.drifted } : {}
30941
31015
  });
30942
31016
  this.opts.rolloutRelay.postTerminal({
30943
31017
  requestId: entry.request_id,
@@ -30975,7 +31049,7 @@ ${pinNote}`.trim();
30975
31049
  }
30976
31050
  rolloutRowExistsInLog(targetRequestId) {
30977
31051
  const path3 = this.auditLogPath();
30978
- if (!existsSync14(path3))
31052
+ if (!existsSync15(path3))
30979
31053
  return false;
30980
31054
  try {
30981
31055
  const raw = readFileSync11(path3, "utf-8");
@@ -30986,7 +31060,7 @@ ${pinNote}`.trim();
30986
31060
  }
30987
31061
  rolloutStatusFromLog(responseRequestId, targetRequestId, started) {
30988
31062
  const path3 = this.auditLogPath();
30989
- if (!existsSync14(path3))
31063
+ if (!existsSync15(path3))
30990
31064
  return null;
30991
31065
  let raw;
30992
31066
  try {
@@ -31006,6 +31080,7 @@ ${pinNote}`.trim();
31006
31080
  ...row.agent ? { agent: row.agent } : {},
31007
31081
  ...row.failed_step ? { failedStep: row.failed_step } : {},
31008
31082
  ...row.failed_agent ? { failedAgent: row.failed_agent } : {},
31083
+ ...row.drifted && row.drifted.length > 0 ? { drifted: row.drifted } : {},
31009
31084
  ...row.pin ? { pin: row.pin } : {},
31010
31085
  ...row.prior_pin ? { prior_pin: row.prior_pin } : {}
31011
31086
  });
@@ -31028,6 +31103,7 @@ ${pinNote}`.trim();
31028
31103
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
31029
31104
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
31030
31105
  ...entry.got !== undefined ? { got: entry.got } : {},
31106
+ ...entry.drifted && entry.drifted.length > 0 ? { drifted: entry.drifted } : {},
31031
31107
  ...entry.pin ? { pin: entry.pin } : {},
31032
31108
  ...entry.prior_pin ? { prior_pin: entry.prior_pin } : {}
31033
31109
  }) : undefined;
@@ -31069,7 +31145,7 @@ ${pinNote}`.trim();
31069
31145
  }
31070
31146
  }
31071
31147
  auditLogPath() {
31072
- return this.opts.auditLogPath ?? join12(this.opts.homeDir, ".switchroom", "host-control-audit.log");
31148
+ return this.opts.auditLogPath ?? join13(this.opts.homeDir, ".switchroom", "host-control-audit.log");
31073
31149
  }
31074
31150
  maybeRotateAuditLog(path3) {
31075
31151
  const { maxBytes, maxFiles } = resolveRotationConfig({
@@ -31139,6 +31215,7 @@ ${pinNote}`.trim();
31139
31215
  ...entry.rolled ? { rolled: entry.rolled } : {},
31140
31216
  ...entry.failed_step ? { failed_step: entry.failed_step } : {},
31141
31217
  ...entry.failed_agent ? { failed_agent: entry.failed_agent } : {},
31218
+ ...entry.drifted && entry.drifted.length > 0 ? { drifted: entry.drifted } : {},
31142
31219
  ...entry.got !== undefined ? { got: entry.got } : {},
31143
31220
  ...entry.deferred ? { deferred: entry.deferred } : {},
31144
31221
  ...entry.prior_pin ? { prior_pin: entry.prior_pin } : {}
@@ -31856,6 +31933,7 @@ class LogTailRolloutNarrator {
31856
31933
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
31857
31934
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
31858
31935
  ...entry.got !== undefined ? { got: entry.got } : {},
31936
+ ...entry.drifted && entry.drifted.length > 0 ? { drifted: entry.drifted } : {},
31859
31937
  ...st.agents.length > 0 ? { agents: st.agents } : {},
31860
31938
  ...st.render.m !== undefined ? { m: st.render.m } : {},
31861
31939
  requestId: entry.request_id,
@@ -32495,7 +32573,7 @@ import { appendFile as appendFile2 } from "node:fs/promises";
32495
32573
  async function loadConfigResilient() {
32496
32574
  try {
32497
32575
  const config = loadConfig();
32498
- clearStaleDegradedMarker(homedir7(), (m) => process.stderr.write(`hostd: ${m}
32576
+ clearStaleDegradedMarker(homedir8(), (m) => process.stderr.write(`hostd: ${m}
32499
32577
  `));
32500
32578
  return config;
32501
32579
  } catch (err2) {
@@ -32507,13 +32585,13 @@ async function loadConfigResilient() {
32507
32585
  } catch {
32508
32586
  configPath = undefined;
32509
32587
  }
32510
- const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join13(homedir7(), ".switchroom", "agents");
32588
+ const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join14(homedir8(), ".switchroom", "agents");
32511
32589
  const listGatewayCandidates = () => {
32512
32590
  const out = [];
32513
32591
  try {
32514
- for (const name of readdirSync6(agentsDir).sort()) {
32592
+ for (const name of readdirSync7(agentsDir).sort()) {
32515
32593
  const sock = resolve10(agentsDir, name, "telegram", "gateway.sock");
32516
- if (existsSync15(sock))
32594
+ if (existsSync16(sock))
32517
32595
  out.push({ agent: name, sock });
32518
32596
  }
32519
32597
  } catch {}
@@ -32536,7 +32614,7 @@ async function loadConfigResilient() {
32536
32614
  };
32537
32615
  return waitForConfigRecovery({
32538
32616
  initialError: err2,
32539
- homeDir: homedir7(),
32617
+ homeDir: homedir8(),
32540
32618
  loadFn: () => loadConfig(),
32541
32619
  configPath,
32542
32620
  notify,
@@ -32560,10 +32638,10 @@ async function main() {
32560
32638
  `);
32561
32639
  process.exit(2);
32562
32640
  }
32563
- const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join13(homedir7(), ".switchroom", "agents");
32641
+ const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join14(homedir8(), ".switchroom", "agents");
32564
32642
  const resolveGatewaySocket = (agentName) => {
32565
32643
  const sock = resolve10(agentsDir, agentName, "telegram", "gateway.sock");
32566
- return existsSync15(sock) ? sock : null;
32644
+ return existsSync16(sock) ? sock : null;
32567
32645
  };
32568
32646
  const approvalGateway = new SocketApprovalGateway({
32569
32647
  resolveGatewaySocket,
@@ -32582,7 +32660,7 @@ async function main() {
32582
32660
  }), { log: (m) => process.stderr.write(`hostd: rollout-narration — ${m}
32583
32661
  `) });
32584
32662
  const server = new HostdServer({
32585
- homeDir: homedir7(),
32663
+ homeDir: homedir8(),
32586
32664
  agentUids,
32587
32665
  config: {
32588
32666
  agents: Object.fromEntries(Object.entries(config.agents).map(([n, a]) => [
@@ -32611,7 +32689,7 @@ async function main() {
32611
32689
  const autoRel = config.host_control?.auto_release_check;
32612
32690
  const watcherMode = resolveReleaseWatcherMode(config);
32613
32691
  if (watcherMode.mode !== "off") {
32614
- const eventsLog = join13(homedir7(), ".switchroom", "release-watcher-events.jsonl");
32692
+ const eventsLog = join14(homedir8(), ".switchroom", "release-watcher-events.jsonl");
32615
32693
  const log = (m) => {
32616
32694
  process.stderr.write(`hostd: ${m}
32617
32695
  `);
@@ -32670,7 +32748,7 @@ async function main() {
32670
32748
  `);
32671
32749
  } else
32672
32750
  notifier = new UpdateNotifier({
32673
- statePath: join13(homedir7(), ".switchroom", "release-notify-state.json"),
32751
+ statePath: join14(homedir8(), ".switchroom", "release-notify-state.json"),
32674
32752
  repostSuppressMs: cardTimeoutMs,
32675
32753
  isFleetMutationInFlight: () => server.isFleetMutationLocked(),
32676
32754
  startApply: (requestId) => {
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.30",
4
+ "version": "0.19.32",
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": {
@@ -127,7 +127,11 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
127
127
  sr_ll_ok=""
128
128
  sr_ll_unreachable=""
129
129
  if [ -z "$sr_ll_key" ]; then
130
- echo "litellm(cron): no virtual key for '{{name}}' falling back to direct OAuth (untracked, unguarded)" >&2
130
+ # Remediation, not just diagnosis. Unlike start.sh this block re-runs on
131
+ # EVERY fire, so no restart is needed — a provisioned key is picked up by
132
+ # the next fire on its own. Say that, so the operator does not bounce the
133
+ # agent chasing a cron-only problem.
134
+ echo "litellm(cron): no virtual key for '{{name}}' — falling back to direct OAuth (untracked, unguarded). FIX: re-run 'switchroom apply' to provision the key; the next fire picks it up (no restart needed)." >&2
131
135
  elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ]; then
132
136
  # Bounded retry probe — short budget for cron latency (3 attempts,
133
137
  # worst-case ~25s: 3×5s curl timeouts + 2×5s sleeps).
@@ -108,7 +108,13 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
108
108
  sr_ll_ok=""
109
109
  sr_ll_unreachable=""
110
110
  if [ -z "$sr_ll_key" ]; then
111
- echo "litellm(outer): WARNING no virtual key for agent '$SWITCHROOM_AGENT_NAME'; gateway falling back to direct Anthropic OAuth (untracked, unguarded)." >&2
111
+ # Say what to DO, not just what happened: the fail-open is a BOOT-TIME
112
+ # decision that is never re-evaluated, so provisioning the key later
113
+ # changes nothing until the agent is restarted. An operator who reads
114
+ # only "untracked" and provisions a key reasonably assumes it took
115
+ # effect; it did not. `switchroom doctor` carries the standing version
116
+ # of this signal (it reads .routing-mode) once the boot log has rotated.
117
+ echo "litellm(outer): WARNING — no virtual key for agent '$SWITCHROOM_AGENT_NAME'; gateway falling back to direct Anthropic OAuth (untracked, unguarded). FIX: re-run 'switchroom apply' to provision the key, then RESTART this agent — the fail-open is decided once at boot and is never re-evaluated for the life of the session." >&2
112
118
  elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ]; then
113
119
  # Bounded retry probe (co-boot race fix, 2026-07): when the whole stack
114
120
  # co-boots, the litellm proxy's heavy Python app is often not yet healthy
@@ -1563,6 +1569,14 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
1563
1569
  echo "WARNING: no litellm virtual key for agent '$SWITCHROOM_AGENT_NAME'." >&2
1564
1570
  echo "WARNING: falling back to direct Anthropic OAuth (untracked, unguarded)" >&2
1565
1571
  echo "WARNING: — cannot authenticate to the proxy without a key." >&2
1572
+ # Remediation, not just diagnosis. The fail-open is decided ONCE here and
1573
+ # never re-evaluated, so a key provisioned mid-session is inert — an
1574
+ # operator who is not told that will provision one and believe the agent
1575
+ # healed. `switchroom doctor` reads .routing-mode for the standing version
1576
+ # of this signal, since this banner only lives in a rotating boot log.
1577
+ echo "WARNING: FIX: re-run 'switchroom apply' to provision the key, then" >&2
1578
+ echo "WARNING: RESTART this agent — the fail-open is decided once at boot" >&2
1579
+ echo "WARNING: and is never re-evaluated for the life of this session." >&2
1566
1580
  echo "==========================================================" >&2
1567
1581
  elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ]; then
1568
1582
  # Bounded retry probe (co-boot race fix, 2026-07): when the whole stack