switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -15921,19 +15921,30 @@ var TelegramChannelSchema = exports_external.object({
15921
15921
  stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
15922
15922
  stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive in-place stream edits " + "during a turn. Lower = more responsive stream, higher = fewer API " + "calls. Floored at 250 by draft-stream itself. Default 400 ms for DMs " + "and 1000 ms for groups/forums (respects Telegram's ~1 edit/sec/message " + "practical ceiling). Override per-agent if a particular agent needs " + "snappier or quieter streaming."),
15923
15923
  clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
15924
+ pin_status_while_working: exports_external.boolean().optional().describe("When true (default), the framework SILENTLY pins the already-" + "rendered status message while its work is in-flight and auto-unpins " + "it on completion — the per-turn activity/status message (foreground) " + "and the '\uD83D\uDEE0 Worker' background-worker message. Keeps in-flight work " + "in view when the conversation scrolls past it (fast turns, stacked " + "background workers, long turns). No new surface is rendered; it pins " + "a message the chat already owns. The pin never buzzes the device. " + "The ONE sanctioned pin under chat-is-the-single-source-of-truth. " + "Set false to disable. Per-agent override; cascades defaults → " + "profile → agent (per-key)."),
15924
15925
  hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
15925
15926
  inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes — suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
15926
15927
  orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
15927
15928
  cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
15928
15929
  deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
15930
+ approval_timeout_minutes: exports_external.number().int().nonnegative().optional().describe("Operator approval-card lifetime (minutes) for the tool-use 'Allow " + "once' card and the vault grant decision wait. After this long with " + "no operator tap, the card auto-denies (a TIMEOUT, not a denial — the " + "agent is told not to retry). Default 60. hostd-gated verbs " + "(mcp__hostd__*) keep their own longer window; the hostd " + "config-propose card is not governed by this key."),
15929
15931
  sub_agent_tick_interval_ms: exports_external.number().int().nonnegative().optional().describe("Heartbeat tick interval (ms) for sub-agent rendering. Forces a " + "re-render of the elapsed-time counter while sub-agents are running, " + "even during silent stretches between tool calls. Default 10000 (10 s). " + "Set to 0 to disable the elapsed-ticker path."),
15930
15932
  edit_budget_threshold: exports_external.number().int().nonnegative().optional().describe("Telegram API edit budget per minute before the progress-card driver " + "falls back to a slower coalesce window. When a chat accumulates more " + "than this many card edits in the trailing 60 s, the driver switches " + "to a wider coalesce interval until the rate drops back. Default 18. " + "Increase if your gateway frequently bumps the Telegram edit-rate ceiling " + "with many parallel sub-agents; decrease for a more conservative buffer."),
15931
15933
  stickers: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Sticker aliases for the `send_sticker` MCP tool (#576). Maps a " + "short alias name (e.g. 'happy', 'thinking') to a Telegram file_id. " + "Operator-curated — capture file_ids from inbound stickers the user " + "sends and add them here. The agent calls send_sticker(chat_id, " + "alias='happy') and the gateway resolves to the file_id at send " + "time. Aliases enable persona-flavored expressiveness without " + "exposing raw file_ids in the agent prompt. Personal-assistant / " + "health-coach personas benefit; coding agents typically don't " + "configure any."),
15932
15934
  voice_in: exports_external.object({
15933
15935
  enabled: exports_external.boolean().optional().describe("Master switch for voice-message transcription."),
15934
15936
  provider: exports_external.enum(["openai"]).optional().describe("Transcription provider. Only 'openai' (Whisper API) supported in the spike (#578); " + "Groq/Deepgram/local-whisper-cli are follow-up choices."),
15935
- language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection.")
15936
- }).optional().describe("Inbound voice-message transcription (#578). When enabled, voice/audio " + "messages from allowlisted users are downloaded, transcribed via the " + "configured provider, and surface to the agent as the user's text. " + "API key read from ~/.switchroom/openai-api-key (mode 0600). Off by " + "default opt-in per agent. Cascades from defaults.channels.telegram.voice_in. " + "(Migrated from per-agent root in #596 — see consistency unification.)"),
15937
+ language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection."),
15938
+ api_key: exports_external.string().optional().describe("Transcription-provider API key, as a `vault:<key>` reference " + "(e.g. 'vault:openai/api-key' the default if omitted). The " + "gateway resolves it through the vault broker at use-time and " + "never writes the resolved value to disk or the agent prompt. " + "`switchroom enable voice-in` vault-stores the key and writes " + "this reference for you. A literal key is accepted but " + "discouraged keep secrets in the vault.")
15939
+ }).optional().describe("Inbound voice-message transcription (#578). When enabled, voice/audio " + "messages from allowlisted users are downloaded, transcribed via the " + "configured provider, and surface to the agent as the user's text. " + "The provider API key is a `vault:` reference (`api_key`, default " + "`vault:openai/api-key`) resolved through the vault broker at " + "use-time — opt-in third-party key, honest-exception per the " + "subscription-honest outcome. Off by default — opt-in per agent. " + "Cascades from defaults.channels.telegram.voice_in. " + "(Migrated from per-agent root in #596 — see consistency unification.)"),
15940
+ voice_out: exports_external.object({
15941
+ enabled: exports_external.boolean().optional().describe("Master switch for spoken-reply (TTS) voice notes."),
15942
+ engine: exports_external.enum(["kokoro", "openai"]).default("kokoro").describe("Synthesis engine. 'kokoro' = local voice sidecar (POST /tts, " + "subscription-honest, no third-party key); 'openai' = OpenAI TTS " + "cloud (honest-exception, requires an `api_key` vault ref). " + "'kokoro' is only active when the host voice verdict is local " + "(SWITCHROOM_VOICE_ENGINE === 'local'); otherwise voice-out is a " + "no-op and replies stay text-only."),
15943
+ voice: exports_external.string().optional().describe("Engine-specific voice id (e.g. a Kokoro voice name or an OpenAI " + "voice like 'alloy'). Optional — the sidecar/engine has its own " + "default when omitted."),
15944
+ reply_mode: exports_external.enum(["voice+text", "voice-only", "on-demand"]).default("voice+text").describe("How the spoken reply accompanies the text. 'voice+text' sends " + "both the normal text reply and a voice note; 'voice-only' sends " + "the voice note and suppresses the text body. 'on-demand' sends " + "the text reply with a single '\uD83D\uDD0A Listen' inline button and " + "synthesizes NO audio until the user taps it — zero GPU/sidecar " + "work unless requested, which keeps the voice pipeline " + "subscription-honest and visible (nothing is generated behind the " + "user's back). The Listen button is injected ONLY when the reply " + "carries no agent-authored buttons, to avoid colliding with the " + "single_use keyboard-strip that protects agent buttons from " + "double-fire. In every mode the text reply is ALWAYS still sent " + "when synthesis fails or the reply exceeds max_chars — the user's " + "answer is never dropped silently."),
15945
+ max_chars: exports_external.number().int().positive().optional().describe("Per-voice-note chunk size (chars). Default 600; clamped to the " + "engine's hard cap (1200). A reply LONGER than this is NOT truncated " + "to text — it's split on sentence/paragraph boundaries and spoken " + "across several sequential voice notes, so the full answer is heard " + "even when the user can't read the screen (driving / cycling). Lower " + "it for snappier individual notes; raise it (up to 1200) for fewer, " + "longer notes."),
15946
+ api_key: exports_external.string().optional().describe("OpenAI TTS API key as a `vault:<key>` reference (only used when " + "engine='openai'; default 'vault:openai/api-key'). Resolved through " + "the vault broker at use-time and never written to disk or the agent " + "prompt. Ignored for engine='kokoro' (the sidecar needs no key).")
15947
+ }).optional().describe("Outbound spoken replies via TTS (PR-C2). When enabled, the gateway " + "synthesizes the agent's text reply into an OGG/Opus voice note and " + "sends it alongside (or instead of) the text, per reply_mode. The " + "'kokoro' engine uses the local voice sidecar (POST /tts) and is only " + "active when the host voice verdict is local; the 'openai' engine is " + "an honest-exception cloud path gated on an `api_key` vault ref. Voice " + "is best-effort and fully non-fatal — any TTS error falls back to the " + "text reply. Off by default — opt-in per agent. " + "Cascades from defaults.channels.telegram.voice_out."),
15937
15948
  telegraph: exports_external.object({
15938
15949
  enabled: exports_external.boolean().optional().describe("Master switch for Telegraph Instant View publishing."),
15939
15950
  threshold: exports_external.number().int().positive().optional().describe("Char count above which a reply is published to Telegraph instead of " + "HTML-chunked into multiple Telegram messages. Default 3000 (≈3 chunks)."),
@@ -22593,7 +22604,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22593
22604
  import { dirname as dirname4, join as join2 } from "node:path";
22594
22605
 
22595
22606
  // src/build-info.ts
22596
- var VERSION = "0.16.28";
22607
+ var VERSION = "0.16.38";
22597
22608
 
22598
22609
  // src/cli/resolve-version.ts
22599
22610
  function readPackageVersion() {
@@ -22696,15 +22707,77 @@ function parseAuditLine(line) {
22696
22707
  entry.failed_step = o.failed_step;
22697
22708
  if (typeof o.failed_agent === "string")
22698
22709
  entry.failed_agent = o.failed_agent;
22710
+ if (typeof o.agent === "string")
22711
+ entry.agent = o.agent;
22712
+ if (typeof o.n === "number")
22713
+ entry.n = o.n;
22714
+ if (typeof o.m === "number")
22715
+ entry.m = o.m;
22699
22716
  if (typeof o.prior_pin === "string")
22700
22717
  entry.prior_pin = o.prior_pin;
22701
22718
  return entry;
22702
22719
  }
22720
+ function latestRolloutRowForRequest(raw, requestId) {
22721
+ const ROLLOUT_OPS = new Set(["rollout", "rollout_orphaned"]);
22722
+ let latest = null;
22723
+ for (const line of raw.split(`
22724
+ `)) {
22725
+ const e = parseAuditLine(line);
22726
+ if (e === null)
22727
+ continue;
22728
+ if (e.request_id !== requestId)
22729
+ continue;
22730
+ if (!ROLLOUT_OPS.has(e.op))
22731
+ continue;
22732
+ latest = e;
22733
+ }
22734
+ return latest;
22735
+ }
22703
22736
 
22704
22737
  // src/cli/rollout.ts
22705
22738
  function isVersionAssertable(target) {
22706
22739
  return /^v?\d+\.\d+\.\d+$/.test(target.trim());
22707
22740
  }
22741
+ var ROLLOUT_PHASE_SENTINEL = "SWITCHROOM_ROLLOUT_PHASE:";
22742
+ function parseRolloutPhaseLine(line) {
22743
+ const trimmed = line.trim();
22744
+ if (!trimmed.startsWith(ROLLOUT_PHASE_SENTINEL))
22745
+ return null;
22746
+ let obj;
22747
+ try {
22748
+ obj = JSON.parse(trimmed.slice(ROLLOUT_PHASE_SENTINEL.length));
22749
+ } catch {
22750
+ return null;
22751
+ }
22752
+ if (typeof obj !== "object" || obj === null)
22753
+ return null;
22754
+ const o = obj;
22755
+ const PHASES = new Set([
22756
+ "apply",
22757
+ "canary-start",
22758
+ "canary-pass",
22759
+ "canary-fail",
22760
+ "agent-start",
22761
+ "agent-done",
22762
+ "persist-pin",
22763
+ "hostd-web-deferred"
22764
+ ]);
22765
+ if (typeof o.phase !== "string" || !PHASES.has(o.phase))
22766
+ return null;
22767
+ if (typeof o.target !== "string")
22768
+ return null;
22769
+ const out = {
22770
+ phase: o.phase,
22771
+ target: o.target
22772
+ };
22773
+ if (typeof o.agent === "string")
22774
+ out.agent = o.agent;
22775
+ if (typeof o.n === "number")
22776
+ out.n = o.n;
22777
+ if (typeof o.m === "number")
22778
+ out.m = o.m;
22779
+ return out;
22780
+ }
22708
22781
  var ROLLOUT_RESULT_SENTINEL = "SWITCHROOM_ROLLOUT_RESULT:";
22709
22782
  function parseRolloutResultLine(stdout) {
22710
22783
  const lines = stdout.split(`
@@ -22735,6 +22808,18 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
22735
22808
  // src/agents/tmux.ts
22736
22809
  var MAX_BYTES = 10 * 1024 * 1024;
22737
22810
 
22811
+ // src/agents/singleton-reconcile.ts
22812
+ var CORE_SINGLETON_SERVICES = [
22813
+ "vault-broker",
22814
+ "approval-kernel",
22815
+ "switchroom-auth-broker"
22816
+ ];
22817
+ var VOICE_SIDECAR_SERVICE = "voice-sidecar";
22818
+ var SINGLETON_SERVICES = [
22819
+ ...CORE_SINGLETON_SERVICES,
22820
+ VOICE_SIDECAR_SERVICE
22821
+ ];
22822
+
22738
22823
  // src/agents/lifecycle.ts
22739
22824
  function containerName(name) {
22740
22825
  return `switchroom-${name}`;
@@ -22855,6 +22940,33 @@ function isTargetPathHeader(headerPath, targetBasename) {
22855
22940
  return false;
22856
22941
  return norm === targetBasename || basename2(norm) === targetBasename;
22857
22942
  }
22943
+ function normalizeDiffHeadersToBasename(unifiedDiff, targetBasename) {
22944
+ const rewriteHeaderLine = (line, marker) => {
22945
+ const rest = line.slice(marker.length);
22946
+ const tabIdx = rest.indexOf("\t");
22947
+ const pathPart = (tabIdx === -1 ? rest : rest.slice(0, tabIdx)).trim();
22948
+ const tail = tabIdx === -1 ? "" : rest.slice(tabIdx);
22949
+ if (pathPart === "/dev/null")
22950
+ return line;
22951
+ let prefix = "";
22952
+ let p = pathPart;
22953
+ if (p.startsWith("a/") || p.startsWith("b/")) {
22954
+ prefix = p.slice(0, 2);
22955
+ p = p.slice(2);
22956
+ }
22957
+ const newPath = `${prefix}${targetBasename}`;
22958
+ return `${marker}${newPath}${tail}`;
22959
+ };
22960
+ return unifiedDiff.split(`
22961
+ `).map((ln) => {
22962
+ if (ln.startsWith("--- "))
22963
+ return rewriteHeaderLine(ln, "--- ");
22964
+ if (ln.startsWith("+++ "))
22965
+ return rewriteHeaderLine(ln, "+++ ");
22966
+ return ln;
22967
+ }).join(`
22968
+ `);
22969
+ }
22858
22970
  function validateShape(unifiedDiff, targetPath) {
22859
22971
  const byteLen = Buffer.byteLength(unifiedDiff, "utf8");
22860
22972
  if (byteLen > MAX_PATCH_BYTES) {
@@ -22929,11 +23041,12 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
22929
23041
  const liveContent = readFileSync5(configPath, "utf8");
22930
23042
  const scratchDir = mkdtempSync(join4(tmpdir(), "config-propose-edit-"));
22931
23043
  try {
22932
- const basename3 = configPath.split("/").pop() ?? "switchroom.yaml";
22933
- const scratchFile = join4(scratchDir, basename3);
23044
+ const targetBasename = configPath.split("/").pop() ?? "switchroom.yaml";
23045
+ const scratchFile = join4(scratchDir, targetBasename);
22934
23046
  writeFileSync2(scratchFile, liveContent);
23047
+ const normalizedDiff = normalizeDiffHeadersToBasename(unifiedDiff, targetBasename);
22935
23048
  const patchFile = join4(scratchDir, "proposal.patch");
22936
- writeFileSync2(patchFile, unifiedDiff);
23049
+ writeFileSync2(patchFile, normalizedDiff);
22937
23050
  const bin = gitBin ?? "git";
22938
23051
  const baseArgs = [
22939
23052
  "apply",
@@ -23314,6 +23427,43 @@ function classifyBlastRadius(beforeYaml, afterYaml) {
23314
23427
  };
23315
23428
  }
23316
23429
 
23430
+ // src/host-control/render-rollout-status.ts
23431
+ function phaseLine(s) {
23432
+ switch (s.phase) {
23433
+ case "apply":
23434
+ return "applying — regenerating compose";
23435
+ case "canary-start":
23436
+ return `canary — restarting ${s.agent ?? "canary agent"}`;
23437
+ case "canary-pass":
23438
+ return `canary passed (${s.agent ?? "canary"}) — rolling the rest`;
23439
+ case "canary-fail":
23440
+ return `canary failed (${s.agent ?? "canary"})`;
23441
+ case "agent-start":
23442
+ return `agent ${s.n ?? "?"}/${s.m ?? "?"} — restarting ${s.agent ?? ""}`.trim();
23443
+ case "agent-done":
23444
+ return `agent ${s.n ?? "?"}/${s.m ?? "?"} — ${s.agent ?? ""} done`.trim();
23445
+ case "persist-pin":
23446
+ return "persisting pin";
23447
+ case "hostd-web-deferred":
23448
+ return "hostd/web refresh deferred (run host-side)";
23449
+ default:
23450
+ return "starting";
23451
+ }
23452
+ }
23453
+ function renderRolloutStatus(s) {
23454
+ const head = `Rollout → ${s.target}`;
23455
+ const rolledCount = s.rolled?.length ?? 0;
23456
+ if (s.terminal === "completed") {
23457
+ return `✅ ${head} — done. Rolled ${rolledCount} agent(s): ${(s.rolled ?? []).join(", ") || "none"}.`;
23458
+ }
23459
+ if (s.terminal === "error") {
23460
+ const where = s.failedStep ? ` at ${s.failedStep}${s.failedAgent ? ` (${s.failedAgent} → ${s.got ?? "unreachable"})` : ""}` : "";
23461
+ return `❌ ${head} — STOPPED${where}. ` + `Rolled before stop: ${(s.rolled ?? []).join(", ") || "none"}.`;
23462
+ }
23463
+ const progress = rolledCount > 0 ? ` · ${rolledCount} rolled` : "";
23464
+ return `⏳ ${head} — ${phaseLine(s)}${progress}`;
23465
+ }
23466
+
23317
23467
  // src/scheduler/dispatch.ts
23318
23468
  import { createHash as createHash4 } from "node:crypto";
23319
23469
  function collectScheduleEntries(config) {
@@ -23424,7 +23574,7 @@ function readCachedInstallType(bindRoot) {
23424
23574
  } catch {}
23425
23575
  return payload;
23426
23576
  }
23427
- var CONFIG_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
23577
+ var CONFIG_APPROVAL_TIMEOUT_MS = 60 * 60 * 1000;
23428
23578
  function defaultApprovalId() {
23429
23579
  return randomBytes(4).toString("hex");
23430
23580
  }
@@ -23467,6 +23617,9 @@ class HostdServer {
23467
23617
  auditChainState;
23468
23618
  idempotencyKeys = new Map;
23469
23619
  fleetMutationInFlight = null;
23620
+ get rolloutNarrator() {
23621
+ return this.opts.rolloutNarrator;
23622
+ }
23470
23623
  constructor(opts) {
23471
23624
  this.opts = opts;
23472
23625
  }
@@ -23779,6 +23932,9 @@ class HostdServer {
23779
23932
  case "get_status": {
23780
23933
  const entry = this.statusByRequestId.get(req.args.target_request_id);
23781
23934
  if (!entry) {
23935
+ if (callerAdmin && this.rolloutRowExistsInLog(req.args.target_request_id)) {
23936
+ return null;
23937
+ }
23782
23938
  return `get_status: request_id not found or not visible to caller "${caller.name}"`;
23783
23939
  }
23784
23940
  const ownCall = entry.caller.kind === caller.kind && ("name" in entry.caller && "name" in caller ? entry.caller.name === caller.name : true);
@@ -24530,7 +24686,12 @@ class HostdServer {
24530
24686
  });
24531
24687
  }
24532
24688
  spawnRollout(args, entry) {
24533
- this.runSwitchroom(args, { SWITCHROOM_HOSTD_CONTEXT: "1" }).then((res) => {
24689
+ const onLine = (line) => {
24690
+ const phase = parseRolloutPhaseLine(line);
24691
+ if (phase)
24692
+ this.onRolloutPhase(entry, phase);
24693
+ };
24694
+ this.runSwitchroom(args, { SWITCHROOM_HOSTD_CONTEXT: "1" }, onLine).then((res) => {
24534
24695
  entry.exit_code = res.exit_code;
24535
24696
  entry.finished_at = Date.now();
24536
24697
  entry.stdout_tail = tail(res.stdout);
@@ -24559,27 +24720,111 @@ class HostdServer {
24559
24720
  delete entry.prior_pin;
24560
24721
  }).finally(() => {
24561
24722
  this.writeTerminalAudit(entry);
24723
+ this.pushRolloutTerminal(entry);
24724
+ try {
24725
+ this.rolloutNarrator?.onTerminal(entry);
24726
+ } catch (e) {
24727
+ process.stderr.write(`hostd: rollout narrator onTerminal threw (non-fatal): ${e.message}
24728
+ `);
24729
+ }
24562
24730
  if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
24563
24731
  this.fleetMutationInFlight = null;
24564
24732
  }
24565
24733
  });
24566
24734
  }
24735
+ pushRolloutTerminal(entry) {
24736
+ if (!this.opts.rolloutRelay)
24737
+ return;
24738
+ if (entry.caller.kind !== "agent")
24739
+ return;
24740
+ try {
24741
+ const text = renderRolloutStatus({
24742
+ target: entry.pin ?? "",
24743
+ rolled: entry.rolled,
24744
+ terminal: entry.result === "completed" ? "completed" : "error",
24745
+ ...entry.failed_step ? { failedStep: entry.failed_step } : {},
24746
+ ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
24747
+ ...entry.got !== undefined ? { got: entry.got } : {}
24748
+ });
24749
+ this.opts.rolloutRelay.postTerminal({
24750
+ requestId: entry.request_id,
24751
+ agentName: entry.caller.name,
24752
+ text
24753
+ });
24754
+ } catch (e) {
24755
+ process.stderr.write(`hostd: rollout terminal push failed (non-fatal): ${e.message}
24756
+ `);
24757
+ }
24758
+ }
24567
24759
  handleGetStatus(req, _caller, started) {
24568
24760
  const entry = this.statusByRequestId.get(req.args.target_request_id);
24569
24761
  if (!entry) {
24762
+ const fromLog = this.rolloutStatusFromLog(req.request_id, req.args.target_request_id, started);
24763
+ if (fromLog)
24764
+ return fromLog;
24570
24765
  const legacy = `get_status: internal: entry missing despite gate accept`;
24571
24766
  const built = err("E_INTERNAL", "entry missing despite gate accept").op("get_status").build(req.request_id, Date.now() - started);
24572
24767
  return { ...built, error: legacy };
24573
24768
  }
24574
24769
  return this.statusEntryToResponse(req.request_id, entry);
24575
24770
  }
24771
+ rolloutRowExistsInLog(targetRequestId) {
24772
+ const path2 = this.auditLogPath();
24773
+ if (!existsSync8(path2))
24774
+ return false;
24775
+ try {
24776
+ const raw = readFileSync6(path2, "utf-8");
24777
+ return latestRolloutRowForRequest(raw, targetRequestId) !== null;
24778
+ } catch {
24779
+ return false;
24780
+ }
24781
+ }
24782
+ rolloutStatusFromLog(responseRequestId, targetRequestId, started) {
24783
+ const path2 = this.auditLogPath();
24784
+ if (!existsSync8(path2))
24785
+ return null;
24786
+ let raw;
24787
+ try {
24788
+ raw = readFileSync6(path2, "utf-8");
24789
+ } catch {
24790
+ return null;
24791
+ }
24792
+ const row = latestRolloutRowForRequest(raw, targetRequestId);
24793
+ if (!row)
24794
+ return null;
24795
+ const isTerminal = row.phase === "terminal";
24796
+ const payload = JSON.stringify({
24797
+ rolled: row.rolled ?? [],
24798
+ ...row.phase && !isTerminal ? { phase: row.phase } : {},
24799
+ ...row.n !== undefined ? { n: row.n } : {},
24800
+ ...row.m !== undefined ? { m: row.m } : {},
24801
+ ...row.agent ? { agent: row.agent } : {},
24802
+ ...row.failed_step ? { failedStep: row.failed_step } : {},
24803
+ ...row.failed_agent ? { failedAgent: row.failed_agent } : {},
24804
+ ...row.pin ? { pin: row.pin } : {},
24805
+ ...row.prior_pin ? { prior_pin: row.prior_pin } : {}
24806
+ });
24807
+ return {
24808
+ v: 1,
24809
+ request_id: responseRequestId,
24810
+ result: row.result,
24811
+ exit_code: row.exit_code,
24812
+ duration_ms: Date.now() - started,
24813
+ payload
24814
+ };
24815
+ }
24576
24816
  statusEntryToResponse(request_id, entry) {
24577
- const rolloutPayload = entry.op === "rollout" && (entry.rolled !== undefined || entry.failed_step !== undefined || entry.failed_agent !== undefined) ? JSON.stringify({
24817
+ const rolloutPayload = entry.op === "rollout" && (entry.rolled !== undefined || entry.failed_step !== undefined || entry.failed_agent !== undefined || entry.current_phase !== undefined) ? JSON.stringify({
24578
24818
  rolled: entry.rolled ?? [],
24819
+ ...entry.current_phase ? { phase: entry.current_phase } : {},
24820
+ ...entry.phase_n !== undefined ? { n: entry.phase_n } : {},
24821
+ ...entry.phase_m !== undefined ? { m: entry.phase_m } : {},
24822
+ ...entry.phase_agent ? { agent: entry.phase_agent } : {},
24579
24823
  ...entry.failed_step ? { failedStep: entry.failed_step } : {},
24580
24824
  ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
24581
24825
  ...entry.got !== undefined ? { got: entry.got } : {},
24582
- ...entry.pin ? { pin: entry.pin } : {}
24826
+ ...entry.pin ? { pin: entry.pin } : {},
24827
+ ...entry.prior_pin ? { prior_pin: entry.prior_pin } : {}
24583
24828
  }) : undefined;
24584
24829
  const updatePayload = entry.op === "update_apply" && (entry.deferred !== undefined || entry.channel !== undefined || entry.pin !== undefined) ? {
24585
24830
  ...entry.deferred !== undefined ? { deferred: entry.deferred } : {},
@@ -24681,7 +24926,36 @@ class HostdServer {
24681
24926
  ...entry.prior_pin ? { prior_pin: entry.prior_pin } : {}
24682
24927
  });
24683
24928
  }
24684
- runSwitchroom(args, extraEnv) {
24929
+ onRolloutPhase(entry, phase) {
24930
+ entry.current_phase = phase.phase;
24931
+ entry.phase_n = phase.n;
24932
+ entry.phase_m = phase.m;
24933
+ entry.phase_agent = phase.agent;
24934
+ this.writePhaseAudit(entry, phase);
24935
+ try {
24936
+ this.rolloutNarrator?.onPhase(entry, phase);
24937
+ } catch (e) {
24938
+ process.stderr.write(`hostd: rollout narrator onPhase threw (non-fatal): ${e.message}
24939
+ `);
24940
+ }
24941
+ }
24942
+ async writePhaseAudit(entry, phase) {
24943
+ await this.appendAuditRow({
24944
+ ts: new Date().toISOString(),
24945
+ op: entry.op,
24946
+ phase: phase.phase,
24947
+ caller: entry.caller.kind === "agent" ? { kind: "agent", name: entry.caller.name } : { kind: "operator" },
24948
+ request_id: entry.request_id,
24949
+ result: "started",
24950
+ exit_code: null,
24951
+ duration_ms: Date.now() - entry.started_at,
24952
+ ...entry.pin ? { pin: entry.pin } : {},
24953
+ ...phase.agent !== undefined ? { agent: phase.agent } : {},
24954
+ ...phase.n !== undefined ? { n: phase.n } : {},
24955
+ ...phase.m !== undefined ? { m: phase.m } : {}
24956
+ });
24957
+ }
24958
+ runSwitchroom(args, extraEnv, onStdoutLine) {
24685
24959
  return new Promise((resolve7, reject) => {
24686
24960
  const bin = this.opts.switchroomBin ?? "switchroom";
24687
24961
  const child = spawn2(bin, args, {
@@ -24690,14 +24964,49 @@ class HostdServer {
24690
24964
  });
24691
24965
  let stdout = "";
24692
24966
  let stderr = "";
24967
+ let lineBuf = "";
24968
+ const drainLines = (flush) => {
24969
+ if (!onStdoutLine)
24970
+ return;
24971
+ let nl;
24972
+ while ((nl = lineBuf.indexOf(`
24973
+ `)) !== -1) {
24974
+ const line = lineBuf.slice(0, nl);
24975
+ lineBuf = lineBuf.slice(nl + 1);
24976
+ try {
24977
+ onStdoutLine(line);
24978
+ } catch (e) {
24979
+ process.stderr.write(`hostd: rollout stdout-tap threw (non-fatal): ${e.message}
24980
+ `);
24981
+ }
24982
+ }
24983
+ if (flush && lineBuf.length > 0) {
24984
+ const line = lineBuf;
24985
+ lineBuf = "";
24986
+ try {
24987
+ onStdoutLine(line);
24988
+ } catch (e) {
24989
+ process.stderr.write(`hostd: rollout stdout-tap threw (non-fatal): ${e.message}
24990
+ `);
24991
+ }
24992
+ }
24993
+ };
24693
24994
  child.stdout.on("data", (d) => {
24694
- stdout += d.toString("utf8");
24995
+ const s = d.toString("utf8");
24996
+ stdout += s;
24997
+ if (onStdoutLine) {
24998
+ lineBuf += s;
24999
+ drainLines(false);
25000
+ }
24695
25001
  });
24696
25002
  child.stderr.on("data", (d) => {
24697
25003
  stderr += d.toString("utf8");
24698
25004
  });
24699
25005
  child.on("error", (err2) => reject(err2));
24700
- child.on("close", (code) => resolve7({ exit_code: code ?? -1, stdout, stderr }));
25006
+ child.on("close", (code) => {
25007
+ drainLines(true);
25008
+ resolve7({ exit_code: code ?? -1, stdout, stderr });
25009
+ });
24701
25010
  });
24702
25011
  }
24703
25012
  }
@@ -24929,6 +25238,395 @@ class SocketApprovalGateway {
24929
25238
  }
24930
25239
  }
24931
25240
 
25241
+ // src/host-control/rollout-relay.ts
25242
+ import { connect as connect2 } from "node:net";
25243
+
25244
+ class SocketRolloutRelay {
25245
+ opts;
25246
+ constructor(opts) {
25247
+ this.opts = opts;
25248
+ }
25249
+ postTerminal(notice) {
25250
+ const log = this.opts.log ?? (() => {});
25251
+ let sockPath;
25252
+ try {
25253
+ sockPath = this.opts.resolveGatewaySocket(notice.agentName);
25254
+ } catch (e) {
25255
+ log(`resolveGatewaySocket threw for ${notice.agentName}: ${e.message}`);
25256
+ return;
25257
+ }
25258
+ if (sockPath === null) {
25259
+ log(`no reachable gateway socket for ${notice.agentName}; dropping terminal ping`);
25260
+ return;
25261
+ }
25262
+ try {
25263
+ const client2 = connect2({ path: sockPath });
25264
+ client2.setTimeout(5000);
25265
+ const done = () => {
25266
+ try {
25267
+ client2.destroy();
25268
+ } catch {}
25269
+ };
25270
+ client2.on("connect", () => {
25271
+ try {
25272
+ client2.write(JSON.stringify({
25273
+ type: "rollout_status_post",
25274
+ requestId: notice.requestId,
25275
+ agentName: notice.agentName,
25276
+ text: notice.text
25277
+ }) + `
25278
+ `);
25279
+ client2.end();
25280
+ } catch (e) {
25281
+ log(`rollout terminal write failed (requestId=${notice.requestId}): ${e.message}`);
25282
+ done();
25283
+ }
25284
+ });
25285
+ client2.on("timeout", () => {
25286
+ log(`rollout terminal connect timed out (requestId=${notice.requestId})`);
25287
+ done();
25288
+ });
25289
+ client2.on("error", (e) => {
25290
+ log(`rollout terminal socket error (requestId=${notice.requestId}): ${e.message}`);
25291
+ done();
25292
+ });
25293
+ client2.on("close", () => {});
25294
+ } catch (e) {
25295
+ log(`rollout terminal connect threw (requestId=${notice.requestId}): ${e.message}`);
25296
+ }
25297
+ }
25298
+ }
25299
+
25300
+ // src/host-control/rollout-narration-relay.ts
25301
+ import { connect as connect3 } from "node:net";
25302
+ var DEFAULT_POST_REPLY_TIMEOUT_MS = 5000;
25303
+
25304
+ class SocketRolloutNarrationRelay {
25305
+ opts;
25306
+ constructor(opts) {
25307
+ this.opts = opts;
25308
+ }
25309
+ post(args) {
25310
+ const log = this.opts.log ?? (() => {});
25311
+ let sockPath;
25312
+ try {
25313
+ sockPath = this.opts.resolveGatewaySocket(args.agentName);
25314
+ } catch (e) {
25315
+ log(`narration resolveGatewaySocket threw: ${e.message}`);
25316
+ return Promise.resolve(null);
25317
+ }
25318
+ if (sockPath === null) {
25319
+ log(`narration post: no reachable gateway for ${args.agentName}`);
25320
+ return Promise.resolve(null);
25321
+ }
25322
+ return new Promise((resolve7) => {
25323
+ let settled = false;
25324
+ const finish = (v) => {
25325
+ if (settled)
25326
+ return;
25327
+ settled = true;
25328
+ try {
25329
+ client2.destroy();
25330
+ } catch {}
25331
+ resolve7(v);
25332
+ };
25333
+ let client2;
25334
+ try {
25335
+ client2 = connect3({ path: sockPath });
25336
+ } catch (e) {
25337
+ log(`narration post connect threw: ${e.message}`);
25338
+ resolve7(null);
25339
+ return;
25340
+ }
25341
+ client2.setTimeout(this.opts.postReplyTimeoutMs ?? DEFAULT_POST_REPLY_TIMEOUT_MS);
25342
+ let buffer = "";
25343
+ client2.on("connect", () => {
25344
+ try {
25345
+ client2.write(JSON.stringify({
25346
+ type: "rollout_status_post",
25347
+ requestId: args.requestId,
25348
+ agentName: args.agentName,
25349
+ text: args.text
25350
+ }) + `
25351
+ `);
25352
+ } catch (e) {
25353
+ log(`narration post write failed: ${e.message}`);
25354
+ finish(null);
25355
+ }
25356
+ });
25357
+ client2.on("data", (chunk2) => {
25358
+ buffer += chunk2.toString("utf8");
25359
+ const lines = buffer.split(`
25360
+ `);
25361
+ buffer = lines.pop() ?? "";
25362
+ for (const line of lines) {
25363
+ if (!line.trim())
25364
+ continue;
25365
+ let obj;
25366
+ try {
25367
+ obj = JSON.parse(line);
25368
+ } catch {
25369
+ continue;
25370
+ }
25371
+ if (obj.type === "rollout_status_posted" && obj.requestId === args.requestId) {
25372
+ if (obj.ok === true && typeof obj.messageId === "number") {
25373
+ finish(obj.messageId);
25374
+ } else {
25375
+ log(`narration post rejected by gateway (requestId=${args.requestId}): ${typeof obj.reason === "string" ? obj.reason : "no message_id"}`);
25376
+ finish(null);
25377
+ }
25378
+ return;
25379
+ }
25380
+ }
25381
+ });
25382
+ client2.on("timeout", () => {
25383
+ log(`narration post reply timed out (requestId=${args.requestId})`);
25384
+ finish(null);
25385
+ });
25386
+ client2.on("error", (e) => {
25387
+ log(`narration post socket error (requestId=${args.requestId}): ${e.message}`);
25388
+ finish(null);
25389
+ });
25390
+ client2.on("close", () => finish(null));
25391
+ });
25392
+ }
25393
+ edit(args) {
25394
+ const log = this.opts.log ?? (() => {});
25395
+ let sockPath;
25396
+ try {
25397
+ sockPath = this.opts.resolveGatewaySocket(args.agentName);
25398
+ } catch (e) {
25399
+ log(`narration edit resolveGatewaySocket threw: ${e.message}`);
25400
+ return;
25401
+ }
25402
+ if (sockPath === null)
25403
+ return;
25404
+ try {
25405
+ const client2 = connect3({ path: sockPath });
25406
+ client2.setTimeout(5000);
25407
+ const done = () => {
25408
+ try {
25409
+ client2.destroy();
25410
+ } catch {}
25411
+ };
25412
+ client2.on("connect", () => {
25413
+ try {
25414
+ client2.write(JSON.stringify({
25415
+ type: "rollout_status_edit",
25416
+ requestId: args.requestId,
25417
+ agentName: args.agentName,
25418
+ messageId: args.messageId,
25419
+ text: args.text
25420
+ }) + `
25421
+ `);
25422
+ client2.end();
25423
+ } catch (e) {
25424
+ log(`narration edit write failed: ${e.message}`);
25425
+ done();
25426
+ }
25427
+ });
25428
+ client2.on("timeout", done);
25429
+ client2.on("error", (e) => {
25430
+ log(`narration edit socket error: ${e.message}`);
25431
+ done();
25432
+ });
25433
+ client2.on("close", () => {});
25434
+ } catch (e) {
25435
+ log(`narration edit connect threw: ${e.message}`);
25436
+ }
25437
+ }
25438
+ }
25439
+
25440
+ // src/host-control/rollout-narrator.ts
25441
+ var DEFAULT_DEBOUNCE_MS = 1500;
25442
+ var MAX_POST_ATTEMPTS = 2;
25443
+
25444
+ class LogTailRolloutNarrator {
25445
+ relay;
25446
+ opts;
25447
+ states = new Map;
25448
+ constructor(relay, opts = {}) {
25449
+ this.relay = relay;
25450
+ this.opts = opts;
25451
+ }
25452
+ static seqFor(phase) {
25453
+ switch (phase.phase) {
25454
+ case "apply":
25455
+ return 0;
25456
+ case "canary-start":
25457
+ return 2 * 1 + 0;
25458
+ case "canary-pass":
25459
+ case "canary-fail":
25460
+ return 2 * 1 + 1;
25461
+ case "persist-pin":
25462
+ return 2 * 1 + 1;
25463
+ case "agent-start":
25464
+ return 2 * (phase.n ?? 1) + 0;
25465
+ case "agent-done":
25466
+ return 2 * (phase.n ?? 1) + 1;
25467
+ case "hostd-web-deferred":
25468
+ return Number.MAX_SAFE_INTEGER - 1;
25469
+ default:
25470
+ return 0;
25471
+ }
25472
+ }
25473
+ onPhase(entry, phase) {
25474
+ try {
25475
+ const st = this.ensureState(entry);
25476
+ if (st.frozen)
25477
+ return;
25478
+ const seq = LogTailRolloutNarrator.seqFor(phase);
25479
+ if (seq < st.lastAppliedSeq)
25480
+ return;
25481
+ st.lastAppliedSeq = seq;
25482
+ st.render = {
25483
+ ...st.render,
25484
+ target: phase.target,
25485
+ phase: phase.phase,
25486
+ ...phase.n !== undefined ? { n: phase.n } : {},
25487
+ ...phase.m !== undefined ? { m: phase.m } : {},
25488
+ ...phase.agent !== undefined ? { agent: phase.agent } : {},
25489
+ rolled: entry.rolled
25490
+ };
25491
+ this.scheduleRenderOrPost(entry.request_id);
25492
+ } catch (e) {
25493
+ this.opts.log?.(`onPhase threw (non-fatal): ${e.message}`);
25494
+ }
25495
+ }
25496
+ onTerminal(entry) {
25497
+ try {
25498
+ const st = this.ensureState(entry);
25499
+ st.render = {
25500
+ target: entry.pin ?? st.render.target,
25501
+ rolled: entry.rolled ?? st.render.rolled,
25502
+ terminal: entry.result === "completed" ? "completed" : "error",
25503
+ ...entry.failed_step ? { failedStep: entry.failed_step } : {},
25504
+ ...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
25505
+ ...entry.got !== undefined ? { got: entry.got } : {}
25506
+ };
25507
+ st.frozen = true;
25508
+ if (st.timer) {
25509
+ clearTimeout(st.timer);
25510
+ st.timer = null;
25511
+ }
25512
+ this.flush(entry.request_id, true);
25513
+ setTimeout(() => this.states.delete(entry.request_id), 1e4).unref?.();
25514
+ } catch (e) {
25515
+ this.opts.log?.(`onTerminal threw (non-fatal): ${e.message}`);
25516
+ }
25517
+ }
25518
+ ensureState(entry) {
25519
+ let st = this.states.get(entry.request_id);
25520
+ if (!st) {
25521
+ const agentName = entry.caller.kind === "agent" ? entry.caller.name : "";
25522
+ st = {
25523
+ agentName,
25524
+ lastAppliedSeq: -1,
25525
+ messageId: null,
25526
+ posting: false,
25527
+ postAttempts: 0,
25528
+ postFailed: false,
25529
+ render: { target: entry.pin ?? "" },
25530
+ frozen: false,
25531
+ timer: null,
25532
+ pendingEditAfterPost: false
25533
+ };
25534
+ this.states.set(entry.request_id, st);
25535
+ }
25536
+ return st;
25537
+ }
25538
+ scheduleRenderOrPost(requestId) {
25539
+ const st = this.states.get(requestId);
25540
+ if (!st)
25541
+ return;
25542
+ if (st.agentName.length === 0)
25543
+ return;
25544
+ if (st.messageId !== null) {
25545
+ this.scheduleDebouncedEdit(requestId);
25546
+ return;
25547
+ }
25548
+ if (st.posting) {
25549
+ st.pendingEditAfterPost = true;
25550
+ return;
25551
+ }
25552
+ if (st.postFailed)
25553
+ return;
25554
+ this.tryPost(requestId, false);
25555
+ }
25556
+ tryPost(requestId, terminal) {
25557
+ const st = this.states.get(requestId);
25558
+ if (!st)
25559
+ return false;
25560
+ if (st.messageId !== null || st.posting)
25561
+ return false;
25562
+ if (st.postAttempts >= MAX_POST_ATTEMPTS)
25563
+ return false;
25564
+ if (st.postFailed && !terminal)
25565
+ return false;
25566
+ st.posting = true;
25567
+ st.postAttempts += 1;
25568
+ const text = renderRolloutStatus(st.render);
25569
+ this.relay.post({ requestId, agentName: st.agentName, text }).then((mid) => {
25570
+ st.posting = false;
25571
+ if (mid === null) {
25572
+ st.postFailed = true;
25573
+ this.opts.log?.(`rollout narration post returned no message_id (requestId=${requestId}); backing off re-posts`);
25574
+ return;
25575
+ }
25576
+ st.messageId = mid;
25577
+ st.postFailed = false;
25578
+ if (st.pendingEditAfterPost) {
25579
+ st.pendingEditAfterPost = false;
25580
+ this.flush(requestId, false);
25581
+ }
25582
+ }).catch((e) => {
25583
+ st.posting = false;
25584
+ st.postFailed = true;
25585
+ this.opts.log?.(`rollout narration post failed (non-fatal): ${e.message}`);
25586
+ });
25587
+ return true;
25588
+ }
25589
+ scheduleDebouncedEdit(requestId) {
25590
+ const st = this.states.get(requestId);
25591
+ if (!st)
25592
+ return;
25593
+ if (st.timer)
25594
+ return;
25595
+ const ms = this.opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
25596
+ st.timer = setTimeout(() => {
25597
+ st.timer = null;
25598
+ this.flush(requestId, true);
25599
+ }, ms);
25600
+ st.timer.unref?.();
25601
+ }
25602
+ flush(requestId, immediate) {
25603
+ const st = this.states.get(requestId);
25604
+ if (!st)
25605
+ return;
25606
+ if (st.agentName.length === 0)
25607
+ return;
25608
+ if (!immediate) {
25609
+ this.scheduleDebouncedEdit(requestId);
25610
+ return;
25611
+ }
25612
+ if (st.messageId === null) {
25613
+ if (st.posting) {
25614
+ st.pendingEditAfterPost = true;
25615
+ return;
25616
+ }
25617
+ this.tryPost(requestId, true);
25618
+ return;
25619
+ }
25620
+ const text = renderRolloutStatus(st.render);
25621
+ this.relay.edit({
25622
+ requestId,
25623
+ agentName: st.agentName,
25624
+ messageId: st.messageId,
25625
+ text
25626
+ });
25627
+ }
25628
+ }
25629
+
24932
25630
  // src/host-control/release-watcher.ts
24933
25631
  class ReleaseWatcher {
24934
25632
  opts;
@@ -25194,14 +25892,26 @@ async function main() {
25194
25892
  process.exit(2);
25195
25893
  }
25196
25894
  const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join6(homedir4(), ".switchroom", "agents");
25895
+ const resolveGatewaySocket = (agentName) => {
25896
+ const sock = resolve7(agentsDir, agentName, "telegram", "gateway.sock");
25897
+ return existsSync9(sock) ? sock : null;
25898
+ };
25197
25899
  const approvalGateway = new SocketApprovalGateway({
25198
- resolveGatewaySocket: (agentName) => {
25199
- const sock = resolve7(agentsDir, agentName, "telegram", "gateway.sock");
25200
- return existsSync9(sock) ? sock : null;
25201
- },
25900
+ resolveGatewaySocket,
25202
25901
  log: (m) => process.stderr.write(`hostd: approval-gateway — ${m}
25203
25902
  `)
25204
25903
  });
25904
+ const rolloutRelay = new SocketRolloutRelay({
25905
+ resolveGatewaySocket,
25906
+ log: (m) => process.stderr.write(`hostd: rollout-relay — ${m}
25907
+ `)
25908
+ });
25909
+ const rolloutNarrator = new LogTailRolloutNarrator(new SocketRolloutNarrationRelay({
25910
+ resolveGatewaySocket,
25911
+ log: (m) => process.stderr.write(`hostd: rollout-narration — ${m}
25912
+ `)
25913
+ }), { log: (m) => process.stderr.write(`hostd: rollout-narration — ${m}
25914
+ `) });
25205
25915
  const server = new HostdServer({
25206
25916
  homeDir: homedir4(),
25207
25917
  agentUids,
@@ -25216,7 +25926,9 @@ async function main() {
25216
25926
  }
25217
25927
  } : {}
25218
25928
  },
25219
- approvalGateway
25929
+ approvalGateway,
25930
+ rolloutRelay,
25931
+ rolloutNarrator
25220
25932
  });
25221
25933
  await server.start();
25222
25934
  const paths = server.getBoundPaths();