switchroom 0.19.17 → 0.19.18

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 (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8306 -7619
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -7598,8 +7598,8 @@ var require_base = __commonJS((exports) => {
7598
7598
  var _logger = require_logger();
7599
7599
  var _logger2 = _interopRequireDefault(_logger);
7600
7600
  var _internalProtoAccess = require_proto_access();
7601
- var VERSION = "4.7.9";
7602
- exports.VERSION = VERSION;
7601
+ var VERSION2 = "4.7.9";
7602
+ exports.VERSION = VERSION2;
7603
7603
  var COMPILER_REVISION = 8;
7604
7604
  exports.COMPILER_REVISION = COMPILER_REVISION;
7605
7605
  var LAST_COMPATIBLE_COMPILER_REVISION = 7;
@@ -18744,7 +18744,7 @@ var AgentMemorySchema = exports_external.object({
18744
18744
  recall: exports_external.object({
18745
18745
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
18746
18746
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
18747
- min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.100.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
18747
+ min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default — current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall — on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
18748
18748
  types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
18749
18749
  additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
18750
18750
  sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
@@ -19056,9 +19056,16 @@ var ReactionDispatchSchema = exports_external.object({
19056
19056
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
19057
19057
  emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
19058
19058
  }).optional();
19059
- var ReleaseBlock = exports_external.object({
19059
+ var releaseBlockFields = {
19060
19060
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
19061
19061
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
19062
+ };
19063
+ var ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
19064
+ message: "release.channel and release.pin are mutually exclusive"
19065
+ });
19066
+ var RootReleaseBlock = exports_external.object({
19067
+ ...releaseBlockFields,
19068
+ auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false — with it " + "unset/false, behaviour is unchanged.")
19062
19069
  }).strict().refine((r) => !(r.channel && r.pin), {
19063
19070
  message: "release.channel and release.pin are mutually exclusive"
19064
19071
  });
@@ -19302,6 +19309,7 @@ var AutoReleaseCheckSchema = exports_external.object({
19302
19309
  enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
19303
19310
  interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
19304
19311
  apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
19312
+ notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
19305
19313
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
19306
19314
  });
19307
19315
  var HostControlConfigSchema = exports_external.object({
@@ -19344,7 +19352,7 @@ var SwitchroomConfigSchema = exports_external.object({
19344
19352
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
19345
19353
  }),
19346
19354
  telegram: TelegramConfigSchema,
19347
- release: ReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely."),
19355
+ release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only — a fleet property read by " + "hostd's release watcher, never per-agent)."),
19348
19356
  memory: MemoryBackendConfigSchema.optional(),
19349
19357
  hindsight: HindsightConfigSchema.optional().describe("Fleet-singleton Hindsight (memory backend) configuration. Currently " + "just the LLM knob (provider + model) used for retain/reflect/" + "consolidation. Both fields optional; when unset the container falls " + "back to provider=claude-code + the hard-coded HINDSIGHT_DEFAULT_MODEL " + "so nothing changes for operators who don't set it. Read at container " + "launch by startHindsight() (src/setup/hindsight.ts) — takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
19350
19358
  vault: VaultConfigSchema.optional(),
@@ -20669,6 +20677,9 @@ function allocateAgentUid(name) {
20669
20677
  return AGENT_UID_MIN + u32 % range;
20670
20678
  }
20671
20679
 
20680
+ // src/build-info.ts
20681
+ var VERSION = "0.19.18";
20682
+
20672
20683
  // src/cli/agent-config.ts
20673
20684
  import { join as join2 } from "node:path";
20674
20685
  import { homedir as homedir2 } from "node:os";
@@ -20732,7 +20743,23 @@ var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.
20732
20743
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
20733
20744
 
20734
20745
  // src/memory/hindsight.ts
20735
- var DEFAULT_RETAIN_MISSION = "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) only retain the outcome once a task " + "actually completes or a decision is made.";
20746
+ var DEFAULT_RETAIN_MISSION = "Extract durable facts that will still be true and useful weeks from now: " + "user preferences and standing rules, ongoing projects and recurring " + "commitments, technical and architectural decisions with their rationale, " + "and people/tool relationships. A preference revealed by a request is " + "durable record the preference (what the user likes, wants, or always " + `does), not the request itself.
20747
+
20748
+ ` + `NEVER extract:
20749
+ ` + "- Agent tool-use traces or narration of what the assistant did (e.g. " + `"the assistant used X to query Y", "ran a search", "sent the message").
20750
+ ` + "- In-flight workflow/process narration (a sub-task started, paused, or is " + "still running) — retain the outcome only once the task completes or a " + `decision is made.
20751
+ ` + `- Operation, request, batch or session IDs, UUIDs, hashes, or error codes.
20752
+ ` + "- Hindsight's own errors, retries, backlogs, or internal state — the " + `memory system's self-reports are not memories.
20753
+ ` + `- Restatements of the user's current request or the task in progress.
20754
+ ` + "- Transient state (unread counts, build status, what is running right now) " + "unless the fact is explicitly dated, in which case record it as a dated " + `observation.
20755
+ ` + `- Greetings, acknowledgements, and routine operational chatter.
20756
+
20757
+ ` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list.";
20758
+ var SUPERSEDED_RETAIN_MISSIONS = [
20759
+ "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
20760
+ "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.",
20761
+ "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made."
20762
+ ];
20736
20763
  var PROFILE_MEMORY_DEFAULTS = {
20737
20764
  "health-coach": {
20738
20765
  disposition: { skepticism: 2, literalism: 2, empathy: 5 },
@@ -21202,6 +21229,7 @@ var materializedDirs = new Set;
21202
21229
  // src/agents/scaffold.ts
21203
21230
  var REPO_ROOT = resolve5(import.meta.dirname, "../..");
21204
21231
  var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
21232
+ var pendingBankOps = new Set;
21205
21233
  var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
21206
21234
  "permissions",
21207
21235
  "mcpServers",
@@ -26662,11 +26690,6 @@ init_atomic();
26662
26690
  // src/cli/resolve-version.ts
26663
26691
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
26664
26692
  import { dirname as dirname5, join as join7 } from "node:path";
26665
-
26666
- // src/build-info.ts
26667
- var VERSION = "0.19.17";
26668
-
26669
- // src/cli/resolve-version.ts
26670
26693
  function readPackageVersion() {
26671
26694
  let dir = import.meta.dirname;
26672
26695
  for (let i = 0;i < 12 && dir && dir !== "/"; i++) {
@@ -27934,6 +27957,11 @@ var STATUS_RETENTION_MS = 10 * 60 * 1000;
27934
27957
  var STATUS_MAX_ENTRIES = 256;
27935
27958
  var TAIL_BYTES = 4096;
27936
27959
  var ORPHAN_RECONCILE_AGE_MS = 15 * 60 * 1000;
27960
+ var AUTO_ROLLOUT_REQUEST_PREFIX = "auto-rollout-";
27961
+ function isAutoRolloutRequestId(requestId) {
27962
+ return requestId.startsWith(AUTO_ROLLOUT_REQUEST_PREFIX);
27963
+ }
27964
+ var AUTO_ROLLOUT_LATCH_FILENAME = "auto-rollout-latch.json";
27937
27965
 
27938
27966
  class HostdServer {
27939
27967
  opts;
@@ -27943,6 +27971,41 @@ class HostdServer {
27943
27971
  auditChainState;
27944
27972
  idempotencyKeys = new Map;
27945
27973
  fleetMutationInFlight = null;
27974
+ lastAutoRolloutFailedPin = null;
27975
+ autoRolloutLatchPath() {
27976
+ return join10(this.hostdDirPath(), AUTO_ROLLOUT_LATCH_FILENAME);
27977
+ }
27978
+ readAutoRolloutLatch() {
27979
+ try {
27980
+ const raw = readFileSync9(this.autoRolloutLatchPath(), "utf-8");
27981
+ const parsed = JSON.parse(raw);
27982
+ if (parsed && parsed.v === 1 && typeof parsed.pin === "string" && parsed.pin.length > 0 && (parsed.outcome === "attempting" || parsed.outcome === "failed")) {
27983
+ return parsed;
27984
+ }
27985
+ return null;
27986
+ } catch {
27987
+ return null;
27988
+ }
27989
+ }
27990
+ autoRolloutLatchedPin() {
27991
+ return this.readAutoRolloutLatch()?.pin ?? this.lastAutoRolloutFailedPin;
27992
+ }
27993
+ writeAutoRolloutLatch(latch) {
27994
+ try {
27995
+ mkdirSync4(this.hostdDirPath(), { recursive: true });
27996
+ writeFileSync5(this.autoRolloutLatchPath(), JSON.stringify(latch, null, 2), {
27997
+ mode: 384
27998
+ });
27999
+ } catch (e) {
28000
+ process.stderr.write(`hostd: auto-rollout latch write failed (falling back to in-memory ` + `latch for this process lifetime): ${e.message}
28001
+ `);
28002
+ }
28003
+ }
28004
+ clearAutoRolloutLatch() {
28005
+ try {
28006
+ unlinkSync2(this.autoRolloutLatchPath());
28007
+ } catch {}
28008
+ }
27946
28009
  get rolloutNarrator() {
27947
28010
  return this.opts.rolloutNarrator;
27948
28011
  }
@@ -28459,6 +28522,23 @@ class HostdServer {
28459
28522
  return null;
28460
28523
  return deniedResponse(request_id, `refused: this switchroom-hostd image is missing apply-time ` + `assets [${missing.join(", ")}]. hostd's in-container ` + `\`switchroom apply\` cannot scaffold agents without them and ` + `would pull images then strand the fleet on the old image ` + `(the klanker incident). Nothing was pulled or changed. Fix: ` + `rebuild/refresh the hostd image — \`switchroom update\` ` + `(refreshes hostd) or \`switchroom hostd install\` on the ` + `host; meanwhile run \`switchroom update\` host-side.`, Date.now() - started);
28461
28524
  }
28525
+ isFleetMutationLocked() {
28526
+ return this.fleetMutationInFlight !== null;
28527
+ }
28528
+ startOperatorApprovedUpdateApply(requestId) {
28529
+ const req = {
28530
+ v: 1,
28531
+ request_id: requestId,
28532
+ op: "update_apply"
28533
+ };
28534
+ const caller = { kind: "operator" };
28535
+ const resp = this.handleUpdateApply(req, caller, Date.now());
28536
+ this.writeAudit({ caller, req, resp }).catch((err2) => {
28537
+ process.stderr.write(`hostd: audit write failed for operator-approved update_apply ` + `(request_id=${requestId}): ${err2.message}
28538
+ `);
28539
+ });
28540
+ return resp;
28541
+ }
28462
28542
  handleUpdateApply(req, caller, started) {
28463
28543
  const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
28464
28544
  if (denied)
@@ -28590,6 +28670,68 @@ class HostdServer {
28590
28670
  duration_ms: Date.now() - started
28591
28671
  };
28592
28672
  }
28673
+ async startAutoRollout(pin) {
28674
+ const started = Date.now();
28675
+ const request_id = `${AUTO_ROLLOUT_REQUEST_PREFIX}${started}`;
28676
+ const diskLatch = this.readAutoRolloutLatch();
28677
+ if (this.lastAutoRolloutFailedPin === pin || diskLatch?.pin === pin) {
28678
+ const why = diskLatch?.pin === pin && diskLatch.outcome === "attempting" ? `a previous unattended roll to ${pin} did not complete (hostd ` + `restarted or crashed mid-roll — the fleet state is unverified)` : `a previous unattended roll to ${pin} FAILED`;
28679
+ return {
28680
+ started: false,
28681
+ reason: `${why} — refusing to retry unattended (the latch is durable ` + `across hostd restarts; fix the cause, then roll manually via ` + `\`switchroom rollout --pin ${pin}\`; a newer release supersedes ` + `the latch)`
28682
+ };
28683
+ }
28684
+ const inFlight = this.fleetMutationInFlight;
28685
+ if (inFlight) {
28686
+ return {
28687
+ started: false,
28688
+ reason: `fleet-mutation lock held by ${inFlight.op} (request_id ` + `"${inFlight.request_id}") — will re-check next tick`
28689
+ };
28690
+ }
28691
+ if (diskLatch && diskLatch.pin !== pin)
28692
+ this.clearAutoRolloutLatch();
28693
+ const caller = { kind: "operator" };
28694
+ if (needsSelfBump(this.opts.selfVersion ?? SWITCHROOM_VERSION, pin)) {
28695
+ const req = {
28696
+ v: 1,
28697
+ request_id,
28698
+ op: "rollout",
28699
+ args: { pin }
28700
+ };
28701
+ this.writeAutoRolloutLatch({
28702
+ v: 1,
28703
+ pin,
28704
+ request_id,
28705
+ outcome: "attempting",
28706
+ at: new Date().toISOString()
28707
+ });
28708
+ const resp = await this.beginSelfBump(req, caller, started);
28709
+ if (resp.result !== "started") {
28710
+ this.clearAutoRolloutLatch();
28711
+ return {
28712
+ started: false,
28713
+ reason: resp.error ?? `self-bump refused (result=${resp.result})`
28714
+ };
28715
+ }
28716
+ return { started: true, request_id };
28717
+ }
28718
+ const assetDenied = this.applyAssetPreflight(request_id, started);
28719
+ if (assetDenied) {
28720
+ return {
28721
+ started: false,
28722
+ reason: assetDenied.error ?? "apply-asset preflight refused"
28723
+ };
28724
+ }
28725
+ this.writeAutoRolloutLatch({
28726
+ v: 1,
28727
+ pin,
28728
+ request_id,
28729
+ outcome: "attempting",
28730
+ at: new Date().toISOString()
28731
+ });
28732
+ this.launchRollout({ pin }, request_id, caller, started);
28733
+ return { started: true, request_id };
28734
+ }
28593
28735
  launchRollout(args, request_id, caller, started) {
28594
28736
  const argv = ["rollout", "--pin", args.pin];
28595
28737
  if (args.agents && args.agents.length > 0) {
@@ -28813,6 +28955,17 @@ class HostdServer {
28813
28955
  this.recordStatus(entry2);
28814
28956
  process.stderr.write(`hostd: self-bump resume failed: ${why}
28815
28957
  `);
28958
+ if (isAutoRolloutRequestId(marker.request_id)) {
28959
+ this.lastAutoRolloutFailedPin = marker.pin;
28960
+ this.writeAutoRolloutLatch({
28961
+ v: 1,
28962
+ pin: marker.pin,
28963
+ request_id: marker.request_id,
28964
+ outcome: "failed",
28965
+ at: new Date().toISOString(),
28966
+ reason: "self-bump"
28967
+ });
28968
+ }
28816
28969
  await this.writeTerminalAudit(entry2);
28817
28970
  this.pushRolloutTerminal(entry2);
28818
28971
  };
@@ -29395,6 +29548,7 @@ ${output.recovery.stderr}` : "";
29395
29548
  });
29396
29549
  }
29397
29550
  spawnRollout(args, entry) {
29551
+ const priorPinAtStart = entry.prior_pin;
29398
29552
  const onLine = (line) => {
29399
29553
  const phase = parseRolloutPhaseLine(line);
29400
29554
  if (phase)
@@ -29429,6 +29583,24 @@ ${output.recovery.stderr}` : "";
29429
29583
  delete entry.prior_pin;
29430
29584
  }).finally(() => {
29431
29585
  this.writeTerminalAudit(entry);
29586
+ if (isAutoRolloutRequestId(entry.request_id) && entry.result !== "completed") {
29587
+ this.lastAutoRolloutFailedPin = entry.pin ?? null;
29588
+ if (entry.pin) {
29589
+ this.writeAutoRolloutLatch({
29590
+ v: 1,
29591
+ pin: entry.pin,
29592
+ request_id: entry.request_id,
29593
+ outcome: "failed",
29594
+ at: new Date().toISOString(),
29595
+ ...entry.failed_step ? { reason: entry.failed_step } : {}
29596
+ });
29597
+ }
29598
+ this.finishFailedAutoRollout(entry, priorPinAtStart);
29599
+ return;
29600
+ }
29601
+ if (isAutoRolloutRequestId(entry.request_id)) {
29602
+ this.clearAutoRolloutLatch();
29603
+ }
29432
29604
  this.pushRolloutTerminal(entry);
29433
29605
  try {
29434
29606
  this.rolloutNarrator?.onTerminal(entry);
@@ -29441,10 +29613,60 @@ ${output.recovery.stderr}` : "";
29441
29613
  }
29442
29614
  });
29443
29615
  }
29444
- pushRolloutTerminal(entry) {
29616
+ async finishFailedAutoRollout(entry, priorPin) {
29617
+ const notes = [
29618
+ `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"}\`).`
29619
+ ];
29620
+ try {
29621
+ notes.push(...await this.recoverFailedAutoRollout(entry, priorPin));
29622
+ } catch (e) {
29623
+ notes.push(`Automatic rollback THREW: ${e.message} — verify compose/` + `canary state host-side.`);
29624
+ } finally {
29625
+ this.pushRolloutTerminal(entry, notes);
29626
+ try {
29627
+ this.rolloutNarrator?.onTerminal(entry);
29628
+ } catch (e) {
29629
+ process.stderr.write(`hostd: rollout narrator onTerminal threw (non-fatal): ${e.message}
29630
+ `);
29631
+ }
29632
+ if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
29633
+ this.fleetMutationInFlight = null;
29634
+ }
29635
+ }
29636
+ }
29637
+ async recoverFailedAutoRollout(entry, priorPin) {
29638
+ const notes = [];
29639
+ const rolledCount = (entry.rolled ?? []).length;
29640
+ const beforeAnyAgent = entry.failed_step === "apply" || entry.failed_step === "restart-agent" && rolledCount === 0;
29641
+ if (!beforeAnyAgent) {
29642
+ 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.`);
29643
+ return notes;
29644
+ }
29645
+ if (!priorPin) {
29646
+ notes.push(`No automatic rollback: no prior version-assertable release.pin was ` + `recorded before this roll. Verify compose host-side ` + `(\`switchroom apply\`).`);
29647
+ return notes;
29648
+ }
29649
+ const applyRes = await this.runSwitchroom(["apply", "--pin", priorPin, "--compose-only", "--non-interactive"], { SWITCHROOM_HOSTD_CONTEXT: "1" });
29650
+ notes.push(applyRes.exit_code === 0 ? `Rollback: compose restored to ${priorPin}.` : `Rollback FAILED: compose restore to ${priorPin} exited ` + `${applyRes.exit_code} — run \`switchroom apply\` host-side ` + `(${tail(applyRes.stderr).trim().slice(-300) || "no stderr"}).`);
29651
+ if (entry.failed_step === "restart-agent" && entry.failed_agent) {
29652
+ const restartRes = await this.runSwitchroom([
29653
+ "agent",
29654
+ "restart",
29655
+ entry.failed_agent,
29656
+ "--wait",
29657
+ "--force",
29658
+ "--pin",
29659
+ priorPin
29660
+ ], { SWITCHROOM_HOSTD_CONTEXT: "1" });
29661
+ notes.push(restartRes.exit_code === 0 ? `Rollback: canary ${entry.failed_agent} restarted back on ${priorPin}.` : `Rollback FAILED: canary ${entry.failed_agent} did not restart ` + `cleanly on ${priorPin} (exit ${restartRes.exit_code}) — restart ` + `it manually.`);
29662
+ }
29663
+ return notes;
29664
+ }
29665
+ pushRolloutTerminal(entry, extraNotes) {
29445
29666
  if (!this.opts.rolloutRelay)
29446
29667
  return;
29447
- if (entry.caller.kind !== "agent")
29668
+ const relayAgent = entry.caller.kind === "agent" ? entry.caller.name : isAutoRolloutRequestId(entry.request_id) ? this.firstAdminAgentName() : null;
29669
+ if (relayAgent === null)
29448
29670
  return;
29449
29671
  try {
29450
29672
  const text = renderRolloutStatus({
@@ -29460,14 +29682,26 @@ ${output.recovery.stderr}` : "";
29460
29682
  });
29461
29683
  this.opts.rolloutRelay.postTerminal({
29462
29684
  requestId: entry.request_id,
29463
- agentName: entry.caller.name,
29464
- text
29685
+ agentName: relayAgent,
29686
+ text: extraNotes && extraNotes.length > 0 ? text + `
29687
+
29688
+ ` + extraNotes.map((n) => `- ${n}`).join(`
29689
+ `) : text
29465
29690
  });
29466
29691
  } catch (e) {
29467
29692
  process.stderr.write(`hostd: rollout terminal push failed (non-fatal): ${e.message}
29468
29693
  `);
29469
29694
  }
29470
29695
  }
29696
+ firstAdminAgentName() {
29697
+ const agents = this.opts.config.agents ?? {};
29698
+ for (const name of Object.keys(agents).sort()) {
29699
+ const a = agents[name];
29700
+ if (a?.admin === true || a?.root === true)
29701
+ return name;
29702
+ }
29703
+ return null;
29704
+ }
29471
29705
  handleGetStatus(req, _caller, started) {
29472
29706
  const entry = this.statusByRequestId.get(req.args.target_request_id);
29473
29707
  if (!entry) {
@@ -29949,7 +30183,8 @@ class SocketApprovalGateway {
29949
30183
  agentName: req.agentName,
29950
30184
  reason: req.reason,
29951
30185
  unifiedDiff: req.unifiedDiff,
29952
- timeoutMs: req.timeoutMs
30186
+ timeoutMs: req.timeoutMs,
30187
+ ...req.title ? { title: req.title } : {}
29953
30188
  }) + `
29954
30189
  `);
29955
30190
  } catch (err2) {
@@ -30537,8 +30772,16 @@ class ReleaseWatcher {
30537
30772
  ...check.version ? { version: check.version } : {}
30538
30773
  });
30539
30774
  this.log(`release detected${check.version ? `: ${check.version}` : ""}` + (this.opts.applyOnDetect ? ", auto-applying" : " (apply_on_detect=false; not rolling)"));
30540
- if (!this.opts.applyOnDetect)
30775
+ if (!this.opts.applyOnDetect) {
30776
+ if (this.opts.notifyFn) {
30777
+ try {
30778
+ await this.opts.notifyFn(check.version ?? "");
30779
+ } catch (err2) {
30780
+ this.log(`notify_failed: ${errMsg(err2)}`);
30781
+ }
30782
+ }
30541
30783
  return;
30784
+ }
30542
30785
  const applyStarted = this.now();
30543
30786
  this.emit({
30544
30787
  at: applyStarted,
@@ -30546,7 +30789,7 @@ class ReleaseWatcher {
30546
30789
  ...check.version ? { version: check.version } : {}
30547
30790
  });
30548
30791
  try {
30549
- await this.opts.applyFn();
30792
+ await this.opts.applyFn(check.version);
30550
30793
  } catch (err2) {
30551
30794
  this.emit({
30552
30795
  at: this.now(),
@@ -30718,6 +30961,13 @@ function makeApply(switchroomBin) {
30718
30961
  }
30719
30962
  };
30720
30963
  }
30964
+ function makeUpdatePlan(switchroomBin) {
30965
+ return async () => {
30966
+ const r = await run(switchroomBin, ["update", "--check"], 2 * 60000);
30967
+ const out = r.stdout.trim();
30968
+ return out.length > 4000 ? out.slice(-4000) : out;
30969
+ };
30970
+ }
30721
30971
  function makeRestart(switchroomBin) {
30722
30972
  return async () => {
30723
30973
  const r = await run(switchroomBin, ["restart", "all"], 5 * 60000);
@@ -30727,6 +30977,248 @@ function makeRestart(switchroomBin) {
30727
30977
  };
30728
30978
  }
30729
30979
 
30980
+ // src/host-control/update-notifier.ts
30981
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync10, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "node:fs";
30982
+ import { randomBytes as randomBytes3 } from "node:crypto";
30983
+ import { dirname as dirname8 } from "node:path";
30984
+ var FINALIZE_DETAIL_MAX = 450;
30985
+ var DEFAULT_REPOST_SUPPRESS_MS = 60 * 60000;
30986
+
30987
+ class UpdateNotifier {
30988
+ opts;
30989
+ constructor(opts) {
30990
+ this.opts = opts;
30991
+ }
30992
+ async notifyIfNew(version2) {
30993
+ try {
30994
+ return await this.run(version2);
30995
+ } catch (err2) {
30996
+ this.log(`notify failed: ${errMsg2(err2)}`);
30997
+ return "dispatch_failed";
30998
+ }
30999
+ }
31000
+ async run(version2) {
31001
+ if (version2 === "") {
31002
+ this.log("release detected but no release id was provided — skipping card");
31003
+ return "skipped_no_version";
31004
+ }
31005
+ const state = this.readState();
31006
+ if (state?.last_notified_version === version2) {
31007
+ return "deduped";
31008
+ }
31009
+ const suppressMs = this.opts.repostSuppressMs ?? DEFAULT_REPOST_SUPPRESS_MS;
31010
+ if (state?.pending_version === version2 && typeof state.pending_posted_at === "number" && this.now() - state.pending_posted_at < suppressMs) {
31011
+ this.log(`release ${short(version2)} has a possibly-live card from before a restart — suppressing re-post`);
31012
+ return "pending_recent";
31013
+ }
31014
+ if (this.opts.isFleetMutationInFlight()) {
31015
+ this.log(`release ${short(version2)} detected but a fleet mutation is in flight — not posting a card this tick`);
31016
+ return "locked";
31017
+ }
31018
+ let plan = "";
31019
+ if (this.opts.planFn) {
31020
+ try {
31021
+ plan = await this.opts.planFn();
31022
+ } catch (err2) {
31023
+ this.log(`update --check plan probe failed: ${errMsg2(err2)}`);
31024
+ }
31025
+ }
31026
+ if (this.opts.isFleetMutationInFlight()) {
31027
+ this.log(`fleet mutation started during the plan probe for ${short(version2)} — not posting a card this tick`);
31028
+ return "locked";
31029
+ }
31030
+ const requestId = this.opts.mintRequestId ? this.opts.mintRequestId() : `relnotify-${randomBytes3(4).toString("hex")}`;
31031
+ this.log(`posting update approval card for release ${short(version2)} (requestId=${requestId})`);
31032
+ this.writeState({
31033
+ ...this.carryNotified(state),
31034
+ pending_version: version2,
31035
+ pending_posted_at: this.now()
31036
+ });
31037
+ let res;
31038
+ try {
31039
+ res = await this.opts.requestApproval({ requestId, version: version2, plan });
31040
+ } catch (err2) {
31041
+ res = {
31042
+ verdict: "deny",
31043
+ reason: `requestApproval threw: ${errMsg2(err2)}`,
31044
+ denySource: "dispatch_failure",
31045
+ finalize: async () => {}
31046
+ };
31047
+ }
31048
+ if (res.verdict === "deny" && res.denySource === "dispatch_failure") {
31049
+ this.log(`update approval card dispatch failed (${res.reason ?? "unknown"}) — will retry next tick`);
31050
+ this.writeState(this.carryNotified(state));
31051
+ return "dispatch_failed";
31052
+ }
31053
+ this.writeState({
31054
+ last_notified_version: version2,
31055
+ notified_at: this.now()
31056
+ });
31057
+ if (res.verdict === "deny") {
31058
+ this.log(`operator denied update for release ${short(version2)}`);
31059
+ return "denied";
31060
+ }
31061
+ if (res.verdict === "timeout") {
31062
+ this.log(`update approval card expired for release ${short(version2)}`);
31063
+ return "timed_out";
31064
+ }
31065
+ let start;
31066
+ try {
31067
+ start = this.opts.startApply(requestId);
31068
+ } catch (err2) {
31069
+ start = { result: "error", error: errMsg2(err2) };
31070
+ }
31071
+ if (start.result === "started") {
31072
+ this.log(`update_apply started (requestId=${requestId})`);
31073
+ await res.finalize({
31074
+ outcome: "applied",
31075
+ detail: clipDetail(`switchroom update started (request_id ${requestId}) — track progress via get_status / the rollout narration message.`)
31076
+ });
31077
+ return "apply_started";
31078
+ }
31079
+ const why = start.error ?? `unexpected result "${start.result}"`;
31080
+ this.log(`update_apply refused after approval: ${why}`);
31081
+ await res.finalize({
31082
+ outcome: "reconcile_failed_rolled_back",
31083
+ detail: clipDetail(`update did not start: ${why}`)
31084
+ });
31085
+ return "apply_refused";
31086
+ }
31087
+ carryNotified(state) {
31088
+ return state?.last_notified_version !== undefined ? {
31089
+ last_notified_version: state.last_notified_version,
31090
+ notified_at: state.notified_at ?? 0
31091
+ } : {};
31092
+ }
31093
+ now() {
31094
+ return this.opts.now ? this.opts.now() : Date.now();
31095
+ }
31096
+ readState() {
31097
+ try {
31098
+ const raw = readFileSync10(this.opts.statePath, "utf8");
31099
+ const parsed = JSON.parse(raw);
31100
+ if (parsed === null || typeof parsed !== "object")
31101
+ return null;
31102
+ const out = {};
31103
+ if (typeof parsed.last_notified_version === "string") {
31104
+ out.last_notified_version = parsed.last_notified_version;
31105
+ out.notified_at = typeof parsed.notified_at === "number" ? parsed.notified_at : 0;
31106
+ }
31107
+ if (typeof parsed.pending_version === "string") {
31108
+ out.pending_version = parsed.pending_version;
31109
+ out.pending_posted_at = typeof parsed.pending_posted_at === "number" ? parsed.pending_posted_at : 0;
31110
+ }
31111
+ return Object.keys(out).length > 0 ? out : null;
31112
+ } catch {
31113
+ return null;
31114
+ }
31115
+ }
31116
+ writeState(state) {
31117
+ try {
31118
+ mkdirSync5(dirname8(this.opts.statePath), { recursive: true });
31119
+ const tmp = `${this.opts.statePath}.tmp`;
31120
+ writeFileSync6(tmp, JSON.stringify(state) + `
31121
+ `, "utf8");
31122
+ renameSync4(tmp, this.opts.statePath);
31123
+ } catch (err2) {
31124
+ this.log(`failed to persist notify state: ${errMsg2(err2)}`);
31125
+ }
31126
+ }
31127
+ log(m) {
31128
+ if (this.opts.log)
31129
+ this.opts.log(`update-notifier: ${m}`);
31130
+ }
31131
+ }
31132
+ function clipDetail(detail) {
31133
+ return detail.length > FINALIZE_DETAIL_MAX ? detail.slice(0, FINALIZE_DETAIL_MAX - 1) + "…" : detail;
31134
+ }
31135
+ function short(version2) {
31136
+ const hex = version2.startsWith("sha256:") ? version2.slice(7) : version2;
31137
+ return hex.length > 12 ? hex.slice(0, 12) : hex;
31138
+ }
31139
+ function errMsg2(err2) {
31140
+ return err2 instanceof Error ? err2.message : String(err2);
31141
+ }
31142
+
31143
+ // src/host-control/auto-update-check.ts
31144
+ var NPM_LATEST_URL = "https://registry.npmjs.org/switchroom/latest";
31145
+ var FETCH_TIMEOUT_MS = 15000;
31146
+ function versionedImageRef(imageRef, version2) {
31147
+ const lastSlash = imageRef.lastIndexOf("/");
31148
+ const colon = imageRef.indexOf(":", lastSlash + 1);
31149
+ const repo = colon === -1 ? imageRef : imageRef.slice(0, colon);
31150
+ return `${repo}:${version2}`;
31151
+ }
31152
+ async function fetchNpmLatestVersion(url = NPM_LATEST_URL) {
31153
+ const res = await fetch(url, {
31154
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
31155
+ headers: { accept: "application/json" }
31156
+ });
31157
+ if (!res.ok) {
31158
+ throw new Error(`npm registry responded ${res.status} for ${url}`);
31159
+ }
31160
+ const body = await res.json();
31161
+ if (typeof body.version !== "string" || !/^\d+\.\d+\.\d+$/.test(body.version)) {
31162
+ throw new Error(`npm registry document at ${url} has no clean semver version field`);
31163
+ }
31164
+ return body.version;
31165
+ }
31166
+ function makeAutoUpdateCheck(opts) {
31167
+ const log = opts.log ?? (() => {});
31168
+ const fetchLatest = opts.fetchLatestVersion ?? fetchNpmLatestVersion;
31169
+ const imageExists = opts.imageExists ?? (async (ref) => {
31170
+ const probe = await probeRemoteDigest(ref);
31171
+ if (probe.digest === null && probe.error) {
31172
+ log(`auto-update: manifest probe for ${ref} returned no digest ` + `(missing image OR registry error — deferring either way): ` + probe.error);
31173
+ }
31174
+ return probe.digest !== null;
31175
+ });
31176
+ let lastSuppressed = "";
31177
+ const logOnce = (key, m) => {
31178
+ if (lastSuppressed === key)
31179
+ return;
31180
+ lastSuppressed = key;
31181
+ log(m);
31182
+ };
31183
+ return async () => {
31184
+ const latest = `v${(await fetchLatest()).trim().replace(/^v/, "")}`;
31185
+ const channel = opts.getCurrentChannel?.();
31186
+ if (channel !== undefined && channel !== "latest") {
31187
+ logOnce(`channel:${channel}`, `auto-update: release.channel is "${channel}" (not "latest") — the ` + `auto-update path tracks the npm latest dist-tag and persisting a ` + `pin would delete your channel, so no unattended roll will happen. ` + `Remove release.channel (or set it to "latest") to enable it. ` + `Latest published: ${latest}`);
31188
+ return { available: false, version: latest };
31189
+ }
31190
+ const latched = opts.getLatchedPin?.() ?? null;
31191
+ if (latched !== null && latched === latest) {
31192
+ logOnce(`latch:${latched}`, `auto-update: ${latest} is latched from a previous unattended roll ` + `that failed or died mid-flight — not re-reporting it as available. ` + `Fix the cause and roll manually (\`switchroom rollout --pin ` + `${latest}\`), or wait for a newer release.`);
31193
+ return { available: false, version: latest };
31194
+ }
31195
+ const pin = opts.getCurrentPin();
31196
+ if (pin !== undefined) {
31197
+ const cmp = compareReleaseTags(latest, pin);
31198
+ if (cmp === null) {
31199
+ log(`auto-update: current release.pin "${pin}" is not a vX.Y.Z tag — ` + `skipping unattended roll (latest published: ${latest})`);
31200
+ return { available: false, version: latest };
31201
+ }
31202
+ if (cmp <= 0)
31203
+ return { available: false, version: latest };
31204
+ }
31205
+ const target = versionedImageRef(opts.imageRef, latest);
31206
+ if (!await imageExists(target)) {
31207
+ log(`auto-update: ${latest} is published but ${target} is not pullable ` + `yet (docker-images workflow may still be building) — deferring`);
31208
+ return { available: false, version: latest };
31209
+ }
31210
+ return { available: true, version: latest };
31211
+ };
31212
+ }
31213
+ function resolveReleaseWatcherMode(config) {
31214
+ if (config.release?.auto_update === true)
31215
+ return { mode: "auto_update" };
31216
+ if (config.host_control?.auto_release_check?.enabled === true) {
31217
+ return { mode: "legacy" };
31218
+ }
31219
+ return { mode: "off" };
31220
+ }
31221
+
30730
31222
  // src/host-control/main.ts
30731
31223
  import { appendFile as appendFile2 } from "node:fs/promises";
30732
31224
  async function loadConfigResilient() {
@@ -30846,28 +31338,126 @@ async function main() {
30846
31338
  `);
30847
31339
  let releaseWatcher = null;
30848
31340
  const autoRel = config.host_control?.auto_release_check;
30849
- if (autoRel?.enabled === true) {
31341
+ const watcherMode = resolveReleaseWatcherMode(config);
31342
+ if (watcherMode.mode !== "off") {
30850
31343
  const eventsLog = join11(homedir6(), ".switchroom", "release-watcher-events.jsonl");
30851
- releaseWatcher = new ReleaseWatcher({
30852
- intervalMs: autoRel.interval_minutes * 60000,
30853
- checkFn: makeReleaseCheck({
30854
- imageRef: autoRel.image_ref,
30855
- log: (m) => process.stderr.write(`hostd: ${m}
30856
- `)
30857
- }),
30858
- applyFn: makeApply("switchroom"),
30859
- restartFn: makeRestart("switchroom"),
30860
- applyOnDetect: autoRel.apply_on_detect,
30861
- log: (m) => process.stderr.write(`hostd: ${m}
30862
- `),
30863
- onEvent: (e) => {
30864
- appendFile2(eventsLog, JSON.stringify(e) + `
31344
+ const log = (m) => {
31345
+ process.stderr.write(`hostd: ${m}
31346
+ `);
31347
+ };
31348
+ const onEvent = (e) => {
31349
+ appendFile2(eventsLog, JSON.stringify(e) + `
30865
31350
  `).catch(() => {});
31351
+ };
31352
+ const intervalMinutes = autoRel?.interval_minutes ?? 5;
31353
+ const imageRef = autoRel?.image_ref ?? "ghcr.io/switchroom/switchroom-agent:latest";
31354
+ if (watcherMode.mode === "auto_update") {
31355
+ if (autoRel?.notify_on_detect === true) {
31356
+ process.stderr.write("hostd: release.auto_update is on, so notify_on_detect is " + "ignored — new releases roll unattended via the canary path and " + "only FAILURES post an operator alert. Turn off " + `release.auto_update if you want the tap-to-apply card instead.
31357
+ `);
30866
31358
  }
30867
- });
30868
- releaseWatcher.start();
30869
- process.stderr.write(`hostd: release-watcher started — polling ${autoRel.image_ref} ` + `every ${autoRel.interval_minutes}m ` + `(apply_on_detect=${autoRel.apply_on_detect})
31359
+ const liveRelease = () => loadConfig().release ?? {};
31360
+ releaseWatcher = new ReleaseWatcher({
31361
+ intervalMs: intervalMinutes * 60000,
31362
+ checkFn: makeAutoUpdateCheck({
31363
+ imageRef,
31364
+ getCurrentPin: () => liveRelease().pin,
31365
+ getCurrentChannel: () => liveRelease().channel,
31366
+ getLatchedPin: () => server.autoRolloutLatchedPin(),
31367
+ log
31368
+ }),
31369
+ applyFn: async (version2) => {
31370
+ if (!version2) {
31371
+ throw new Error("auto-update check reported no target version");
31372
+ }
31373
+ const r = await server.startAutoRollout(version2);
31374
+ if (!r.started) {
31375
+ throw new Error(`auto rollout not started: ${r.reason}`);
31376
+ }
31377
+ log(`auto-update: unattended rollout to ${version2} started ` + `(request_id ${r.request_id})`);
31378
+ },
31379
+ restartFn: async () => {},
31380
+ applyOnDetect: true,
31381
+ log,
31382
+ onEvent
31383
+ });
31384
+ releaseWatcher.start();
31385
+ process.stderr.write(`hostd: auto-update watcher started (release.auto_update) — ` + `checking the published release every ${intervalMinutes}m; new ` + `releases roll via the staggered canary rollout
31386
+ `);
31387
+ } else {
31388
+ const applyOnDetect = autoRel?.apply_on_detect ?? true;
31389
+ const notifyOnDetect = autoRel?.notify_on_detect ?? false;
31390
+ let notifier = null;
31391
+ if (!applyOnDetect && notifyOnDetect) {
31392
+ const adminAgents = Object.keys(config.agents).filter((n) => {
31393
+ const a = config.agents[n];
31394
+ return a.admin === true || a.root === true;
31395
+ }).sort();
31396
+ const cardTimeoutMs = 60 * 60000;
31397
+ if (adminAgents.length === 0) {
31398
+ process.stderr.write("hostd: notify_on_detect is enabled but no agent has " + "admin/root — update approval cards have nowhere to go; " + `notifier disabled
31399
+ `);
31400
+ } else
31401
+ notifier = new UpdateNotifier({
31402
+ statePath: join11(homedir6(), ".switchroom", "release-notify-state.json"),
31403
+ repostSuppressMs: cardTimeoutMs,
31404
+ isFleetMutationInFlight: () => server.isFleetMutationLocked(),
31405
+ startApply: (requestId) => {
31406
+ const res = server.startOperatorApprovedUpdateApply(requestId);
31407
+ return { result: res.result, ...res.error ? { error: res.error } : {} };
31408
+ },
31409
+ planFn: makeUpdatePlan("switchroom"),
31410
+ requestApproval: async ({ requestId, version: version2, plan }) => {
31411
+ let last = {
31412
+ verdict: "deny",
31413
+ reason: "no admin agent with a reachable gateway socket",
31414
+ denySource: "dispatch_failure",
31415
+ finalize: async () => {}
31416
+ };
31417
+ for (const agent of adminAgents) {
31418
+ const res = await approvalGateway.requestApproval({
31419
+ requestId,
31420
+ agentName: agent,
31421
+ title: "⬆️ **Switchroom update available — fleet is behind**",
31422
+ reason: `New release detected for ${imageRef} ` + `(digest ${short(version2)}). Approve to run ` + `switchroom update (the hostd update_apply path).`,
31423
+ unifiedDiff: plan.length > 0 ? plan : `switchroom update --check produced no plan output;
31424
+ ` + `remote digest: ${version2}`,
31425
+ timeoutMs: cardTimeoutMs
31426
+ });
31427
+ if (res.verdict === "deny" && res.denySource === "dispatch_failure") {
31428
+ last = res;
31429
+ continue;
31430
+ }
31431
+ return res;
31432
+ }
31433
+ return last;
31434
+ },
31435
+ log: (m) => process.stderr.write(`hostd: ${m}
31436
+ `)
31437
+ });
31438
+ }
31439
+ const notifierRef = notifier;
31440
+ releaseWatcher = new ReleaseWatcher({
31441
+ intervalMs: intervalMinutes * 60000,
31442
+ checkFn: makeReleaseCheck({
31443
+ imageRef,
31444
+ log
31445
+ }),
31446
+ applyFn: makeApply("switchroom"),
31447
+ restartFn: makeRestart("switchroom"),
31448
+ applyOnDetect,
31449
+ ...notifierRef ? {
31450
+ notifyFn: async (version2) => {
31451
+ await notifierRef.notifyIfNew(version2);
31452
+ }
31453
+ } : {},
31454
+ log,
31455
+ onEvent
31456
+ });
31457
+ releaseWatcher.start();
31458
+ process.stderr.write(`hostd: release-watcher started — polling ${imageRef} ` + `every ${intervalMinutes}m ` + `(apply_on_detect=${applyOnDetect}, ` + `notify_on_detect=${notifyOnDetect})
30870
31459
  `);
31460
+ }
30871
31461
  }
30872
31462
  let stopping = false;
30873
31463
  async function shutdown(reason) {