switchroom 0.18.10 → 0.18.12

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 (125) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2636 -1369
  6. package/dist/cli/ui/apple-touch-icon.png +0 -0
  7. package/dist/cli/ui/favicon-32.png +0 -0
  8. package/dist/cli/ui/favicon.ico +0 -0
  9. package/dist/cli/ui/index.html +163 -17
  10. package/dist/host-control/main.js +1248 -342
  11. package/dist/vault/approvals/kernel-server.js +54 -13
  12. package/dist/vault/broker/server.js +163 -114
  13. package/package.json +3 -4
  14. package/profiles/_base/start.sh.hbs +65 -0
  15. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  16. package/profiles/coding/CLAUDE.md.hbs +1 -1
  17. package/profiles/default/CLAUDE.md.hbs +2 -2
  18. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  19. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  20. package/telegram-plugin/bridge/bridge.ts +37 -0
  21. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  22. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  23. package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
  24. package/telegram-plugin/dist/server.js +74 -2
  25. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  26. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1482 -165
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  54. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  55. package/telegram-plugin/hooks/hooks.json +9 -0
  56. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  57. package/telegram-plugin/operator-events.ts +23 -0
  58. package/telegram-plugin/package.json +0 -1
  59. package/telegram-plugin/permission-rule.ts +1 -0
  60. package/telegram-plugin/permission-title.ts +1 -0
  61. package/telegram-plugin/retry-api-call.ts +212 -2
  62. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  63. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  64. package/telegram-plugin/send-gate-observability.ts +355 -0
  65. package/telegram-plugin/send-gate.test.ts +698 -0
  66. package/telegram-plugin/send-gate.ts +982 -0
  67. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  68. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  69. package/telegram-plugin/status-pin-driver.ts +52 -7
  70. package/telegram-plugin/status-pin.ts +81 -0
  71. package/telegram-plugin/subagent-watcher.ts +102 -2
  72. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  73. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  74. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  75. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  76. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  77. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  78. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  79. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  80. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  81. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  82. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  83. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  85. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  86. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  87. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  88. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  89. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  90. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  91. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  92. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  93. package/telegram-plugin/tests/model-command.test.ts +14 -0
  94. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  95. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  96. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  97. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  98. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  99. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  100. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  101. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  102. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  103. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  104. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  105. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  106. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  107. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  108. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  109. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  110. package/telegram-plugin/typing-emitter.ts +224 -0
  111. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  112. package/telegram-plugin/welcome-text.ts +42 -0
  113. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  114. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  115. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  116. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  117. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  118. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  119. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  121. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  122. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  123. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  124. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  125. package/telegram-plugin/channel-envelope-safety.ts +0 -56
