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
@@ -4180,19 +4180,30 @@ var init_schema = __esm(() => {
4180
4180
  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."),
4181
4181
  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."),
4182
4182
  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)."),
4183
+ 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)."),
4183
4184
  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."),
4184
4185
  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)."),
4185
4186
  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."),
4186
4187
  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."),
4187
4188
  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)."),
4189
+ 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."),
4188
4190
  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."),
4189
4191
  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."),
4190
4192
  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."),
4191
4193
  voice_in: exports_external.object({
4192
4194
  enabled: exports_external.boolean().optional().describe("Master switch for voice-message transcription."),
4193
4195
  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."),
4194
- language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection.")
4195
- }).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.)"),
4196
+ language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection."),
4197
+ 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.")
4198
+ }).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.)"),
4199
+ voice_out: exports_external.object({
4200
+ enabled: exports_external.boolean().optional().describe("Master switch for spoken-reply (TTS) voice notes."),
4201
+ 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."),
4202
+ 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."),
4203
+ 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."),
4204
+ 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."),
4205
+ 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).")
4206
+ }).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."),
4196
4207
  telegraph: exports_external.object({
4197
4208
  enabled: exports_external.boolean().optional().describe("Master switch for Telegraph Instant View publishing."),
4198
4209
  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)."),
@@ -5029,6 +5040,41 @@ var init_merge = __esm(() => {
5029
5040
  })(mergeAgentConfig ||= {});
5030
5041
  });
5031
5042
 
5043
+ // src/config/paths.ts
5044
+ import { existsSync } from "node:fs";
5045
+ import { resolve } from "node:path";
5046
+ function home() {
5047
+ return process.env.HOME ?? "/root";
5048
+ }
5049
+ function warnLegacyStateOnce(legacy) {
5050
+ if (_legacyStateWarned)
5051
+ return;
5052
+ _legacyStateWarned = true;
5053
+ process.stderr.write(`[switchroom] DEPRECATED: reading legacy state from ${legacy}. ` + "Run `mv ~/.clerk ~/.switchroom` (and rename any top-level `clerk:` " + "key in switchroom.yaml to `switchroom:`). This back-compat shim is " + `REMOVED in v0.13.0 — no automatic migration exists.
5054
+ `);
5055
+ }
5056
+ function resolveDualPath(pathStr) {
5057
+ const h = home();
5058
+ if (pathStr.startsWith("~/")) {
5059
+ const rest = pathStr.slice(2);
5060
+ const absolute = resolve(h, rest);
5061
+ if (rest.startsWith(`${DEFAULT_STATE_DIR}/`)) {
5062
+ const frag = rest.slice(DEFAULT_STATE_DIR.length + 1);
5063
+ if (!existsSync(absolute)) {
5064
+ const legacy = resolve(h, LEGACY_STATE_DIR, frag);
5065
+ if (existsSync(legacy)) {
5066
+ warnLegacyStateOnce(legacy);
5067
+ return legacy;
5068
+ }
5069
+ }
5070
+ }
5071
+ return absolute;
5072
+ }
5073
+ return resolve(pathStr);
5074
+ }
5075
+ var DEFAULT_STATE_DIR = ".switchroom", LEGACY_STATE_DIR = ".clerk", _legacyStateWarned = false;
5076
+ var init_paths = () => {};
5077
+
5032
5078
  // node_modules/.bun/yaml@2.8.3/node_modules/yaml/dist/nodes/identity.js
