switchroom 0.19.48 → 0.20.1

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 (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -10994,7 +10994,7 @@ var init_observation_scopes = __esm(() => {
10994
10994
  });
10995
10995
 
10996
10996
  // src/config/schema.ts
10997
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
10997
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
10998
10998
  var init_schema = __esm(() => {
10999
10999
  init_zod();
11000
11000
  init_observation_scopes();
@@ -11241,6 +11241,7 @@ var init_schema = __esm(() => {
11241
11241
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11242
11242
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11243
11243
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
11244
+ briefing: exports_external.enum(["gateway", "legacy"]).optional().describe("Which mechanism assembles the fresh-session reorientation briefing " + "(default 'legacy'). 'legacy' keeps today's behaviour: the Stop-hook " + ".handoff.md and/or bin/handoff-briefing.sh, injected via " + "--append-system-prompt. 'gateway' moves it to a gateway boot-time " + "builder sourced from the durable history.db (crash-independent, " + "surface-scoped, token-budgeted) and injects it as a synthetic " + '<channel source="boot_briefing"> inbound over the durable spool — ' + "keeping the system-prompt prefix stable for cross-session prompt " + "caching. Suppressed automatically when resume_mode is " + "'continue'/'auto' (the transcript may be replayed) and on a /reset " + "force-fresh boot. Threaded to the gateway as " + "SWITCHROOM_SESSION_BRIEFING."),
11244
11245
  boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
11245
11246
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
11246
11247
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
@@ -11373,8 +11374,24 @@ var init_schema = __esm(() => {
11373
11374
  }
11374
11375
  return tg;
11375
11376
  });
11377
+ BuzzChannelSchema = exports_external.object({
11378
+ enabled: exports_external.boolean().default(false).describe("Master switch for the per-agent Buzz sidecar. Default false — the " + "channel ships dark; start.sh forks the sidecar only when true."),
11379
+ relay_url: exports_external.string().regex(/^wss?:\/\//, "relay_url must be a ws:// or wss:// URL").describe("CANONICAL WebSocket URL of the closed Buzz relay — the exact string " + "the relay expects in the NIP-42 `relay` auth tag (e.g. " + "'ws://127.0.0.1:3000'). A live probe proved the relay validates this " + "tag as an exact string match against its own URL BEFORE the " + "membership check, so it is the relay's advertised identity, NOT " + "necessarily the address the sidecar dials. Set relay_dial_url when " + "the reachable address differs (a docker-network IP)."),
11380
+ relay_dial_url: exports_external.string().regex(/^wss?:\/\//, "relay_dial_url must be a ws:// or wss:// URL").optional().describe("Reachable ws:// / wss:// address the sidecar DIALS when it differs " + "from the canonical relay_url (e.g. a docker-network IP the relay's " + "own 127.0.0.1 can't stand in for). The NIP-42 auth tag still uses " + "relay_url. Defaults to relay_url when unset."),
11381
+ relay_host: exports_external.string().regex(/^(\[[0-9a-fA-F:]+\]|[^\s/?#:@]+)(:\d+)?$/, "relay_host must be a bare host[:port] authority — no scheme, path, or userinfo (e.g. '127.0.0.1:3000')").describe("REQUIRED HTTP Host header authority sent verbatim on the WS upgrade " + "(e.g. '127.0.0.1:3000', port included). The relay resolves its " + "community from this header before the upgrade and returns HTTP 404 if " + "it is missing/wrong, so it must match the relay's configured " + "authority and is deployment config, never derived from the dial URL."),
11382
+ nsec_vault_key: exports_external.string().default("buzz/{agent}-nsec").describe("Vault KEY NAME for the agent's Nostr secret key. Broker-fetched " + "in-process at sidecar boot; NEVER resolved into env or logged. " + "'{agent}' is substituted with the agent name."),
11383
+ operator_pubkey: exports_external.string().regex(/^(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})$/, "operator_pubkey must be a bech32 npub or 64-char hex pubkey").describe("The operator's Nostr pubkey (npub or hex). Always in the effective " + "inbound allowlist — the fail-closed default is operator-only."),
11384
+ authorized_pubkeys: exports_external.array(exports_external.string()).default([]).describe("Additional pubkeys (npub or hex) whose signed events may become " + "turns. Effective allowlist = this ∪ {operator_pubkey}. Empty by " + "default (operator-only)."),
11385
+ mirror: exports_external.enum(["both", "origin", "off"]).default("both").describe("Cross-surface mirror mode. 'both' answers on the origin channel AND " + "mirrors a copy to the other; 'off' is a true kill-switch that disables " + "the channel in BOTH directions (the inbound sidecar exits idle). " + "Phase 2b (S2): 'origin' is DEFERRED — the hub's mirror hook lives only " + "in sendReply, so 'origin' cannot be honored soundly; a configured " + "'origin' is degraded to 'off' (dark) at runtime by both the sidecar " + "config loader and the hub (channel-route.ts parseConfiguredMirrorMode). " + "Only 'both' and 'off' ship live in 2b."),
11386
+ chat_id: exports_external.string().min(1, "chat_id must be a non-empty Telegram chat id").describe("Telegram chat id an injected Buzz turn is routed to. Phase 1 is " + "inbound-only, so the agent's reply lands here on Telegram (the " + "authoritative surface); in later phases this is the chat the Buzz " + "turn's Telegram copy maps to. Required — the sidecar refuses to run " + "live without it (BUZZ_CHAT_ID)."),
11387
+ default_channel_id: exports_external.string().describe("Relay-minted group UUID (the NIP-29 `h` tag) the sidecar subscribes " + "to and stamps on injected turns."),
11388
+ channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs → friendly labels."),
11389
+ pubkey_names: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional petnames: hex/npub pubkey → display name, used to label " + "the sender on injected turns."),
11390
+ pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). RESERVED — no consumer of this field " + "exists; the existing compat-check (compat-check.ts) validates only " + "the wire contract (AUTH kind, message kind, tag names) and does not " + "read this field. Kept in the schema so the intended digest-pin can " + "be wired without a config shape change.")
11391
+ }).strict();
11376
11392
  ChannelsSchema = exports_external.object({
11377
- telegram: TelegramChannelSchema
11393
+ telegram: TelegramChannelSchema,
11394
+ buzz: BuzzChannelSchema.optional()
11378
11395
  }).optional();
11379
11396
  TIMEZONE_REGEX = /^UTC$|^[A-Z][A-Za-z0-9_+-]+(\/[A-Z][A-Za-z0-9_+-]+){1,2}$/;
11380
11397
  ApproverIdSchema = exports_external.union([exports_external.number(), exports_external.string().regex(/^\d+$/)]);
@@ -21415,7 +21432,7 @@ function allocateAgentUid(name) {
21415
21432
  }
21416
21433
 
21417
21434
  // src/build-info.ts
21418
- var VERSION = "0.19.48";
21435
+ var VERSION = "0.20.1";
21419
21436
 
21420
21437
  // src/setup/hindsight-recall-tunables.ts
21421
21438
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
@@ -28355,6 +28372,20 @@ function resolveOperatorUid(ownershipDeps = {}) {
28355
28372
  // src/cli/rollout.ts
28356
28373
  init_atomic();
28357
28374
 
28375
+ // src/config/hostd-template-version.ts
28376
+ var HOSTD_TEMPLATE_LAST_CHANGED = "v0.19.43";
28377
+ function hostdTemplateRegenVerdict(target, fromVersion) {
28378
+ const cmpTarget = compareReleaseTags(HOSTD_TEMPLATE_LAST_CHANGED, target);
28379
+ if (cmpTarget === null)
28380
+ return "unknown";
28381
+ const cmpFrom = compareReleaseTags(HOSTD_TEMPLATE_LAST_CHANGED, fromVersion);
28382
+ if (cmpFrom === null)
28383
+ return "unknown";
28384
+ const targetHas = cmpTarget <= 0;
28385
+ const fromHas = cmpFrom <= 0;
28386
+ return targetHas === fromHas ? "not-needed" : "required";
28387
+ }
28388
+
28358
28389
  // src/cli/resolve-version.ts
28359
28390
  import { existsSync as existsSync14, readFileSync as readFileSync10 } from "node:fs";
28360
28391
  import { dirname as dirname7, join as join9 } from "node:path";
@@ -29716,12 +29747,31 @@ function etaLine(s) {
29716
29747
  const meanMs = done.reduce((t, a) => t + (a.durationMs ?? 0), 0) / done.length;
29717
29748
  return `~${formatDurationMs(meanMs * remaining)} left (rough est.)`;
29718
29749
  }
29719
- function deferredLines(target) {
29750
+ function deferredLines(target, fromVersion) {
29720
29751
  const bare = target.trim().replace(/^v/, "");
29721
- return [
29752
+ const head = [
29722
29753
  `**Verified on ${target}** — every component this roll owned passed \`verify-components\`, so this is host convergence, not just the agents. Anything the roll was told to skip is named in its warnings.`,
29723
- `**Still host-side (nothing in a roll can do these):**`,
29724
- `- host operator CLI — \`sudo npm i -g switchroom@${bare}\``,
29754
+ `**Still host-side (nothing in a roll can do these):**`
29755
+ ];
29756
+ const verdict = hostdTemplateRegenVerdict(target, fromVersion);
29757
+ if (verdict === "required") {
29758
+ return [
29759
+ ...head,
29760
+ `- host operator CLI + hostd template regen — regen is **REQUIRED** for this roll (hostd mounts/env changed in ${HOSTD_TEMPLATE_LAST_CHANGED}). One copy-paste:`,
29761
+ ` \`sudo npm i -g switchroom@${bare} && switchroom hostd install --tag ${target}\``
29762
+ ];
29763
+ }
29764
+ const cliLine = `- host operator CLI — \`sudo npm i -g switchroom@${bare}\``;
29765
+ if (verdict === "not-needed") {
29766
+ return [
29767
+ ...head,
29768
+ cliLine,
29769
+ `- hostd template regen: **not needed** for this release — hostd mounts/env unchanged since ${HOSTD_TEMPLATE_LAST_CHANGED}.`
29770
+ ];
29771
+ }
29772
+ return [
29773
+ ...head,
29774
+ cliLine,
29725
29775
  `- hostd template regen (only if the release changed hostd mounts/env) — \`switchroom hostd install --tag ${target}\``
29726
29776
  ];
29727
29777
  }
@@ -29765,7 +29815,7 @@ function renderWith(s, compact) {
29765
29815
  if (warnings.length > 0)
29766
29816
  parts2.push("", ...warnings);
29767
29817
  if (s.deferred !== false)
29768
- parts2.push("", ...deferredLines(s.target));
29818
+ parts2.push("", ...deferredLines(s.target, s.fromVersion));
29769
29819
  return parts2.join(`
29770
29820
  `);
29771
29821
  }
@@ -32834,7 +32884,7 @@ class LogTailRolloutNarrator {
32834
32884
  case "hindsight-refresh":
32835
32885
  case "hindsight-refresh-done":
32836
32886
  case "hindsight-skipped":
32837
- return Number.MAX_SAFE_INTEGER - 3;
32887
+ return 1;
32838
32888
  case "hostd-web-deferred":
32839
32889
  return Number.MAX_SAFE_INTEGER - 1;
32840
32890
  default:
@@ -4434,7 +4434,7 @@ var init_observation_scopes = __esm(() => {
4434
4434
  });
4435
4435
 
4436
4436
  // src/config/schema.ts
4437
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
4437
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
4438
4438
  var init_schema = __esm(() => {
4439
4439
  init_zod();
4440
4440
  init_observation_scopes();
@@ -4681,6 +4681,7 @@ var init_schema = __esm(() => {
4681
4681
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
4682
4682
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
4683
4683
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
4684
+ briefing: exports_external.enum(["gateway", "legacy"]).optional().describe("Which mechanism assembles the fresh-session reorientation briefing " + "(default 'legacy'). 'legacy' keeps today's behaviour: the Stop-hook " + ".handoff.md and/or bin/handoff-briefing.sh, injected via " + "--append-system-prompt. 'gateway' moves it to a gateway boot-time " + "builder sourced from the durable history.db (crash-independent, " + "surface-scoped, token-budgeted) and injects it as a synthetic " + '<channel source="boot_briefing"> inbound over the durable spool — ' + "keeping the system-prompt prefix stable for cross-session prompt " + "caching. Suppressed automatically when resume_mode is " + "'continue'/'auto' (the transcript may be replayed) and on a /reset " + "force-fresh boot. Threaded to the gateway as " + "SWITCHROOM_SESSION_BRIEFING."),
4684
4685
  boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
4685
4686
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
4686
4687
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
@@ -4813,8 +4814,24 @@ var init_schema = __esm(() => {
4813
4814
  }
4814
4815
  return tg;
4815
4816
  });
4817
+ BuzzChannelSchema = exports_external.object({
4818
+ enabled: exports_external.boolean().default(false).describe("Master switch for the per-agent Buzz sidecar. Default false — the " + "channel ships dark; start.sh forks the sidecar only when true."),
4819
+ relay_url: exports_external.string().regex(/^wss?:\/\//, "relay_url must be a ws:// or wss:// URL").describe("CANONICAL WebSocket URL of the closed Buzz relay — the exact string " + "the relay expects in the NIP-42 `relay` auth tag (e.g. " + "'ws://127.0.0.1:3000'). A live probe proved the relay validates this " + "tag as an exact string match against its own URL BEFORE the " + "membership check, so it is the relay's advertised identity, NOT " + "necessarily the address the sidecar dials. Set relay_dial_url when " + "the reachable address differs (a docker-network IP)."),
4820
+ relay_dial_url: exports_external.string().regex(/^wss?:\/\//, "relay_dial_url must be a ws:// or wss:// URL").optional().describe("Reachable ws:// / wss:// address the sidecar DIALS when it differs " + "from the canonical relay_url (e.g. a docker-network IP the relay's " + "own 127.0.0.1 can't stand in for). The NIP-42 auth tag still uses " + "relay_url. Defaults to relay_url when unset."),
4821
+ relay_host: exports_external.string().regex(/^(\[[0-9a-fA-F:]+\]|[^\s/?#:@]+)(:\d+)?$/, "relay_host must be a bare host[:port] authority — no scheme, path, or userinfo (e.g. '127.0.0.1:3000')").describe("REQUIRED HTTP Host header authority sent verbatim on the WS upgrade " + "(e.g. '127.0.0.1:3000', port included). The relay resolves its " + "community from this header before the upgrade and returns HTTP 404 if " + "it is missing/wrong, so it must match the relay's configured " + "authority and is deployment config, never derived from the dial URL."),
4822
+ nsec_vault_key: exports_external.string().default("buzz/{agent}-nsec").describe("Vault KEY NAME for the agent's Nostr secret key. Broker-fetched " + "in-process at sidecar boot; NEVER resolved into env or logged. " + "'{agent}' is substituted with the agent name."),
4823
+ operator_pubkey: exports_external.string().regex(/^(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})$/, "operator_pubkey must be a bech32 npub or 64-char hex pubkey").describe("The operator's Nostr pubkey (npub or hex). Always in the effective " + "inbound allowlist — the fail-closed default is operator-only."),
4824
+ authorized_pubkeys: exports_external.array(exports_external.string()).default([]).describe("Additional pubkeys (npub or hex) whose signed events may become " + "turns. Effective allowlist = this ∪ {operator_pubkey}. Empty by " + "default (operator-only)."),
4825
+ mirror: exports_external.enum(["both", "origin", "off"]).default("both").describe("Cross-surface mirror mode. 'both' answers on the origin channel AND " + "mirrors a copy to the other; 'off' is a true kill-switch that disables " + "the channel in BOTH directions (the inbound sidecar exits idle). " + "Phase 2b (S2): 'origin' is DEFERRED — the hub's mirror hook lives only " + "in sendReply, so 'origin' cannot be honored soundly; a configured " + "'origin' is degraded to 'off' (dark) at runtime by both the sidecar " + "config loader and the hub (channel-route.ts parseConfiguredMirrorMode). " + "Only 'both' and 'off' ship live in 2b."),
4826
+ chat_id: exports_external.string().min(1, "chat_id must be a non-empty Telegram chat id").describe("Telegram chat id an injected Buzz turn is routed to. Phase 1 is " + "inbound-only, so the agent's reply lands here on Telegram (the " + "authoritative surface); in later phases this is the chat the Buzz " + "turn's Telegram copy maps to. Required — the sidecar refuses to run " + "live without it (BUZZ_CHAT_ID)."),
4827
+ default_channel_id: exports_external.string().describe("Relay-minted group UUID (the NIP-29 `h` tag) the sidecar subscribes " + "to and stamps on injected turns."),
4828
+ channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs → friendly labels."),
4829
+ pubkey_names: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional petnames: hex/npub pubkey → display name, used to label " + "the sender on injected turns."),
4830
+ pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). RESERVED — no consumer of this field " + "exists; the existing compat-check (compat-check.ts) validates only " + "the wire contract (AUTH kind, message kind, tag names) and does not " + "read this field. Kept in the schema so the intended digest-pin can " + "be wired without a config shape change.")
4831
+ }).strict();
4816
4832
  ChannelsSchema = exports_external.object({
4817
- telegram: TelegramChannelSchema
4833
+ telegram: TelegramChannelSchema,
4834
+ buzz: BuzzChannelSchema.optional()
4818
4835
  }).optional();
4819
4836
  TIMEZONE_REGEX = /^UTC$|^[A-Z][A-Za-z0-9_+-]+(\/[A-Z][A-Za-z0-9_+-]+){1,2}$/;
4820
4837
  ApproverIdSchema = exports_external.union([exports_external.number(), exports_external.string().regex(/^\d+$/)]);
@@ -4030,7 +4030,7 @@ var init_observation_scopes = __esm(() => {
4030
4030
  });
4031
4031
 
4032
4032
  // src/config/schema.ts
4033
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
4033
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
4034
4034
  var init_schema = __esm(() => {
4035
4035
  init_zod();
4036
4036
  init_observation_scopes();
@@ -4277,6 +4277,7 @@ var init_schema = __esm(() => {
4277
4277
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
4278
4278
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
4279
4279
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
4280
+ briefing: exports_external.enum(["gateway", "legacy"]).optional().describe("Which mechanism assembles the fresh-session reorientation briefing " + "(default 'legacy'). 'legacy' keeps today's behaviour: the Stop-hook " + ".handoff.md and/or bin/handoff-briefing.sh, injected via " + "--append-system-prompt. 'gateway' moves it to a gateway boot-time " + "builder sourced from the durable history.db (crash-independent, " + "surface-scoped, token-budgeted) and injects it as a synthetic " + '<channel source="boot_briefing"> inbound over the durable spool — ' + "keeping the system-prompt prefix stable for cross-session prompt " + "caching. Suppressed automatically when resume_mode is " + "'continue'/'auto' (the transcript may be replayed) and on a /reset " + "force-fresh boot. Threaded to the gateway as " + "SWITCHROOM_SESSION_BRIEFING."),
4280
4281
  boot_resume: exports_external.enum(["always", "in-flight", "never"]).optional().describe("How the gateway auto-resumes a turn that was IN FLIGHT when the " + "agent restarted. 'in-flight' (default) resumes genuinely " + "interrupted work even after a deliberate/operator restart — a " + "sanctioned restart landing mid-turn no longer silently drops the " + "work. 'always' forces resume unconditionally (same as the " + "SWITCHROOM_BOOT_RESUME_ALWAYS=1 escape hatch). 'never' is the " + "quota-saving posture: don't auto-replay work across a clean " + "restart — but the user is STILL sent a passive notice of what was " + "in flight (silence is never used). Independent of the at-most-once " + "resume ledger and the bounded resume-chain loop-guard, which always " + "apply. Threaded to the gateway as SWITCHROOM_BOOT_RESUME."),
4281
4282
  session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
4282
4283
  session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
@@ -4409,8 +4410,24 @@ var init_schema = __esm(() => {
4409
4410
  }
4410
4411
  return tg;
4411
4412
  });
4413
+ BuzzChannelSchema = exports_external.object({
4414
+ enabled: exports_external.boolean().default(false).describe("Master switch for the per-agent Buzz sidecar. Default false — the " + "channel ships dark; start.sh forks the sidecar only when true."),
4415
+ relay_url: exports_external.string().regex(/^wss?:\/\//, "relay_url must be a ws:// or wss:// URL").describe("CANONICAL WebSocket URL of the closed Buzz relay — the exact string " + "the relay expects in the NIP-42 `relay` auth tag (e.g. " + "'ws://127.0.0.1:3000'). A live probe proved the relay validates this " + "tag as an exact string match against its own URL BEFORE the " + "membership check, so it is the relay's advertised identity, NOT " + "necessarily the address the sidecar dials. Set relay_dial_url when " + "the reachable address differs (a docker-network IP)."),
4416
+ relay_dial_url: exports_external.string().regex(/^wss?:\/\//, "relay_dial_url must be a ws:// or wss:// URL").optional().describe("Reachable ws:// / wss:// address the sidecar DIALS when it differs " + "from the canonical relay_url (e.g. a docker-network IP the relay's " + "own 127.0.0.1 can't stand in for). The NIP-42 auth tag still uses " + "relay_url. Defaults to relay_url when unset."),
4417
+ relay_host: exports_external.string().regex(/^(\[[0-9a-fA-F:]+\]|[^\s/?#:@]+)(:\d+)?$/, "relay_host must be a bare host[:port] authority — no scheme, path, or userinfo (e.g. '127.0.0.1:3000')").describe("REQUIRED HTTP Host header authority sent verbatim on the WS upgrade " + "(e.g. '127.0.0.1:3000', port included). The relay resolves its " + "community from this header before the upgrade and returns HTTP 404 if " + "it is missing/wrong, so it must match the relay's configured " + "authority and is deployment config, never derived from the dial URL."),
4418
+ nsec_vault_key: exports_external.string().default("buzz/{agent}-nsec").describe("Vault KEY NAME for the agent's Nostr secret key. Broker-fetched " + "in-process at sidecar boot; NEVER resolved into env or logged. " + "'{agent}' is substituted with the agent name."),
4419
+ operator_pubkey: exports_external.string().regex(/^(npub1[02-9ac-hj-np-z]{58}|[0-9a-f]{64})$/, "operator_pubkey must be a bech32 npub or 64-char hex pubkey").describe("The operator's Nostr pubkey (npub or hex). Always in the effective " + "inbound allowlist — the fail-closed default is operator-only."),
4420
+ authorized_pubkeys: exports_external.array(exports_external.string()).default([]).describe("Additional pubkeys (npub or hex) whose signed events may become " + "turns. Effective allowlist = this ∪ {operator_pubkey}. Empty by " + "default (operator-only)."),
4421
+ mirror: exports_external.enum(["both", "origin", "off"]).default("both").describe("Cross-surface mirror mode. 'both' answers on the origin channel AND " + "mirrors a copy to the other; 'off' is a true kill-switch that disables " + "the channel in BOTH directions (the inbound sidecar exits idle). " + "Phase 2b (S2): 'origin' is DEFERRED — the hub's mirror hook lives only " + "in sendReply, so 'origin' cannot be honored soundly; a configured " + "'origin' is degraded to 'off' (dark) at runtime by both the sidecar " + "config loader and the hub (channel-route.ts parseConfiguredMirrorMode). " + "Only 'both' and 'off' ship live in 2b."),
4422
+ chat_id: exports_external.string().min(1, "chat_id must be a non-empty Telegram chat id").describe("Telegram chat id an injected Buzz turn is routed to. Phase 1 is " + "inbound-only, so the agent's reply lands here on Telegram (the " + "authoritative surface); in later phases this is the chat the Buzz " + "turn's Telegram copy maps to. Required — the sidecar refuses to run " + "live without it (BUZZ_CHAT_ID)."),
4423
+ default_channel_id: exports_external.string().describe("Relay-minted group UUID (the NIP-29 `h` tag) the sidecar subscribes " + "to and stamps on injected turns."),
4424
+ channel_map: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional map of extra group UUIDs → friendly labels."),
4425
+ pubkey_names: exports_external.record(exports_external.string(), exports_external.string()).default({}).describe("Optional petnames: hex/npub pubkey → display name, used to label " + "the sender on injected turns."),
4426
+ pinned_relay_digest: exports_external.string().optional().describe("Pinned relay image digest (M4). RESERVED — no consumer of this field " + "exists; the existing compat-check (compat-check.ts) validates only " + "the wire contract (AUTH kind, message kind, tag names) and does not " + "read this field. Kept in the schema so the intended digest-pin can " + "be wired without a config shape change.")
4427
+ }).strict();
4412
4428
  ChannelsSchema = exports_external.object({
4413
- telegram: TelegramChannelSchema
4429
+ telegram: TelegramChannelSchema,
4430
+ buzz: BuzzChannelSchema.optional()
4414
4431
  }).optional();
4415
4432
  TIMEZONE_REGEX = /^UTC$|^[A-Z][A-Za-z0-9_+-]+(\/[A-Z][A-Za-z0-9_+-]+){1,2}$/;
4416
4433
  ApproverIdSchema = exports_external.union([exports_external.number(), exports_external.string().regex(/^\d+$/)]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
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.19.48",
4
+ "version": "0.20.1",
5
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.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -24,11 +24,11 @@
24
24
  "build": "node scripts/build.mjs",
25
25
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
26
26
  "pretest": "npm run build",
27
- "test": "vitest run && bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
27
+ "test": "vitest run && bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
28
28
  "test:vitest": "vitest run",
29
- "test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-scope-persist.test.ts src/vault/broker/server-tokenless-scope.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/self-approval-bypass.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
29
+ "test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-scope-persist.test.ts src/vault/broker/server-tokenless-scope.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/self-approval-bypass.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts",
31
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
32
32
  "lint:tsc": "tsc --noEmit",
33
33
  "lint:hindsight-write-redaction": "node scripts/check-hindsight-write-redaction.mjs",
34
34
  "lint:secret-pattern-parity": "bun scripts/check-secret-pattern-parity.ts",
@@ -50,6 +50,7 @@
50
50
  "lint:litellm-config-guard": "node scripts/check-litellm-config-guard.mjs",
51
51
  "lint:release-asset-contract": "node scripts/check-release-asset-names.mjs",
52
52
  "lint:agent-attribution-trailers": "node scripts/check-agent-attribution-trailers.mjs",
53
+ "lint:hostd-template-guard": "bun scripts/check-hostd-template-guard.ts",
53
54
  "prepublishOnly": "npm run build && npm run lint && npm test"
54
55
  },
55
56
  "dependencies": {
@@ -63,6 +64,7 @@
63
64
  "commander": "^13.1.0",
64
65
  "grammy": "^1.44",
65
66
  "handlebars": "^4.7.8",
67
+ "nostr-tools": "^2.24.1",
66
68
  "posthog-node": "^5.29.2",
67
69
  "yaml": "^2.7.0",
68
70
  "zod": "^3.24.0"
@@ -57,6 +57,63 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
57
57
  # `claude` --continue path, not the gateway), so this needs its own hoist.
58
58
  export SWITCHROOM_BOOT_RESUME="{{#if bootResumeMode}}{{{bootResumeMode}}}{{else}}in-flight{{/if}}"
59
59
 
60
+ # Boot-briefing transport flag (session_continuity.briefing; default
61
+ # 'legacy'). When 'gateway', the gateway daemon assembles the fresh-session
62
+ # reorientation briefing at boot from the durable history.db and injects it
63
+ # as a spooled synthetic inbound (<channel source="boot_briefing">) instead
64
+ # of the handoff-briefing.sh --append-system-prompt path. MUST be exported
65
+ # here, before the gateway fork below (same env-fork landmine as
66
+ # SWITCHROOM_BOOT_RESUME). The gateway also needs the resume MODE to
67
+ # suppress the briefing when 'continue'/'auto' may replay the transcript —
68
+ # hoist that too (the inner-pass SWITCHROOM_RESUME_MODE assignment below
69
+ # renders the same template value, so the two can never disagree).
70
+ export SWITCHROOM_SESSION_BRIEFING="{{#if sessionBriefingMode}}{{{sessionBriefingMode}}}{{else}}legacy{{/if}}"
71
+ export SWITCHROOM_RESUME_MODE="{{#if resumeMode}}{{{resumeMode}}}{{else}}handoff{{/if}}"
72
+
73
+ {{#if hindsightEnabled}}
74
+ # Hindsight endpoint for the gateway boot briefing (session_continuity.briefing:
75
+ # gateway). When assembling the fresh-session reorientation briefing the gateway
76
+ # daemon does its OWN Hindsight recall — mirroring bin/handoff-briefing.sh — to
77
+ # fold recent memory into the briefing alongside the durable Telegram history.
78
+ # The authoritative exports live in the inner pass (~line 893), AFTER the
79
+ # gateway fork below, so without this hoist the forked daemon never sees the
80
+ # endpoint and the Hindsight section silently no-ops for every boot (same
81
+ # start.sh env-fork landmine as SWITCHROOM_SESSION_BRIEFING above). Hoist both
82
+ # here, before the fork; idempotent with the inner-pass exports (same rendered
83
+ # template values). Pinned by tests/scaffold.gateway-env-order.test.ts.
84
+ export HINDSIGHT_API_URL={{{hindsightApiBaseUrlQ}}}
85
+ export HINDSIGHT_BANK_ID={{{hindsightBankIdQ}}}
86
+ {{/if}}
87
+
88
+ # Force-fresh suppression signal for the gateway boot briefing. The gateway
89
+ # suppresses the briefing when this boot is a /reset·/new fresh start —
90
+ # re-feeding the just-reset conversation into the fresh session would defeat
91
+ # the reset. It MUST NOT rely on existsSync(.force-fresh-session) at its own
92
+ # module-eval time: this OUTER pass forks the gateway with `&` (below) then
93
+ # execs the inner tmux pass, whose CONTINUE_FLAG block `rm`s the marker (see
94
+ # ~line 1262). The fork and the `rm` race with no ordering, and under boot
95
+ # CPU contention the `rm` can win before the gateway bundle evaluates its
96
+ # briefing check — the marker vanishes and the reset session gets re-fed the
97
+ # conversation it just reset. Snapshot the marker into env HERE, before the
98
+ # fork, so the gateway reads a value fixed at fork time and immune to the
99
+ # later `rm`. The inner pass still consumes the marker for the claude
100
+ # --continue / session-mode logic; this only mirrors it for the fork race.
101
+ if [ -f "{{agentDir}}/.force-fresh-session" ]; then
102
+ export SWITCHROOM_FORCE_FRESH=1
103
+ fi
104
+
105
+ # Session-generation stamp for the gateway boot briefing (#4242). Set ONCE
106
+ # here, per real boot, before the gateway fork below. `_switchroom_supervise`
107
+ # respawns the gateway `bun` process in a `while` loop inside this same shell
108
+ # (it does NOT re-run start.sh), so every crash-respawn inherits this exact
109
+ # value, while the next real container boot re-derives a fresh one. The
110
+ # gateway persists it the first time it briefs and skips re-minting when the
111
+ # persisted stamp matches — that distinguishes "supervisor respawned me into
112
+ # a still-live Claude session" (skip) from "genuine new boot" (brief). The
113
+ # epoch second keeps it monotonic across boots; $$ (this outer pass's PID)
114
+ # plus ${RANDOM} guarantee uniqueness even for two boots within one second.
115
+ export SWITCHROOM_GATEWAY_BOOT_ID="$(date +%s)-$$-${RANDOM}"
116
+
60
117
  # Gateway-consumed env MUST be exported HERE, before the gateway fork
61
118
  # below. The gateway daemon reads channels.telegram.* knobs (and any
62
119
  # agent env) from process.env at startup — e.g. SWITCHROOM_TG_STREAM_
@@ -408,6 +465,32 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
408
465
 
409
466
  _gateway_bundle=/opt/switchroom/telegram-plugin/dist/gateway/gateway.js
410
467
  _telegram_enabled={{#if telegramEnabledFlag}}{{telegramEnabledFlag}}{{else}}true{{/if}}
468
+
469
+ # Version-skew handshake for the gateway boot briefing (#4245). This start.sh
470
+ # is fresh; the deployed /opt/switchroom gateway bundle may NOT be. When it
471
+ # sets briefing=gateway it also skips the legacy shell handoff-briefing
472
+ # assembler (inner pass, below) — so if the bundle PREDATES the boot-briefing
473
+ # builder, BOTH paths are dead and the agent gets a SILENT no-briefing until
474
+ # the image is updated. The stale bundle cannot self-report a feature it lacks,
475
+ # so this fresh start.sh does the handshake: grep the bundle for the capability
476
+ # sentinel the builder exports (GATEWAY_BOOT_BRIEFING_CAPABILITY in
477
+ # telegram-plugin/gateway/boot-briefing-capability.ts; the bundle is built
478
+ # un-minified so the literal survives verbatim). On a miss, warn LOUDLY and
479
+ # drop a marker so the inner pass runs the legacy handoff assembler as a
480
+ # fallback instead of leaving this boot with nothing. Resolve the marker afresh
481
+ # every boot (rm, then re-touch only on a confirmed miss) so a later image
482
+ # update self-heals with no leftover state.
483
+ _briefing_skew_marker="{{agentDir}}/.gateway-briefing-unavailable"
484
+ rm -f "$_briefing_skew_marker"
485
+ if [ "$SWITCHROOM_SESSION_BRIEFING" = "gateway" ]; then
486
+ if [ -f "$_gateway_bundle" ] && grep -q '{{gatewayBriefingCapability}}' "$_gateway_bundle" 2>/dev/null; then
487
+ : # bundle carries the boot-briefing builder — gateway path is live
488
+ else
489
+ echo "[start.sh] WARNING: session_continuity.briefing=gateway is configured, but the deployed gateway bundle ($_gateway_bundle) PREDATES the boot-briefing builder (capability sentinel '{{gatewayBriefingCapability}}' absent from the bundle). The gateway cannot assemble the reorientation briefing, and the legacy shell handoff path is skipped for 'gateway' mode — so this boot would otherwise get NO briefing at all. FALLING BACK to the legacy handoff-briefing assembler for this boot. Update the agent image (/opt/switchroom) to a build that includes the boot-briefing feature to use the gateway path." >&2
490
+ touch "$_briefing_skew_marker" 2>/dev/null || true
491
+ fi
492
+ fi
493
+
411
494
  if [ "$_telegram_enabled" = "true" ] && [ -f "$_gateway_bundle" ] && command -v bun >/dev/null 2>&1; then
412
495
  _switchroom_supervise gateway /var/log/switchroom/gateway-supervisor.log \
413
496
  bun "$_gateway_bundle" &
@@ -455,6 +538,33 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
455
538
  bun /opt/switchroom/agent-scheduler/index.js &
456
539
  fi
457
540
 
541
+ # 3b) Buzz co-channel inbound sidecar (Phase 1, channels.buzz).
542
+ # A supervised sibling that opens a WebSocket Nostr subscription to a
543
+ # closed Buzz relay, NIP-42-authenticates, and injects allowlisted
544
+ # messages onto the gateway socket as synthesized turns
545
+ # (meta.source="buzz") — exactly like the cron sidecar above. INBOUND
546
+ # ONLY in Phase 1; replies land on Telegram (the authoritative surface).
547
+ #
548
+ # Default OFF and deterministic: the block is present for every agent
549
+ # (start.sh stays byte-identical fleet-wide) but forks the sidecar ONLY
550
+ # when BUZZ_ENABLED=1 is set in the container env. That var is projected
551
+ # by src/agents/compose.ts from the cascade-resolved channels.buzz block,
552
+ # and BUZZ_ENABLED=1 lands ONLY when that block sets enabled:true. An
553
+ # enabled:false/absent block leaves it unset, so for those agents this
554
+ # block is a no-op and the channel is dark by construction. The sidecar
555
+ # itself also fails closed if its env is incomplete, and fetches the nsec
556
+ # in-process from the vault broker (never an env var, never logged).
557
+ if [ "$BUZZ_ENABLED" = "1" ] \
558
+ && [ -f /opt/switchroom/buzz-gateway/index.js ] \
559
+ && command -v bun >/dev/null 2>&1; then
560
+ # --oneshot-ok: the sidecar exits 0 when the channel resolves not-live
561
+ # (e.g. mirror:off, or a not-live env). A clean exit 0 = "nothing to do",
562
+ # so DON'T respawn it every 60s (that is the documented churn pattern
563
+ # above). A non-zero crash while live still respawns.
564
+ _switchroom_supervise buzz-gateway /var/log/switchroom/buzz-gateway.log --oneshot-ok \
565
+ bun /opt/switchroom/buzz-gateway/index.js &
566
+ fi
567
+
458
568
  {{#if cronSessionEnabled}}
459
569
  # 4) cheap cron SESSION (Tier 1, reference/rfcs/cheap-cron-sessions.md §2.2).
460
570
  # A SECOND interactive claude (no -p) dedicated to context:fresh cron
@@ -1353,9 +1463,45 @@ if [ "$_HANDOFF_STALE" = "1" ]; then
1353
1463
  # daily memory into a compact context brief. Requires python3 (stdlib
1354
1464
  # sqlite3) for the Telegram history section; other sections only need
1355
1465
  # curl + jq (both optional — each source degrades gracefully).
1356
- if [ "$SWITCHROOM_RESUME_MODE" = "handoff" ] && command -v handoff-briefing.sh >/dev/null 2>&1; then
1466
+ #
1467
+ # SKIPPED when session_continuity.briefing='gateway': the gateway daemon
1468
+ # then owns the reorientation briefing (built from the durable history.db
1469
+ # and injected as a spooled <channel source="boot_briefing"> inbound), so
1470
+ # running the shell assembler here would double-inject the same recent
1471
+ # conversation. The Stop-hook .handoff.md path below is left untouched in
1472
+ # this stage of the migration.
1473
+ #
1474
+ # EXCEPTION (#4245): when the outer pass detected a bundle version skew
1475
+ # (briefing=gateway requested but the deployed gateway bundle predates the
1476
+ # boot-briefing builder), it dropped the .gateway-briefing-unavailable marker.
1477
+ # In that case the gateway path is dead, so run the legacy assembler here as a
1478
+ # fallback even in 'gateway' mode — the double-inject risk does not apply
1479
+ # because the stale gateway never queues a briefing. This is a file-based
1480
+ # handshake on purpose: the marker survives the outer→inner tmux re-exec
1481
+ # deterministically (written before the exec, read after), unlike env.
1482
+ if [ "$SWITCHROOM_RESUME_MODE" = "handoff" ] \
1483
+ && { [ "{{#if sessionBriefingMode}}{{{sessionBriefingMode}}}{{else}}legacy{{/if}}" != "gateway" ] || [ -f "{{agentDir}}/.gateway-briefing-unavailable" ]; } \
1484
+ && command -v handoff-briefing.sh >/dev/null 2>&1; then
1357
1485
  export AGENT_DIR="{{agentDir}}"
1358
- timeout 5 handoff-briefing.sh 2>/dev/null || true
1486
+ # Clear any prior-boot briefing BEFORE rebuilding (#continuity). The
1487
+ # sidecar rm below (post-injection) is skipped if a boot crashes between
1488
+ # the rebuild and injection, leaving last boot's .handoff-briefing.md on
1489
+ # disk. If this boot's rebuild is then killed by the outer `timeout`
1490
+ # (SIGTERM — `timeout 10` sends SIGTERM by default, not SIGKILL) before
1491
+ # handoff-briefing.sh's atomic write, the injection would re-serve that
1492
+ # STALE prior-boot briefing. Deleting the target up front makes a killed
1493
+ # rebuild yield a MISSING file (injection skips) rather than a stale one.
1494
+ # handoff-briefing.sh recreates it via tmp+mv on success.
1495
+ rm -f "$HANDOFF_BRIEFING_FILE" 2>/dev/null || true
1496
+ # Outer kill budget (10s) must strictly exceed the inner Hindsight cap
1497
+ # (3s, HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT) with margin, so the SIGTERM
1498
+ # (`timeout 10`'s default signal) lands only on a genuinely wedged run —
1499
+ # never mid-write of a recall that was about to return. The local SQLite
1500
+ # (Telegram) and daily-memory reads are sub-second; Hindsight is the only
1501
+ # network hop and is self-capped, so 10s leaves ~7s of headroom over the
1502
+ # worst-case source.
1503
+ export HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT="${HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT:-3}"
1504
+ timeout 10 handoff-briefing.sh 2>/dev/null || true
1359
1505
  fi
1360
1506
  fi
1361
1507
  APPEND_PROMPT={{#if systemPromptAppendShellQuoted}}{{{systemPromptAppendShellQuoted}}}{{else}}""{{/if}}
@@ -50,7 +50,7 @@ Creating, updating, or refreshing a mental model is **operator-approved** — do
50
50
  ### Directives (replaces feedback rules)
51
51
  Hard rules the agent must follow during reflect — guardrails that are always applied.
52
52
 
53
- - `mcp__hindsight__create_directive(text)` — e.g., `create_directive("Always prefer TypeScript over JavaScript for this user's projects")`. When the user gives you a correction or "always do X" rule, create a directive instead of writing a feedback `.md` file.
53
+ - `mcp__hindsight__create_directive(text)` — e.g., `create_directive("Always prefer TypeScript over JavaScript for this user's projects")`. When the user gives you a correction or "always do X" rule, create a directive instead of writing a feedback `.md` file. If the rule can be enforced deterministically — a settings.json hook, a permission rule, a skill/script edit, or a config change — prefer that (instead of, or in addition to, the directive) and say which you did; reserve directives for judgment rules code can't enforce.
54
54
 
55
55
  (Read-only inspection tools like `list_memories`, `list_mental_models`, `get_mental_model`, `list_directives` are available under the `mcp__hindsight__*` namespace if you ever need them, but you rarely should — Hindsight's own auto-recall surfaces what matters and the operator handles bank curation out-of-band. The mental-model WRITE tools — `create`/`update`/`refresh`/`delete_mental_model` — are gated: propose via `mcp__switchroom-telegram__mental_model_propose`, don't call them directly.)
56
56
 
@@ -18,6 +18,32 @@ judgement criteria. This skill carries the parts that are *this fleet's
18
18
  specific opinion* — the ones you would get wrong by defaulting to generic
19
19
  good practice, because our answer differs from the obvious one.
20
20
 
21
+ ## 0. The design contract binds first (repos that declare one)
22
+
23
+ Switchroom declares its design contract in `reference/` (`reference/README.md`
24
+ is the map; the repo CLAUDE.md "Design contract" section binds it to every
25
+ PR). When the repo you are changing carries one, the whole protocol below
26
+ runs *inside* that contract:
27
+
28
+ - **Before non-trivial work, place the change in the contract:** which of the
29
+ four outcomes it advances, and which job spec it satisfies —
30
+ `reference/product-spec.md` owns the job index; survey specs cheaply with
31
+ `head -7 reference/jobs/*.md`. A change that maps to no outcome and no job
32
+ is a scoping question, not a coding task.
33
+ - **The ship gate is the verdict rule, not just CI green:** a change ships
34
+ only when it (a) advances one of the four outcomes, (b) satisfies its job
35
+ spec — proven by that job's outcome UAT, (c) passes all three principle
36
+ checks in `reference/principles.md` (docs / defaults / consistency; a "no"
37
+ is a redesign, not a ship-and-patch), and (d) crosses no invariant in
38
+ `reference/invariants.md`. Cite the job spec in the PR body.
39
+ - **Design lives in `reference/`, not `docs/`.** A design decision worth
40
+ recording is an RFC or design record in `reference/rfcs/` (`serves:` a job
41
+ or `backs:` an invariant), never a new doc under `docs/`.
42
+
43
+ This does not add ceremony to small changes: §1's fast path stands, and on a
44
+ single-concern change the verdict check is a one-line sanity pass, not a
45
+ report.
46
+
21
47
  ## 1. Is this a "larger" task? (decides whether you design-align first)
22
48
 
23
49
  Treat it as larger — design report before implementing — when ANY of these hold:
@@ -37,7 +63,10 @@ Design-aligning a one-liner is its own failure mode.
37
63
 
38
64
  The report states what exists today **with citations**, what will change, the
39
65
  chosen approach, the alternatives you rejected and why, and the PR staging
40
- plan. Get alignment before implementing.
66
+ plan. In a contract-carrying repo (§0) it opens with the verdict-rule
67
+ mapping: the outcome advanced, the job spec satisfied, and any principle
68
+ check or invariant the design brushes against. Get alignment before
69
+ implementing.
41
70
 
42
71
  Then red-team your own plan item by item. Each item gets a verdict —
43
72
  `SOUND`, `RISK`, or `WRONG` — backed by evidence you can point at (a file you
@@ -59,3 +59,8 @@ Switchroom is a multi-agent orchestrator built on Claude Code. It manages multip
59
59
  - [cascade.md](cascade.md) — three-layer config cascade semantics
60
60
  - [sub-agents.md](sub-agents.md) — delegation patterns and model routing
61
61
  - [telegram.md](telegram.md) — enhanced Telegram plugin features
62
+
63
+ **Why it's built this way** lives in the repo's `reference/` directory — the
64
+ design contract (`reference/README.md` is the map: vision, principles,
65
+ invariants, product spec, job specs, RFCs). For "why does switchroom do X"
66
+ questions, that is the source of truth; `docs/` is usage/operation only.
@@ -290,7 +290,7 @@ config-plus-recreate operation with three known traps. **Follow the runbook:
290
290
 
291
291
  ## Telegram plugin reference — "what MCP tools", "how does reply work"
292
292
 
293
- The `switchroom-telegram` plugin is an enhanced fork of the official Telegram MCP plugin and is the default for all switchroom agents. It exposes **9 MCP tools** (all prefixed `mcp__switchroom-telegram__`):
293
+ The `switchroom-telegram` plugin is an enhanced fork of the official Telegram MCP plugin and is the default for all switchroom agents. It exposes **20+ MCP tools** (all prefixed `mcp__switchroom-telegram__`; the authoritative list is the tool declarations in `telegram-plugin/bridge/bridge.ts`). The core messaging set:
294
294
 
295
295
  | Tool | Purpose |
296
296
  |---|---|