switchroom 0.18.9 → 0.18.11

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 (65) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +198 -13
  3. package/dist/cli/notion-write-pretool.mjs +1 -0
  4. package/dist/cli/switchroom.js +214 -83
  5. package/dist/cli/ui/apple-touch-icon.png +0 -0
  6. package/dist/cli/ui/favicon-32.png +0 -0
  7. package/dist/cli/ui/favicon.ico +0 -0
  8. package/dist/host-control/main.js +760 -257
  9. package/dist/vault/approvals/kernel-server.js +3 -1
  10. package/dist/vault/broker/server.js +3 -1
  11. package/package.json +1 -1
  12. package/profiles/_base/start.sh.hbs +119 -37
  13. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  14. package/skills/dev-protocol/SKILL.md +131 -0
  15. package/telegram-plugin/README.md +2 -1
  16. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  17. package/telegram-plugin/admin-commands/index.ts +6 -1
  18. package/telegram-plugin/bridge/bridge.ts +23 -1
  19. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  20. package/telegram-plugin/chat-lock.ts +13 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  22. package/telegram-plugin/dist/gateway/gateway.js +1832 -263
  23. package/telegram-plugin/dist/server.js +29 -2
  24. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/effort-command.ts +47 -3
  27. package/telegram-plugin/gateway/gateway.ts +1435 -211
  28. package/telegram-plugin/gateway/model-command.ts +94 -8
  29. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  31. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  32. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  33. package/telegram-plugin/gateway/stop-command.ts +56 -0
  34. package/telegram-plugin/photo-precheck.ts +201 -0
  35. package/telegram-plugin/quota-watch.ts +141 -2
  36. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  37. package/telegram-plugin/registry/subagents.test.ts +67 -0
  38. package/telegram-plugin/retry-api-call.ts +31 -0
  39. package/telegram-plugin/subagent-watcher.ts +392 -1
  40. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  41. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  42. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  43. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  44. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  45. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  46. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  47. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  48. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  49. package/telegram-plugin/tests/model-command.test.ts +46 -3
  50. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  51. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  52. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  53. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  54. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  55. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  57. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  58. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  59. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  60. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  61. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  62. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  63. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  64. package/telegram-plugin/welcome-text.ts +4 -3
  65. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -9966,7 +9966,7 @@ var require_util = __commonJS((exports) => {
9966
9966
  return path;
9967
9967
  }
9968
9968
  exports.normalize = normalize;
9969
- function join2(aRoot, aPath) {
9969
+ function join3(aRoot, aPath) {
9970
9970
  if (aRoot === "") {
9971
9971
  aRoot = ".";
9972
9972
  }
@@ -9998,7 +9998,7 @@ var require_util = __commonJS((exports) => {
9998
9998
  }
9999
9999
  return joined;
10000
10000
  }
10001
- exports.join = join2;
10001
+ exports.join = join3;
10002
10002
  exports.isAbsolute = function(aPath) {
10003
10003
  return aPath.charAt(0) === "/" || urlRegexp.test(aPath);
10004
10004
  };
@@ -10171,7 +10171,7 @@ var require_util = __commonJS((exports) => {
10171
10171
  parsed.path = parsed.path.substring(0, index + 1);
10172
10172
  }
10173
10173
  }
10174
- sourceURL = join2(urlGenerate(parsed), sourceURL);
10174
+ sourceURL = join3(urlGenerate(parsed), sourceURL);
10175
10175
  }
10176
10176
  return normalize(sourceURL);
10177
10177
  }
@@ -15274,7 +15274,7 @@ var init_secretlint_source = __esm(() => {
15274
15274
  });
15275
15275
 
15276
15276
  // src/util/atomic.ts
15277
- import { closeSync as closeSync2, constants, fsyncSync, openSync as openSync2, renameSync, rmSync, writeSync } from "node:fs";
15277
+ import { closeSync as closeSync2, constants, fsyncSync, openSync as openSync2, renameSync as renameSync2, rmSync, writeSync } from "node:fs";
15278
15278
  var TMP_OPEN_FLAGS;
15279
15279
  var init_atomic = __esm(() => {
15280
15280
  TMP_OPEN_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
@@ -17316,8 +17316,8 @@ var require_commander = __commonJS((exports2) => {
17316
17316
 
17317
17317
  // src/host-control/main.ts
17318
17318
  import { homedir as homedir6 } from "node:os";
17319
- import { existsSync as existsSync10 } from "node:fs";
17320
- import { join as join10, resolve as resolve9 } from "node:path";
17319
+ import { existsSync as existsSync11, readdirSync as readdirSync4 } from "node:fs";
17320
+ import { join as join11, resolve as resolve9 } from "node:path";
17321
17321
 
17322
17322
  // src/config/loader.ts
17323
17323
  var import_yaml2 = __toESM(require_dist(), 1);
@@ -21986,6 +21986,7 @@ var SwitchroomConfigSchema = exports_external.object({
21986
21986
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
21987
21987
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
21988
21988
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
21989
+ proactive_failover_pct: exports_external.number().min(1).max(99).optional().describe("Soft-avoid threshold (percent) for proactive auth failover (#3031). " + "When set (e.g. 95), an account whose fresh 7-day utilization is at/" + "above this value — or whose 5h utilization is at/above min(pct+3, " + "98) — enters the SOFT-AVOID preference tier: the broker prefers a " + "fully-eligible fallback account on the serving path, without ever " + "blocking the account (the hard wall stays 99.5). Enter/exit uses " + "hysteresis (exit below pct-5 or on window reset) so the preference " + "does not flap between probe ticks. Accounts serving past the wall " + "via `allow_overage_accounts` are never soft-avoided. UNSET (the " + "default) disables the tier entirely — behavior is identical to a " + "fleet without this field."),
21989
21990
  allow_overage_accounts: exports_external.array(exports_external.string().min(1)).optional().describe("Opt-in list of account labels (bare strings matching `auth.active` / " + "`auth.fallback_order` entries) that may be served PAST the weekly " + "utilization wall when Anthropic overage billing is available for the " + "account (`overageStatus === 'allowed'`). Overage is REAL MONEY — " + "default is empty (no account gets this). An account in this list is " + "only kept eligible when its fresh quota snapshot reports " + "`overageStatus: 'allowed'` AND `overageDisabledReason` is NOT " + "'out_of_credits' (i.e. the overage credit has not been exhausted). " + "As soon as `overageDisabledReason` becomes 'out_of_credits', the " + "account is blocked immediately regardless of this flag. Overage lifts " + "ONLY the utilization wall — it cannot lift an active exhaustion mark " + "written by a real 429 (`mark-exhausted`).")
21990
21991
  }).optional().describe("Switchroom-auth-broker configuration (RFC H). Fleet-wide active account, " + "fallback order, admin-agent ACL, and ephemeral-consumer surface. " + "Required from the v0.8+ schema onwards; pre-v0.8 fleets are migrated " + "in-place by `switchroom apply` (see src/auth/migrate-schema.ts)."),
21991
21992
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
@@ -22796,11 +22797,391 @@ function resolveAgentsDir(config) {
22796
22797
  return resolveDualPath(config.switchroom.agents_dir || "~/.switchroom/agents");
22797
22798
  }
22798
22799
 
22800
+ // src/host-control/config-degraded.ts
22801
+ import { existsSync as existsSync4, readFileSync as readFileSync3, renameSync, unlinkSync, watchFile, unwatchFile, writeFileSync, mkdirSync } from "node:fs";
22802
+ import { connect } from "node:net";
22803
+ import { join } from "node:path";
22804
+
22805
+ // src/config/release-resolve.ts
22806
+ function parseSemverTag(tag) {
22807
+ if (!tag)
22808
+ return null;
22809
+ const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
22810
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
22811
+ }
22812
+ function compareReleaseTags(a, b) {
22813
+ const pa = parseSemverTag(a);
22814
+ const pb = parseSemverTag(b);
22815
+ if (!pa || !pb)
22816
+ return null;
22817
+ for (let i = 0;i < 3; i++) {
22818
+ if (pa[i] !== pb[i])
22819
+ return pa[i] < pb[i] ? -1 : 1;
22820
+ }
22821
+ return 0;
22822
+ }
22823
+
22824
+ // src/host-control/self-bump.ts
22825
+ var SELF_BUMP_MARKER_FILENAME = "pending-rollout.json";
22826
+ var SELF_BUMP_HELPER_CONTAINER = "switchroom-hostd-selfbump";
22827
+ var SELF_BUMP_LOG_FILENAME = "selfbump.log";
22828
+ var SELF_BUMP_MARKER_MAX_AGE_MS = 15 * 60 * 1000;
22829
+ var SEMVER_PIN_RE = /^v\d+\.\d+\.\d+$/;
22830
+ function needsSelfBump(ownVersion, pin) {
22831
+ if (!ownVersion)
22832
+ return false;
22833
+ const cmp = compareReleaseTags(`v${ownVersion.trim().replace(/^v/, "")}`, `v${pin.trim().replace(/^v/, "")}`);
22834
+ return cmp !== null && cmp < 0;
22835
+ }
22836
+ function bumpHostdComposeImageTag(yaml, pin) {
22837
+ const re = /^(\s*image:\s*)(\S*switchroom-hostd):(\S+)\s*$/m;
22838
+ const m = yaml.match(re);
22839
+ if (!m)
22840
+ return null;
22841
+ const oldImageRef = `${m[2]}:${m[3]}`;
22842
+ const newImageRef = `${m[2]}:${pin}`;
22843
+ return {
22844
+ yaml: yaml.replace(re, `$1${newImageRef}`),
22845
+ oldImageRef,
22846
+ newImageRef
22847
+ };
22848
+ }
22849
+ function encodePendingRolloutMarker(m) {
22850
+ return JSON.stringify(m, null, 2) + `
22851
+ `;
22852
+ }
22853
+ function parsePendingRolloutMarker(raw) {
22854
+ let obj;
22855
+ try {
22856
+ obj = JSON.parse(raw);
22857
+ } catch {
22858
+ return null;
22859
+ }
22860
+ if (typeof obj !== "object" || obj === null)
22861
+ return null;
22862
+ const o = obj;
22863
+ if (o.v !== 1)
22864
+ return null;
22865
+ if (typeof o.request_id !== "string" || o.request_id.length === 0)
22866
+ return null;
22867
+ if (typeof o.pin !== "string" || !SEMVER_PIN_RE.test(o.pin))
22868
+ return null;
22869
+ if (typeof o.created_at !== "string" || !Number.isFinite(Date.parse(o.created_at))) {
22870
+ return null;
22871
+ }
22872
+ if (typeof o.prior_hostd_version !== "string")
22873
+ return null;
22874
+ const caller = o.caller;
22875
+ if (typeof caller !== "object" || caller === null)
22876
+ return null;
22877
+ if (caller.kind === "agent") {
22878
+ if (typeof caller.name !== "string" || caller.name.length === 0)
22879
+ return null;
22880
+ } else if (caller.kind !== "operator") {
22881
+ return null;
22882
+ }
22883
+ if (o.agents !== undefined) {
22884
+ if (!Array.isArray(o.agents) || o.agents.length === 0 || !o.agents.every((a) => typeof a === "string" && /^[\w-]+$/.test(a))) {
22885
+ return null;
22886
+ }
22887
+ }
22888
+ if (o.skip_web !== undefined && typeof o.skip_web !== "boolean")
22889
+ return null;
22890
+ if (o.allow_downgrade !== undefined && typeof o.allow_downgrade !== "boolean") {
22891
+ return null;
22892
+ }
22893
+ return {
22894
+ v: 1,
22895
+ request_id: o.request_id,
22896
+ pin: o.pin,
22897
+ ...o.agents !== undefined ? { agents: o.agents } : {},
22898
+ ...o.skip_web !== undefined ? { skip_web: o.skip_web } : {},
22899
+ ...o.allow_downgrade !== undefined ? { allow_downgrade: o.allow_downgrade } : {},
22900
+ caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
22901
+ created_at: o.created_at,
22902
+ prior_hostd_version: o.prior_hostd_version
22903
+ };
22904
+ }
22905
+ function isMarkerFresh(marker, nowMs) {
22906
+ const created = Date.parse(marker.created_at);
22907
+ if (!Number.isFinite(created))
22908
+ return false;
22909
+ return nowMs - created < SELF_BUMP_MARKER_MAX_AGE_MS;
22910
+ }
22911
+ function selfBumpHelperArgs(opts) {
22912
+ const composePath = `${opts.hostdDirHostPath}/docker-compose.yml`;
22913
+ const logPath = `${opts.hostdDirHostPath}/${SELF_BUMP_LOG_FILENAME}`;
22914
+ const script = `sleep 2; ` + `echo "selfbump $(date -u) -> ${opts.targetImage}" >> "${logPath}" 2>&1; ` + `docker compose -p switchroom-hostd -f "${composePath}" pull >> "${logPath}" 2>&1 && ` + `docker compose -p switchroom-hostd -f "${composePath}" up -d >> "${logPath}" 2>&1; ` + `rc=$?; echo "selfbump done rc=$rc" >> "${logPath}" 2>&1; exit $rc`;
22915
+ return [
22916
+ "run",
22917
+ "-d",
22918
+ "--rm",
22919
+ "--name",
22920
+ SELF_BUMP_HELPER_CONTAINER,
22921
+ "--label",
22922
+ "switchroom.hostd-selfbump=true",
22923
+ "-v",
22924
+ "/var/run/docker.sock:/var/run/docker.sock",
22925
+ "-v",
22926
+ `${opts.hostdDirHostPath}:${opts.hostdDirHostPath}`,
22927
+ "--entrypoint",
22928
+ "/bin/sh",
22929
+ opts.helperImage,
22930
+ "-c",
22931
+ script
22932
+ ];
22933
+ }
22934
+
22935
+ // src/host-control/config-degraded.ts
22936
+ var CONFIG_DEGRADED_MARKER_FILENAME = "config-degraded.json";
22937
+ var CONFIG_DEGRADED_RETRY_MS = 15000;
22938
+ var MAX_PRESERVED_DEGRADED_MS = 4 * 60 * 60 * 1000;
22939
+ function buildDegradedMarker(err, nowMs) {
22940
+ return {
22941
+ v: 1,
22942
+ since: new Date(nowMs).toISOString(),
22943
+ error: err.message,
22944
+ ...err.details && err.details.length > 0 ? { details: err.details } : {}
22945
+ };
22946
+ }
22947
+ function readDegradedSince(markerPath) {
22948
+ try {
22949
+ if (!existsSync4(markerPath))
22950
+ return null;
22951
+ const parsed = JSON.parse(readFileSync3(markerPath, "utf8"));
22952
+ if (parsed?.v !== 1 || typeof parsed.since !== "string")
22953
+ return null;
22954
+ const since = Date.parse(parsed.since);
22955
+ return Number.isFinite(since) ? since : null;
22956
+ } catch {
22957
+ return null;
22958
+ }
22959
+ }
22960
+ function formatDegradedNotice(err) {
22961
+ const details = (err.details ?? []).map((d) => d.trim()).filter(Boolean).slice(0, 6).join(`
22962
+ `);
22963
+ return `⚠️ hostd: switchroom.yaml failed to load — running DEGRADED.
22964
+ ` + `${err.message}
22965
+ ` + (details ? details + `
22966
+ ` : "") + `Retrying every ${Math.round(CONFIG_DEGRADED_RETRY_MS / 1000)}s and on file change. ` + "hostd's sockets are not bound yet, so fleet operations (rollouts, config " + "edits, restarts) will fail with connection errors until the config parses. " + "Fix the YAML (`switchroom config check` validates it) — no hostd restart " + "needed. A pending self-bump rollout, if any, will resume once the config " + "loads (its 15-min freshness budget is frozen while degraded, up to " + `${Math.round(MAX_PRESERVED_DEGRADED_MS / 3600000)}h).`;
22967
+ }
22968
+ function humanMins(ms) {
22969
+ const mins = Math.max(1, Math.round(ms / 60000));
22970
+ return mins >= 90 ? `${(mins / 60).toFixed(1)}h` : `${mins}m`;
22971
+ }
22972
+ function formatRecoveredNotice(degradedMs, pending) {
22973
+ let tail = "";
22974
+ if (pending?.preserved) {
22975
+ tail = ` Pending self-bump rollout to ${pending.pin} was preserved through the ` + `outage (marker now ${humanMins(pending.ageMs)} old) and will resume now.`;
22976
+ } else if (pending) {
22977
+ tail = ` NOTE: pending self-bump rollout to ${pending.pin} was NOT auto-resumed — ` + `degraded for ~${humanMins(degradedMs)}, past the ` + `${Math.round(MAX_PRESERVED_DEGRADED_MS / 3600000)}h preservation cap. Its ` + `marker (${humanMins(pending.ageMs)} old) will be treated as stale; re-run ` + `the rollout if it is still wanted.`;
22978
+ }
22979
+ return `✅ hostd: switchroom.yaml loads again after ~${humanMins(degradedMs)} degraded — ` + `normal service resumed.` + tail;
22980
+ }
22981
+ function shiftPendingRolloutMarker(raw, degradedMs) {
22982
+ if (degradedMs <= 0)
22983
+ return null;
22984
+ const marker = parsePendingRolloutMarker(raw);
22985
+ if (!marker)
22986
+ return null;
22987
+ const created = Date.parse(marker.created_at);
22988
+ if (!Number.isFinite(created))
22989
+ return null;
22990
+ return encodePendingRolloutMarker({
22991
+ ...marker,
22992
+ created_at: new Date(created + degradedMs).toISOString()
22993
+ });
22994
+ }
22995
+ function clearStaleDegradedMarker(homeDir, log) {
22996
+ const markerPath = join(homeDir, ".switchroom", "hostd", CONFIG_DEGRADED_MARKER_FILENAME);
22997
+ try {
22998
+ if (!existsSync4(markerPath))
22999
+ return false;
23000
+ unlinkSync(markerPath);
23001
+ log(`config-degraded: clearing stale degraded marker from a prior crash ` + `(${markerPath}) — config loads cleanly on this boot`);
23002
+ return true;
23003
+ } catch (e) {
23004
+ log(`config-degraded: could not clear stale marker ${markerPath}: ${e.message}`);
23005
+ return false;
23006
+ }
23007
+ }
23008
+ function postOperatorNoticeViaGateways(candidates, text, log, connectTimeoutMs = 5000) {
23009
+ const tryOne = (c) => new Promise((resolve4) => {
23010
+ let done = false;
23011
+ const finish = (ok, why) => {
23012
+ if (done)
23013
+ return;
23014
+ done = true;
23015
+ if (why)
23016
+ log(`config-degraded: gateway ${c.agent} — ${why}`);
23017
+ try {
23018
+ client.destroy();
23019
+ } catch {}
23020
+ resolve4(ok);
23021
+ };
23022
+ let client;
23023
+ try {
23024
+ client = connect({ path: c.sock });
23025
+ } catch (e) {
23026
+ log(`config-degraded: gateway ${c.agent} — connect threw: ${e.message}`);
23027
+ resolve4(false);
23028
+ return;
23029
+ }
23030
+ client.setTimeout(connectTimeoutMs);
23031
+ client.on("connect", () => {
23032
+ try {
23033
+ client.write(JSON.stringify({
23034
+ type: "rollout_status_post",
23035
+ requestId: "config-degraded",
23036
+ agentName: c.agent,
23037
+ text
23038
+ }) + `
23039
+ `, () => {
23040
+ client.end();
23041
+ finish(true);
23042
+ });
23043
+ } catch (e) {
23044
+ finish(false, `write failed: ${e.message}`);
23045
+ }
23046
+ });
23047
+ client.on("timeout", () => finish(false, "connect timed out"));
23048
+ client.on("error", (e) => finish(false, `socket error: ${e.message}`));
23049
+ });
23050
+ return (async () => {
23051
+ for (const c of candidates) {
23052
+ if (await tryOne(c))
23053
+ return c.agent;
23054
+ }
23055
+ if (candidates.length > 0) {
23056
+ log("config-degraded: every candidate gateway socket failed; relying on marker file");
23057
+ }
23058
+ return null;
23059
+ })();
23060
+ }
23061
+ async function waitForConfigRecovery(opts) {
23062
+ const now = opts.nowFn ?? Date.now;
23063
+ const retryMs = opts.retryMs ?? CONFIG_DEGRADED_RETRY_MS;
23064
+ const hostdDir = join(opts.homeDir, ".switchroom", "hostd");
23065
+ const degradedMarkerPath = join(hostdDir, CONFIG_DEGRADED_MARKER_FILENAME);
23066
+ const pendingMarkerPath = join(hostdDir, SELF_BUMP_MARKER_FILENAME);
23067
+ const persistedSince = readDegradedSince(degradedMarkerPath);
23068
+ const enteredAt = Math.min(persistedSince ?? now(), now());
23069
+ if (persistedSince !== null) {
23070
+ opts.log(`config-degraded: resuming an existing degraded window (since ` + `${new Date(enteredAt).toISOString()}, persisted in ${degradedMarkerPath})`);
23071
+ }
23072
+ try {
23073
+ mkdirSync(hostdDir, { recursive: true, mode: 448 });
23074
+ writeFileSync(degradedMarkerPath + ".tmp", JSON.stringify(buildDegradedMarker(opts.initialError, enteredAt), null, 2) + `
23075
+ `, { mode: 384 });
23076
+ renameSync(degradedMarkerPath + ".tmp", degradedMarkerPath);
23077
+ } catch (e) {
23078
+ opts.log(`config-degraded: could not write marker ${degradedMarkerPath}: ${e.message}`);
23079
+ }
23080
+ try {
23081
+ opts.notify(formatDegradedNotice(opts.initialError));
23082
+ } catch (e) {
23083
+ opts.log(`config-degraded: notify threw: ${e.message}`);
23084
+ }
23085
+ opts.log(`config-degraded: entering degraded mode — ${opts.initialError.message}. ` + `Retrying every ${Math.round(retryMs / 1000)}s` + (opts.configPath ? ` and on change to ${opts.configPath}` : "") + `. Marker: ${degradedMarkerPath}`);
23086
+ const config = await new Promise((resolve4, reject) => {
23087
+ let timer = null;
23088
+ let settled = false;
23089
+ const cleanup = () => {
23090
+ if (timer)
23091
+ clearInterval(timer);
23092
+ if (opts.configPath) {
23093
+ try {
23094
+ unwatchFile(opts.configPath, onFileChange);
23095
+ } catch {}
23096
+ }
23097
+ };
23098
+ const attempt = (why) => {
23099
+ if (settled)
23100
+ return;
23101
+ try {
23102
+ const cfg = opts.loadFn();
23103
+ settled = true;
23104
+ cleanup();
23105
+ resolve4(cfg);
23106
+ } catch (err) {
23107
+ if (err instanceof ConfigError) {
23108
+ opts.log(`config-degraded: reload attempt (${why}) still failing — ${err.message}`);
23109
+ return;
23110
+ }
23111
+ settled = true;
23112
+ cleanup();
23113
+ reject(err);
23114
+ }
23115
+ };
23116
+ const onFileChange = () => attempt("file change");
23117
+ timer = setInterval(() => attempt("interval"), retryMs);
23118
+ if (opts.configPath) {
23119
+ try {
23120
+ watchFile(opts.configPath, { interval: 5000 }, onFileChange);
23121
+ } catch (e) {
23122
+ opts.log(`config-degraded: could not watch ${opts.configPath}: ${e.message}`);
23123
+ }
23124
+ }
23125
+ });
23126
+ const degradedMs = Math.max(0, now() - enteredAt);
23127
+ let pendingOutcome = null;
23128
+ try {
23129
+ if (existsSync4(pendingMarkerPath)) {
23130
+ const raw = readFileSync3(pendingMarkerPath, "utf8");
23131
+ const marker = parsePendingRolloutMarker(raw);
23132
+ if (marker && degradedMs <= 0) {
23133
+ pendingOutcome = {
23134
+ pin: marker.pin,
23135
+ ageMs: Math.max(0, now() - Date.parse(marker.created_at)),
23136
+ preserved: true
23137
+ };
23138
+ } else if (marker) {
23139
+ const withinCap = degradedMs <= MAX_PRESERVED_DEGRADED_MS;
23140
+ const shifted = withinCap ? shiftPendingRolloutMarker(raw, degradedMs) : null;
23141
+ if (shifted !== null) {
23142
+ writeFileSync(pendingMarkerPath + ".tmp", shifted, { mode: 384 });
23143
+ renameSync(pendingMarkerPath + ".tmp", pendingMarkerPath);
23144
+ pendingOutcome = {
23145
+ pin: marker.pin,
23146
+ ageMs: Math.max(0, now() - (Date.parse(marker.created_at) + degradedMs)),
23147
+ preserved: true
23148
+ };
23149
+ opts.log(`config-degraded: shifted pending-rollout marker (${marker.pin}) ` + `created_at by ${Math.round(degradedMs / 1000)}s so the degraded ` + `window does not count against the resume cutoff`);
23150
+ } else {
23151
+ pendingOutcome = {
23152
+ pin: marker.pin,
23153
+ ageMs: Math.max(0, now() - Date.parse(marker.created_at)),
23154
+ preserved: false
23155
+ };
23156
+ if (!withinCap) {
23157
+ opts.log(`config-degraded: NOT preserving pending-rollout marker ` + `(${marker.pin}) — degraded ${Math.round(degradedMs / 60000)}m, ` + `past the ${Math.round(MAX_PRESERVED_DEGRADED_MS / 3600000)}h ` + `preservation cap; the roll will not auto-resume`);
23158
+ }
23159
+ }
23160
+ }
23161
+ }
23162
+ } catch (e) {
23163
+ opts.log(`config-degraded: could not preserve pending-rollout marker: ${e.message}`);
23164
+ }
23165
+ try {
23166
+ if (existsSync4(degradedMarkerPath))
23167
+ unlinkSync(degradedMarkerPath);
23168
+ } catch (e) {
23169
+ opts.log(`config-degraded: could not remove marker ${degradedMarkerPath}: ${e.message}`);
23170
+ }
23171
+ try {
23172
+ opts.notify(formatRecoveredNotice(degradedMs, pendingOutcome));
23173
+ } catch (e) {
23174
+ opts.log(`config-degraded: recovery notify threw: ${e.message}`);
23175
+ }
23176
+ opts.log(`config-degraded: config loads again after ${Math.round(degradedMs / 1000)}s — resuming normal boot`);
23177
+ return config;
23178
+ }
23179
+
22799
23180
  // src/agents/compose.ts
22800
23181
  import { createHash } from "node:crypto";
22801
23182
 
22802
23183
  // src/agents/scaffold.ts
22803
- import { join as join4, resolve as resolve5 } from "node:path";
23184
+ import { join as join5, resolve as resolve5 } from "node:path";
22804
23185
 
22805
23186
  // src/config/timezone.ts
22806
23187
  var CONTAINER_DEFAULT_UTC_ZONES = new Set([
@@ -22811,7 +23192,7 @@ var CONTAINER_DEFAULT_UTC_ZONES = new Set([
22811
23192
  ]);
22812
23193
 
22813
23194
  // src/cli/agent-config.ts
22814
- import { join } from "node:path";
23195
+ import { join as join2 } from "node:path";
22815
23196
  import { homedir as homedir2 } from "node:os";
22816
23197
 
22817
23198
  // src/vault/broker/acl.ts
@@ -22822,12 +23203,12 @@ var WEBKITE_VAULT_KEYS = new Set([
22822
23203
  ]);
22823
23204
 
22824
23205
  // src/cli/agent-config.ts
22825
- var AUDIT_ROOT = join(homedir2(), ".switchroom", "audit");
23206
+ var AUDIT_ROOT = join2(homedir2(), ".switchroom", "audit");
22826
23207
 
22827
23208
  // src/agents/profiles.ts
22828
23209
  var import_handlebars = __toESM(require_lib(), 1);
22829
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync, realpathSync } from "node:fs";
22830
- import { resolve as resolve4, join as join2, sep as pathSep } from "node:path";
23210
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync5, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync as mkdirSync2, realpathSync } from "node:fs";
23211
+ import { resolve as resolve4, join as join3, sep as pathSep } from "node:path";
22831
23212
  var PROFILES_ROOT = resolve4(import.meta.dirname, "../../profiles");
22832
23213
  import_handlebars.default.registerHelper("json", (value) => {
22833
23214
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
@@ -22836,11 +23217,11 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
22836
23217
  return typeof value === "number" && Number.isFinite(value);
22837
23218
  });
22838
23219
  var SHARED_FRAGMENTS_DIR = resolve4(PROFILES_ROOT, "_shared");
22839
- var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline"];
23220
+ var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
22840
23221
  for (const name of SHARED_FRAGMENTS) {
22841
- const fragPath = join2(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
22842
- if (existsSync4(fragPath)) {
22843
- import_handlebars.default.registerPartial(name, readFileSync3(fragPath, "utf-8"));
23222
+ const fragPath = join3(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
23223
+ if (existsSync5(fragPath)) {
23224
+ import_handlebars.default.registerPartial(name, readFileSync4(fragPath, "utf-8"));
22844
23225
  }
22845
23226
  }
22846
23227
 
@@ -22928,7 +23309,7 @@ var SCRYPT_MAXMEM = 128 * 1024 * 1024;
22928
23309
 
22929
23310
  // src/vault/broker/client.ts
22930
23311
  import { homedir as homedir3 } from "node:os";
22931
- import { join as join3 } from "node:path";
23312
+ import { join as join4 } from "node:path";
22932
23313
 
22933
23314
  // src/broker-common/peercred-path.ts
22934
23315
  function matchSocketName(socketPath, patterns, maxNameLen) {
@@ -23278,8 +23659,8 @@ var ResponseSchema = exports_external.union([
23278
23659
  ]);
23279
23660
 
23280
23661
  // src/vault/broker/client.ts
23281
- var LEGACY_SOCKET_PATH = join3(homedir3(), ".switchroom", "vault-broker.sock");
23282
- var OPERATOR_SOCKET_PATH = join3(homedir3(), ".switchroom", "broker-operator", "sock");
23662
+ var LEGACY_SOCKET_PATH = join4(homedir3(), ".switchroom", "vault-broker.sock");
23663
+ var OPERATOR_SOCKET_PATH = join4(homedir3(), ".switchroom", "broker-operator", "sock");
23283
23664
 
23284
23665
  // src/vault/resolver.ts
23285
23666
  var materializedDirs = new Set;
@@ -23316,20 +23697,20 @@ import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
23316
23697
  import { mkdir, chmod, chown, unlink, appendFile } from "node:fs/promises";
23317
23698
  import {
23318
23699
  readdirSync as readdirSync3,
23319
- existsSync as existsSync9,
23320
- readFileSync as readFileSync7,
23321
- writeFileSync as writeFileSync4,
23322
- renameSync as renameSync2,
23323
- mkdirSync as mkdirSync3,
23700
+ existsSync as existsSync10,
23701
+ readFileSync as readFileSync8,
23702
+ writeFileSync as writeFileSync5,
23703
+ renameSync as renameSync3,
23704
+ mkdirSync as mkdirSync4,
23324
23705
  openSync as openSync3,
23325
23706
  ftruncateSync,
23326
23707
  writeSync as writeSync2,
23327
23708
  fsyncSync as fsyncSync2,
23328
23709
  closeSync as closeSync3,
23329
23710
  copyFileSync as copyFileSync2,
23330
- unlinkSync
23711
+ unlinkSync as unlinkSync2
23331
23712
  } from "node:fs";
23332
- import { join as join9, dirname as dirname6, resolve as resolve8 } from "node:path";
23713
+ import { join as join10, dirname as dirname6, resolve as resolve8 } from "node:path";
23333
23714
  import { createHash as createHash5, randomUUID as randomUUID2, randomBytes } from "node:crypto";
23334
23715
 
23335
23716
  // src/host-control/protocol.ts
@@ -28018,10 +28399,10 @@ class PostHog extends PostHogBackendClient {
28018
28399
 
28019
28400
  // src/analytics/posthog.ts
28020
28401
  import {
28021
- existsSync as existsSync5,
28022
- mkdirSync as mkdirSync2,
28023
- readFileSync as readFileSync4,
28024
- writeFileSync as writeFileSync2
28402
+ existsSync as existsSync6,
28403
+ mkdirSync as mkdirSync3,
28404
+ readFileSync as readFileSync5,
28405
+ writeFileSync as writeFileSync3
28025
28406
  } from "node:fs";
28026
28407
  import { dirname as dirname2 } from "node:path";
28027
28408
  import { randomUUID } from "node:crypto";
@@ -28039,8 +28420,8 @@ function getDistinctId() {
28039
28420
  return cachedDistinctId;
28040
28421
  const path = resolveStatePath("analytics-id");
28041
28422
  try {
28042
- if (existsSync5(path)) {
28043
- const existing = readFileSync4(path, "utf-8").trim();
28423
+ if (existsSync6(path)) {
28424
+ const existing = readFileSync5(path, "utf-8").trim();
28044
28425
  if (existing) {
28045
28426
  cachedDistinctId = existing;
28046
28427
  return existing;
@@ -28050,8 +28431,8 @@ function getDistinctId() {
28050
28431
  const id = randomUUID();
28051
28432
  cachedDistinctId = id;
28052
28433
  try {
28053
- mkdirSync2(dirname2(path), { recursive: true });
28054
- writeFileSync2(path, id, "utf-8");
28434
+ mkdirSync3(dirname2(path), { recursive: true });
28435
+ writeFileSync3(path, id, "utf-8");
28055
28436
  } catch {}
28056
28437
  return id;
28057
28438
  }
@@ -28714,40 +29095,21 @@ var import_yaml3 = __toESM(require_dist(), 1);
28714
29095
  // src/cli/rollout.ts
28715
29096
  init_atomic();
28716
29097
 
28717
- // src/config/release-resolve.ts
28718
- function parseSemverTag(tag) {
28719
- if (!tag)
28720
- return null;
28721
- const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
28722
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
28723
- }
28724
- function compareReleaseTags(a, b) {
28725
- const pa = parseSemverTag(a);
28726
- const pb = parseSemverTag(b);
28727
- if (!pa || !pb)
28728
- return null;
28729
- for (let i = 0;i < 3; i++) {
28730
- if (pa[i] !== pb[i])
28731
- return pa[i] < pb[i] ? -1 : 1;
28732
- }
28733
- return 0;
28734
- }
28735
-
28736
29098
  // src/cli/resolve-version.ts
28737
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
28738
- import { dirname as dirname4, join as join6 } from "node:path";
29099
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "node:fs";
29100
+ import { dirname as dirname4, join as join7 } from "node:path";
28739
29101
 
28740
29102
  // src/build-info.ts
28741
- var VERSION = "0.18.9";
29103
+ var VERSION = "0.18.11";
28742
29104
 
28743
29105
  // src/cli/resolve-version.ts
28744
29106
  function readPackageVersion() {
28745
29107
  let dir = import.meta.dirname;
28746
29108
  for (let i = 0;i < 12 && dir && dir !== "/"; i++) {
28747
- const p = join6(dir, "package.json");
28748
- if (existsSync7(p)) {
29109
+ const p = join7(dir, "package.json");
29110
+ if (existsSync8(p)) {
28749
29111
  try {
28750
- const pkg = JSON.parse(readFileSync5(p, "utf-8"));
29112
+ const pkg = JSON.parse(readFileSync6(p, "utf-8"));
28751
29113
  if (pkg?.name === "switchroom" && typeof pkg.version === "string") {
28752
29114
  return pkg.version;
28753
29115
  }
@@ -28894,6 +29256,7 @@ function parseRolloutPhaseLine(line) {
28894
29256
  "agent-start",
28895
29257
  "agent-done",
28896
29258
  "persist-pin",
29259
+ "web-refresh",
28897
29260
  "hostd-web-deferred",
28898
29261
  "self-bump",
28899
29262
  "self-bump-done"
@@ -28931,122 +29294,11 @@ function parseRolloutResultLine(stdout) {
28931
29294
  return null;
28932
29295
  }
28933
29296
 
28934
- // src/host-control/self-bump.ts
28935
- var SELF_BUMP_MARKER_FILENAME = "pending-rollout.json";
28936
- var SELF_BUMP_HELPER_CONTAINER = "switchroom-hostd-selfbump";
28937
- var SELF_BUMP_LOG_FILENAME = "selfbump.log";
28938
- var SELF_BUMP_MARKER_MAX_AGE_MS = 15 * 60 * 1000;
28939
- var SEMVER_PIN_RE = /^v\d+\.\d+\.\d+$/;
28940
- function needsSelfBump(ownVersion, pin) {
28941
- if (!ownVersion)
28942
- return false;
28943
- const cmp = compareReleaseTags(`v${ownVersion.trim().replace(/^v/, "")}`, `v${pin.trim().replace(/^v/, "")}`);
28944
- return cmp !== null && cmp < 0;
28945
- }
28946
- function bumpHostdComposeImageTag(yaml, pin) {
28947
- const re = /^(\s*image:\s*)(\S*switchroom-hostd):(\S+)\s*$/m;
28948
- const m = yaml.match(re);
28949
- if (!m)
28950
- return null;
28951
- const oldImageRef = `${m[2]}:${m[3]}`;
28952
- const newImageRef = `${m[2]}:${pin}`;
28953
- return {
28954
- yaml: yaml.replace(re, `$1${newImageRef}`),
28955
- oldImageRef,
28956
- newImageRef
28957
- };
28958
- }
28959
- function encodePendingRolloutMarker(m) {
28960
- return JSON.stringify(m, null, 2) + `
28961
- `;
28962
- }
28963
- function parsePendingRolloutMarker(raw) {
28964
- let obj;
28965
- try {
28966
- obj = JSON.parse(raw);
28967
- } catch {
28968
- return null;
28969
- }
28970
- if (typeof obj !== "object" || obj === null)
28971
- return null;
28972
- const o = obj;
28973
- if (o.v !== 1)
28974
- return null;
28975
- if (typeof o.request_id !== "string" || o.request_id.length === 0)
28976
- return null;
28977
- if (typeof o.pin !== "string" || !SEMVER_PIN_RE.test(o.pin))
28978
- return null;
28979
- if (typeof o.created_at !== "string" || !Number.isFinite(Date.parse(o.created_at))) {
28980
- return null;
28981
- }
28982
- if (typeof o.prior_hostd_version !== "string")
28983
- return null;
28984
- const caller = o.caller;
28985
- if (typeof caller !== "object" || caller === null)
28986
- return null;
28987
- if (caller.kind === "agent") {
28988
- if (typeof caller.name !== "string" || caller.name.length === 0)
28989
- return null;
28990
- } else if (caller.kind !== "operator") {
28991
- return null;
28992
- }
28993
- if (o.agents !== undefined) {
28994
- if (!Array.isArray(o.agents) || o.agents.length === 0 || !o.agents.every((a) => typeof a === "string" && /^[\w-]+$/.test(a))) {
28995
- return null;
28996
- }
28997
- }
28998
- if (o.skip_web !== undefined && typeof o.skip_web !== "boolean")
28999
- return null;
29000
- if (o.allow_downgrade !== undefined && typeof o.allow_downgrade !== "boolean") {
29001
- return null;
29002
- }
29003
- return {
29004
- v: 1,
29005
- request_id: o.request_id,
29006
- pin: o.pin,
29007
- ...o.agents !== undefined ? { agents: o.agents } : {},
29008
- ...o.skip_web !== undefined ? { skip_web: o.skip_web } : {},
29009
- ...o.allow_downgrade !== undefined ? { allow_downgrade: o.allow_downgrade } : {},
29010
- caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
29011
- created_at: o.created_at,
29012
- prior_hostd_version: o.prior_hostd_version
29013
- };
29014
- }
29015
- function isMarkerFresh(marker, nowMs) {
29016
- const created = Date.parse(marker.created_at);
29017
- if (!Number.isFinite(created))
29018
- return false;
29019
- return nowMs - created < SELF_BUMP_MARKER_MAX_AGE_MS;
29020
- }
29021
- function selfBumpHelperArgs(opts) {
29022
- const composePath = `${opts.hostdDirHostPath}/docker-compose.yml`;
29023
- const logPath = `${opts.hostdDirHostPath}/${SELF_BUMP_LOG_FILENAME}`;
29024
- const script = `sleep 2; ` + `echo "selfbump $(date -u) -> ${opts.targetImage}" >> "${logPath}" 2>&1; ` + `docker compose -p switchroom-hostd -f "${composePath}" pull >> "${logPath}" 2>&1 && ` + `docker compose -p switchroom-hostd -f "${composePath}" up -d >> "${logPath}" 2>&1; ` + `rc=$?; echo "selfbump done rc=$rc" >> "${logPath}" 2>&1; exit $rc`;
29025
- return [
29026
- "run",
29027
- "-d",
29028
- "--rm",
29029
- "--name",
29030
- SELF_BUMP_HELPER_CONTAINER,
29031
- "--label",
29032
- "switchroom.hostd-selfbump=true",
29033
- "-v",
29034
- "/var/run/docker.sock:/var/run/docker.sock",
29035
- "-v",
29036
- `${opts.hostdDirHostPath}:${opts.hostdDirHostPath}`,
29037
- "--entrypoint",
29038
- "/bin/sh",
29039
- opts.helperImage,
29040
- "-c",
29041
- script
29042
- ];
29043
- }
29044
-
29045
29297
  // node_modules/.bun/commander@13.1.0/node_modules/commander/esm.mjs
29046
29298
  var import__3 = __toESM(require_commander(), 1);
29047
29299
 
29048
29300
  // src/cli/update.ts
29049
- import { join as join7, dirname as dirname5, resolve as resolve7 } from "node:path";
29301
+ import { join as join8, dirname as dirname5, resolve as resolve7 } from "node:path";
29050
29302
  import { homedir as homedir5 } from "node:os";
29051
29303
 
29052
29304
  // src/agents/lifecycle.ts
@@ -29143,7 +29395,7 @@ function getAllAgentStatuses(config) {
29143
29395
 
29144
29396
  // src/cli/update.ts
29145
29397
  init_atomic();
29146
- var DEFAULT_COMPOSE_PATH = join7(homedir5(), ".switchroom", "compose", "docker-compose.yml");
29398
+ var DEFAULT_COMPOSE_PATH = join8(homedir5(), ".switchroom", "compose", "docker-compose.yml");
29147
29399
  var UPDATE_RESULT_SENTINEL = "SWITCHROOM_UPDATE_RESULT:";
29148
29400
  function parseUpdateResultLine(stdout) {
29149
29401
  const idx = stdout.lastIndexOf(UPDATE_RESULT_SENTINEL);
@@ -29166,9 +29418,9 @@ function parseUpdateResultLine(stdout) {
29166
29418
 
29167
29419
  // src/host-control/config-edit-validator.ts
29168
29420
  var import_yaml5 = __toESM(require_dist(), 1);
29169
- import { mkdtempSync, writeFileSync as writeFileSync3, rmSync as rmSync2, existsSync as existsSync8, readFileSync as readFileSync6 } from "node:fs";
29421
+ import { mkdtempSync, writeFileSync as writeFileSync4, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
29170
29422
  import { tmpdir } from "node:os";
29171
- import { join as join8, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
29423
+ import { join as join9, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
29172
29424
  import { spawnSync as spawnSync2 } from "node:child_process";
29173
29425
  import { isDeepStrictEqual } from "node:util";
29174
29426
  var MAX_PATCH_BYTES = 1024 * 1024;
@@ -29281,22 +29533,22 @@ function validateShape(unifiedDiff, targetPath) {
29281
29533
  return null;
29282
29534
  }
29283
29535
  function applyPatch(unifiedDiff, configPath, gitBin) {
29284
- if (!existsSync8(configPath)) {
29536
+ if (!existsSync9(configPath)) {
29285
29537
  return {
29286
29538
  ok: false,
29287
29539
  code: "E_PATCH_APPLY_FAILED",
29288
29540
  detail: `target config not found at ${configPath}`
29289
29541
  };
29290
29542
  }
29291
- const liveContent = readFileSync6(configPath, "utf8");
29292
- const scratchDir = mkdtempSync(join8(tmpdir(), "config-propose-edit-"));
29543
+ const liveContent = readFileSync7(configPath, "utf8");
29544
+ const scratchDir = mkdtempSync(join9(tmpdir(), "config-propose-edit-"));
29293
29545
  try {
29294
29546
  const targetBasename = configPath.split("/").pop() ?? "switchroom.yaml";
29295
- const scratchFile = join8(scratchDir, targetBasename);
29296
- writeFileSync3(scratchFile, liveContent);
29547
+ const scratchFile = join9(scratchDir, targetBasename);
29548
+ writeFileSync4(scratchFile, liveContent);
29297
29549
  const normalizedDiff = normalizeDiffHeadersToBasename(unifiedDiff, targetBasename);
29298
- const patchFile = join8(scratchDir, "proposal.patch");
29299
- writeFileSync3(patchFile, normalizedDiff);
29550
+ const patchFile = join9(scratchDir, "proposal.patch");
29551
+ writeFileSync4(patchFile, normalizedDiff);
29300
29552
  const bin = gitBin ?? "git";
29301
29553
  const baseArgs = [
29302
29554
  "apply",
@@ -29326,7 +29578,7 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
29326
29578
  detail: `git apply failed: ${(real.stderr || "").trim().slice(0, 500)}`
29327
29579
  };
29328
29580
  }
29329
- const after = readFileSync6(scratchFile, "utf8");
29581
+ const after = readFileSync7(scratchFile, "utf8");
29330
29582
  return { ok: true, after };
29331
29583
  } finally {
29332
29584
  rmSync2(scratchDir, { recursive: true, force: true });
@@ -29524,7 +29776,7 @@ function validateConfigEdit(opts) {
29524
29776
  return schemaErr;
29525
29777
  let beforeData = {};
29526
29778
  try {
29527
- const beforeRaw = existsSync8(opts.configPath) ? readFileSync6(opts.configPath, "utf8") : "";
29779
+ const beforeRaw = existsSync9(opts.configPath) ? readFileSync7(opts.configPath, "utf8") : "";
29528
29780
  const beforeDoc = import_yaml5.parseDocument(beforeRaw, { merge: false, strict: false });
29529
29781
  beforeData = beforeDoc.toJS();
29530
29782
  } catch {
@@ -29770,19 +30022,21 @@ function phaseLine(s) {
29770
30022
  case "apply":
29771
30023
  return "applying — regenerating compose";
29772
30024
  case "canary-start":
29773
- return `canary — restarting ${s.agent ?? "canary agent"}`;
30025
+ return `canary — restarting ${s.agent ? `\`${s.agent}\`` : "canary agent"}`;
29774
30026
  case "canary-pass":
29775
- return `canary passed (${s.agent ?? "canary"}) — rolling the rest`;
30027
+ return `canary passed (${s.agent ? `\`${s.agent}\`` : "canary"}) — rolling the rest`;
29776
30028
  case "canary-fail":
29777
- return `canary failed (${s.agent ?? "canary"})`;
30029
+ return `canary failed (${s.agent ? `\`${s.agent}\`` : "canary"})`;
29778
30030
  case "agent-start":
29779
- return `agent ${s.n ?? "?"}/${s.m ?? "?"} — restarting ${s.agent ?? ""}`.trim();
30031
+ return `agent ${s.n ?? "?"}/${s.m ?? "?"} — restarting ${s.agent ? `\`${s.agent}\`` : ""}`.trim();
29780
30032
  case "agent-done":
29781
- return `agent ${s.n ?? "?"}/${s.m ?? "?"} — ${s.agent ?? ""} done`.trim();
30033
+ return `agent ${s.n ?? "?"}/${s.m ?? "?"} — ${s.agent ? `\`${s.agent}\`` : ""} done`.trim();
29782
30034
  case "persist-pin":
29783
30035
  return "persisting pin";
30036
+ case "web-refresh":
30037
+ return "refreshing web dashboard (webd install)";
29784
30038
  case "hostd-web-deferred":
29785
- return "hostd/web refresh deferred (run host-side)";
30039
+ return "hostd refresh deferred (run host-side)";
29786
30040
  case "self-bump":
29787
30041
  return "hostd refreshing itself to the target (brief control blip)…";
29788
30042
  case "self-bump-done":
@@ -29791,18 +30045,147 @@ function phaseLine(s) {
29791
30045
  return "starting";
29792
30046
  }
29793
30047
  }
29794
- function renderRolloutStatus(s) {
29795
- const head = `Rollout ${s.target}`;
29796
- const rolledCount = s.rolled?.length ?? 0;
30048
+ function formatDurationMs(ms) {
30049
+ const totalSec = Math.max(0, Math.round(ms / 1000));
30050
+ if (totalSec < 60)
30051
+ return `${totalSec}s`;
30052
+ const totalMin = Math.floor(totalSec / 60);
30053
+ const sec = totalSec % 60;
30054
+ if (totalMin < 60)
30055
+ return sec > 0 ? `${totalMin}m ${sec}s` : `${totalMin}m`;
30056
+ const hr = Math.floor(totalMin / 60);
30057
+ const min = totalMin % 60;
30058
+ return min > 0 ? `${hr}h ${min}m` : `${hr}h`;
30059
+ }
30060
+ function shortRequestId(id) {
30061
+ return id.length > 16 ? id.slice(0, 16) + "…" : id;
30062
+ }
30063
+ function headerLine(s, icon) {
30064
+ const arrow = s.fromVersion ? `\`${s.fromVersion}\` → \`${s.target}\`` : `→ \`${s.target}\``;
30065
+ const req = s.requestId ? ` · req \`${shortRequestId(s.requestId)}\`` : "";
30066
+ return `${icon} **Rollout** ${arrow}${req}`;
30067
+ }
30068
+ function checklistLines(s, compact) {
30069
+ const agents = s.agents ?? [];
30070
+ const lines = [];
30071
+ let doneCount = 0;
30072
+ let pendingNamed = 0;
30073
+ for (const a of agents) {
30074
+ const canary = a.canary ? " (canary)" : "";
30075
+ const dur = a.durationMs !== undefined ? ` — ${formatDurationMs(a.durationMs)}` : "";
30076
+ switch (a.status) {
30077
+ case "done":
30078
+ if (compact)
30079
+ doneCount += 1;
30080
+ else
30081
+ lines.push(`- ✓ \`${a.name}\`${canary}${dur}`);
30082
+ break;
30083
+ case "running":
30084
+ lines.push(`- ⏳ \`${a.name}\`${canary} — restarting…`);
30085
+ break;
30086
+ case "failed":
30087
+ lines.push(`- ✗ \`${a.name}\`${canary} — failed${dur}`);
30088
+ break;
30089
+ default:
30090
+ if (compact)
30091
+ pendingNamed += 1;
30092
+ else
30093
+ lines.push(`- · \`${a.name}\`${canary}`);
30094
+ }
30095
+ }
30096
+ if (doneCount > 0)
30097
+ lines.unshift(`- ✓ ${doneCount} done`);
30098
+ const unseen = s.m !== undefined ? Math.max(0, s.m - agents.length) : 0;
30099
+ const pendingTotal = unseen + pendingNamed;
30100
+ if (pendingTotal > 0 && agents.length > 0) {
30101
+ lines.push(`- · ${pendingTotal} more pending`);
30102
+ }
30103
+ return lines;
30104
+ }
30105
+ function etaLine(s) {
30106
+ if (s.m === undefined)
30107
+ return null;
30108
+ const done = (s.agents ?? []).filter((a) => a.status === "done" && a.durationMs !== undefined);
30109
+ if (done.length === 0)
30110
+ return null;
30111
+ const remaining = s.m - done.length;
30112
+ if (remaining <= 0)
30113
+ return null;
30114
+ const meanMs = done.reduce((t, a) => t + (a.durationMs ?? 0), 0) / done.length;
30115
+ return `~${formatDurationMs(meanMs * remaining)} left (rough est.)`;
30116
+ }
30117
+ function deferredLines(target) {
30118
+ const bare = target.trim().replace(/^v/, "");
30119
+ return [
30120
+ `**Deferred (still on the prior version — run host-side):**`,
30121
+ `- \`switchroom-web\` — \`switchroom webd install --tag ${target}\``,
30122
+ `- host operator CLI — \`sudo npm i -g switchroom@${bare}\``,
30123
+ `- hostd template regen (only if the release changed hostd mounts/env) — \`switchroom hostd install --tag ${target}\``
30124
+ ];
30125
+ }
30126
+ var MAX_RENDER_CHARS = 3800;
30127
+ function rolledList(rolled, compact) {
30128
+ if (rolled.length === 0)
30129
+ return "none";
30130
+ if (compact)
30131
+ return `${rolled.length} agent(s)`;
30132
+ return rolled.map((r) => `\`${r}\``).join(", ");
30133
+ }
30134
+ function renderWith(s, compact) {
30135
+ const rolled = s.rolled ?? [];
30136
+ const rolledCount = rolled.length;
30137
+ const checklist = checklistLines(s, compact);
30138
+ const elapsedMs = s.elapsedMs ?? (s.startedAtMs !== undefined && s.nowMs !== undefined ? s.nowMs - s.startedAtMs : undefined);
29797
30139
  if (s.terminal === "completed") {
29798
- return `✅ ${head} — done. Rolled ${rolledCount} agent(s): ${(s.rolled ?? []).join(", ") || "none"}.`;
30140
+ const parts2 = [headerLine(s, "✅")];
30141
+ let summary = `**Done** — rolled ${rolledCount}${s.m !== undefined ? `/${s.m}` : ""} agent(s)`;
30142
+ if (elapsedMs !== undefined)
30143
+ summary += ` in ${formatDurationMs(elapsedMs)}`;
30144
+ summary += compact ? "." : `: ${rolledList(rolled, compact)}.`;
30145
+ parts2.push(summary);
30146
+ if (checklist.length > 0)
30147
+ parts2.push("", ...checklist);
30148
+ if (s.deferred !== false)
30149
+ parts2.push("", ...deferredLines(s.target));
30150
+ return parts2.join(`
30151
+ `);
29799
30152
  }
29800
30153
  if (s.terminal === "error") {
29801
- const where = s.failedStep ? ` at ${s.failedStep}${s.failedAgent ? ` (${s.failedAgent} → ${s.got ?? "unreachable"})` : ""}` : "";
29802
- return `❌ ${head} — STOPPED${where}. ` + `Rolled before stop: ${(s.rolled ?? []).join(", ") || "none"}.`;
30154
+ const where = s.failedStep ? ` at \`${s.failedStep}\`${s.failedAgent ? ` (\`${s.failedAgent}\` → ${s.got ?? "unreachable"})` : ""}` : "";
30155
+ const parts2 = [headerLine(s, "❌")];
30156
+ let summary = `**STOPPED**${where}`;
30157
+ if (elapsedMs !== undefined)
30158
+ summary += ` after ${formatDurationMs(elapsedMs)}`;
30159
+ summary += `. Rolled before stop: ${rolledList(rolled, compact)}.`;
30160
+ parts2.push(summary);
30161
+ if (checklist.length > 0)
30162
+ parts2.push("", ...checklist);
30163
+ return parts2.join(`
30164
+ `);
29803
30165
  }
29804
- const progress = rolledCount > 0 ? ` · ${rolledCount} rolled` : "";
29805
- return `⏳ ${head} — ${phaseLine(s)}${progress}`;
30166
+ const parts = [headerLine(s, "⏳"), `**Phase:** ${phaseLine(s)}`];
30167
+ if (checklist.length > 0)
30168
+ parts.push("", ...checklist);
30169
+ const footer = [];
30170
+ if (s.m !== undefined)
30171
+ footer.push(`${rolledCount}/${s.m} rolled`);
30172
+ else if (rolledCount > 0)
30173
+ footer.push(`${rolledCount} rolled`);
30174
+ if (elapsedMs !== undefined)
30175
+ footer.push(`elapsed ${formatDurationMs(elapsedMs)}`);
30176
+ const eta = etaLine(s);
30177
+ if (eta)
30178
+ footer.push(eta);
30179
+ if (footer.length > 0)
30180
+ parts.push("", footer.join(" · "));
30181
+ return parts.join(`
30182
+ `);
30183
+ }
30184
+ function renderRolloutStatus(s) {
30185
+ const full = renderWith(s, false);
30186
+ if (full.length <= MAX_RENDER_CHARS)
30187
+ return full;
30188
+ return renderWith(s, true);
29806
30189
  }
29807
30190
 
29808
30191
  // src/scheduler/dispatch.ts
@@ -29887,11 +30270,11 @@ function resolveDigests(imageRefs) {
29887
30270
  return out;
29888
30271
  }
29889
30272
  function readCachedInstallType(bindRoot) {
29890
- const cacheDir = join9(bindRoot, ".switchroom");
29891
- const cachePath = join9(cacheDir, "install-type.json");
29892
- if (existsSync9(cachePath)) {
30273
+ const cacheDir = join10(bindRoot, ".switchroom");
30274
+ const cachePath = join10(cacheDir, "install-type.json");
30275
+ if (existsSync10(cachePath)) {
29893
30276
  try {
29894
- const raw = readFileSync7(cachePath, "utf-8");
30277
+ const raw = readFileSync8(cachePath, "utf-8");
29895
30278
  const parsed = JSON.parse(raw);
29896
30279
  if (parsed && typeof parsed.install_type === "string" && typeof parsed.detected_at === "string") {
29897
30280
  return parsed;
@@ -29908,10 +30291,10 @@ function readCachedInstallType(bindRoot) {
29908
30291
  source_paths: ctx.source_paths
29909
30292
  };
29910
30293
  try {
29911
- mkdirSync3(cacheDir, { recursive: true });
30294
+ mkdirSync4(cacheDir, { recursive: true });
29912
30295
  const tmp = `${cachePath}.tmp`;
29913
- writeFileSync4(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
29914
- renameSync2(tmp, cachePath);
30296
+ writeFileSync5(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
30297
+ renameSync3(tmp, cachePath);
29915
30298
  } catch {}
29916
30299
  return payload;
29917
30300
  }
@@ -29940,7 +30323,7 @@ function writeFileInPlacePreservingInode(targetPath, content) {
29940
30323
  } finally {
29941
30324
  closeSync3(fd);
29942
30325
  }
29943
- const readBack = readFileSync7(targetPath);
30326
+ const readBack = readFileSync8(targetPath);
29944
30327
  if (readBack.length !== buf.length) {
29945
30328
  throw new Error(`in-place write short: wrote ${buf.length} bytes but read back ${readBack.length}`);
29946
30329
  }
@@ -29965,7 +30348,7 @@ class HostdServer {
29965
30348
  this.opts = opts;
29966
30349
  }
29967
30350
  async start() {
29968
- const hostdDir = join9(this.opts.homeDir, ".switchroom", "hostd");
30351
+ const hostdDir = join10(this.opts.homeDir, ".switchroom", "hostd");
29969
30352
  await mkdir(hostdDir, { recursive: true });
29970
30353
  await chmod(hostdDir, 493).catch(() => {
29971
30354
  return;
@@ -29980,13 +30363,13 @@ class HostdServer {
29980
30363
  }
29981
30364
  try {
29982
30365
  for (const name of agentNames) {
29983
- const dir = join9(hostdDir, name);
29984
- const sockPath = join9(dir, "sock");
30366
+ const dir = join10(hostdDir, name);
30367
+ const sockPath = join10(dir, "sock");
29985
30368
  await mkdir(dir, { recursive: true });
29986
30369
  await chmod(dir, 493).catch(() => {
29987
30370
  return;
29988
30371
  });
29989
- if (existsSync9(sockPath))
30372
+ if (existsSync10(sockPath))
29990
30373
  await unlink(sockPath).catch(() => {
29991
30374
  return;
29992
30375
  });
@@ -30017,13 +30400,13 @@ class HostdServer {
30017
30400
  process.stderr.write(`hostd: SWITCHROOM_HOSTD_OPERATOR_UID='${opUidStr}' is not a positive integer; skipping operator listener
30018
30401
  `);
30019
30402
  } else {
30020
- const dir = join9(hostdDir, "operator");
30021
- const sockPath = join9(dir, "sock");
30403
+ const dir = join10(hostdDir, "operator");
30404
+ const sockPath = join10(dir, "sock");
30022
30405
  await mkdir(dir, { recursive: true });
30023
30406
  await chmod(dir, 493).catch(() => {
30024
30407
  return;
30025
30408
  });
30026
- if (existsSync9(sockPath))
30409
+ if (existsSync10(sockPath))
30027
30410
  await unlink(sockPath).catch(() => {
30028
30411
  return;
30029
30412
  });
@@ -30059,11 +30442,11 @@ class HostdServer {
30059
30442
  }
30060
30443
  async reconcileOrphanedFleetMutations() {
30061
30444
  const path2 = this.auditLogPath();
30062
- if (!existsSync9(path2))
30445
+ if (!existsSync10(path2))
30063
30446
  return;
30064
30447
  let raw;
30065
30448
  try {
30066
- raw = readFileSync7(path2, "utf-8");
30449
+ raw = readFileSync8(path2, "utf-8");
30067
30450
  } catch {
30068
30451
  return;
30069
30452
  }
@@ -30393,10 +30776,10 @@ class HostdServer {
30393
30776
  missingApplyAssets() {
30394
30777
  const root = this.opts.applyAssetsRoot ?? resolve8(import.meta.dirname, "../..");
30395
30778
  return [
30396
- join9(root, "profiles"),
30397
- join9(root, "profiles", "default"),
30398
- join9(root, "vendor", "hindsight-memory")
30399
- ].filter((p) => !existsSync9(p));
30779
+ join10(root, "profiles"),
30780
+ join10(root, "profiles", "default"),
30781
+ join10(root, "vendor", "hindsight-memory")
30782
+ ].filter((p) => !existsSync10(p));
30400
30783
  }
30401
30784
  applyAssetPreflight(request_id, started) {
30402
30785
  const missing = this.missingApplyAssets();
@@ -30560,19 +30943,19 @@ class HostdServer {
30560
30943
  return entry;
30561
30944
  }
30562
30945
  hostdDirPath() {
30563
- return join9(this.opts.homeDir, ".switchroom", "hostd");
30946
+ return join10(this.opts.homeDir, ".switchroom", "hostd");
30564
30947
  }
30565
30948
  hostdDirHostPath() {
30566
30949
  const hostHome = this.opts.hostHomeDir ?? process.env.SWITCHROOM_HOST_HOME?.trim() ?? this.opts.homeDir;
30567
- return join9(hostHome, ".switchroom", "hostd");
30950
+ return join10(hostHome, ".switchroom", "hostd");
30568
30951
  }
30569
30952
  async beginSelfBump(req, caller, started) {
30570
30953
  const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
30571
- const composePath = join9(this.hostdDirPath(), "docker-compose.yml");
30572
- if (!existsSync9(composePath)) {
30954
+ const composePath = join10(this.hostdDirPath(), "docker-compose.yml");
30955
+ if (!existsSync10(composePath)) {
30573
30956
  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);
30574
30957
  }
30575
- const before = readFileSync7(composePath, "utf8");
30958
+ const before = readFileSync8(composePath, "utf8");
30576
30959
  const bumped = bumpHostdComposeImageTag(before, req.args.pin);
30577
30960
  if (!bumped) {
30578
30961
  return deniedResponse(req.request_id, `rollout: hostd needs a self-bump to ${req.args.pin} but no ` + `switchroom-hostd image line was found in ${composePath} ` + `(hand-edited compose?). Run \`switchroom hostd install --tag ` + `${req.args.pin}\` on the host, then re-run the roll. Nothing ` + `was changed.`, Date.now() - started);
@@ -30593,10 +30976,10 @@ class HostdServer {
30593
30976
  try {
30594
30977
  const baks = readdirSync3(this.hostdDirPath()).filter((f) => f.startsWith("docker-compose.yml.bak-selfbump-")).sort();
30595
30978
  for (const f of baks.slice(0, Math.max(0, baks.length - 4))) {
30596
- unlinkSync(join9(this.hostdDirPath(), f));
30979
+ unlinkSync2(join10(this.hostdDirPath(), f));
30597
30980
  }
30598
30981
  } catch {}
30599
- const markerPath = join9(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30982
+ const markerPath = join10(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30600
30983
  const marker = {
30601
30984
  v: 1,
30602
30985
  request_id: req.request_id,
@@ -30609,8 +30992,8 @@ class HostdServer {
30609
30992
  prior_hostd_version: ownVersion
30610
30993
  };
30611
30994
  copyFileSync2(composePath, bakPath);
30612
- writeFileSync4(composePath, bumped.yaml, "utf8");
30613
- writeFileSync4(markerPath, encodePendingRolloutMarker(marker), {
30995
+ writeFileSync5(composePath, bumped.yaml, "utf8");
30996
+ writeFileSync5(markerPath, encodePendingRolloutMarker(marker), {
30614
30997
  mode: 384
30615
30998
  });
30616
30999
  const entry = {
@@ -30635,7 +31018,7 @@ class HostdServer {
30635
31018
  const rollbackBump = () => {
30636
31019
  try {
30637
31020
  copyFileSync2(bakPath, composePath);
30638
- unlinkSync(markerPath);
31021
+ unlinkSync2(markerPath);
30639
31022
  } catch (e) {
30640
31023
  process.stderr.write(`hostd: self-bump rollback failed (compose/marker may need manual ` + `restore from ${bakPath}): ${e.message}
30641
31024
  `);
@@ -30678,7 +31061,7 @@ class HostdServer {
30678
31061
  rollbackBump();
30679
31062
  entry.result = "error";
30680
31063
  entry.finished_at = Date.now();
30681
- entry.error = `self-bump helper failed (container exit ${Number.isFinite(code) ? code : "unknown"}). ` + `See ${join9(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.`;
31064
+ entry.error = `self-bump helper failed (container exit ${Number.isFinite(code) ? code : "unknown"}). ` + `See ${join10(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.`;
30682
31065
  if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
30683
31066
  this.fleetMutationInFlight = null;
30684
31067
  }
@@ -30702,15 +31085,15 @@ class HostdServer {
30702
31085
  };
30703
31086
  }
30704
31087
  async resumePendingSelfBumpRollout() {
30705
- const markerPath = join9(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30706
- if (!existsSync9(markerPath))
31088
+ const markerPath = join10(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
31089
+ if (!existsSync10(markerPath))
30707
31090
  return;
30708
31091
  let raw;
30709
31092
  try {
30710
- raw = readFileSync7(markerPath, "utf8");
31093
+ raw = readFileSync8(markerPath, "utf8");
30711
31094
  } finally {
30712
31095
  try {
30713
- unlinkSync(markerPath);
31096
+ unlinkSync2(markerPath);
30714
31097
  } catch {}
30715
31098
  }
30716
31099
  const marker = parsePendingRolloutMarker(raw);
@@ -30747,7 +31130,7 @@ class HostdServer {
30747
31130
  return;
30748
31131
  }
30749
31132
  if (needsSelfBump(ownVersion, marker.pin)) {
30750
- await failResume(`hostd is still on v${ownVersion} after the self-bump to ` + `${marker.pin} (helper failed? see ` + `${join9(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)}). ` + `Fallback: \`switchroom hostd install --tag ${marker.pin}\` ` + `host-side, then re-issue the rollout.`);
31133
+ await failResume(`hostd is still on v${ownVersion} after the self-bump to ` + `${marker.pin} (helper failed? see ` + `${join10(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)}). ` + `Fallback: \`switchroom hostd install --tag ${marker.pin}\` ` + `host-side, then re-issue the rollout.`);
30751
31134
  return;
30752
31135
  }
30753
31136
  process.stderr.write(`hostd: resuming rollout ${marker.request_id} → ${marker.pin} after ` + `self-bump (v${marker.prior_hostd_version} → v${ownVersion})
@@ -30841,7 +31224,7 @@ class HostdServer {
30841
31224
  if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
30842
31225
  let beforeContent;
30843
31226
  try {
30844
- beforeContent = readFileSync7(configPath, "utf-8");
31227
+ beforeContent = readFileSync8(configPath, "utf-8");
30845
31228
  } catch {
30846
31229
  beforeContent = "";
30847
31230
  }
@@ -30945,7 +31328,7 @@ class HostdServer {
30945
31328
  try {
30946
31329
  let snapshot;
30947
31330
  try {
30948
- snapshot = readFileSync7(configPath, "utf-8");
31331
+ snapshot = readFileSync8(configPath, "utf-8");
30949
31332
  } catch (e) {
30950
31333
  await approval.finalize({
30951
31334
  outcome: "reconcile_failed_rolled_back",
@@ -31197,8 +31580,8 @@ ${output.recovery.stderr}` : "";
31197
31580
  if (this.opts.imageRefsForDigests)
31198
31581
  return this.opts.imageRefsForDigests();
31199
31582
  try {
31200
- const composePath = join9(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
31201
- if (!existsSync9(composePath))
31583
+ const composePath = join10(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
31584
+ if (!existsSync10(composePath))
31202
31585
  return [];
31203
31586
  const r = spawnSync3("docker", [
31204
31587
  "compose",
@@ -31306,6 +31689,9 @@ ${output.recovery.stderr}` : "";
31306
31689
  target: entry.pin ?? "",
31307
31690
  rolled: entry.rolled,
31308
31691
  terminal: entry.result === "completed" ? "completed" : "error",
31692
+ requestId: entry.request_id,
31693
+ ...entry.prior_pin ? { fromVersion: entry.prior_pin } : {},
31694
+ ...entry.finished_at !== null ? { elapsedMs: entry.finished_at - entry.started_at } : {},
31309
31695
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
31310
31696
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
31311
31697
  ...entry.got !== undefined ? { got: entry.got } : {}
@@ -31334,10 +31720,10 @@ ${output.recovery.stderr}` : "";
31334
31720
  }
31335
31721
  rolloutRowExistsInLog(targetRequestId) {
31336
31722
  const path2 = this.auditLogPath();
31337
- if (!existsSync9(path2))
31723
+ if (!existsSync10(path2))
31338
31724
  return false;
31339
31725
  try {
31340
- const raw = readFileSync7(path2, "utf-8");
31726
+ const raw = readFileSync8(path2, "utf-8");
31341
31727
  return latestRolloutRowForRequest(raw, targetRequestId) !== null;
31342
31728
  } catch {
31343
31729
  return false;
@@ -31345,11 +31731,11 @@ ${output.recovery.stderr}` : "";
31345
31731
  }
31346
31732
  rolloutStatusFromLog(responseRequestId, targetRequestId, started) {
31347
31733
  const path2 = this.auditLogPath();
31348
- if (!existsSync9(path2))
31734
+ if (!existsSync10(path2))
31349
31735
  return null;
31350
31736
  let raw;
31351
31737
  try {
31352
- raw = readFileSync7(path2, "utf-8");
31738
+ raw = readFileSync8(path2, "utf-8");
31353
31739
  } catch {
31354
31740
  return null;
31355
31741
  }
@@ -31428,7 +31814,7 @@ ${output.recovery.stderr}` : "";
31428
31814
  }
31429
31815
  }
31430
31816
  auditLogPath() {
31431
- return this.opts.auditLogPath ?? join9(this.opts.homeDir, ".switchroom", "host-control-audit.log");
31817
+ return this.opts.auditLogPath ?? join10(this.opts.homeDir, ".switchroom", "host-control-audit.log");
31432
31818
  }
31433
31819
  appendAuditRow(row) {
31434
31820
  const path2 = this.auditLogPath();
@@ -31681,7 +32067,7 @@ function isSafeExecArgvElement(s) {
31681
32067
  }
31682
32068
 
31683
32069
  // src/host-control/approval-gateway.ts
31684
- import { connect } from "node:net";
32070
+ import { connect as connect2 } from "node:net";
31685
32071
 
31686
32072
  class SocketApprovalGateway {
31687
32073
  opts;
@@ -31699,7 +32085,7 @@ class SocketApprovalGateway {
31699
32085
  };
31700
32086
  }
31701
32087
  return await new Promise((resolve9) => {
31702
- const client2 = connect({ path: sockPath });
32088
+ const client2 = connect2({ path: sockPath });
31703
32089
  let buffer = "";
31704
32090
  let resolved = false;
31705
32091
  const log = this.opts.log ?? (() => {});
@@ -31803,7 +32189,7 @@ class SocketApprovalGateway {
31803
32189
  }
31804
32190
 
31805
32191
  // src/host-control/rollout-relay.ts
31806
- import { connect as connect2 } from "node:net";
32192
+ import { connect as connect3 } from "node:net";
31807
32193
 
31808
32194
  class SocketRolloutRelay {
31809
32195
  opts;
@@ -31824,7 +32210,7 @@ class SocketRolloutRelay {
31824
32210
  return;
31825
32211
  }
31826
32212
  try {
31827
- const client2 = connect2({ path: sockPath });
32213
+ const client2 = connect3({ path: sockPath });
31828
32214
  client2.setTimeout(5000);
31829
32215
  const done = () => {
31830
32216
  try {
@@ -31862,7 +32248,7 @@ class SocketRolloutRelay {
31862
32248
  }
31863
32249
 
31864
32250
  // src/host-control/rollout-narration-relay.ts
31865
- import { connect as connect3 } from "node:net";
32251
+ import { connect as connect4 } from "node:net";
31866
32252
  var DEFAULT_POST_REPLY_TIMEOUT_MS = 5000;
31867
32253
 
31868
32254
  class SocketRolloutNarrationRelay {
@@ -31896,7 +32282,7 @@ class SocketRolloutNarrationRelay {
31896
32282
  };
31897
32283
  let client2;
31898
32284
  try {
31899
- client2 = connect3({ path: sockPath });
32285
+ client2 = connect4({ path: sockPath });
31900
32286
  } catch (e) {
31901
32287
  log(`narration post connect threw: ${e.message}`);
31902
32288
  resolve9(null);
@@ -31966,7 +32352,7 @@ class SocketRolloutNarrationRelay {
31966
32352
  if (sockPath === null)
31967
32353
  return;
31968
32354
  try {
31969
- const client2 = connect3({ path: sockPath });
32355
+ const client2 = connect4({ path: sockPath });
31970
32356
  client2.setTimeout(5000);
31971
32357
  const done = () => {
31972
32358
  try {
@@ -32013,6 +32399,47 @@ class LogTailRolloutNarrator {
32013
32399
  this.relay = relay;
32014
32400
  this.opts = opts;
32015
32401
  }
32402
+ now() {
32403
+ return this.opts.now?.() ?? Date.now();
32404
+ }
32405
+ static upsertAgent(st, name, patch) {
32406
+ const existing = st.agents.find((a) => a.name === name);
32407
+ if (existing)
32408
+ Object.assign(existing, patch);
32409
+ else
32410
+ st.agents.push({ name, status: "pending", ...patch });
32411
+ }
32412
+ trackAgentProgress(st, phase) {
32413
+ const now = this.now();
32414
+ switch (phase.phase) {
32415
+ case "canary-start":
32416
+ case "agent-start":
32417
+ if (phase.agent) {
32418
+ LogTailRolloutNarrator.upsertAgent(st, phase.agent, {
32419
+ status: "running",
32420
+ startedAtMs: now,
32421
+ ...phase.phase === "canary-start" ? { canary: true } : {}
32422
+ });
32423
+ }
32424
+ break;
32425
+ case "canary-pass":
32426
+ case "agent-done":
32427
+ case "canary-fail": {
32428
+ if (!phase.agent)
32429
+ break;
32430
+ const a = st.agents.find((x) => x.name === phase.agent);
32431
+ const durationMs = a?.startedAtMs !== undefined ? now - a.startedAtMs : undefined;
32432
+ LogTailRolloutNarrator.upsertAgent(st, phase.agent, {
32433
+ status: phase.phase === "canary-fail" ? "failed" : "done",
32434
+ ...durationMs !== undefined ? { durationMs } : {},
32435
+ ...phase.phase !== "agent-done" ? { canary: true } : {}
32436
+ });
32437
+ break;
32438
+ }
32439
+ default:
32440
+ break;
32441
+ }
32442
+ }
32016
32443
  static seqFor(phase) {
32017
32444
  switch (phase.phase) {
32018
32445
  case "self-bump":
@@ -32031,6 +32458,8 @@ class LogTailRolloutNarrator {
32031
32458
  return 2 * (phase.n ?? 1) + 0;
32032
32459
  case "agent-done":
32033
32460
  return 2 * (phase.n ?? 1) + 1;
32461
+ case "web-refresh":
32462
+ return Number.MAX_SAFE_INTEGER - 2;
32034
32463
  case "hostd-web-deferred":
32035
32464
  return Number.MAX_SAFE_INTEGER - 1;
32036
32465
  default:
@@ -32046,6 +32475,7 @@ class LogTailRolloutNarrator {
32046
32475
  if (seq < st.lastAppliedSeq)
32047
32476
  return;
32048
32477
  st.lastAppliedSeq = seq;
32478
+ this.trackAgentProgress(st, phase);
32049
32479
  st.render = {
32050
32480
  ...st.render,
32051
32481
  target: phase.target,
@@ -32053,7 +32483,11 @@ class LogTailRolloutNarrator {
32053
32483
  ...phase.n !== undefined ? { n: phase.n } : {},
32054
32484
  ...phase.m !== undefined ? { m: phase.m } : {},
32055
32485
  ...phase.agent !== undefined ? { agent: phase.agent } : {},
32056
- rolled: entry.rolled
32486
+ rolled: entry.rolled,
32487
+ agents: st.agents,
32488
+ requestId: entry.request_id,
32489
+ ...entry.prior_pin ? { fromVersion: entry.prior_pin } : {},
32490
+ startedAtMs: entry.started_at
32057
32491
  };
32058
32492
  this.scheduleRenderOrPost(entry.request_id);
32059
32493
  } catch (e) {
@@ -32063,13 +32497,29 @@ class LogTailRolloutNarrator {
32063
32497
  onTerminal(entry) {
32064
32498
  try {
32065
32499
  const st = this.ensureState(entry);
32500
+ const rolled = new Set(entry.rolled ?? []);
32501
+ for (const a of st.agents) {
32502
+ if (entry.failed_agent && a.name === entry.failed_agent) {
32503
+ a.status = "failed";
32504
+ } else if (rolled.has(a.name)) {
32505
+ a.status = "done";
32506
+ } else if (a.status === "running") {
32507
+ a.status = "pending";
32508
+ }
32509
+ }
32066
32510
  st.render = {
32067
32511
  target: entry.pin ?? st.render.target,
32068
32512
  rolled: entry.rolled ?? st.render.rolled,
32069
32513
  terminal: entry.result === "completed" ? "completed" : "error",
32070
32514
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
32071
32515
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
32072
- ...entry.got !== undefined ? { got: entry.got } : {}
32516
+ ...entry.got !== undefined ? { got: entry.got } : {},
32517
+ ...st.agents.length > 0 ? { agents: st.agents } : {},
32518
+ ...st.render.m !== undefined ? { m: st.render.m } : {},
32519
+ requestId: entry.request_id,
32520
+ ...entry.prior_pin ?? st.render.fromVersion ? { fromVersion: entry.prior_pin ?? st.render.fromVersion } : {},
32521
+ elapsedMs: (entry.finished_at ?? this.now()) - entry.started_at,
32522
+ deferred: false
32073
32523
  };
32074
32524
  st.frozen = true;
32075
32525
  if (st.timer) {
@@ -32094,6 +32544,7 @@ class LogTailRolloutNarrator {
32094
32544
  postAttempts: 0,
32095
32545
  postFailed: false,
32096
32546
  render: { target: entry.pin ?? "" },
32547
+ agents: [],
32097
32548
  frozen: false,
32098
32549
  timer: null,
32099
32550
  pendingEditAfterPost: false
@@ -32132,7 +32583,7 @@ class LogTailRolloutNarrator {
32132
32583
  return false;
32133
32584
  st.posting = true;
32134
32585
  st.postAttempts += 1;
32135
- const text = renderRolloutStatus(st.render);
32586
+ const text = renderRolloutStatus({ ...st.render, nowMs: this.now() });
32136
32587
  this.relay.post({ requestId, agentName: st.agentName, text }).then((mid) => {
32137
32588
  st.posting = false;
32138
32589
  if (mid === null) {
@@ -32184,7 +32635,7 @@ class LogTailRolloutNarrator {
32184
32635
  this.tryPost(requestId, true);
32185
32636
  return;
32186
32637
  }
32187
- const text = renderRolloutStatus(st.render);
32638
+ const text = renderRolloutStatus({ ...st.render, nowMs: this.now() });
32188
32639
  this.relay.edit({
32189
32640
  requestId,
32190
32641
  agentName: st.agentName,
@@ -32442,8 +32893,60 @@ function makeRestart(switchroomBin) {
32442
32893
 
32443
32894
  // src/host-control/main.ts
32444
32895
  import { appendFile as appendFile2 } from "node:fs/promises";
32896
+ async function loadConfigResilient() {
32897
+ try {
32898
+ const config = loadConfig();
32899
+ clearStaleDegradedMarker(homedir6(), (m) => process.stderr.write(`hostd: ${m}
32900
+ `));
32901
+ return config;
32902
+ } catch (err2) {
32903
+ if (!(err2 instanceof ConfigError))
32904
+ throw err2;
32905
+ let configPath;
32906
+ try {
32907
+ configPath = findConfigFile();
32908
+ } catch {
32909
+ configPath = undefined;
32910
+ }
32911
+ const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join11(homedir6(), ".switchroom", "agents");
32912
+ const listGatewayCandidates = () => {
32913
+ const out = [];
32914
+ try {
32915
+ for (const name of readdirSync4(agentsDir).sort()) {
32916
+ const sock = resolve9(agentsDir, name, "telegram", "gateway.sock");
32917
+ if (existsSync11(sock))
32918
+ out.push({ agent: name, sock });
32919
+ }
32920
+ } catch {}
32921
+ return out;
32922
+ };
32923
+ const log = (m) => {
32924
+ process.stderr.write(`hostd: ${m}
32925
+ `);
32926
+ };
32927
+ const notify = (text) => {
32928
+ const candidates = listGatewayCandidates();
32929
+ if (candidates.length === 0) {
32930
+ log("config-degraded: no gateway socket paths found; relying on marker file");
32931
+ return;
32932
+ }
32933
+ postOperatorNoticeViaGateways(candidates, text, log).then((agent) => {
32934
+ if (agent !== null)
32935
+ log(`config-degraded: operator DM relayed via ${agent}`);
32936
+ });
32937
+ };
32938
+ return waitForConfigRecovery({
32939
+ initialError: err2,
32940
+ homeDir: homedir6(),
32941
+ loadFn: () => loadConfig(),
32942
+ configPath,
32943
+ notify,
32944
+ log
32945
+ });
32946
+ }
32947
+ }
32445
32948
  async function main() {
32446
- const config = loadConfig();
32949
+ const config = await loadConfigResilient();
32447
32950
  if (config.host_control?.enabled !== true) {
32448
32951
  process.stderr.write(`hostd: refusing to start — host_control.enabled is not true in switchroom.yaml
32449
32952
  `);
@@ -32458,10 +32961,10 @@ async function main() {
32458
32961
  `);
32459
32962
  process.exit(2);
32460
32963
  }
32461
- const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join10(homedir6(), ".switchroom", "agents");
32964
+ const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join11(homedir6(), ".switchroom", "agents");
32462
32965
  const resolveGatewaySocket = (agentName) => {
32463
32966
  const sock = resolve9(agentsDir, agentName, "telegram", "gateway.sock");
32464
- return existsSync10(sock) ? sock : null;
32967
+ return existsSync11(sock) ? sock : null;
32465
32968
  };
32466
32969
  const approvalGateway = new SocketApprovalGateway({
32467
32970
  resolveGatewaySocket,
@@ -32504,7 +33007,7 @@ async function main() {
32504
33007
  let releaseWatcher = null;
32505
33008
  const autoRel = config.host_control?.auto_release_check;
32506
33009
  if (autoRel?.enabled === true) {
32507
- const eventsLog = join10(homedir6(), ".switchroom", "release-watcher-events.jsonl");
33010
+ const eventsLog = join11(homedir6(), ".switchroom", "release-watcher-events.jsonl");
32508
33011
  releaseWatcher = new ReleaseWatcher({
32509
33012
  intervalMs: autoRel.interval_minutes * 60000,
32510
33013
  checkFn: makeReleaseCheck({