5033
5079
  var require_identity = __commonJS((exports) => {
5034
5080
  var ALIAS = Symbol.for("yaml.alias");
@@ -11993,41 +12039,6 @@ var init_dist = __esm(() => {
11993
12039
  $visitAsync = visit.visitAsync;
11994
12040
  });
11995
12041
 
11996
- // src/config/paths.ts
11997
- import { existsSync } from "node:fs";
11998
- import { resolve } from "node:path";
11999
- function home() {
12000
- return process.env.HOME ?? "/root";
12001
- }
12002
- function warnLegacyStateOnce(legacy) {
12003
- if (_legacyStateWarned)
12004
- return;
12005
- _legacyStateWarned = true;
12006
- process.stderr.write(`[switchroom] DEPRECATED: reading legacy state from ${legacy}. ` + "Run `mv ~/.clerk ~/.switchroom` (and rename any top-level `clerk:` " + "key in switchroom.yaml to `switchroom:`). This back-compat shim is " + `REMOVED in v0.13.0 — no automatic migration exists.
12007
- `);
12008
- }
12009
- function resolveDualPath(pathStr) {
12010
- const h = home();
12011
- if (pathStr.startsWith("~/")) {
12012
- const rest = pathStr.slice(2);
12013
- const absolute = resolve(h, rest);
12014
- if (rest.startsWith(`${DEFAULT_STATE_DIR}/`)) {
12015
- const frag = rest.slice(DEFAULT_STATE_DIR.length + 1);
12016
- if (!existsSync(absolute)) {
12017
- const legacy = resolve(h, LEGACY_STATE_DIR, frag);
12018
- if (existsSync(legacy)) {
12019
- warnLegacyStateOnce(legacy);
12020
- return legacy;
12021
- }
12022
- }
12023
- }
12024
- return absolute;
12025
- }
12026
- return resolve(pathStr);
12027
- }
12028
- var DEFAULT_STATE_DIR = ".switchroom", LEGACY_STATE_DIR = ".clerk", _legacyStateWarned = false;
12029
- var init_paths = () => {};
12030
-
12031
12042
  // src/config/overlay-schema.ts
12032
12043
  var OverlayDocSchema;
12033
12044
  var init_overlay_schema = __esm(() => {
@@ -13182,6 +13193,9 @@ var DEFAULT_RETAIN_MISSION = "Extract user preferences, ongoing projects, recurr
13182
13193
  // src/agents/reconcile-default-skills.ts
13183
13194
  var warnedMissingPool = new Set;
13184
13195
 
13196
+ // src/setup/host-capabilities.ts
13197
+ init_paths();
13198
+
13185
13199
  // src/agents/compose.ts
13186
13200
  var AGENT_UID_MIN = 10001;
13187
13201
  var AGENT_UID_MAX = 10999;
@@ -4180,19 +4180,30 @@ var init_schema = __esm(() => {
4180
4180
  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."),
4181
4181
  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."),
4182
4182
  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)."),
4183
+ 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)."),
4183
4184
  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."),
4184
4185
  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)."),
4185
4186
  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."),
4186
4187
  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."),
4187
4188
  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)."),
4189
+ 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."),
4188
4190
  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."),
4189
4191
  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."),
4190
4192
  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."),
4191
4193
  voice_in: exports_external.object({
4192
4194
  enabled: exports_external.boolean().optional().describe("Master switch for voice-message transcription."),
4193
4195
  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."),
4194
- language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection.")
4195
- }).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.)"),
4196
+ language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection."),
4197
+ 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.")
4198
+ }).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.)"),
4199
+ voice_out: exports_external.object({
4200
+ enabled: exports_external.boolean().optional().describe("Master switch for spoken-reply (TTS) voice notes."),
4201
+ 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."),
4202
+ 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."),
4203
+ 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."),
4204
+ 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."),
4205
+ 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).")
4206
+ }).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."),
4196
4207
  telegraph: exports_external.object({
4197
4208
  enabled: exports_external.boolean().optional().describe("Master switch for Telegraph Instant View publishing."),
4198
4209
  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)."),
@@ -5029,6 +5040,41 @@ var init_merge = __esm(() => {
5029
5040
  })(mergeAgentConfig ||= {});
5030
5041
  });
5031
5042
 