@@ -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);
@@ -15989,7 +15989,7 @@ var require_command = __commonJS((exports2) => {
15989
15989
  var EventEmitter2 = __require("node:events").EventEmitter;
15990
15990
  var childProcess = __require("node:child_process");
15991
15991
  var path2 = __require("node:path");
15992
- var fs2 = __require("node:fs");
15992
+ var fs3 = __require("node:fs");
15993
15993
  var process2 = __require("node:process");
15994
15994
  var { Argument, humanReadableArgName } = require_argument();
15995
15995
  var { CommanderError } = require_error();
@@ -16510,7 +16510,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
16510
16510
  this.processedArgs = [];
16511
16511
  }
16512
16512
  _checkForMissingExecutable(executableFile, executableDir, subcommandName) {
16513
- if (fs2.existsSync(executableFile))
16513
+ if (fs3.existsSync(executableFile))
16514
16514
  return;
16515
16515
  const executableDirMessage = executableDir ? `searched for local subcommand relative to directory '${executableDir}'` : "no directory for search for local subcommand, use .executableDir() to supply a custom directory";
16516
16516
  const executableMissing = `'${executableFile}' does not exist
@@ -16525,11 +16525,11 @@ Expecting one of '${allowedValues.join("', '")}'`);
16525
16525
  const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"];
16526
16526
  function findFile(baseDir, baseName) {
16527
16527
  const localBin = path2.resolve(baseDir, baseName);
16528
- if (fs2.existsSync(localBin))
16528
+ if (fs3.existsSync(localBin))
16529
16529
  return localBin;
16530
16530
  if (sourceExt.includes(path2.extname(baseName)))
16531
16531
  return;
16532
- const foundExt = sourceExt.find((ext) => fs2.existsSync(`${localBin}${ext}`));
16532
+ const foundExt = sourceExt.find((ext) => fs3.existsSync(`${localBin}${ext}`));
16533
16533
  if (foundExt)
16534
16534
  return `${localBin}${foundExt}`;
16535
16535
  return;
@@ -16541,7 +16541,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
16541
16541
  if (this._scriptPath) {
16542
16542
  let resolvedScriptPath;
16543
16543
  try {
16544
- resolvedScriptPath = fs2.realpathSync(this._scriptPath);
16544
+ resolvedScriptPath = fs3.realpathSync(this._scriptPath);
16545
16545
  } catch {
16546
16546
  resolvedScriptPath = this._scriptPath;
16547
16547
  }
@@ -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 existsSync12, 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);
@@ -21399,7 +21399,12 @@ var ScheduleEntrySchema = exports_external.object({
21399
21399
  var AgentSoulSchema = exports_external.object({
21400
21400
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
21401
21401
  style: exports_external.string().describe("Communication style description"),
21402
- boundaries: exports_external.string().optional().describe("Behavioral boundaries and disclaimers")
21402
+ creature: exports_external.string().optional().describe("Persona creature/form (e.g., 'owl', 'octopus')"),
21403
+ vibe: exports_external.string().optional().describe("One-line personality vibe (e.g., 'calm, precise')"),
21404
+ expertise: exports_external.string().optional().describe("Domain expertise summary rendered into the persona"),
21405
+ emoji: exports_external.string().optional().describe("Signature emoji for the persona (e.g., '\uD83E\uDD89')"),
21406
+ boundaries: exports_external.string().optional().describe("Behavioral boundaries and disclaimers"),
21407
+ shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).default("generalist").describe("Persona-shape discriminator the per-agent CLAUDE.md template can " + "branch on (e.g., 'you are an executive assistant' vs 'a senior " + "engineer'). No runtime behavior yet — template work lands in a " + "later issue. Cascade: override (per-agent wins over default).")
21403
21408
  }).optional();
21404
21409
  var AgentToolsSchema = exports_external.object({
21405
21410
  allow: exports_external.array(exports_external.string()).default([]).describe("Allowed tools (use ['all'] for unrestricted)"),
@@ -21564,6 +21569,11 @@ var TelegramChannelSchema = exports_external.object({
21564
21569
  safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
21565
21570
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
21566
21571
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
21572
+ button_choice_confirmation: exports_external.object({
21573
+ enabled: exports_external.boolean().default(false).describe("When true, tapping an agent-emitted inline_keyboard button annotates " + "the source message body with a '✅ You chose: <label> · HH:MM' line so " + "the chat surface is self-documenting. Only applies to single-use " + "keyboards (re-tappable keyboards are never annotated). Requires the " + "agent's parseMode to be the default 'html'. Opt-in (default false). " + "Limitation: the annotated body is rebuilt from the message's plain " + "text, so any formatting entities (bold, links, code, ...) on the " + "original message are lost when the annotation is applied."),
21574
+ format: exports_external.string().default("✅ You chose: {label} · {time}").describe("Template for the annotation line. `{label}` is replaced with the " + "tapped button's text (newlines stripped, HTML-escaped, trimmed to 60 " + "chars) and `{time}` with HH:MM in the chosen timezone. Note: retap " + "dedup (replacing a prior annotation instead of appending another " + "line) only recognizes the DEFAULT format shape — a custom format " + "accumulates one line per retap."),
21575
+ timezone: exports_external.enum(["gateway", "utc"]).default("gateway").describe("Timezone for the {time} placeholder. 'gateway' uses the gateway " + "process's local time; 'utc' uses UTC. Per-user timezones are out of " + "scope for v1.")
21576
+ }).optional().describe("Auto-confirm 'You chose: X' annotation on inline_keyboard taps (#789). " + "Cascades from defaults.channels.telegram.button_choice_confirmation."),
21567
21577
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic", "linear"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token, " + "'linear' = Linear-Signature bare-hex HMAC-SHA256 of the raw body). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
21568
21578
  webhook_dispatch: exports_external.object({
21569
21579
  github: exports_external.array(webhookDispatchRule).optional(),
@@ -21712,7 +21722,12 @@ var profileFields = {
21712
21722
  soul: exports_external.object({
21713
21723
  name: exports_external.string().optional(),
21714
21724
  style: exports_external.string().optional(),
21715
- boundaries: exports_external.string().optional()
21725
+ creature: exports_external.string().optional(),
21726
+ vibe: exports_external.string().optional(),
21727
+ expertise: exports_external.string().optional(),
21728
+ emoji: exports_external.string().optional(),
21729
+ boundaries: exports_external.string().optional(),
21730
+ shape: exports_external.enum(["executive-assistant", "developer", "coach", "generalist"]).optional()
21716
21731
  }).optional(),
21717
21732
  tools: exports_external.object({
21718
21733
  allow: exports_external.array(exports_external.string()).optional(),
@@ -21909,7 +21924,8 @@ var VaultConfigSchema = exports_external.object({
21909
21924
  autoUnlockCredentialPath: exports_external.string().default("~/.switchroom/vault-auto-unlock").describe("Path to the machine-bound auto-unlock blob (see " + "src/vault/auto-unlock.ts for the format). Default lives under " + "~/.switchroom so it can be bind-mounted into the vault-broker " + "container by docker compose. Tilde-expansion happens " + "at read time."),
21910
21925
  approvalAuth: exports_external.enum(["passphrase", "telegram-id"]).default("passphrase").describe("Posture for tap-to-Approve on vault grant cards. `passphrase` " + "(default) prompts the operator to type the vault passphrase on " + "every Approve — two-factor (Telegram ID + passphrase). " + "`telegram-id` mints immediately on Approve with no passphrase " + "prompt — single-factor (Telegram ID only); REQUIRES " + "`autoUnlock: true` so the broker already holds the passphrase. " + "Trades a factor of security for smoother UX; opt-in only."),
21911
21926
  postureMintAgents: exports_external.array(exports_external.string().min(1)).default([]).describe("Per-agent opt-in for posture-attested broker calls (`mint_grant` / " + "`list_grants` / `put` with `attest_via_posture: true`). Only agents " + "whose names are in this list can use the silent-mint path under " + "`approvalAuth: telegram-id`. Default `[]` — no agent can self-mint " + "until the operator explicitly opts it in. The request's `agent` " + "field must also equal the calling peer's resolved agent name " + "(broker rejects cross-agent posture mints). When `approvalAuth` is " + "`passphrase` this list is ignored — passphrase attestation still " + "works as before. Each entry is an agent slug exactly as it appears " + "under `agents:` in this config."),
21912
- adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload).")
21927
+ adminOnlyKeys: exports_external.array(exports_external.string().min(1)).default([]).describe("Vault keys held to a higher approval bar: only the admin operator " + "(`access.allowFrom[0]`) may approve a grant for them, and they can " + "NEVER be minted via posture attestation — granting one requires the " + "operator passphrase (so an agent, even one on `postureMintAgents`, " + "cannot self-grant it). Entries are exact key names or `*` globs, " + "e.g. `stripe/*`, `*/oauth-token`, `microsoft/ken-tokens` (`*` matches " + "any run of characters incl. `/`; case-sensitive). Default `[]` — no " + "key is admin-only. Posture may RETAIN an admin-only key across a " + "union re-mint but never ADD one. Takes effect on broker + gateway " + "restart (broker has no ACL hot-reload)."),
21928
+ auditFailClosed: exports_external.boolean().default(false).describe("sec WS10-F3 (#1420) — fail-CLOSED on an audit-append failure. When " + "`false` (default, backward-compatible) the broker fails OPEN: if a " + "vault-audit.log append fails it logs to stderr, bumps the durable " + "fail-open counter, and STILL releases the secret. When `true`, a " + "failed audit append DENIES the secret release with " + "`AUDIT_UNAVAILABLE` — no secret leaves the broker without a durable " + "audit row. The env var `SWITCHROOM_VAULT_AUDIT_FAIL_CLOSED` " + "(`1`/`true` or `0`/`false`) overrides this at runtime. Either way " + "the fail-open counter is surfaced by `switchroom doctor`. Takes " + "effect on broker restart.")
21913
21929
  }).default({}).superRefine((broker, ctx) => {
21914
21930
  if (broker.approvalAuth === "telegram-id" && broker.autoUnlock !== true) {
21915
21931
  ctx.addIssue({
@@ -21948,7 +21964,9 @@ var FleetHealthConfigSchema = exports_external.object({
21948
21964
  });
21949
21965
  var HostdConfigSchema = exports_external.object({
21950
21966
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
21951
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat.")
21967
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. ENFORCED server-side: a caller exceeding this in a sliding " + "1-hour window is rejected with `E_RATE_LIMITED` (carrying a " + "`retry_after` fix) instead of posting another operator approval " + "card — so a looping agent is throttled rather than spamming the chat."),
21968
+ operator_attest_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the operator-passphrase 2nd factor on hostd's " + "mutating verbs (#1841, RFC host-control-daemon.md §5.4). Default " + "false — attestation is ACCEPTED-and-audited whenever present, but " + "no verb REQUIRES it (behaviour is byte-identical to today, so " + "existing fleets and the Telegram approval-card flow are unchanged). " + "When true, the verbs in `operator_attest_required_verbs` demand a " + "valid operator-passphrase attestation IN ADDITION to admin: hostd " + "forwards the passphrase to the vault broker over its own admin-client " + "connection (`/run/switchroom/broker/hostd/sock`) and treats a broker " + "DENIED as a gate failure. hostd never holds the passphrase. Leave " + "this off until BOTH the gateway passphrase-forward and the " + "cross-compose broker socket are deployed, or gated verbs will fail."),
21969
+ operator_attest_required_verbs: exports_external.array(exports_external.string().min(1)).default(["update_apply", "apply", "rollout"]).describe("Which mutating hostd verbs REQUIRE a valid operator-passphrase " + "attestation when `operator_attest_enabled` is true (#1841). Default " + "is the RFC §5.4 fleet-mutation set (`update_apply`, `apply`, " + "`rollout`). Verbs NOT in this list still accept-and-audit an " + "attestation when one is supplied, but never require it. Ignored " + "entirely when `operator_attest_enabled` is false.")
21952
21970
  });
21953
21971
  var CronEgressSchema = exports_external.object({
21954
21972
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -22371,6 +22389,11 @@ function mergeAgentConfig(defaultsIn, agentIn) {
22371
22389
  if (v !== undefined)
22372
22390
  combined[k] = v;
22373
22391
  }
22392
+ const baseSoul = base;
22393
+ const overrideSoul = override;
22394
+ if (baseSoul.shape !== undefined && (overrideSoul.shape === undefined || overrideSoul.shape === "generalist")) {
22395
+ combined.shape = baseSoul.shape;
22396
+ }
22374
22397
  merged.soul = combined;
22375
22398
  }
22376
22399
  if (defaults.memory || merged.memory) {
@@ -22797,11 +22820,391 @@ function resolveAgentsDir(config) {
22797
22820
  return resolveDualPath(config.switchroom.agents_dir || "~/.switchroom/agents");
22798
22821
  }
22799
22822
 
22823
+ // src/host-control/config-degraded.ts
22824
+ import { existsSync as existsSync4, readFileSync as readFileSync3, renameSync, unlinkSync, watchFile, unwatchFile, writeFileSync, mkdirSync } from "node:fs";
22825
+ import { connect } from "node:net";
22826
+ import { join } from "node:path";
22827
+
22828
+ // src/config/release-resolve.ts
22829
+ function parseSemverTag(tag) {
22830
+ if (!tag)
22831
+ return null;
22832
+ const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
22833
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
22834
+ }
22835
+ function compareReleaseTags(a, b) {
22836
+ const pa = parseSemverTag(a);
22837
+ const pb = parseSemverTag(b);
22838
+ if (!pa || !pb)
22839
+ return null;
22840
+ for (let i = 0;i < 3; i++) {
22841
+ if (pa[i] !== pb[i])
22842
+ return pa[i] < pb[i] ? -1 : 1;
22843
+ }
22844
+ return 0;
22845
+ }
22846
+
22847
+ // src/host-control/self-bump.ts
22848
+ var SELF_BUMP_MARKER_FILENAME = "pending-rollout.json";
22849
+ var SELF_BUMP_HELPER_CONTAINER = "switchroom-hostd-selfbump";
22850
+ var SELF_BUMP_LOG_FILENAME = "selfbump.log";
22851
+ var SELF_BUMP_MARKER_MAX_AGE_MS = 15 * 60 * 1000;
22852
+ var SEMVER_PIN_RE = /^v\d+\.\d+\.\d+$/;
22853
+ function needsSelfBump(ownVersion, pin) {
22854
+ if (!ownVersion)
22855
+ return false;
22856
+ const cmp = compareReleaseTags(`v${ownVersion.trim().replace(/^v/, "")}`, `v${pin.trim().replace(/^v/, "")}`);
22857
+ return cmp !== null && cmp < 0;
22858
+ }
22859
+ function bumpHostdComposeImageTag(yaml, pin) {
22860
+ const re = /^(\s*image:\s*)(\S*switchroom-hostd):(\S+)\s*$/m;
22861
+ const m = yaml.match(re);
22862
+ if (!m)
22863
+ return null;
22864
+ const oldImageRef = `${m[2]}:${m[3]}`;
22865
+ const newImageRef = `${m[2]}:${pin}`;
22866
+ return {
22867
+ yaml: yaml.replace(re, `$1${newImageRef}`),
22868
+ oldImageRef,
22869
+ newImageRef
22870
+ };
22871
+ }
22872
+ function encodePendingRolloutMarker(m) {
22873
+ return JSON.stringify(m, null, 2) + `
22874
+ `;
22875
+ }
22876
+ function parsePendingRolloutMarker(raw) {
22877
+ let obj;
22878
+ try {
22879
+ obj = JSON.parse(raw);
22880
+ } catch {
22881
+ return null;
22882
+ }
22883
+ if (typeof obj !== "object" || obj === null)
22884
+ return null;
22885
+ const o = obj;
22886
+ if (o.v !== 1)
22887
+ return null;
22888
+ if (typeof o.request_id !== "string" || o.request_id.length === 0)
22889
+ return null;
22890
+ if (typeof o.pin !== "string" || !SEMVER_PIN_RE.test(o.pin))
22891
+ return null;
22892
+ if (typeof o.created_at !== "string" || !Number.isFinite(Date.parse(o.created_at))) {
22893
+ return null;
22894
+ }
22895
+ if (typeof o.prior_hostd_version !== "string")
22896
+ return null;
22897
+ const caller = o.caller;
22898
+ if (typeof caller !== "object" || caller === null)
22899
+ return null;
22900
+ if (caller.kind === "agent") {
22901
+ if (typeof caller.name !== "string" || caller.name.length === 0)
22902
+ return null;
22903
+ } else if (caller.kind !== "operator") {
22904
+ return null;
22905
+ }
22906
+ if (o.agents !== undefined) {
22907
+ if (!Array.isArray(o.agents) || o.agents.length === 0 || !o.agents.every((a) => typeof a === "string" && /^[\w-]+$/.test(a))) {
22908
+ return null;
22909
+ }
22910
+ }
22911
+ if (o.skip_web !== undefined && typeof o.skip_web !== "boolean")
22912
+ return null;
22913
+ if (o.allow_downgrade !== undefined && typeof o.allow_downgrade !== "boolean") {
22914
+ return null;
22915
+ }
22916
+ return {
22917
+ v: 1,
22918
+ request_id: o.request_id,
22919
+ pin: o.pin,
22920
+ ...o.agents !== undefined ? { agents: o.agents } : {},
22921
+ ...o.skip_web !== undefined ? { skip_web: o.skip_web } : {},
22922
+ ...o.allow_downgrade !== undefined ? { allow_downgrade: o.allow_downgrade } : {},
22923
+ caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
22924
+ created_at: o.created_at,
22925
+ prior_hostd_version: o.prior_hostd_version
22926
+ };
22927
+ }
22928
+ function isMarkerFresh(marker, nowMs) {
22929
+ const created = Date.parse(marker.created_at);
22930
+ if (!Number.isFinite(created))
22931
+ return false;
22932
+ return nowMs - created < SELF_BUMP_MARKER_MAX_AGE_MS;
22933
+ }
22934
+ function selfBumpHelperArgs(opts) {
22935
+ const composePath = `${opts.hostdDirHostPath}/docker-compose.yml`;
22936
+ const logPath = `${opts.hostdDirHostPath}/${SELF_BUMP_LOG_FILENAME}`;
22937
+ 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`;
22938
+ return [
22939
+ "run",
22940
+ "-d",
22941
+ "--rm",
22942
+ "--name",
22943
+ SELF_BUMP_HELPER_CONTAINER,
22944
+ "--label",
22945
+ "switchroom.hostd-selfbump=true",
22946
+ "-v",
22947
+ "/var/run/docker.sock:/var/run/docker.sock",
22948
+ "-v",
22949
+ `${opts.hostdDirHostPath}:${opts.hostdDirHostPath}`,
22950
+ "--entrypoint",
22951
+ "/bin/sh",
22952
+ opts.helperImage,
22953
+ "-c",
22954
+ script
22955
+ ];
22956
+ }
22957
+
22958
+ // src/host-control/config-degraded.ts
22959
+ var CONFIG_DEGRADED_MARKER_FILENAME = "config-degraded.json";
22960
+ var CONFIG_DEGRADED_RETRY_MS = 15000;
22961
+ var MAX_PRESERVED_DEGRADED_MS = 4 * 60 * 60 * 1000;
22962
+ function buildDegradedMarker(err, nowMs) {
22963
+ return {
22964
+ v: 1,
22965
+ since: new Date(nowMs).toISOString(),
22966
+ error: err.message,
22967
+ ...err.details && err.details.length > 0 ? { details: err.details } : {}
22968
+ };
22969
+ }
22970
+ function readDegradedSince(markerPath) {
22971
+ try {
22972
+ if (!existsSync4(markerPath))
22973
+ return null;
22974
+ const parsed = JSON.parse(readFileSync3(markerPath, "utf8"));
22975
+ if (parsed?.v !== 1 || typeof parsed.since !== "string")
22976
+ return null;
22977
+ const since = Date.parse(parsed.since);
22978
+ return Number.isFinite(since) ? since : null;
22979
+ } catch {
22980
+ return null;
22981
+ }
22982
+ }
22983
+ function formatDegradedNotice(err) {
22984
+ const details = (err.details ?? []).map((d) => d.trim()).filter(Boolean).slice(0, 6).join(`
22985
+ `);
22986
+ return `⚠️ hostd: switchroom.yaml failed to load — running DEGRADED.
22987
+ ` + `${err.message}
22988
+ ` + (details ? details + `
22989
+ ` : "") + `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).`;
22990
+ }
22991
+ function humanMins(ms) {
22992
+ const mins = Math.max(1, Math.round(ms / 60000));
22993
+ return mins >= 90 ? `${(mins / 60).toFixed(1)}h` : `${mins}m`;
22994
+ }
22995
+ function formatRecoveredNotice(degradedMs, pending) {
22996
+ let tail = "";
22997
+ if (pending?.preserved) {
22998
+ tail = ` Pending self-bump rollout to ${pending.pin} was preserved through the ` + `outage (marker now ${humanMins(pending.ageMs)} old) and will resume now.`;
22999
+ } else if (pending) {
23000
+ 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.`;
23001
+ }
23002
+ return `✅ hostd: switchroom.yaml loads again after ~${humanMins(degradedMs)} degraded — ` + `normal service resumed.` + tail;
23003
+ }
23004
+ function shiftPendingRolloutMarker(raw, degradedMs) {
23005
+ if (degradedMs <= 0)
23006
+ return null;
23007
+ const marker = parsePendingRolloutMarker(raw);
23008
+ if (!marker)
23009
+ return null;
23010
+ const created = Date.parse(marker.created_at);
23011
+ if (!Number.isFinite(created))
23012
+ return null;
23013
+ return encodePendingRolloutMarker({
23014
+ ...marker,
23015
+ created_at: new Date(created + degradedMs).toISOString()
23016
+ });
23017
+ }
23018
+ function clearStaleDegradedMarker(homeDir, log) {
23019
+ const markerPath = join(homeDir, ".switchroom", "hostd", CONFIG_DEGRADED_MARKER_FILENAME);
23020
+ try {
23021
+ if (!existsSync4(markerPath))
23022
+ return false;
23023
+ unlinkSync(markerPath);
23024
+ log(`config-degraded: clearing stale degraded marker from a prior crash ` + `(${markerPath}) — config loads cleanly on this boot`);
23025
+ return true;
23026
+ } catch (e) {
23027
+ log(`config-degraded: could not clear stale marker ${markerPath}: ${e.message}`);
23028
+ return false;
23029
+ }
23030
+ }
23031
+ function postOperatorNoticeViaGateways(candidates, text, log, connectTimeoutMs = 5000) {
23032
+ const tryOne = (c) => new Promise((resolve4) => {
23033
+ let done = false;
23034
+ const finish = (ok, why) => {
23035
+ if (done)
23036
+ return;
23037
+ done = true;
23038
+ if (why)
23039
+ log(`config-degraded: gateway ${c.agent} — ${why}`);
23040
+ try {
23041
+ client.destroy();
23042
+ } catch {}
23043
+ resolve4(ok);
23044
+ };
23045
+ let client;
23046
+ try {
23047
+ client = connect({ path: c.sock });
23048
+ } catch (e) {
23049
+ log(`config-degraded: gateway ${c.agent} — connect threw: ${e.message}`);
23050
+ resolve4(false);
23051
+ return;
23052
+ }
23053
+ client.setTimeout(connectTimeoutMs);
23054
+ client.on("connect", () => {
23055
+ try {
23056
+ client.write(JSON.stringify({
23057
+ type: "rollout_status_post",
23058
+ requestId: "config-degraded",
23059
+ agentName: c.agent,
23060
+ text
23061
+ }) + `
23062
+ `, () => {
23063
+ client.end();
23064
+ finish(true);
23065
+ });
23066
+ } catch (e) {
23067
+ finish(false, `write failed: ${e.message}`);
23068
+ }
23069
+ });
23070
+ client.on("timeout", () => finish(false, "connect timed out"));
23071
+ client.on("error", (e) => finish(false, `socket error: ${e.message}`));
23072
+ });
23073
+ return (async () => {
23074
+ for (const c of candidates) {
23075
+ if (await tryOne(c))
23076
+ return c.agent;
23077
+ }
23078
+ if (candidates.length > 0) {
23079
+ log("config-degraded: every candidate gateway socket failed; relying on marker file");
23080
+ }
23081
+ return null;
23082
+ })();
23083
+ }
23084
+ async function waitForConfigRecovery(opts) {
23085
+ const now = opts.nowFn ?? Date.now;
23086
+ const retryMs = opts.retryMs ?? CONFIG_DEGRADED_RETRY_MS;
23087
+ const hostdDir = join(opts.homeDir, ".switchroom", "hostd");
23088
+ const degradedMarkerPath = join(hostdDir, CONFIG_DEGRADED_MARKER_FILENAME);
23089
+ const pendingMarkerPath = join(hostdDir, SELF_BUMP_MARKER_FILENAME);
23090
+ const persistedSince = readDegradedSince(degradedMarkerPath);
23091
+ const enteredAt = Math.min(persistedSince ?? now(), now());
23092
+ if (persistedSince !== null) {
23093
+ opts.log(`config-degraded: resuming an existing degraded window (since ` + `${new Date(enteredAt).toISOString()}, persisted in ${degradedMarkerPath})`);
23094
+ }
23095
+ try {
23096
+ mkdirSync(hostdDir, { recursive: true, mode: 448 });
23097
+ writeFileSync(degradedMarkerPath + ".tmp", JSON.stringify(buildDegradedMarker(opts.initialError, enteredAt), null, 2) + `
23098
+ `, { mode: 384 });
23099
+ renameSync(degradedMarkerPath + ".tmp", degradedMarkerPath);
23100
+ } catch (e) {
23101
+ opts.log(`config-degraded: could not write marker ${degradedMarkerPath}: ${e.message}`);
23102
+ }
23103
+ try {
23104
+ opts.notify(formatDegradedNotice(opts.initialError));
23105
+ } catch (e) {
23106
+ opts.log(`config-degraded: notify threw: ${e.message}`);
23107
+ }
23108
+ 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}`);
23109
+ const config = await new Promise((resolve4, reject) => {
23110
+ let timer = null;
23111
+ let settled = false;
23112
+ const cleanup = () => {
23113
+ if (timer)
23114
+ clearInterval(timer);
23115
+ if (opts.configPath) {
23116
+ try {
23117
+ unwatchFile(opts.configPath, onFileChange);
23118
+ } catch {}
23119
+ }
23120
+ };
23121
+ const attempt = (why) => {
23122
+ if (settled)
23123
+ return;
23124
+ try {
23125
+ const cfg = opts.loadFn();
23126
+ settled = true;
23127
+ cleanup();
23128
+ resolve4(cfg);
23129
+ } catch (err) {
23130
+ if (err instanceof ConfigError) {
23131
+ opts.log(`config-degraded: reload attempt (${why}) still failing — ${err.message}`);
23132
+ return;
23133
+ }
23134
+ settled = true;
23135
+ cleanup();
23136
+ reject(err);
23137
+ }
23138
+ };
23139
+ const onFileChange = () => attempt("file change");
23140
+ timer = setInterval(() => attempt("interval"), retryMs);
23141
+ if (opts.configPath) {
23142
+ try {
23143
+ watchFile(opts.configPath, { interval: 5000 }, onFileChange);
23144
+ } catch (e) {
23145
+ opts.log(`config-degraded: could not watch ${opts.configPath}: ${e.message}`);
23146
+ }
23147
+ }
23148
+ });
23149
+ const degradedMs = Math.max(0, now() - enteredAt);
23150
+ let pendingOutcome = null;
23151
+ try {
23152
+ if (existsSync4(pendingMarkerPath)) {
23153
+ const raw = readFileSync3(pendingMarkerPath, "utf8");
23154
+ const marker = parsePendingRolloutMarker(raw);
23155
+ if (marker && degradedMs <= 0) {
23156
+ pendingOutcome = {
23157
+ pin: marker.pin,
23158
+ ageMs: Math.max(0, now() - Date.parse(marker.created_at)),
23159
+ preserved: true
23160
+ };
23161
+ } else if (marker) {
23162
+ const withinCap = degradedMs <= MAX_PRESERVED_DEGRADED_MS;
23163
+ const shifted = withinCap ? shiftPendingRolloutMarker(raw, degradedMs) : null;
23164
+ if (shifted !== null) {
23165
+ writeFileSync(pendingMarkerPath + ".tmp", shifted, { mode: 384 });
23166
+ renameSync(pendingMarkerPath + ".tmp", pendingMarkerPath);
23167
+ pendingOutcome = {
23168
+ pin: marker.pin,
23169
+ ageMs: Math.max(0, now() - (Date.parse(marker.created_at) + degradedMs)),
23170
+ preserved: true
23171
+ };
23172
+ 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`);
23173
+ } else {
23174
+ pendingOutcome = {
23175
+ pin: marker.pin,
23176
+ ageMs: Math.max(0, now() - Date.parse(marker.created_at)),
23177
+ preserved: false
23178
+ };
23179
+ if (!withinCap) {
23180
+ 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`);
23181
+ }
23182
+ }
23183
+ }
23184
+ }
23185
+ } catch (e) {
23186
+ opts.log(`config-degraded: could not preserve pending-rollout marker: ${e.message}`);
23187
+ }
23188
+ try {
23189
+ if (existsSync4(degradedMarkerPath))
23190
+ unlinkSync(degradedMarkerPath);
23191
+ } catch (e) {
23192
+ opts.log(`config-degraded: could not remove marker ${degradedMarkerPath}: ${e.message}`);
23193
+ }
23194
+ try {
23195
+ opts.notify(formatRecoveredNotice(degradedMs, pendingOutcome));
23196
+ } catch (e) {
23197
+ opts.log(`config-degraded: recovery notify threw: ${e.message}`);
23198
+ }
23199
+ opts.log(`config-degraded: config loads again after ${Math.round(degradedMs / 1000)}s — resuming normal boot`);
23200
+ return config;
23201
+ }
23202
+
22800
23203
  // src/agents/compose.ts
22801
23204
  import { createHash } from "node:crypto";
22802
23205
 
22803
23206
  // src/agents/scaffold.ts
22804
- import { join as join4, resolve as resolve5 } from "node:path";
23207
+ import { join as join5, resolve as resolve5 } from "node:path";
22805
23208
 
22806
23209
  // src/config/timezone.ts
22807
23210
  var CONTAINER_DEFAULT_UTC_ZONES = new Set([
@@ -22812,7 +23215,7 @@ var CONTAINER_DEFAULT_UTC_ZONES = new Set([
22812
23215
  ]);
22813
23216
 
22814
23217
  // src/cli/agent-config.ts
22815
- import { join } from "node:path";
23218
+ import { join as join2 } from "node:path";
22816
23219
  import { homedir as homedir2 } from "node:os";
22817
23220
 
22818
23221
  // src/vault/broker/acl.ts
@@ -22823,12 +23226,12 @@ var WEBKITE_VAULT_KEYS = new Set([
22823
23226
  ]);
22824
23227
 
22825
23228
  // src/cli/agent-config.ts
22826
- var AUDIT_ROOT = join(homedir2(), ".switchroom", "audit");
23229
+ var AUDIT_ROOT = join2(homedir2(), ".switchroom", "audit");
22827
23230
 
22828
23231
  // src/agents/profiles.ts
22829
23232
  var import_handlebars = __toESM(require_lib(), 1);
22830
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync4, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync, realpathSync } from "node:fs";
22831
- import { resolve as resolve4, join as join2, sep as pathSep } from "node:path";
23233
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync5, readdirSync as readdirSync2, statSync as statSync2, copyFileSync, mkdirSync as mkdirSync2, realpathSync } from "node:fs";
23234
+ import { resolve as resolve4, join as join3, sep as pathSep } from "node:path";
22832
23235
  var PROFILES_ROOT = resolve4(import.meta.dirname, "../../profiles");
22833
23236
  import_handlebars.default.registerHelper("json", (value) => {
22834
23237
  return new import_handlebars.default.SafeString(JSON.stringify(value, null, 2));
@@ -22839,9 +23242,9 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
22839
23242
  var SHARED_FRAGMENTS_DIR = resolve4(PROFILES_ROOT, "_shared");
22840
23243
  var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
22841
23244
  for (const name of SHARED_FRAGMENTS) {
22842
- const fragPath = join2(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
22843
- if (existsSync4(fragPath)) {
22844
- import_handlebars.default.registerPartial(name, readFileSync3(fragPath, "utf-8"));
23245
+ const fragPath = join3(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
23246
+ if (existsSync5(fragPath)) {
23247
+ import_handlebars.default.registerPartial(name, readFileSync4(fragPath, "utf-8"));
22845
23248
  }
22846
23249
  }
22847
23250
 
@@ -22850,18 +23253,22 @@ var tails = new Map;
22850
23253
 
22851
23254
  // src/agents/inject.ts
22852
23255
  var INJECT_COMMANDS = new Map([
22853
- ["/cost", { description: "Show session cost", expectsOutput: true, dialog: true }],
22854
- ["/status", { description: "Show session status", expectsOutput: true, dialog: true }],
22855
- ["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true }],
22856
- ["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true }],
22857
- ["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true }],
22858
- ["/model", { description: "Open model picker", expectsOutput: true }],
23256
+ ["/cost", { description: "Show session cost", expectsOutput: true, dialog: true, argsAllowed: false }],
23257
+ ["/status", { description: "Show session status", expectsOutput: true, dialog: true, argsAllowed: false }],
23258
+ ["/usage", { description: "Show plan quota", expectsOutput: true, dialog: true, argsAllowed: false }],
23259
+ ["/hooks", { description: "List configured hooks", expectsOutput: true, dialog: true, argsAllowed: false }],
23260
+ ["/memory", { description: "Open memory picker", expectsOutput: true, dialog: true, argsAllowed: false }],
23261
+ ["/help", { description: "Show help / command discovery", expectsOutput: true, dialog: true, argsAllowed: false }],
23262
+ ["/context", { description: "Show context window usage", expectsOutput: true, argsAllowed: false }],
23263
+ ["/release-notes", { description: "Show release-notes version list", expectsOutput: true, dialog: true, argsAllowed: false }],
23264
+ ["/model", { description: "Open model picker", expectsOutput: true, argsAllowed: true }],
22859
23265
  [
22860
23266
  "/clear",
22861
23267
  {
22862
23268
  description: "Clear session screen",
22863
23269
  expectsOutput: false,
22864
- silentNote: "context cleared — fresh slate"
23270
+ silentNote: "context cleared — fresh slate",
23271
+ argsAllowed: false
22865
23272
  }
22866
23273
  ],
22867
23274
  [
@@ -22869,7 +23276,8 @@ var INJECT_COMMANDS = new Map([
22869
23276
  {
22870
23277
  description: "Compact conversation history",
22871
23278
  expectsOutput: false,
22872
- silentNote: "compaction runs silently"
23279
+ silentNote: "compaction runs silently",
23280
+ argsAllowed: false
22873
23281
  }
22874
23282
  ]
22875
23283
  ]);
@@ -22879,6 +23287,15 @@ var INJECT_BLOCKED = new Map([
22879
23287
  ["/logout", { reason: "would terminate the agent's auth session" }],
22880
23288
  ["/exit", { reason: "would kill the agent process" }],
22881
23289
  ["/quit", { reason: "would kill the agent process" }],
23290
+ ["/upgrade", { reason: "mutates the Claude Code installation" }],
23291
+ ["/init", { reason: "generates/overwrites CLAUDE.md and runs a model turn" }],
23292
+ ["/mcp", { reason: "opens an interactive MCP server management dialog" }],
23293
+ ["/permissions", { reason: "opens an interactive permissions editor that mutates tool policy" }],
23294
+ ["/install-github-app", { reason: "runs a network/OAuth install flow" }],
23295
+ ["/add-dir", { reason: "mutates the session's working-directory set" }],
23296
+ ["/terminal-setup", { reason: "mutates terminal keybinding configuration" }],
23297
+ ["/privacy-settings", { reason: "opens an interactive privacy-settings dialog" }],
23298
+ ["/bug", { reason: "submits a bug report over the network" }],
22882
23299
  [
22883
23300
  "/effort",
22884
23301
  {
@@ -22896,6 +23313,7 @@ var HINDSIGHT_CONSUMER_NAME = "hindsight";
22896
23313
  var HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight";
22897
23314
  var HINDSIGHT_IMAGE = `${HINDSIGHT_IMAGE_REPO}:latest`;
22898
23315
  var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`;
23316
+ var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
22899
23317
  var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
22900
23318
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
22901
23319
 
@@ -22928,8 +23346,10 @@ var KNOWN_VAULT_ARTIFACT_NAMES = new Set([
22928
23346
  var SCRYPT_MAXMEM = 128 * 1024 * 1024;
22929
23347
 
22930
23348
  // src/vault/broker/client.ts
23349
+ import * as net from "node:net";
23350
+ import * as fs from "node:fs";
22931
23351
  import { homedir as homedir3 } from "node:os";
22932
- import { join as join3 } from "node:path";
23352
+ import { join as join4 } from "node:path";
22933
23353
 
22934
23354
  // src/broker-common/peercred-path.ts
22935
23355
  function matchSocketName(socketPath, patterns, maxNameLen) {
@@ -23140,7 +23560,8 @@ var ErrorCode = exports_external.enum([
23140
23560
  "DENIED",
23141
23561
  "UNKNOWN_KEY",
23142
23562
  "BAD_REQUEST",
23143
- "INTERNAL"
23563
+ "INTERNAL",
23564
+ "AUDIT_UNAVAILABLE"
23144
23565
  ]);
23145
23566
  var OkEntryResponseSchema = exports_external.object({
23146
23567
  ok: exports_external.literal(true),
@@ -23277,16 +23698,123 @@ var ResponseSchema = exports_external.union([
23277
23698
  OkApprovalConsumeRecordResponseSchema,
23278
23699
  ErrorResponseSchema
23279
23700
  ]);
23701
+ function encodeRequest(req) {
23702
+ const json = JSON.stringify(req);
23703
+ if (Buffer.byteLength(json, "utf8") > MAX_FRAME_BYTES) {
23704
+ throw new Error(`Request frame too large (${Buffer.byteLength(json, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
23705
+ }
23706
+ return json + `
23707
+ `;
23708
+ }
23709
+ function decodeResponse(line) {
23710
+ if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
23711
+ throw new RangeError(`Response frame too large (${Buffer.byteLength(line, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
23712
+ }
23713
+ const obj = JSON.parse(line);
23714
+ return ResponseSchema.parse(obj);
23715
+ }
23716
+
23717
+ // src/runtime-mode.ts
23718
+ function isDockerRuntime() {
23719
+ return process.env.SWITCHROOM_RUNTIME === "docker";
23720
+ }
23280
23721
 
23281
23722
  // src/vault/broker/client.ts
23282
- var LEGACY_SOCKET_PATH = join3(homedir3(), ".switchroom", "vault-broker.sock");
23283
- var OPERATOR_SOCKET_PATH = join3(homedir3(), ".switchroom", "broker-operator", "sock");
23723
+ var DEFAULT_TIMEOUT_MS = 2000;
23724
+ var LEGACY_SOCKET_PATH = join4(homedir3(), ".switchroom", "vault-broker.sock");
23725
+ var OPERATOR_SOCKET_PATH = join4(homedir3(), ".switchroom", "broker-operator", "sock");
23726
+ function defaultBrokerSocketPath() {
23727
+ if (fs.existsSync(OPERATOR_SOCKET_PATH))
23728
+ return OPERATOR_SOCKET_PATH;
23729
+ if (isDockerRuntime())
23730
+ return OPERATOR_SOCKET_PATH;
23731
+ return LEGACY_SOCKET_PATH;
23732
+ }
23733
+ function resolveBrokerSocketPath(opts) {
23734
+ if (opts?.socket)
23735
+ return opts.socket;
23736
+ const env = process.env.SWITCHROOM_VAULT_BROKER_SOCK;
23737
+ if (env)
23738
+ return env;
23739
+ if (opts?.vaultBrokerSocket)
23740
+ return opts.vaultBrokerSocket;
23741
+ return defaultBrokerSocketPath();
23742
+ }
23743
+ async function rpcRaw(req, opts) {
23744
+ return rpc(req, opts);
23745
+ }
23746
+ async function rpc(req, opts) {
23747
+ const socketPath = resolveBrokerSocketPath(opts);
23748
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
23749
+ return new Promise((resolve5) => {
23750
+ let settled = false;
23751
+ const settle = (val) => {
23752
+ if (settled)
23753
+ return;
23754
+ settled = true;
23755
+ resolve5(val);
23756
+ };
23757
+ const client = new net.Socket;
23758
+ const timer = setTimeout(() => {
23759
+ client.destroy();
23760
+ settle({ kind: "unreachable", msg: `broker did not respond within ${timeoutMs}ms` });
23761
+ }, timeoutMs);
23762
+ client.on("error", (err) => {
23763
+ clearTimeout(timer);
23764
+ const code = err.code ?? "ERR";
23765
+ let msg;
23766
+ if (code === "ENOENT")
23767
+ msg = "broker socket not found (is the daemon running?)";
23768
+ else if (code === "ECONNREFUSED")
23769
+ msg = "broker socket exists but refused connection";
23770
+ else if (code === "EACCES")
23771
+ msg = "broker socket access denied (wrong UID?)";
23772
+ else
23773
+ msg = `broker connection failed: ${err.message}`;
23774
+ settle({ kind: "unreachable", msg });
23775
+ });
23776
+ let buffer = "";
23777
+ client.on("data", (chunk) => {
23778
+ buffer += chunk.toString("utf8");
23779
+ const newlineIdx = buffer.indexOf(`
23780
+ `);
23781
+ if (newlineIdx !== -1) {
23782
+ const line = buffer.slice(0, newlineIdx).trimEnd();
23783
+ clearTimeout(timer);
23784
+ client.destroy();
23785
+ try {
23786
+ const resp = decodeResponse(line);
23787
+ settle({ kind: "response", resp });
23788
+ } catch (err) {
23789
+ settle({
23790
+ kind: "unreachable",
23791
+ msg: `unparseable broker response: ${err instanceof Error ? err.message : String(err)}`
23792
+ });
23793
+ }
23794
+ }
23795
+ });
23796
+ client.on("connect", () => {
23797
+ try {
23798
+ client.write(encodeRequest(req));
23799
+ } catch (err) {
23800
+ clearTimeout(timer);
23801
+ client.destroy();
23802
+ settle({
23803
+ kind: "unreachable",
23804
+ msg: `failed to send request: ${err instanceof Error ? err.message : String(err)}`
23805
+ });
23806
+ }
23807
+ });
23808
+ client.connect({ path: socketPath });
23809
+ });
23810
+ }
23284
23811
 
23285
23812
  // src/vault/resolver.ts
23286
23813
  var materializedDirs = new Set;
23287
23814
 
23288
23815
  // src/agents/scaffold.ts
23289
23816
  var REPO_ROOT = resolve5(import.meta.dirname, "../..");
23817
+ 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.";
23290
23818
  var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
23291
23819
  "permissions",
23292
23820
  "mcpServers",
@@ -23317,20 +23845,20 @@ import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
23317
23845
  import { mkdir, chmod, chown, unlink, appendFile } from "node:fs/promises";
23318
23846
  import {
23319
23847
  readdirSync as readdirSync3,
23320
- existsSync as existsSync9,
23321
- readFileSync as readFileSync7,
23322
- writeFileSync as writeFileSync4,
23323
- renameSync as renameSync2,
23324
- mkdirSync as mkdirSync3,
23848
+ existsSync as existsSync11,
23849
+ readFileSync as readFileSync9,
23850
+ writeFileSync as writeFileSync5,
23851
+ renameSync as renameSync3,
23852
+ mkdirSync as mkdirSync4,
23325
23853
  openSync as openSync3,
23326
23854
  ftruncateSync,
23327
23855
  writeSync as writeSync2,
23328
23856
  fsyncSync as fsyncSync2,
23329
23857
  closeSync as closeSync3,
23330
23858
  copyFileSync as copyFileSync2,
23331
- unlinkSync
23859
+ unlinkSync as unlinkSync2
23332
23860
  } from "node:fs";
23333
- import { join as join9, dirname as dirname6, resolve as resolve8 } from "node:path";
23861
+ import { join as join10, dirname as dirname6, resolve as resolve8 } from "node:path";
23334
23862
  import { createHash as createHash5, randomUUID as randomUUID2, randomBytes } from "node:crypto";
23335
23863
 
23336
23864
  // src/host-control/protocol.ts
@@ -23339,7 +23867,8 @@ var IDEMPOTENCY_WINDOW_MS = 15000;
23339
23867
  var RequestEnvelope = {
23340
23868
  v: exports_external.literal(1),
23341
23869
  request_id: exports_external.string().min(1).max(128),
23342
- idempotency_key: exports_external.string().min(1).max(128).optional()
23870
+ idempotency_key: exports_external.string().min(1).max(128).optional(),
23871
+ operator_passphrase: exports_external.string().min(1).max(1024).optional()
23343
23872
  };
23344
23873
  var AgentRestartRequestSchema = exports_external.object({
23345
23874
  ...RequestEnvelope,
@@ -28019,10 +28548,10 @@ class PostHog extends PostHogBackendClient {
28019
28548
 
28020
28549
  // src/analytics/posthog.ts
28021
28550
  import {
28022
- existsSync as existsSync5,
28023
- mkdirSync as mkdirSync2,
28024
- readFileSync as readFileSync4,
28025
- writeFileSync as writeFileSync2
28551
+ existsSync as existsSync7,
28552
+ mkdirSync as mkdirSync3,
28553
+ readFileSync as readFileSync6,
28554
+ writeFileSync as writeFileSync3
28026
28555
  } from "node:fs";
28027
28556
  import { dirname as dirname2 } from "node:path";
28028
28557
  import { randomUUID } from "node:crypto";
@@ -28040,8 +28569,8 @@ function getDistinctId() {
28040
28569
  return cachedDistinctId;
28041
28570
  const path = resolveStatePath("analytics-id");
28042
28571
  try {
28043
- if (existsSync5(path)) {
28044
- const existing = readFileSync4(path, "utf-8").trim();
28572
+ if (existsSync7(path)) {
28573
+ const existing = readFileSync6(path, "utf-8").trim();
28045
28574
  if (existing) {
28046
28575
  cachedDistinctId = existing;
28047
28576
  return existing;
@@ -28051,8 +28580,8 @@ function getDistinctId() {
28051
28580
  const id = randomUUID();
28052
28581
  cachedDistinctId = id;
28053
28582
  try {
28054
- mkdirSync2(dirname2(path), { recursive: true });
28055
- writeFileSync2(path, id, "utf-8");
28583
+ mkdirSync3(dirname2(path), { recursive: true });
28584
+ writeFileSync3(path, id, "utf-8");
28056
28585
  } catch {}
28057
28586
  return id;
28058
28587
  }
@@ -28660,7 +29189,7 @@ function redactedMarker(ruleId) {
28660
29189
  return `[REDACTED:${trimmed}]`;
28661
29190
  }
28662
29191
  // src/cli/install-detect.ts
28663
- import * as fs from "node:fs";
29192
+ import * as fs2 from "node:fs";
28664
29193
  import * as path from "node:path";
28665
29194
  import * as os from "node:os";
28666
29195
  var BIN_PATH = "/usr/local/bin/switchroom";
@@ -28674,12 +29203,12 @@ function detectInstallType() {
28674
29203
  try {
28675
29204
  const repoArtifact = sourceArtifactPath();
28676
29205
  const distPrefix = sourceDistPrefix();
28677
- const binExists = fs.existsSync(BIN_PATH);
28678
- const repoExists = fs.existsSync(repoArtifact);
29206
+ const binExists = fs2.existsSync(BIN_PATH);
29207
+ const repoExists = fs2.existsSync(repoArtifact);
28679
29208
  if (binExists) {
28680
- const lst = fs.lstatSync(BIN_PATH);
29209
+ const lst = fs2.lstatSync(BIN_PATH);
28681
29210
  if (lst.isSymbolicLink()) {
28682
- const target = fs.readlinkSync(BIN_PATH);
29211
+ const target = fs2.readlinkSync(BIN_PATH);
28683
29212
  const resolved = path.isAbsolute(target) ? target : path.resolve(path.dirname(BIN_PATH), target);
28684
29213
  if (resolved.startsWith(distPrefix)) {
28685
29214
  return {
@@ -28715,40 +29244,21 @@ var import_yaml3 = __toESM(require_dist(), 1);
28715
29244
  // src/cli/rollout.ts
28716
29245
  init_atomic();
28717
29246
 
28718
- // src/config/release-resolve.ts
28719
- function parseSemverTag(tag) {
28720
- if (!tag)
28721
- return null;
28722
- const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
28723
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
28724
- }
28725
- function compareReleaseTags(a, b) {
28726
- const pa = parseSemverTag(a);
28727
- const pb = parseSemverTag(b);
28728
- if (!pa || !pb)
28729
- return null;
28730
- for (let i = 0;i < 3; i++) {
28731
- if (pa[i] !== pb[i])
28732
- return pa[i] < pb[i] ? -1 : 1;
28733
- }
28734
- return 0;
28735
- }
28736
-
28737
29247
  // src/cli/resolve-version.ts
28738
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
28739
- import { dirname as dirname4, join as join6 } from "node:path";
29248
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
29249
+ import { dirname as dirname4, join as join7 } from "node:path";
28740
29250
 
28741
29251
  // src/build-info.ts
28742
- var VERSION = "0.18.10";
29252
+ var VERSION = "0.18.12";
28743
29253
 
28744
29254
  // src/cli/resolve-version.ts
28745
29255
  function readPackageVersion() {
28746
29256
  let dir = import.meta.dirname;
28747
29257
  for (let i = 0;i < 12 && dir && dir !== "/"; i++) {
28748
- const p = join6(dir, "package.json");
28749
- if (existsSync7(p)) {
29258
+ const p = join7(dir, "package.json");
29259
+ if (existsSync9(p)) {
28750
29260
  try {
28751
- const pkg = JSON.parse(readFileSync5(p, "utf-8"));
29261
+ const pkg = JSON.parse(readFileSync7(p, "utf-8"));
28752
29262
  if (pkg?.name === "switchroom" && typeof pkg.version === "string") {
28753
29263
  return pkg.version;
28754
29264
  }
@@ -28809,6 +29319,8 @@ function parseAuditLine(line) {
28809
29319
  entry.error = o.error;
28810
29320
  if (typeof o.phase === "string")
28811
29321
  entry.phase = o.phase;
29322
+ if (typeof o.method === "string")
29323
+ entry.method = o.method;
28812
29324
  if (typeof o.stdout_tail === "string")
28813
29325
  entry.stdout_tail = o.stdout_tail;
28814
29326
  if (typeof o.stderr_tail === "string")
@@ -28895,6 +29407,7 @@ function parseRolloutPhaseLine(line) {
28895
29407
  "agent-start",
28896
29408
  "agent-done",
28897
29409
  "persist-pin",
29410
+ "web-refresh",
28898
29411
  "hostd-web-deferred",
28899
29412
  "self-bump",
28900
29413
  "self-bump-done"
@@ -28905,149 +29418,38 @@ function parseRolloutPhaseLine(line) {
28905
29418
  return null;
28906
29419
  const out = {
28907
29420
  phase: o.phase,
28908
- target: o.target
28909
- };
28910
- if (typeof o.agent === "string")
28911
- out.agent = o.agent;
28912
- if (typeof o.n === "number")
28913
- out.n = o.n;
28914
- if (typeof o.m === "number")
28915
- out.m = o.m;
28916
- return out;
28917
- }
28918
- var ROLLOUT_RESULT_SENTINEL = "SWITCHROOM_ROLLOUT_RESULT:";
28919
- function parseRolloutResultLine(stdout) {
28920
- const lines = stdout.split(`
28921
- `);
28922
- for (let i = lines.length - 1;i >= 0; i--) {
28923
- const line = lines[i].trim();
28924
- if (line.startsWith(ROLLOUT_RESULT_SENTINEL)) {
28925
- try {
28926
- return JSON.parse(line.slice(ROLLOUT_RESULT_SENTINEL.length));
28927
- } catch {
28928
- return null;
28929
- }
28930
- }
28931
- }
28932
- return null;
28933
- }
28934
-
28935
- // src/host-control/self-bump.ts
28936
- var SELF_BUMP_MARKER_FILENAME = "pending-rollout.json";
28937
- var SELF_BUMP_HELPER_CONTAINER = "switchroom-hostd-selfbump";
28938
- var SELF_BUMP_LOG_FILENAME = "selfbump.log";
28939
- var SELF_BUMP_MARKER_MAX_AGE_MS = 15 * 60 * 1000;
28940
- var SEMVER_PIN_RE = /^v\d+\.\d+\.\d+$/;
28941
- function needsSelfBump(ownVersion, pin) {
28942
- if (!ownVersion)
28943
- return false;
28944
- const cmp = compareReleaseTags(`v${ownVersion.trim().replace(/^v/, "")}`, `v${pin.trim().replace(/^v/, "")}`);
28945
- return cmp !== null && cmp < 0;
28946
- }
28947
- function bumpHostdComposeImageTag(yaml, pin) {
28948
- const re = /^(\s*image:\s*)(\S*switchroom-hostd):(\S+)\s*$/m;
28949
- const m = yaml.match(re);
28950
- if (!m)
28951
- return null;
28952
- const oldImageRef = `${m[2]}:${m[3]}`;
28953
- const newImageRef = `${m[2]}:${pin}`;
28954
- return {
28955
- yaml: yaml.replace(re, `$1${newImageRef}`),
28956
- oldImageRef,
28957
- newImageRef
28958
- };
28959
- }
28960
- function encodePendingRolloutMarker(m) {
28961
- return JSON.stringify(m, null, 2) + `
28962
- `;
28963
- }
28964
- function parsePendingRolloutMarker(raw) {
28965
- let obj;
28966
- try {
28967
- obj = JSON.parse(raw);
28968
- } catch {
28969
- return null;
28970
- }
28971
- if (typeof obj !== "object" || obj === null)
28972
- return null;
28973
- const o = obj;
28974
- if (o.v !== 1)
28975
- return null;
28976
- if (typeof o.request_id !== "string" || o.request_id.length === 0)
28977
- return null;
28978
- if (typeof o.pin !== "string" || !SEMVER_PIN_RE.test(o.pin))
28979
- return null;
28980
- if (typeof o.created_at !== "string" || !Number.isFinite(Date.parse(o.created_at))) {
28981
- return null;
28982
- }
28983
- if (typeof o.prior_hostd_version !== "string")
28984
- return null;
28985
- const caller = o.caller;
28986
- if (typeof caller !== "object" || caller === null)
28987
- return null;
28988
- if (caller.kind === "agent") {
28989
- if (typeof caller.name !== "string" || caller.name.length === 0)
28990
- return null;
28991
- } else if (caller.kind !== "operator") {
28992
- return null;
28993
- }
28994
- if (o.agents !== undefined) {
28995
- if (!Array.isArray(o.agents) || o.agents.length === 0 || !o.agents.every((a) => typeof a === "string" && /^[\w-]+$/.test(a))) {
28996
- return null;
28997
- }
28998
- }
28999
- if (o.skip_web !== undefined && typeof o.skip_web !== "boolean")
29000
- return null;
29001
- if (o.allow_downgrade !== undefined && typeof o.allow_downgrade !== "boolean") {
29002
- return null;
29003
- }
29004
- return {
29005
- v: 1,
29006
- request_id: o.request_id,
29007
- pin: o.pin,
29008
- ...o.agents !== undefined ? { agents: o.agents } : {},
29009
- ...o.skip_web !== undefined ? { skip_web: o.skip_web } : {},
29010
- ...o.allow_downgrade !== undefined ? { allow_downgrade: o.allow_downgrade } : {},
29011
- caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
29012
- created_at: o.created_at,
29013
- prior_hostd_version: o.prior_hostd_version
29421
+ target: o.target
29014
29422
  };
29423
+ if (typeof o.agent === "string")
29424
+ out.agent = o.agent;
29425
+ if (typeof o.n === "number")
29426
+ out.n = o.n;
29427
+ if (typeof o.m === "number")
29428
+ out.m = o.m;
29429
+ return out;
29015
29430
  }
29016
- function isMarkerFresh(marker, nowMs) {
29017
- const created = Date.parse(marker.created_at);
29018
- if (!Number.isFinite(created))
29019
- return false;
29020
- return nowMs - created < SELF_BUMP_MARKER_MAX_AGE_MS;
29021
- }
29022
- function selfBumpHelperArgs(opts) {
29023
- const composePath = `${opts.hostdDirHostPath}/docker-compose.yml`;
29024
- const logPath = `${opts.hostdDirHostPath}/${SELF_BUMP_LOG_FILENAME}`;
29025
- 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`;
29026
- return [
29027
- "run",
29028
- "-d",
29029
- "--rm",
29030
- "--name",
29031
- SELF_BUMP_HELPER_CONTAINER,
29032
- "--label",
29033
- "switchroom.hostd-selfbump=true",
29034
- "-v",
29035
- "/var/run/docker.sock:/var/run/docker.sock",
29036
- "-v",
29037
- `${opts.hostdDirHostPath}:${opts.hostdDirHostPath}`,
29038
- "--entrypoint",
29039
- "/bin/sh",
29040
- opts.helperImage,
29041
- "-c",
29042
- script
29043
- ];
29431
+ var ROLLOUT_RESULT_SENTINEL = "SWITCHROOM_ROLLOUT_RESULT:";
29432
+ function parseRolloutResultLine(stdout) {
29433
+ const lines = stdout.split(`
29434
+ `);
29435
+ for (let i = lines.length - 1;i >= 0; i--) {
29436
+ const line = lines[i].trim();
29437
+ if (line.startsWith(ROLLOUT_RESULT_SENTINEL)) {
29438
+ try {
29439
+ return JSON.parse(line.slice(ROLLOUT_RESULT_SENTINEL.length));
29440
+ } catch {
29441
+ return null;
29442
+ }
29443
+ }
29444
+ }
29445
+ return null;
29044
29446
  }
29045
29447
 
29046
29448
  // node_modules/.bun/commander@13.1.0/node_modules/commander/esm.mjs
29047
29449
  var import__3 = __toESM(require_commander(), 1);
29048
29450
 
29049
29451
  // src/cli/update.ts
29050
- import { join as join7, dirname as dirname5, resolve as resolve7 } from "node:path";
29452
+ import { join as join8, dirname as dirname5, resolve as resolve7 } from "node:path";
29051
29453
  import { homedir as homedir5 } from "node:os";
29052
29454
 
29053
29455
  // src/agents/lifecycle.ts
@@ -29144,7 +29546,7 @@ function getAllAgentStatuses(config) {
29144
29546
 
29145
29547
  // src/cli/update.ts
29146
29548
  init_atomic();
29147
- var DEFAULT_COMPOSE_PATH = join7(homedir5(), ".switchroom", "compose", "docker-compose.yml");
29549
+ var DEFAULT_COMPOSE_PATH = join8(homedir5(), ".switchroom", "compose", "docker-compose.yml");
29148
29550
  var UPDATE_RESULT_SENTINEL = "SWITCHROOM_UPDATE_RESULT:";
29149
29551
  function parseUpdateResultLine(stdout) {
29150
29552
  const idx = stdout.lastIndexOf(UPDATE_RESULT_SENTINEL);
@@ -29167,9 +29569,9 @@ function parseUpdateResultLine(stdout) {
29167
29569
 
29168
29570
  // src/host-control/config-edit-validator.ts
29169
29571
  var import_yaml5 = __toESM(require_dist(), 1);
29170
- import { mkdtempSync, writeFileSync as writeFileSync3, rmSync as rmSync2, existsSync as existsSync8, readFileSync as readFileSync6 } from "node:fs";
29572
+ import { mkdtempSync, writeFileSync as writeFileSync4, rmSync as rmSync2, existsSync as existsSync10, readFileSync as readFileSync8 } from "node:fs";
29171
29573
  import { tmpdir } from "node:os";
29172
- import { join as join8, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
29574
+ import { join as join9, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
29173
29575
  import { spawnSync as spawnSync2 } from "node:child_process";
29174
29576
  import { isDeepStrictEqual } from "node:util";
29175
29577
  var MAX_PATCH_BYTES = 1024 * 1024;
@@ -29279,31 +29681,57 @@ function validateShape(unifiedDiff, targetPath) {
29279
29681
  };
29280
29682
  }
29281
29683
  }
29684
+ {
29685
+ let inHunk = false;
29686
+ let hunkHasContext = false;
29687
+ const failZeroContext = {
29688
+ ok: false,
29689
+ code: "E_PATCH_INVALID_SHAPE",
29690
+ detail: "zero-context hunk not allowed; regenerate the diff with context " + "lines (git diff default of 3)"
29691
+ };
29692
+ const scanLines = lines.length > 0 && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
29693
+ for (const ln of scanLines) {
29694
+ if (ln.startsWith("@@")) {
29695
+ if (inHunk && !hunkHasContext)
29696
+ return failZeroContext;
29697
+ inHunk = true;
29698
+ hunkHasContext = false;
29699
+ } else if (ln.startsWith("--- ") || ln.startsWith("+++ ")) {
29700
+ if (inHunk && !hunkHasContext)
29701
+ return failZeroContext;
29702
+ inHunk = false;
29703
+ } else if (inHunk) {
29704
+ if (ln.startsWith(" ") || ln === "")
29705
+ hunkHasContext = true;
29706
+ }
29707
+ }
29708
+ if (inHunk && !hunkHasContext)
29709
+ return failZeroContext;
29710
+ }
29282
29711
  return null;
29283
29712
  }
29284
29713
  function applyPatch(unifiedDiff, configPath, gitBin) {
29285
- if (!existsSync8(configPath)) {
29714
+ if (!existsSync10(configPath)) {
29286
29715
  return {
29287
29716
  ok: false,
29288
29717
  code: "E_PATCH_APPLY_FAILED",
29289
29718
  detail: `target config not found at ${configPath}`
29290
29719
  };
29291
29720
  }
29292
- const liveContent = readFileSync6(configPath, "utf8");
29293
- const scratchDir = mkdtempSync(join8(tmpdir(), "config-propose-edit-"));
29721
+ const liveContent = readFileSync8(configPath, "utf8");
29722
+ const scratchDir = mkdtempSync(join9(tmpdir(), "config-propose-edit-"));
29294
29723
  try {
29295
29724
  const targetBasename = configPath.split("/").pop() ?? "switchroom.yaml";
29296
- const scratchFile = join8(scratchDir, targetBasename);
29297
- writeFileSync3(scratchFile, liveContent);
29725
+ const scratchFile = join9(scratchDir, targetBasename);
29726
+ writeFileSync4(scratchFile, liveContent);
29298
29727
  const normalizedDiff = normalizeDiffHeadersToBasename(unifiedDiff, targetBasename);
29299
- const patchFile = join8(scratchDir, "proposal.patch");
29300
- writeFileSync3(patchFile, normalizedDiff);
29728
+ const patchFile = join9(scratchDir, "proposal.patch");
29729
+ writeFileSync4(patchFile, normalizedDiff);
29301
29730
  const bin = gitBin ?? "git";
29302
29731
  const baseArgs = [
29303
29732
  "apply",
29304
29733
  "--whitespace=nowarn",
29305
- "--recount",
29306
- "--unidiff-zero"
29734
+ "--recount"
29307
29735
  ];
29308
29736
  const checkP1 = spawnSync2(bin, [...baseArgs.slice(0, 1), "--check", ...baseArgs.slice(1), "-p1", patchFile], { cwd: scratchDir, encoding: "utf8", timeout: 1e4 });
29309
29737
  let pStrip = "-p1";
@@ -29327,7 +29755,7 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
29327
29755
  detail: `git apply failed: ${(real.stderr || "").trim().slice(0, 500)}`
29328
29756
  };
29329
29757
  }
29330
- const after = readFileSync6(scratchFile, "utf8");
29758
+ const after = readFileSync8(scratchFile, "utf8");
29331
29759
  return { ok: true, after };
29332
29760
  } finally {
29333
29761
  rmSync2(scratchDir, { recursive: true, force: true });
@@ -29525,7 +29953,7 @@ function validateConfigEdit(opts) {
29525
29953
  return schemaErr;
29526
29954
  let beforeData = {};
29527
29955
  try {
29528
- const beforeRaw = existsSync8(opts.configPath) ? readFileSync6(opts.configPath, "utf8") : "";
29956
+ const beforeRaw = existsSync10(opts.configPath) ? readFileSync8(opts.configPath, "utf8") : "";
29529
29957
  const beforeDoc = import_yaml5.parseDocument(beforeRaw, { merge: false, strict: false });
29530
29958
  beforeData = beforeDoc.toJS();
29531
29959
  } catch {
@@ -29771,19 +30199,21 @@ function phaseLine(s) {
29771
30199
  case "apply":
29772
30200
  return "applying — regenerating compose";
29773
30201
  case "canary-start":
29774
- return `canary — restarting ${s.agent ?? "canary agent"}`;
30202
+ return `canary — restarting ${s.agent ? `\`${s.agent}\`` : "canary agent"}`;
29775
30203
  case "canary-pass":
29776
- return `canary passed (${s.agent ?? "canary"}) — rolling the rest`;
30204
+ return `canary passed (${s.agent ? `\`${s.agent}\`` : "canary"}) — rolling the rest`;
29777
30205
  case "canary-fail":
29778
- return `canary failed (${s.agent ?? "canary"})`;
30206
+ return `canary failed (${s.agent ? `\`${s.agent}\`` : "canary"})`;
29779
30207
  case "agent-start":
29780
- return `agent ${s.n ?? "?"}/${s.m ?? "?"} — restarting ${s.agent ?? ""}`.trim();
30208
+ return `agent ${s.n ?? "?"}/${s.m ?? "?"} — restarting ${s.agent ? `\`${s.agent}\`` : ""}`.trim();
29781
30209
  case "agent-done":
29782
- return `agent ${s.n ?? "?"}/${s.m ?? "?"} — ${s.agent ?? ""} done`.trim();
30210
+ return `agent ${s.n ?? "?"}/${s.m ?? "?"} — ${s.agent ? `\`${s.agent}\`` : ""} done`.trim();
29783
30211
  case "persist-pin":
29784
30212
  return "persisting pin";
30213
+ case "web-refresh":
30214
+ return "refreshing web dashboard (webd install)";
29785
30215
  case "hostd-web-deferred":
29786
- return "hostd/web refresh deferred (run host-side)";
30216
+ return "hostd refresh deferred (run host-side)";
29787
30217
  case "self-bump":
29788
30218
  return "hostd refreshing itself to the target (brief control blip)…";
29789
30219
  case "self-bump-done":
@@ -29792,18 +30222,147 @@ function phaseLine(s) {
29792
30222
  return "starting";
29793
30223
  }
29794
30224
  }
29795
- function renderRolloutStatus(s) {
29796
- const head = `Rollout ${s.target}`;
29797
- const rolledCount = s.rolled?.length ?? 0;
30225
+ function formatDurationMs(ms) {
30226
+ const totalSec = Math.max(0, Math.round(ms / 1000));
30227
+ if (totalSec < 60)
30228
+ return `${totalSec}s`;
30229
+ const totalMin = Math.floor(totalSec / 60);
30230
+ const sec = totalSec % 60;
30231
+ if (totalMin < 60)
30232
+ return sec > 0 ? `${totalMin}m ${sec}s` : `${totalMin}m`;
30233
+ const hr = Math.floor(totalMin / 60);
30234
+ const min = totalMin % 60;
30235
+ return min > 0 ? `${hr}h ${min}m` : `${hr}h`;
30236
+ }
30237
+ function shortRequestId(id) {
30238
+ return id.length > 16 ? id.slice(0, 16) + "…" : id;
30239
+ }
30240
+ function headerLine(s, icon) {
30241
+ const arrow = s.fromVersion ? `\`${s.fromVersion}\` → \`${s.target}\`` : `→ \`${s.target}\``;
30242
+ const req = s.requestId ? ` · req \`${shortRequestId(s.requestId)}\`` : "";
30243
+ return `${icon} **Rollout** ${arrow}${req}`;
30244
+ }
30245
+ function checklistLines(s, compact) {
30246
+ const agents = s.agents ?? [];
30247
+ const lines = [];
30248
+ let doneCount = 0;
30249
+ let pendingNamed = 0;
30250
+ for (const a of agents) {
30251
+ const canary = a.canary ? " (canary)" : "";
30252
+ const dur = a.durationMs !== undefined ? ` — ${formatDurationMs(a.durationMs)}` : "";
30253
+ switch (a.status) {
30254
+ case "done":
30255
+ if (compact)
30256
+ doneCount += 1;
30257
+ else
30258
+ lines.push(`- ✓ \`${a.name}\`${canary}${dur}`);
30259
+ break;
30260
+ case "running":
30261
+ lines.push(`- ⏳ \`${a.name}\`${canary} — restarting…`);
30262
+ break;
30263
+ case "failed":
30264
+ lines.push(`- ✗ \`${a.name}\`${canary} — failed${dur}`);
30265
+ break;
30266
+ default:
30267
+ if (compact)
30268
+ pendingNamed += 1;
30269
+ else
30270
+ lines.push(`- · \`${a.name}\`${canary}`);
30271
+ }
30272
+ }
30273
+ if (doneCount > 0)
30274
+ lines.unshift(`- ✓ ${doneCount} done`);
30275
+ const unseen = s.m !== undefined ? Math.max(0, s.m - agents.length) : 0;
30276
+ const pendingTotal = unseen + pendingNamed;
30277
+ if (pendingTotal > 0 && agents.length > 0) {
30278
+ lines.push(`- · ${pendingTotal} more pending`);
30279
+ }
30280
+ return lines;
30281
+ }
30282
+ function etaLine(s) {
30283
+ if (s.m === undefined)
30284
+ return null;
30285
+ const done = (s.agents ?? []).filter((a) => a.status === "done" && a.durationMs !== undefined);
30286
+ if (done.length === 0)
30287
+ return null;
30288
+ const remaining = s.m - done.length;
30289
+ if (remaining <= 0)
30290
+ return null;
30291
+ const meanMs = done.reduce((t, a) => t + (a.durationMs ?? 0), 0) / done.length;
30292
+ return `~${formatDurationMs(meanMs * remaining)} left (rough est.)`;
30293
+ }
30294
+ function deferredLines(target) {
30295
+ const bare = target.trim().replace(/^v/, "");
30296
+ return [
30297
+ `**Deferred (still on the prior version — run host-side):**`,
30298
+ `- \`switchroom-web\` — \`switchroom webd install --tag ${target}\``,
30299
+ `- host operator CLI — \`sudo npm i -g switchroom@${bare}\``,
30300
+ `- hostd template regen (only if the release changed hostd mounts/env) — \`switchroom hostd install --tag ${target}\``
30301
+ ];
30302
+ }
30303
+ var MAX_RENDER_CHARS = 3800;
30304
+ function rolledList(rolled, compact) {
30305
+ if (rolled.length === 0)
30306
+ return "none";
30307
+ if (compact)
30308
+ return `${rolled.length} agent(s)`;
30309
+ return rolled.map((r) => `\`${r}\``).join(", ");
30310
+ }
30311
+ function renderWith(s, compact) {
30312
+ const rolled = s.rolled ?? [];
30313
+ const rolledCount = rolled.length;
30314
+ const checklist = checklistLines(s, compact);
30315
+ const elapsedMs = s.elapsedMs ?? (s.startedAtMs !== undefined && s.nowMs !== undefined ? s.nowMs - s.startedAtMs : undefined);
29798
30316
  if (s.terminal === "completed") {
29799
- return `✅ ${head} — done. Rolled ${rolledCount} agent(s): ${(s.rolled ?? []).join(", ") || "none"}.`;
30317
+ const parts2 = [headerLine(s, "✅")];
30318
+ let summary = `**Done** — rolled ${rolledCount}${s.m !== undefined ? `/${s.m}` : ""} agent(s)`;
30319
+ if (elapsedMs !== undefined)
30320
+ summary += ` in ${formatDurationMs(elapsedMs)}`;
30321
+ summary += compact ? "." : `: ${rolledList(rolled, compact)}.`;
30322
+ parts2.push(summary);
30323
+ if (checklist.length > 0)
30324
+ parts2.push("", ...checklist);
30325
+ if (s.deferred !== false)
30326
+ parts2.push("", ...deferredLines(s.target));
30327
+ return parts2.join(`
30328
+ `);
29800
30329
  }
29801
30330
  if (s.terminal === "error") {
29802
- const where = s.failedStep ? ` at ${s.failedStep}${s.failedAgent ? ` (${s.failedAgent} → ${s.got ?? "unreachable"})` : ""}` : "";
29803
- return `❌ ${head} — STOPPED${where}. ` + `Rolled before stop: ${(s.rolled ?? []).join(", ") || "none"}.`;
30331
+ const where = s.failedStep ? ` at \`${s.failedStep}\`${s.failedAgent ? ` (\`${s.failedAgent}\` → ${s.got ?? "unreachable"})` : ""}` : "";
30332
+ const parts2 = [headerLine(s, "❌")];
30333
+ let summary = `**STOPPED**${where}`;
30334
+ if (elapsedMs !== undefined)
30335
+ summary += ` after ${formatDurationMs(elapsedMs)}`;
30336
+ summary += `. Rolled before stop: ${rolledList(rolled, compact)}.`;
30337
+ parts2.push(summary);
30338
+ if (checklist.length > 0)
30339
+ parts2.push("", ...checklist);
30340
+ return parts2.join(`
30341
+ `);
29804
30342
  }
29805
- const progress = rolledCount > 0 ? ` · ${rolledCount} rolled` : "";
29806
- return `⏳ ${head} — ${phaseLine(s)}${progress}`;
30343
+ const parts = [headerLine(s, "⏳"), `**Phase:** ${phaseLine(s)}`];
30344
+ if (checklist.length > 0)
30345
+ parts.push("", ...checklist);
30346
+ const footer = [];
30347
+ if (s.m !== undefined)
30348
+ footer.push(`${rolledCount}/${s.m} rolled`);
30349
+ else if (rolledCount > 0)
30350
+ footer.push(`${rolledCount} rolled`);
30351
+ if (elapsedMs !== undefined)
30352
+ footer.push(`elapsed ${formatDurationMs(elapsedMs)}`);
30353
+ const eta = etaLine(s);
30354
+ if (eta)
30355
+ footer.push(eta);
30356
+ if (footer.length > 0)
30357
+ parts.push("", footer.join(" · "));
30358
+ return parts.join(`
30359
+ `);
30360
+ }
30361
+ function renderRolloutStatus(s) {
30362
+ const full = renderWith(s, false);
30363
+ if (full.length <= MAX_RENDER_CHARS)
30364
+ return full;
30365
+ return renderWith(s, true);
29807
30366
  }
29808
30367
 
29809
30368
  // src/scheduler/dispatch.ts
@@ -29843,12 +30402,12 @@ function collectScheduleEntries(config) {
29843
30402
  // src/agent-scheduler/replay.ts
29844
30403
  var STALE_LOOKBACK_MAX_MIN = 14 * 24 * 60;
29845
30404
  function readRecentFires(jsonlPath) {
29846
- const fs2 = __require("node:fs");
29847
- if (!fs2.existsSync(jsonlPath))
30405
+ const fs3 = __require("node:fs");
30406
+ if (!fs3.existsSync(jsonlPath))
29848
30407
  return [];
29849
30408
  let raw;
29850
30409
  try {
29851
- raw = fs2.readFileSync(jsonlPath, "utf8");
30410
+ raw = fs3.readFileSync(jsonlPath, "utf8");
29852
30411
  } catch {
29853
30412
  return [];
29854
30413
  }
@@ -29866,6 +30425,13 @@ function readRecentFires(jsonlPath) {
29866
30425
  }
29867
30426
 
29868
30427
  // src/host-control/server.ts
30428
+ var HOSTD_BROKER_SOCKET_PATH = "/run/switchroom/broker/hostd/sock";
30429
+ var DEFAULT_OPERATOR_ATTEST_VERBS = [
30430
+ "update_apply",
30431
+ "apply",
30432
+ "rollout"
30433
+ ];
30434
+ var ATTEST_AUDIT_METHOD = "passphrase-attest";
29869
30435
  function resolveDigests(imageRefs) {
29870
30436
  const out = new Map;
29871
30437
  for (const ref of imageRefs) {
@@ -29888,11 +30454,11 @@ function resolveDigests(imageRefs) {
29888
30454
  return out;
29889
30455
  }
29890
30456
  function readCachedInstallType(bindRoot) {
29891
- const cacheDir = join9(bindRoot, ".switchroom");
29892
- const cachePath = join9(cacheDir, "install-type.json");
29893
- if (existsSync9(cachePath)) {
30457
+ const cacheDir = join10(bindRoot, ".switchroom");
30458
+ const cachePath = join10(cacheDir, "install-type.json");
30459
+ if (existsSync11(cachePath)) {
29894
30460
  try {
29895
- const raw = readFileSync7(cachePath, "utf-8");
30461
+ const raw = readFileSync9(cachePath, "utf-8");
29896
30462
  const parsed = JSON.parse(raw);
29897
30463
  if (parsed && typeof parsed.install_type === "string" && typeof parsed.detected_at === "string") {
29898
30464
  return parsed;
@@ -29909,10 +30475,10 @@ function readCachedInstallType(bindRoot) {
29909
30475
  source_paths: ctx.source_paths
29910
30476
  };
29911
30477
  try {
29912
- mkdirSync3(cacheDir, { recursive: true });
30478
+ mkdirSync4(cacheDir, { recursive: true });
29913
30479
  const tmp = `${cachePath}.tmp`;
29914
- writeFileSync4(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
29915
- renameSync2(tmp, cachePath);
30480
+ writeFileSync5(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
30481
+ renameSync3(tmp, cachePath);
29916
30482
  } catch {}
29917
30483
  return payload;
29918
30484
  }
@@ -29941,7 +30507,7 @@ function writeFileInPlacePreservingInode(targetPath, content) {
29941
30507
  } finally {
29942
30508
  closeSync3(fd);
29943
30509
  }
29944
- const readBack = readFileSync7(targetPath);
30510
+ const readBack = readFileSync9(targetPath);
29945
30511
  if (readBack.length !== buf.length) {
29946
30512
  throw new Error(`in-place write short: wrote ${buf.length} bytes but read back ${readBack.length}`);
29947
30513
  }
@@ -29966,7 +30532,7 @@ class HostdServer {
29966
30532
  this.opts = opts;
29967
30533
  }
29968
30534
  async start() {
29969
- const hostdDir = join9(this.opts.homeDir, ".switchroom", "hostd");
30535
+ const hostdDir = join10(this.opts.homeDir, ".switchroom", "hostd");
29970
30536
  await mkdir(hostdDir, { recursive: true });
29971
30537
  await chmod(hostdDir, 493).catch(() => {
29972
30538
  return;
@@ -29981,13 +30547,13 @@ class HostdServer {
29981
30547
  }
29982
30548
  try {
29983
30549
  for (const name of agentNames) {
29984
- const dir = join9(hostdDir, name);
29985
- const sockPath = join9(dir, "sock");
30550
+ const dir = join10(hostdDir, name);
30551
+ const sockPath = join10(dir, "sock");
29986
30552
  await mkdir(dir, { recursive: true });
29987
30553
  await chmod(dir, 493).catch(() => {
29988
30554
  return;
29989
30555
  });
29990
- if (existsSync9(sockPath))
30556
+ if (existsSync11(sockPath))
29991
30557
  await unlink(sockPath).catch(() => {
29992
30558
  return;
29993
30559
  });
@@ -30018,13 +30584,13 @@ class HostdServer {
30018
30584
  process.stderr.write(`hostd: SWITCHROOM_HOSTD_OPERATOR_UID='${opUidStr}' is not a positive integer; skipping operator listener
30019
30585
  `);
30020
30586
  } else {
30021
- const dir = join9(hostdDir, "operator");
30022
- const sockPath = join9(dir, "sock");
30587
+ const dir = join10(hostdDir, "operator");
30588
+ const sockPath = join10(dir, "sock");
30023
30589
  await mkdir(dir, { recursive: true });
30024
30590
  await chmod(dir, 493).catch(() => {
30025
30591
  return;
30026
30592
  });
30027
- if (existsSync9(sockPath))
30593
+ if (existsSync11(sockPath))
30028
30594
  await unlink(sockPath).catch(() => {
30029
30595
  return;
30030
30596
  });
@@ -30060,11 +30626,11 @@ class HostdServer {
30060
30626
  }
30061
30627
  async reconcileOrphanedFleetMutations() {
30062
30628
  const path2 = this.auditLogPath();
30063
- if (!existsSync9(path2))
30629
+ if (!existsSync11(path2))
30064
30630
  return;
30065
30631
  let raw;
30066
30632
  try {
30067
- raw = readFileSync7(path2, "utf-8");
30633
+ raw = readFileSync9(path2, "utf-8");
30068
30634
  } catch {
30069
30635
  return;
30070
30636
  }
@@ -30202,6 +30768,15 @@ class HostdServer {
30202
30768
  socket.end();
30203
30769
  return;
30204
30770
  }
30771
+ const attest = await this.checkOperatorAttest(req, caller);
30772
+ if (attest.denied !== null) {
30773
+ const resp2 = deniedResponse(req.request_id, attest.denied);
30774
+ await this.writeAudit({ caller, req, resp: resp2, method: attest.method });
30775
+ socket.write(encodeResponse(resp2));
30776
+ socket.end();
30777
+ return;
30778
+ }
30779
+ const attestMethod = attest.method;
30205
30780
  const started = Date.now();
30206
30781
  let resp;
30207
30782
  try {
@@ -30260,10 +30835,70 @@ class HostdServer {
30260
30835
  resp = err("E_DISPATCH_FAILED", "hostd dispatch failed").why(msg).op(req.op).caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
30261
30836
  resp = { ...resp, error: `hostd dispatch failed: ${msg}` };
30262
30837
  }
30263
- await this.writeAudit({ caller, req, resp });
30838
+ await this.writeAudit({ caller, req, resp, method: attestMethod });
30264
30839
  socket.write(encodeResponse(resp));
30265
30840
  socket.end();
30266
30841
  }
30842
+ async checkOperatorAttest(req, caller) {
30843
+ const hostdCfg = this.opts.config.hostd;
30844
+ if (hostdCfg?.operator_attest_enabled !== true) {
30845
+ return { denied: null };
30846
+ }
30847
+ if (caller.kind === "operator")
30848
+ return { denied: null };
30849
+ const requiredVerbs = hostdCfg.operator_attest_required_verbs ?? DEFAULT_OPERATOR_ATTEST_VERBS;
30850
+ const required = requiredVerbs.includes(req.op);
30851
+ const passphrase = req.operator_passphrase;
30852
+ if (!required) {
30853
+ if (passphrase === undefined)
30854
+ return { denied: null };
30855
+ const v2 = await this.attestVerifier().verify(passphrase);
30856
+ return v2.ok ? { denied: null, method: ATTEST_AUDIT_METHOD } : {
30857
+ denied: `${req.op} operator-attest failed: ${v2.reason}`,
30858
+ method: ATTEST_AUDIT_METHOD
30859
+ };
30860
+ }
30861
+ if (passphrase === undefined) {
30862
+ return {
30863
+ denied: `${req.op} requires operator-passphrase attestation ` + `(hostd.operator_attest_enabled is on and ${req.op} is in ` + `operator_attest_required_verbs)`,
30864
+ method: ATTEST_AUDIT_METHOD
30865
+ };
30866
+ }
30867
+ const v = await this.attestVerifier().verify(passphrase);
30868
+ return v.ok ? { denied: null, method: ATTEST_AUDIT_METHOD } : {
30869
+ denied: `${req.op} operator-attest failed: ${v.reason}`,
30870
+ method: ATTEST_AUDIT_METHOD
30871
+ };
30872
+ }
30873
+ attestVerifier() {
30874
+ if (this.opts.attestVerifier)
30875
+ return this.opts.attestVerifier;
30876
+ if (!this._defaultAttestVerifier) {
30877
+ this._defaultAttestVerifier = {
30878
+ verify: (passphrase) => this.brokerAttestVerify(passphrase)
30879
+ };
30880
+ }
30881
+ return this._defaultAttestVerifier;
30882
+ }
30883
+ _defaultAttestVerifier;
30884
+ async brokerAttestVerify(passphrase) {
30885
+ const res = await rpcRaw({
30886
+ v: 1,
30887
+ op: "list_grants",
30888
+ agent: "hostd",
30889
+ passphrase
30890
+ }, { vaultBrokerSocket: HOSTD_BROKER_SOCKET_PATH, timeoutMs: 5000 });
30891
+ if (res.kind === "unreachable") {
30892
+ return { ok: false, reason: `broker unreachable: ${res.msg}` };
30893
+ }
30894
+ const resp = res.resp;
30895
+ if (resp.ok === true)
30896
+ return { ok: true };
30897
+ return {
30898
+ ok: false,
30899
+ reason: `broker denied attestation (${resp.code ?? "DENIED"})`
30900
+ };
30901
+ }
30267
30902
  checkGate(req, caller) {
30268
30903
  if (caller.kind === "operator")
30269
30904
  return null;
@@ -30394,10 +31029,10 @@ class HostdServer {
30394
31029
  missingApplyAssets() {
30395
31030
  const root = this.opts.applyAssetsRoot ?? resolve8(import.meta.dirname, "../..");
30396
31031
  return [
30397
- join9(root, "profiles"),
30398
- join9(root, "profiles", "default"),
30399
- join9(root, "vendor", "hindsight-memory")
30400
- ].filter((p) => !existsSync9(p));
31032
+ join10(root, "profiles"),
31033
+ join10(root, "profiles", "default"),
31034
+ join10(root, "vendor", "hindsight-memory")
31035
+ ].filter((p) => !existsSync11(p));
30401
31036
  }
30402
31037
  applyAssetPreflight(request_id, started) {
30403
31038
  const missing = this.missingApplyAssets();
@@ -30507,6 +31142,26 @@ class HostdServer {
30507
31142
  const assetDenied = this.applyAssetPreflight(req.request_id, started);
30508
31143
  if (assetDenied)
30509
31144
  return assetDenied;
31145
+ if (req.args.agents !== undefined) {
31146
+ let validAgents = null;
31147
+ try {
31148
+ const cfg = loadConfig(this.opts.configPath);
31149
+ validAgents = Object.keys(cfg.agents ?? {});
31150
+ } catch {
31151
+ validAgents = null;
31152
+ }
31153
+ if (validAgents !== null) {
31154
+ const buildDenied = (human) => err("E_UNKNOWN_AGENT", human).fixBadInput("agents").op("rollout").caller(caller.kind).agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
31155
+ const requested = req.args.agents;
31156
+ if (requested.length === 0) {
31157
+ return buildDenied(`empty agents list — pass at least one agent, ` + `or omit "agents" to roll all. Valid agents: ${validAgents.join(", ")}`);
31158
+ }
31159
+ const unknown = requested.filter((a) => !validAgents.includes(a));
31160
+ if (unknown.length > 0) {
31161
+ return buildDenied(`unknown agent(s): ${unknown.join(", ")}. ` + `Valid agents: ${validAgents.join(", ")}`);
31162
+ }
31163
+ }
31164
+ }
30510
31165
  const entry = this.launchRollout(req.args, req.request_id, caller, started);
30511
31166
  return {
30512
31167
  v: 1,
@@ -30561,19 +31216,19 @@ class HostdServer {
30561
31216
  return entry;
30562
31217
  }
30563
31218
  hostdDirPath() {
30564
- return join9(this.opts.homeDir, ".switchroom", "hostd");
31219
+ return join10(this.opts.homeDir, ".switchroom", "hostd");
30565
31220
  }
30566
31221
  hostdDirHostPath() {
30567
31222
  const hostHome = this.opts.hostHomeDir ?? process.env.SWITCHROOM_HOST_HOME?.trim() ?? this.opts.homeDir;
30568
- return join9(hostHome, ".switchroom", "hostd");
31223
+ return join10(hostHome, ".switchroom", "hostd");
30569
31224
  }
30570
31225
  async beginSelfBump(req, caller, started) {
30571
31226
  const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
30572
- const composePath = join9(this.hostdDirPath(), "docker-compose.yml");
30573
- if (!existsSync9(composePath)) {
31227
+ const composePath = join10(this.hostdDirPath(), "docker-compose.yml");
31228
+ if (!existsSync11(composePath)) {
30574
31229
  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);
30575
31230
  }
30576
- const before = readFileSync7(composePath, "utf8");
31231
+ const before = readFileSync9(composePath, "utf8");
30577
31232
  const bumped = bumpHostdComposeImageTag(before, req.args.pin);
30578
31233
  if (!bumped) {
30579
31234
  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);
@@ -30594,10 +31249,10 @@ class HostdServer {
30594
31249
  try {
30595
31250
  const baks = readdirSync3(this.hostdDirPath()).filter((f) => f.startsWith("docker-compose.yml.bak-selfbump-")).sort();
30596
31251
  for (const f of baks.slice(0, Math.max(0, baks.length - 4))) {
30597
- unlinkSync(join9(this.hostdDirPath(), f));
31252
+ unlinkSync2(join10(this.hostdDirPath(), f));
30598
31253
  }
30599
31254
  } catch {}
30600
- const markerPath = join9(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
31255
+ const markerPath = join10(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30601
31256
  const marker = {
30602
31257
  v: 1,
30603
31258
  request_id: req.request_id,
@@ -30610,8 +31265,8 @@ class HostdServer {
30610
31265
  prior_hostd_version: ownVersion
30611
31266
  };
30612
31267
  copyFileSync2(composePath, bakPath);
30613
- writeFileSync4(composePath, bumped.yaml, "utf8");
30614
- writeFileSync4(markerPath, encodePendingRolloutMarker(marker), {
31268
+ writeFileSync5(composePath, bumped.yaml, "utf8");
31269
+ writeFileSync5(markerPath, encodePendingRolloutMarker(marker), {
30615
31270
  mode: 384
30616
31271
  });
30617
31272
  const entry = {
@@ -30636,7 +31291,7 @@ class HostdServer {
30636
31291
  const rollbackBump = () => {
30637
31292
  try {
30638
31293
  copyFileSync2(bakPath, composePath);
30639
- unlinkSync(markerPath);
31294
+ unlinkSync2(markerPath);
30640
31295
  } catch (e) {
30641
31296
  process.stderr.write(`hostd: self-bump rollback failed (compose/marker may need manual ` + `restore from ${bakPath}): ${e.message}
30642
31297
  `);
@@ -30679,7 +31334,7 @@ class HostdServer {
30679
31334
  rollbackBump();
30680
31335
  entry.result = "error";
30681
31336
  entry.finished_at = Date.now();
30682
- 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.`;
31337
+ 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.`;
30683
31338
  if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
30684
31339
  this.fleetMutationInFlight = null;
30685
31340
  }
@@ -30703,15 +31358,15 @@ class HostdServer {
30703
31358
  };
30704
31359
  }
30705
31360
  async resumePendingSelfBumpRollout() {
30706
- const markerPath = join9(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
30707
- if (!existsSync9(markerPath))
31361
+ const markerPath = join10(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
31362
+ if (!existsSync11(markerPath))
30708
31363
  return;
30709
31364
  let raw;
30710
31365
  try {
30711
- raw = readFileSync7(markerPath, "utf8");
31366
+ raw = readFileSync9(markerPath, "utf8");
30712
31367
  } finally {
30713
31368
  try {
30714
- unlinkSync(markerPath);
31369
+ unlinkSync2(markerPath);
30715
31370
  } catch {}
30716
31371
  }
30717
31372
  const marker = parsePendingRolloutMarker(raw);
@@ -30748,7 +31403,7 @@ class HostdServer {
30748
31403
  return;
30749
31404
  }
30750
31405
  if (needsSelfBump(ownVersion, marker.pin)) {
30751
- 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.`);
31406
+ 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.`);
30752
31407
  return;
30753
31408
  }
30754
31409
  process.stderr.write(`hostd: resuming rollout ${marker.request_id} → ${marker.pin} after ` + `self-bump (v${marker.prior_hostd_version} → v${ownVersion})
@@ -30839,18 +31494,19 @@ class HostdServer {
30839
31494
  if (!verdict.ok) {
30840
31495
  return err(verdict.code, verdict.detail).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
30841
31496
  }
31497
+ let beforeContent;
31498
+ try {
31499
+ beforeContent = readFileSync9(configPath, "utf-8");
31500
+ } catch {
31501
+ beforeContent = "";
31502
+ }
30842
31503
  if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
30843
- let beforeContent;
30844
- try {
30845
- beforeContent = readFileSync7(configPath, "utf-8");
30846
- } catch {
30847
- beforeContent = "";
30848
- }
30849
31504
  const admission = admitSelfScopedNonAdminEdit(beforeContent, verdict.postApplyContent, caller.name);
30850
31505
  if (!admission.ok) {
30851
31506
  return err("E_NOT_SELF_SCOPED", admission.detail).why("non-admin agents may only add rules to their own " + "agents.<self>.tools.allow OR append their own " + "agents.<self>.memory.mental_models via config_propose_edit " + `(mental-model check: ${admission.mentalModelDetail})`).fixBadInput("unified_diff").op("config_propose_edit").caller("agent").agentName(caller.name).asDenied().build(req.request_id, Date.now() - started);
30852
31507
  }
30853
31508
  }
31509
+ const proposedChangedPaths = classifyBlastRadius(beforeContent, verdict.postApplyContent).changedPaths;
30854
31510
  if (!this.opts.approvalGateway) {
30855
31511
  return err("E_NO_APPROVAL_GATEWAY", "validation passed but hostd was started without an approval-gateway wiring; the operator build is missing the telegram-plugin link").fixOperatorAction("infra", [
30856
31512
  "ensure hostd was launched with --approval-gateway / telegram-plugin link"
@@ -30864,23 +31520,42 @@ class HostdServer {
30864
31520
  `);
30865
31521
  return await pending;
30866
31522
  }
30867
- const rate = this.checkConfigEditRate(callerName, Date.now());
30868
- if (!rate.ok) {
30869
- const retryAtIso = new Date(rate.retryAtMs).toISOString();
30870
- process.stderr.write(`hostd: config_propose_edit — RATE-LIMITED ${callerName} ` + `(>${rate.limit}/hour); next slot ${retryAtIso}
31523
+ const run = this.runConfigProposeRateThenApply(req, caller, callerName, configPath, started, proposedChangedPaths);
31524
+ this.inflightConfigProposals.set(dedupeKey, run);
31525
+ try {
31526
+ return await run;
31527
+ } finally {
31528
+ this.inflightConfigProposals.delete(dedupeKey);
31529
+ }
31530
+ }
31531
+ async runConfigProposeRateThenApply(req, caller, callerName, configPath, started, proposedChangedPaths) {
31532
+ let preApproved = false;
31533
+ try {
31534
+ if (this.opts.approvalGateway.checkPreApproved) {
31535
+ preApproved = await this.opts.approvalGateway.checkPreApproved(callerName, req.args.unified_diff);
31536
+ }
31537
+ } catch {
31538
+ preApproved = false;
31539
+ }
31540
+ if (!preApproved) {
31541
+ const rate = this.checkConfigEditRate(callerName, Date.now());
31542
+ if (!rate.ok) {
31543
+ const retryAtIso = new Date(rate.retryAtMs).toISOString();
31544
+ process.stderr.write(`hostd: config_propose_edit — RATE-LIMITED ${callerName} ` + `(>${rate.limit}/hour); next slot ${retryAtIso}
30871
31545
  `);
30872
- this.appendAuditRow({
30873
- ts: new Date().toISOString(),
30874
- op: "config_propose_edit",
30875
- phase: "rate_limited",
30876
- request_id: req.request_id,
30877
- caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
30878
- result: "denied",
30879
- exit_code: null,
30880
- duration_ms: Date.now() - started,
30881
- error: `E_RATE_LIMITED: >${rate.limit} config_propose_edit cards/hour`
30882
- });
30883
- return err("E_RATE_LIMITED", `config_propose_edit rate limit exceeded (max ${rate.limit}/hour for this agent)`).why(`next slot opens at ${retryAtIso}`).fixRetryAfter(retryAtIso).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
31546
+ this.appendAuditRow({
31547
+ ts: new Date().toISOString(),
31548
+ op: "config_propose_edit",
31549
+ phase: "rate_limited",
31550
+ request_id: req.request_id,
31551
+ caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
31552
+ result: "denied",
31553
+ exit_code: null,
31554
+ duration_ms: Date.now() - started,
31555
+ error: `E_RATE_LIMITED: >${rate.limit} config_propose_edit cards/hour`
31556
+ });
31557
+ return err("E_RATE_LIMITED", `config_propose_edit rate limit exceeded (max ${rate.limit}/hour for this agent)`).why(`next slot opens at ${retryAtIso}`).fixRetryAfter(retryAtIso).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
31558
+ }
30884
31559
  }
30885
31560
  this.appendAuditRow({
30886
31561
  ts: new Date().toISOString(),
@@ -30890,15 +31565,10 @@ class HostdServer {
30890
31565
  caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
30891
31566
  result: "started",
30892
31567
  exit_code: null,
30893
- duration_ms: Date.now() - started
31568
+ duration_ms: Date.now() - started,
31569
+ ...preApproved ? { pre_approved: true } : {}
30894
31570
  });
30895
- const run = this.runConfigProposeApprovalAndApply(req, caller, callerName, configPath, verdict.postApplyContent, started);
30896
- this.inflightConfigProposals.set(dedupeKey, run);
30897
- try {
30898
- return await run;
30899
- } finally {
30900
- this.inflightConfigProposals.delete(dedupeKey);
30901
- }
31571
+ return await this.runConfigProposeApprovalAndApply(req, caller, callerName, configPath, started, proposedChangedPaths);
30902
31572
  }
30903
31573
  inflightConfigProposals = new Map;
30904
31574
  configEditPostTimes = new Map;
@@ -30917,7 +31587,7 @@ class HostdServer {
30917
31587
  this.configEditPostTimes.set(callerName, prior);
30918
31588
  return { ok: true };
30919
31589
  }
30920
- async runConfigProposeApprovalAndApply(req, caller, callerName, configPath, postApply, started) {
31590
+ async runConfigProposeApprovalAndApply(req, caller, callerName, configPath, started, proposedChangedPaths) {
30921
31591
  const approvalId = (this.opts.generateApprovalId ?? defaultApprovalId)();
30922
31592
  const approval = await this.opts.approvalGateway.requestApproval({
30923
31593
  requestId: approvalId,
@@ -30946,7 +31616,7 @@ class HostdServer {
30946
31616
  try {
30947
31617
  let snapshot;
30948
31618
  try {
30949
- snapshot = readFileSync7(configPath, "utf-8");
31619
+ snapshot = readFileSync9(configPath, "utf-8");
30950
31620
  } catch (e) {
30951
31621
  await approval.finalize({
30952
31622
  outcome: "reconcile_failed_rolled_back",
@@ -30954,8 +31624,43 @@ class HostdServer {
30954
31624
  });
30955
31625
  return this.reconcileFailedRolledBack(`snapshot read failed: ${e.message}`, req, caller, started);
30956
31626
  }
31627
+ const reverdict = validateConfigEdit({
31628
+ configPath,
31629
+ targetPath: req.args.target_path,
31630
+ unifiedDiff: req.args.unified_diff
31631
+ });
31632
+ if (!reverdict.ok) {
31633
+ const why = `stored diff no longer applies against the current config` + `: ${reverdict.detail}`;
31634
+ await approval.finalize({
31635
+ outcome: "aborted_config_changed",
31636
+ detail: `config changed since proposal — re-propose (${why})`
31637
+ });
31638
+ return this.configChangedSinceProposal(why, req, caller, started);
31639
+ }
31640
+ const postApplyFresh = reverdict.postApplyContent;
31641
+ const applyChangedPaths = classifyBlastRadius(snapshot, postApplyFresh).changedPaths;
31642
+ const sameChangeSet = applyChangedPaths.length === proposedChangedPaths.length && applyChangedPaths.every((p, i) => p === proposedChangedPaths[i]);
31643
+ if (!sameChangeSet) {
31644
+ const why = `re-applied diff changes different config paths than approved ` + `(approved: [${proposedChangedPaths.join(", ")}]; ` + `would change: [${applyChangedPaths.join(", ")}])`;
31645
+ await approval.finalize({
31646
+ outcome: "aborted_config_changed",
31647
+ detail: `config changed since proposal — re-propose (${why})`
31648
+ });
31649
+ return this.configChangedSinceProposal(why, req, caller, started);
31650
+ }
31651
+ if (caller.kind === "agent" && this.opts.config.agents[caller.name]?.admin !== true) {
31652
+ const reAdmission = admitSelfScopedNonAdminEdit(snapshot, postApplyFresh, caller.name);
31653
+ if (!reAdmission.ok) {
31654
+ const why = `re-applied diff is no longer self-scoped for non-admin caller ` + `"${caller.name}": ${reAdmission.detail}`;
31655
+ await approval.finalize({
31656
+ outcome: "aborted_config_changed",
31657
+ detail: `config changed since proposal — re-propose (${why})`
31658
+ });
31659
+ return this.configChangedSinceProposal(why, req, caller, started);
31660
+ }
31661
+ }
30957
31662
  try {
30958
- writeFileInPlacePreservingInode(configPath, postApply);
31663
+ writeFileInPlacePreservingInode(configPath, postApplyFresh);
30959
31664
  } catch (e) {
30960
31665
  try {
30961
31666
  writeFileInPlacePreservingInode(configPath, snapshot);
@@ -30969,7 +31674,7 @@ class HostdServer {
30969
31674
  const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(caller.kind === "agent" ? ["apply", "--only", callerName, "--non-interactive"] : ["apply", "--non-interactive"]));
30970
31675
  const recRes = await runner({ requestId: approvalId });
30971
31676
  if (recRes.exit_code === 0) {
30972
- const blast = classifyBlastRadius(snapshot, postApply);
31677
+ const blast = classifyBlastRadius(snapshot, postApplyFresh);
30973
31678
  await approval.finalize({
30974
31679
  outcome: "applied",
30975
31680
  affectedAgents: blast.agents,
@@ -31007,6 +31712,11 @@ class HostdServer {
31007
31712
  release();
31008
31713
  }
31009
31714
  }
31715
+ configChangedSinceProposal(why, req, caller, started) {
31716
+ const legacy = `E_CONFIG_CHANGED: config changed since proposal — re-propose (${why})`;
31717
+ const built = err("E_CONFIG_CHANGED", "config changed since the proposal was validated; re-propose against the current config").why(why).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
31718
+ return { ...built, error: legacy };
31719
+ }
31010
31720
  reconcileFailedRolledBack(detail, req, caller, started, output) {
31011
31721
  const legacy = `E_RECONCILE_FAILED_ROLLED_BACK: ${detail}`;
31012
31722
  const built = err("E_RECONCILE_FAILED_ROLLED_BACK", "config write or reconcile failed; live file rolled back to snapshot").why(detail).fixOperatorAction("infra", [
@@ -31198,8 +31908,8 @@ ${output.recovery.stderr}` : "";
31198
31908
  if (this.opts.imageRefsForDigests)
31199
31909
  return this.opts.imageRefsForDigests();
31200
31910
  try {
31201
- const composePath = join9(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
31202
- if (!existsSync9(composePath))
31911
+ const composePath = join10(this.opts.bindRoot ?? this.opts.homeDir, ".switchroom", "compose", "docker-compose.yml");
31912
+ if (!existsSync11(composePath))
31203
31913
  return [];
31204
31914
  const r = spawnSync3("docker", [
31205
31915
  "compose",
@@ -31307,6 +32017,9 @@ ${output.recovery.stderr}` : "";
31307
32017
  target: entry.pin ?? "",
31308
32018
  rolled: entry.rolled,
31309
32019
  terminal: entry.result === "completed" ? "completed" : "error",
32020
+ requestId: entry.request_id,
32021
+ ...entry.prior_pin ? { fromVersion: entry.prior_pin } : {},
32022
+ ...entry.finished_at !== null ? { elapsedMs: entry.finished_at - entry.started_at } : {},
31310
32023
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
31311
32024
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
31312
32025
  ...entry.got !== undefined ? { got: entry.got } : {}
@@ -31335,10 +32048,10 @@ ${output.recovery.stderr}` : "";
31335
32048
  }
31336
32049
  rolloutRowExistsInLog(targetRequestId) {
31337
32050
  const path2 = this.auditLogPath();
31338
- if (!existsSync9(path2))
32051
+ if (!existsSync11(path2))
31339
32052
  return false;
31340
32053
  try {
31341
- const raw = readFileSync7(path2, "utf-8");
32054
+ const raw = readFileSync9(path2, "utf-8");
31342
32055
  return latestRolloutRowForRequest(raw, targetRequestId) !== null;
31343
32056
  } catch {
31344
32057
  return false;
@@ -31346,11 +32059,11 @@ ${output.recovery.stderr}` : "";
31346
32059
  }
31347
32060
  rolloutStatusFromLog(responseRequestId, targetRequestId, started) {
31348
32061
  const path2 = this.auditLogPath();
31349
- if (!existsSync9(path2))
32062
+ if (!existsSync11(path2))
31350
32063
  return null;
31351
32064
  let raw;
31352
32065
  try {
31353
- raw = readFileSync7(path2, "utf-8");
32066
+ raw = readFileSync9(path2, "utf-8");
31354
32067
  } catch {
31355
32068
  return null;
31356
32069
  }
@@ -31429,7 +32142,7 @@ ${output.recovery.stderr}` : "";
31429
32142
  }
31430
32143
  }
31431
32144
  auditLogPath() {
31432
- return this.opts.auditLogPath ?? join9(this.opts.homeDir, ".switchroom", "host-control-audit.log");
32145
+ return this.opts.auditLogPath ?? join10(this.opts.homeDir, ".switchroom", "host-control-audit.log");
31433
32146
  }
31434
32147
  appendAuditRow(row) {
31435
32148
  const path2 = this.auditLogPath();
@@ -31455,6 +32168,7 @@ ${output.recovery.stderr}` : "";
31455
32168
  await this.appendAuditRow({
31456
32169
  ts: new Date().toISOString(),
31457
32170
  op: args.req.op,
32171
+ ...args.method ? { method: args.method } : {},
31458
32172
  caller: args.caller.kind === "agent" ? { kind: "agent", name: args.caller.name } : { kind: "operator" },
31459
32173
  request_id: args.req.request_id,
31460
32174
  result: args.resp.result,
@@ -31682,13 +32396,84 @@ function isSafeExecArgvElement(s) {
31682
32396
  }
31683
32397
 
31684
32398
  // src/host-control/approval-gateway.ts
31685
- import { connect } from "node:net";
32399
+ import { connect as connect2 } from "node:net";
32400
+ import { randomBytes as randomBytes2 } from "node:crypto";
32401
+ var PRE_APPROVED_QUERY_TIMEOUT_MS = 2000;
31686
32402
 
31687
32403
  class SocketApprovalGateway {
31688
32404
  opts;
31689
32405
  constructor(opts) {
31690
32406
  this.opts = opts;
31691
32407
  }
32408
+ async checkPreApproved(agentName, unifiedDiff) {
32409
+ const sockPath = this.opts.resolveGatewaySocket(agentName);
32410
+ if (sockPath === null)
32411
+ return false;
32412
+ const log = this.opts.log ?? (() => {});
32413
+ const correlationId = `preapp-${randomBytes2(8).toString("hex")}`;
32414
+ return await new Promise((resolve9) => {
32415
+ let settled = false;
32416
+ const client2 = connect2({ path: sockPath });
32417
+ let buffer = "";
32418
+ const done = (result) => {
32419
+ if (settled)
32420
+ return;
32421
+ settled = true;
32422
+ clearTimeout(timer);
32423
+ try {
32424
+ client2.destroy();
32425
+ } catch {}
32426
+ resolve9(result);
32427
+ };
32428
+ const timer = setTimeout(() => {
32429
+ log(`checkPreApproved timed out after ${PRE_APPROVED_QUERY_TIMEOUT_MS}ms (agent=${agentName}) — failing closed`);
32430
+ done(false);
32431
+ }, PRE_APPROVED_QUERY_TIMEOUT_MS);
32432
+ client2.on("connect", () => {
32433
+ try {
32434
+ client2.write(JSON.stringify({
32435
+ type: "check_pre_approved",
32436
+ agentName,
32437
+ correlationId,
32438
+ unifiedDiff
32439
+ }) + `
32440
+ `);
32441
+ } catch (err2) {
32442
+ log(`check_pre_approved write failed (agent=${agentName}): ${err2.message} — failing closed`);
32443
+ done(false);
32444
+ }
32445
+ });
32446
+ client2.on("data", (chunk2) => {
32447
+ buffer += chunk2.toString("utf8");
32448
+ const lines = buffer.split(`
32449
+ `);
32450
+ buffer = lines.pop() ?? "";
32451
+ for (const line of lines) {
32452
+ if (!line.trim())
32453
+ continue;
32454
+ let parsed;
32455
+ try {
32456
+ parsed = JSON.parse(line);
32457
+ } catch {
32458
+ done(false);
32459
+ return;
32460
+ }
32461
+ const obj = parsed;
32462
+ if (obj.type === "pre_approved_result" && obj.correlationId === correlationId) {
32463
+ done(obj.preApproved === true);
32464
+ return;
32465
+ }
32466
+ done(false);
32467
+ return;
32468
+ }
32469
+ });
32470
+ client2.on("error", (err2) => {
32471
+ log(`checkPreApproved socket error (agent=${agentName}): ${err2.message} — failing closed`);
32472
+ done(false);
32473
+ });
32474
+ client2.on("close", () => done(false));
32475
+ });
32476
+ }
31692
32477
  async requestApproval(req) {
31693
32478
  const sockPath = this.opts.resolveGatewaySocket(req.agentName);
31694
32479
  if (sockPath === null) {
@@ -31700,7 +32485,7 @@ class SocketApprovalGateway {
31700
32485
  };
31701
32486
  }
31702
32487
  return await new Promise((resolve9) => {
31703
- const client2 = connect({ path: sockPath });
32488
+ const client2 = connect2({ path: sockPath });
31704
32489
  let buffer = "";
31705
32490
  let resolved = false;
31706
32491
  const log = this.opts.log ?? (() => {});
@@ -31804,7 +32589,7 @@ class SocketApprovalGateway {
31804
32589
  }
31805
32590
 
31806
32591
  // src/host-control/rollout-relay.ts
31807
- import { connect as connect2 } from "node:net";
32592
+ import { connect as connect3 } from "node:net";
31808
32593
 
31809
32594
  class SocketRolloutRelay {
31810
32595
  opts;
@@ -31825,7 +32610,7 @@ class SocketRolloutRelay {
31825
32610
  return;
31826
32611
  }
31827
32612
  try {
31828
- const client2 = connect2({ path: sockPath });
32613
+ const client2 = connect3({ path: sockPath });
31829
32614
  client2.setTimeout(5000);
31830
32615
  const done = () => {
31831
32616
  try {
@@ -31863,7 +32648,7 @@ class SocketRolloutRelay {
31863
32648
  }
31864
32649
 
31865
32650
  // src/host-control/rollout-narration-relay.ts
31866
- import { connect as connect3 } from "node:net";
32651
+ import { connect as connect4 } from "node:net";
31867
32652
  var DEFAULT_POST_REPLY_TIMEOUT_MS = 5000;
31868
32653
 
31869
32654
  class SocketRolloutNarrationRelay {
@@ -31897,7 +32682,7 @@ class SocketRolloutNarrationRelay {
31897
32682
  };
31898
32683
  let client2;
31899
32684
  try {
31900
- client2 = connect3({ path: sockPath });
32685
+ client2 = connect4({ path: sockPath });
31901
32686
  } catch (e) {
31902
32687
  log(`narration post connect threw: ${e.message}`);
31903
32688
  resolve9(null);
@@ -31967,7 +32752,7 @@ class SocketRolloutNarrationRelay {
31967
32752
  if (sockPath === null)
31968
32753
  return;
31969
32754
  try {
31970
- const client2 = connect3({ path: sockPath });
32755
+ const client2 = connect4({ path: sockPath });
31971
32756
  client2.setTimeout(5000);
31972
32757
  const done = () => {
31973
32758
  try {
@@ -32014,6 +32799,47 @@ class LogTailRolloutNarrator {
32014
32799
  this.relay = relay;
32015
32800
  this.opts = opts;
32016
32801
  }
32802
+ now() {
32803
+ return this.opts.now?.() ?? Date.now();
32804
+ }
32805
+ static upsertAgent(st, name, patch) {
32806
+ const existing = st.agents.find((a) => a.name === name);
32807
+ if (existing)
32808
+ Object.assign(existing, patch);
32809
+ else
32810
+ st.agents.push({ name, status: "pending", ...patch });
32811
+ }
32812
+ trackAgentProgress(st, phase) {
32813
+ const now = this.now();
32814
+ switch (phase.phase) {
32815
+ case "canary-start":
32816
+ case "agent-start":
32817
+ if (phase.agent) {
32818
+ LogTailRolloutNarrator.upsertAgent(st, phase.agent, {
32819
+ status: "running",
32820
+ startedAtMs: now,
32821
+ ...phase.phase === "canary-start" ? { canary: true } : {}
32822
+ });
32823
+ }
32824
+ break;
32825
+ case "canary-pass":
32826
+ case "agent-done":
32827
+ case "canary-fail": {
32828
+ if (!phase.agent)
32829
+ break;
32830
+ const a = st.agents.find((x) => x.name === phase.agent);
32831
+ const durationMs = a?.startedAtMs !== undefined ? now - a.startedAtMs : undefined;
32832
+ LogTailRolloutNarrator.upsertAgent(st, phase.agent, {
32833
+ status: phase.phase === "canary-fail" ? "failed" : "done",
32834
+ ...durationMs !== undefined ? { durationMs } : {},
32835
+ ...phase.phase !== "agent-done" ? { canary: true } : {}
32836
+ });
32837
+ break;
32838
+ }
32839
+ default:
32840
+ break;
32841
+ }
32842
+ }
32017
32843
  static seqFor(phase) {
32018
32844
  switch (phase.phase) {
32019
32845
  case "self-bump":
@@ -32032,6 +32858,8 @@ class LogTailRolloutNarrator {
32032
32858
  return 2 * (phase.n ?? 1) + 0;
32033
32859
  case "agent-done":
32034
32860
  return 2 * (phase.n ?? 1) + 1;
32861
+ case "web-refresh":
32862
+ return Number.MAX_SAFE_INTEGER - 2;
32035
32863
  case "hostd-web-deferred":
32036
32864
  return Number.MAX_SAFE_INTEGER - 1;
32037
32865
  default:
@@ -32047,6 +32875,7 @@ class LogTailRolloutNarrator {
32047
32875
  if (seq < st.lastAppliedSeq)
32048
32876
  return;
32049
32877
  st.lastAppliedSeq = seq;
32878
+ this.trackAgentProgress(st, phase);
32050
32879
  st.render = {
32051
32880
  ...st.render,
32052
32881
  target: phase.target,
@@ -32054,7 +32883,11 @@ class LogTailRolloutNarrator {
32054
32883
  ...phase.n !== undefined ? { n: phase.n } : {},
32055
32884
  ...phase.m !== undefined ? { m: phase.m } : {},
32056
32885
  ...phase.agent !== undefined ? { agent: phase.agent } : {},
32057
- rolled: entry.rolled
32886
+ rolled: entry.rolled,
32887
+ agents: st.agents,
32888
+ requestId: entry.request_id,
32889
+ ...entry.prior_pin ? { fromVersion: entry.prior_pin } : {},
32890
+ startedAtMs: entry.started_at
32058
32891
  };
32059
32892
  this.scheduleRenderOrPost(entry.request_id);
32060
32893
  } catch (e) {
@@ -32064,13 +32897,29 @@ class LogTailRolloutNarrator {
32064
32897
  onTerminal(entry) {
32065
32898
  try {
32066
32899
  const st = this.ensureState(entry);
32900
+ const rolled = new Set(entry.rolled ?? []);
32901
+ for (const a of st.agents) {
32902
+ if (entry.failed_agent && a.name === entry.failed_agent) {
32903
+ a.status = "failed";
32904
+ } else if (rolled.has(a.name)) {
32905
+ a.status = "done";
32906
+ } else if (a.status === "running") {
32907
+ a.status = "pending";
32908
+ }
32909
+ }
32067
32910
  st.render = {
32068
32911
  target: entry.pin ?? st.render.target,
32069
32912
  rolled: entry.rolled ?? st.render.rolled,
32070
32913
  terminal: entry.result === "completed" ? "completed" : "error",
32071
32914
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
32072
32915
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
32073
- ...entry.got !== undefined ? { got: entry.got } : {}
32916
+ ...entry.got !== undefined ? { got: entry.got } : {},
32917
+ ...st.agents.length > 0 ? { agents: st.agents } : {},
32918
+ ...st.render.m !== undefined ? { m: st.render.m } : {},
32919
+ requestId: entry.request_id,
32920
+ ...entry.prior_pin ?? st.render.fromVersion ? { fromVersion: entry.prior_pin ?? st.render.fromVersion } : {},
32921
+ elapsedMs: (entry.finished_at ?? this.now()) - entry.started_at,
32922
+ deferred: false
32074
32923
  };
32075
32924
  st.frozen = true;
32076
32925
  if (st.timer) {
@@ -32095,6 +32944,7 @@ class LogTailRolloutNarrator {
32095
32944
  postAttempts: 0,
32096
32945
  postFailed: false,
32097
32946
  render: { target: entry.pin ?? "" },
32947
+ agents: [],
32098
32948
  frozen: false,
32099
32949
  timer: null,
32100
32950
  pendingEditAfterPost: false
@@ -32133,7 +32983,7 @@ class LogTailRolloutNarrator {
32133
32983
  return false;
32134
32984
  st.posting = true;
32135
32985
  st.postAttempts += 1;
32136
- const text = renderRolloutStatus(st.render);
32986
+ const text = renderRolloutStatus({ ...st.render, nowMs: this.now() });
32137
32987
  this.relay.post({ requestId, agentName: st.agentName, text }).then((mid) => {
32138
32988
  st.posting = false;
32139
32989
  if (mid === null) {
@@ -32185,7 +33035,7 @@ class LogTailRolloutNarrator {
32185
33035
  this.tryPost(requestId, true);
32186
33036
  return;
32187
33037
  }
32188
- const text = renderRolloutStatus(st.render);
33038
+ const text = renderRolloutStatus({ ...st.render, nowMs: this.now() });
32189
33039
  this.relay.edit({
32190
33040
  requestId,
32191
33041
  agentName: st.agentName,
@@ -32443,8 +33293,60 @@ function makeRestart(switchroomBin) {
32443
33293
 
32444
33294
  // src/host-control/main.ts
32445
33295
  import { appendFile as appendFile2 } from "node:fs/promises";
33296
+ async function loadConfigResilient() {
33297
+ try {
33298
+ const config = loadConfig();
33299
+ clearStaleDegradedMarker(homedir6(), (m) => process.stderr.write(`hostd: ${m}
33300
+ `));
33301
+ return config;
33302
+ } catch (err2) {
33303
+ if (!(err2 instanceof ConfigError))
33304
+ throw err2;
33305
+ let configPath;
33306
+ try {
33307
+ configPath = findConfigFile();
33308
+ } catch {
33309
+ configPath = undefined;
33310
+ }
33311
+ const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join11(homedir6(), ".switchroom", "agents");
33312
+ const listGatewayCandidates = () => {
33313
+ const out = [];
33314
+ try {
33315
+ for (const name of readdirSync4(agentsDir).sort()) {
33316
+ const sock = resolve9(agentsDir, name, "telegram", "gateway.sock");
33317
+ if (existsSync12(sock))
33318
+ out.push({ agent: name, sock });
33319
+ }
33320
+ } catch {}
33321
+ return out;
33322
+ };
33323
+ const log = (m) => {
33324
+ process.stderr.write(`hostd: ${m}
33325
+ `);
33326
+ };
33327
+ const notify = (text) => {
33328
+ const candidates = listGatewayCandidates();
33329
+ if (candidates.length === 0) {
33330
+ log("config-degraded: no gateway socket paths found; relying on marker file");
33331
+ return;
33332
+ }
33333
+ postOperatorNoticeViaGateways(candidates, text, log).then((agent) => {
33334
+ if (agent !== null)
33335
+ log(`config-degraded: operator DM relayed via ${agent}`);
33336
+ });
33337
+ };
33338
+ return waitForConfigRecovery({
33339
+ initialError: err2,
33340
+ homeDir: homedir6(),
33341
+ loadFn: () => loadConfig(),
33342
+ configPath,
33343
+ notify,
33344
+ log
33345
+ });
33346
+ }
33347
+ }
32446
33348
  async function main() {
32447
- const config = loadConfig();
33349
+ const config = await loadConfigResilient();
32448
33350
  if (config.host_control?.enabled !== true) {
32449
33351
  process.stderr.write(`hostd: refusing to start — host_control.enabled is not true in switchroom.yaml
32450
33352
  `);
@@ -32459,10 +33361,10 @@ async function main() {
32459
33361
  `);
32460
33362
  process.exit(2);
32461
33363
  }
32462
- const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join10(homedir6(), ".switchroom", "agents");
33364
+ const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join11(homedir6(), ".switchroom", "agents");
32463
33365
  const resolveGatewaySocket = (agentName) => {
32464
33366
  const sock = resolve9(agentsDir, agentName, "telegram", "gateway.sock");
32465
- return existsSync10(sock) ? sock : null;
33367
+ return existsSync12(sock) ? sock : null;
32466
33368
  };
32467
33369
  const approvalGateway = new SocketApprovalGateway({
32468
33370
  resolveGatewaySocket,
@@ -32490,7 +33392,11 @@ async function main() {
32490
33392
  ])),
32491
33393
  ...config.hostd ? {
32492
33394
  hostd: {
32493
- ...config.hostd.config_edit_enabled !== undefined ? { config_edit_enabled: config.hostd.config_edit_enabled } : {}
33395
+ ...config.hostd.config_edit_enabled !== undefined ? { config_edit_enabled: config.hostd.config_edit_enabled } : {},
33396
+ ...config.hostd.operator_attest_enabled !== undefined ? { operator_attest_enabled: config.hostd.operator_attest_enabled } : {},
33397
+ ...config.hostd.operator_attest_required_verbs !== undefined ? {
33398
+ operator_attest_required_verbs: config.hostd.operator_attest_required_verbs
33399
+ } : {}
32494
33400
  }
32495
33401
  } : {}
32496
33402
  },
@@ -32505,7 +33411,7 @@ async function main() {
32505
33411
  let releaseWatcher = null;
32506
33412
  const autoRel = config.host_control?.auto_release_check;
32507
33413
  if (autoRel?.enabled === true) {
32508
- const eventsLog = join10(homedir6(), ".switchroom", "release-watcher-events.jsonl");
33414
+ const eventsLog = join11(homedir6(), ".switchroom", "release-watcher-events.jsonl");
32509
33415
  releaseWatcher = new ReleaseWatcher({
32510
33416
  intervalMs: autoRel.interval_minutes * 60000,
32511
33417
  checkFn: makeReleaseCheck({