5043
+ // src/config/paths.ts
5044
+ import { existsSync } from "node:fs";
5045
+ import { resolve } from "node:path";
5046
+ function home() {
5047
+ return process.env.HOME ?? "/root";
5048
+ }
5049
+ function warnLegacyStateOnce(legacy) {
5050
+ if (_legacyStateWarned)
5051
+ return;
5052
+ _legacyStateWarned = true;
5053
+ process.stderr.write(`[switchroom] DEPRECATED: reading legacy state from ${legacy}. ` + "Run `mv ~/.clerk ~/.switchroom` (and rename any top-level `clerk:` " + "key in switchroom.yaml to `switchroom:`). This back-compat shim is " + `REMOVED in v0.13.0 — no automatic migration exists.
5054
+ `);
5055
+ }
5056
+ function resolveDualPath(pathStr) {
5057
+ const h = home();
5058
+ if (pathStr.startsWith("~/")) {
5059
+ const rest = pathStr.slice(2);
5060
+ const absolute = resolve(h, rest);
5061
+ if (rest.startsWith(`${DEFAULT_STATE_DIR}/`)) {
5062
+ const frag = rest.slice(DEFAULT_STATE_DIR.length + 1);
5063
+ if (!existsSync(absolute)) {
5064
+ const legacy = resolve(h, LEGACY_STATE_DIR, frag);
5065
+ if (existsSync(legacy)) {
5066
+ warnLegacyStateOnce(legacy);
5067
+ return legacy;
5068
+ }
5069
+ }
5070
+ }
5071
+ return absolute;
5072
+ }
5073
+ return resolve(pathStr);
5074
+ }
5075
+ var DEFAULT_STATE_DIR = ".switchroom", LEGACY_STATE_DIR = ".clerk", _legacyStateWarned = false;
5076
+ var init_paths = () => {};
5077
+
5032
5078
  // node_modules/.bun/yaml@2.8.3/node_modules/yaml/dist/nodes/identity.js
5033
5079
  var require_identity = __commonJS((exports) => {
5034
5080
  var ALIAS = Symbol.for("yaml.alias");
@@ -11993,41 +12039,6 @@ var init_dist = __esm(() => {
11993
12039
  $visitAsync = visit.visitAsync;
11994
12040
  });
11995
12041
 
11996
- // src/config/paths.ts
11997
- import { existsSync as existsSync4 } from "node:fs";
11998
- import { resolve as resolve2 } from "node:path";
11999
- function home() {
12000
- return process.env.HOME ?? "/root";
12001
- }
12002
- function warnLegacyStateOnce(legacy) {
12003
- if (_legacyStateWarned)
12004
- return;
12005
- _legacyStateWarned = true;
12006
- process.stderr.write(`[switchroom] DEPRECATED: reading legacy state from ${legacy}. ` + "Run `mv ~/.clerk ~/.switchroom` (and rename any top-level `clerk:` " + "key in switchroom.yaml to `switchroom:`). This back-compat shim is " + `REMOVED in v0.13.0 — no automatic migration exists.
12007
- `);
12008
- }
12009
- function resolveDualPath(pathStr) {
12010
- const h = home();
12011
- if (pathStr.startsWith("~/")) {
12012
- const rest = pathStr.slice(2);
12013
- const absolute = resolve2(h, rest);
12014
- if (rest.startsWith(`${DEFAULT_STATE_DIR}/`)) {
12015
- const frag = rest.slice(DEFAULT_STATE_DIR.length + 1);
12016
- if (!existsSync4(absolute)) {
12017
- const legacy = resolve2(h, LEGACY_STATE_DIR, frag);
12018
- if (existsSync4(legacy)) {
12019
- warnLegacyStateOnce(legacy);
12020
- return legacy;
12021
- }
12022
- }
12023
- }
12024
- return absolute;
12025
- }
12026
- return resolve2(pathStr);
12027
- }
12028
- var DEFAULT_STATE_DIR = ".switchroom", LEGACY_STATE_DIR = ".clerk", _legacyStateWarned = false;
12029
- var init_paths = () => {};
12030
-
12031
12042
  // src/config/overlay-schema.ts
12032
12043
  var OverlayDocSchema;
12033
12044
  var init_overlay_schema = __esm(() => {
@@ -12726,6 +12737,9 @@ var DEFAULT_RETAIN_MISSION = "Extract user preferences, ongoing projects, recurr
12726
12737
  // src/agents/reconcile-default-skills.ts
12727
12738
  var warnedMissingPool = new Set;
12728
12739
 
12740
+ // src/setup/host-capabilities.ts
12741
+ init_paths();
12742
+
12729
12743
  // src/agents/compose.ts
12730
12744
  var AGENT_UID_MIN = 10001;
12731
12745
  var AGENT_UID_MAX = 10999;
@@ -12750,18 +12764,18 @@ import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:
12750
12764
  import {
12751
12765
  readFileSync as readFileSync3,
12752
12766
  writeFileSync,
12753
- existsSync as existsSync2,
12767
+ existsSync as existsSync3,
12754
12768
  renameSync,
12755
12769
  mkdirSync,
12756
12770
  unlinkSync as unlinkSync2,
12757
12771
  lstatSync,
12758
12772
  realpathSync
12759
12773
  } from "node:fs";
12760
- import { dirname, basename, resolve } from "node:path";
12774
+ import { dirname, basename, resolve as resolve2 } from "node:path";
12761
12775
 
12762
12776
  // src/vault/flock.ts
12763
12777
  import {
12764
- existsSync,
12778
+ existsSync as existsSync2,
12765
12779
  openSync,
12766
12780
  closeSync,
12767
12781
  writeSync,
@@ -12795,7 +12809,7 @@ function readLockHolder(lockPath) {
12795
12809
  }
12796
12810
  function pidIsLive(pid) {
12797
12811
  if (process.platform === "linux") {
12798
- return existsSync(`/proc/${pid}`);
12812
+ return existsSync2(`/proc/${pid}`);
12799
12813
  }
12800
12814
  try {
12801
12815
  process.kill(pid, 0);
@@ -12857,7 +12871,7 @@ function lockFileMtimeIsOlderThan(lockPath, budgetMs) {
12857
12871
  var SENTINEL_DIR_RECENT_WRITE_MS = 60000;
12858
12872
  function clearStaleSentinelDir(lockPath) {
12859
12873
  try {
12860
- if (!existsSync(lockPath))
12874
+ if (!existsSync2(lockPath))
12861
12875
  return true;
12862
12876
  const s = statSync(lockPath);
12863
12877
  if (!s.isDirectory())
@@ -12990,18 +13004,18 @@ var SCRYPT_MAXMEM = 128 * 1024 * 1024;
12990
13004
  function atomicWriteFileSync(path, data, mode) {
12991
13005
  let effectivePath = path;
12992
13006
  try {
12993
- if (existsSync2(path) && lstatSync(path).isSymbolicLink()) {
13007
+ if (existsSync3(path) && lstatSync(path).isSymbolicLink()) {
12994
13008
  effectivePath = realpathSync(path);
12995
13009
  }
12996
13010
  } catch {}
12997
- const dir = dirname(resolve(effectivePath));
12998
- const tmp = resolve(dir, `.${basename(effectivePath)}.${process.pid}.${Date.now()}.tmp`);
13011
+ const dir = dirname(resolve2(effectivePath));
13012
+ const tmp = resolve2(dir, `.${basename(effectivePath)}.${process.pid}.${Date.now()}.tmp`);
12999
13013
  try {
13000
13014
  writeFileSync(tmp, data, { encoding: "utf8", mode });
13001
13015
  renameSync(tmp, effectivePath);
13002
13016
  } catch (err) {
13003
13017
  try {
13004
- if (existsSync2(tmp))
13018
+ if (existsSync3(tmp))
13005
13019
  unlinkSync2(tmp);
13006
13020
  } catch {}
13007
13021
  throw err;
@@ -13060,7 +13074,7 @@ function normalizeSecrets(raw) {
13060
13074
  return out;
13061
13075
  }
13062
13076
  function openVault(passphrase, vaultPath) {
13063
- if (!existsSync2(vaultPath)) {
13077
+ if (!existsSync3(vaultPath)) {
13064
13078
  throw new VaultError(`Vault file not found: ${vaultPath}`);
13065
13079
  }
13066
13080
  let vaultFile;
@@ -13087,7 +13101,7 @@ function openVault(passphrase, vaultPath) {
13087
13101
  return normalizeSecrets(vaultData.secrets ?? {});
13088
13102
  }
13089
13103
  function saveVault(passphrase, vaultPath, secrets) {
13090
- if (!existsSync2(vaultPath)) {
13104
+ if (!existsSync3(vaultPath)) {
13091
13105
  throw new VaultError(`Vault file not found: ${vaultPath}`);
13092
13106
  }
13093
13107
  let releaseLock = null;
@@ -13138,7 +13152,7 @@ function acquireVaultLock(vaultPath) {
13138
13152
  import {
13139
13153
  copyFileSync,
13140
13154
  chmodSync,
13141
- existsSync as existsSync3,
13155
+ existsSync as existsSync4,
13142
13156
  fsyncSync as fsyncSync3,
13143
13157
  lstatSync as lstatSync2,
13144
13158
  mkdirSync as mkdirSync2,
@@ -13152,8 +13166,8 @@ import {
13152
13166
  } from "node:fs";
13153
13167
  import { createHash as createHash2 } from "node:crypto";
13154
13168
  import { basename as basename2, dirname as dirname2, join } from "node:path";
13155
- function vaultLayoutPaths(home) {
13156
- const switchroomRoot = join(home, ".switchroom");
13169
+ function vaultLayoutPaths(home2) {
13170
+ const switchroomRoot = join(home2, ".switchroom");
13157
13171
  return {
13158
13172
  oldPath: join(switchroomRoot, "vault.enc"),
13159
13173
  newPath: join(switchroomRoot, "vault", "vault.enc"),
@@ -13161,16 +13175,16 @@ function vaultLayoutPaths(home) {
13161
13175
  switchroomRoot
13162
13176
  };
13163
13177
  }
13164
- function inspectVaultLayout(home) {
13165
- return runMigration(home, { dryRun: true });
13178
+ function inspectVaultLayout(home2) {
13179
+ return runMigration(home2, { dryRun: true });
13166
13180
  }
13167
- function runMigration(home, opts) {
13168
- const { oldPath, newPath, parent, switchroomRoot } = vaultLayoutPaths(home);
13169
- const lockTarget = existsSync3(newPath) ? newPath : existsSync3(oldPath) ? oldPath : null;
13181
+ function runMigration(home2, opts) {
13182
+ const { oldPath, newPath, parent, switchroomRoot } = vaultLayoutPaths(home2);
13183
+ const lockTarget = existsSync4(newPath) ? newPath : existsSync4(oldPath) ? oldPath : null;
13170
13184
  const release = !opts.dryRun && lockTarget !== null ? acquireVaultLock(lockTarget) : null;
13171
13185
  try {
13172
13186
  const oldStat = lstatSyncOrNull(oldPath);
13173
- const newExists = existsSync3(newPath);
13187
+ const newExists = existsSync4(newPath);
13174
13188
  if (oldStat === null && !newExists) {
13175
13189
  return { kind: "no-vault" };
13176
13190
  }
@@ -13239,7 +13253,7 @@ function sha256File(path) {
13239
13253
  }
13240
13254
  function atomicReplaceWithSymlink(target, linkTarget) {
13241
13255
  const tmp = join(dirname2(target), `.${basename2(target)}.symlink-tmp`);
13242
- if (existsSync3(tmp)) {
13256
+ if (existsSync4(tmp)) {
13243
13257
  try {
13244
13258
  unlinkSync3(tmp);
13245
13259
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.28",
3
+ "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
+ "version": "0.16.38",
4
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
6
  "type": "module",
6
7
  "bin": {
@@ -43,7 +43,9 @@ tools is to let you do the edit yourself.
43
43
  still shares your memory + tools but drops your accumulated conversation
44
44
  context). A **daily/weekly** cron defaults to a **full turn in your live
45
45
  session** (Tier 2: your model, your whole context). So routine frequent
46
- checks are already cheap without you doing anything.
46
+ checks are already cheap without you doing anything. Cron-fresh sessions
47
+ render rich markdown too, so scheduled/digest/daily-memory output should
48
+ use **bold** section headers + `code` for identifiers, just like a live reply.
47
49
  - *`model: "sonnet"` / `context: "fresh"`* — force the cheap Tier-1 session
48
50
  even for a **daily/weekly** self-contained job (summarise a feed, format a
49
51
  digest) — overrides the cadence default. *`context: "agent"`* does the
@@ -53,7 +55,10 @@ tools is to let you do the edit yourself.
53
55
  ping — text fully determined, no thinking) — ask the **operator** for a
54
56
  **`kind: action`**: it runs **model-free, zero tokens** (no session at all),
55
57
  posting your fixed/templated text or firing a fixed request. The cheapest
56
- tier there is.
58
+ tier there is. The action `text` is rendered as full GFM markdown
59
+ (**bold**, _italic_, `code`, ~~strikethrough~~, links) — so a literal `*`
60
+ or `_` in fixed text becomes formatting unless you set `parse_mode: text`,
61
+ which sends the string verbatim (the escape hatch).
57
62
  - *"Only act when X changes"* — don't poll with a frequent prompt cron (every
58
63
  fire is a wasted turn when nothing changed). Ask the **operator** for a
59
64
  **`kind: poll`** (model-free check, e.g. a webpage/API — only a *change*