switchroom 0.21.11 → 0.21.13

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.
@@ -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, 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, ConfigRepoConfigSchema, 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, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
10998
10998
  var init_schema = __esm(() => {
10999
10999
  init_zod();
11000
11000
  init_observation_scopes();
@@ -11867,6 +11867,27 @@ var init_schema = __esm(() => {
11867
11867
  notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
11868
11868
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
11869
11869
  });
11870
+ ScratchConfigSchema = exports_external.object({
11871
+ enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true — but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
11872
+ volume: exports_external.string().refine((v) => v.startsWith("/"), {
11873
+ message: "scratch.volume must be an absolute host path"
11874
+ }).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist — its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
11875
+ subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
11876
+ message: "scratch.subdir must be a relative path without '..' segments"
11877
+ }).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
11878
+ });
11879
+ DiskConfigSchema = exports_external.object({
11880
+ warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 — the " + "reference fleet was at 85% when the condition was found by hand."),
11881
+ fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
11882
+ reap_report: exports_external.object({
11883
+ enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
11884
+ log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` — it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
11885
+ max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 — twice the " + "documented daily cadence, so a single missed run is not noise.")
11886
+ }).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
11887
+ }).refine((v) => v.fail_pct > v.warn_pct, {
11888
+ message: "disk.fail_pct must be greater than disk.warn_pct",
11889
+ path: ["fail_pct"]
11890
+ });
11870
11891
  HostControlConfigSchema = exports_external.object({
11871
11892
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
11872
11893
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
@@ -11941,6 +11962,8 @@ var init_schema = __esm(() => {
11941
11962
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
11942
11963
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
11943
11964
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
11965
+ scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent — not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
11966
+ disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
11944
11967
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
11945
11968
  hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
11946
11969
  fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health — job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent → inert; the admin page renders an empty state."),
@@ -21565,7 +21588,7 @@ function allocateAgentUid(name) {
21565
21588
  }
21566
21589
 
21567
21590
  // src/build-info.ts
21568
- var VERSION = "0.21.11";
21591
+ var VERSION = "0.21.13";
21569
21592
 
21570
21593
  // src/setup/hindsight-recall-passthrough.ts
21571
21594
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
@@ -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, 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, ConfigRepoConfigSchema, 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, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
4438
4438
  var init_schema = __esm(() => {
4439
4439
  init_zod();
4440
4440
  init_observation_scopes();
@@ -5307,6 +5307,27 @@ var init_schema = __esm(() => {
5307
5307
  notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
5308
5308
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
5309
5309
  });
5310
+ ScratchConfigSchema = exports_external.object({
5311
+ enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true — but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
5312
+ volume: exports_external.string().refine((v) => v.startsWith("/"), {
5313
+ message: "scratch.volume must be an absolute host path"
5314
+ }).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist — its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
5315
+ subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
5316
+ message: "scratch.subdir must be a relative path without '..' segments"
5317
+ }).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
5318
+ });
5319
+ DiskConfigSchema = exports_external.object({
5320
+ warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 — the " + "reference fleet was at 85% when the condition was found by hand."),
5321
+ fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
5322
+ reap_report: exports_external.object({
5323
+ enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
5324
+ log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` — it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
5325
+ max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 — twice the " + "documented daily cadence, so a single missed run is not noise.")
5326
+ }).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
5327
+ }).refine((v) => v.fail_pct > v.warn_pct, {
5328
+ message: "disk.fail_pct must be greater than disk.warn_pct",
5329
+ path: ["fail_pct"]
5330
+ });
5310
5331
  HostControlConfigSchema = exports_external.object({
5311
5332
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
5312
5333
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
@@ -5381,6 +5402,8 @@ var init_schema = __esm(() => {
5381
5402
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
5382
5403
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
5383
5404
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
5405
+ scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent — not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
5406
+ disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
5384
5407
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
5385
5408
  hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
5386
5409
  fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health — job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent → inert; the admin page renders an empty state."),
@@ -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, 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, ConfigRepoConfigSchema, 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, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
4034
4034
  var init_schema = __esm(() => {
4035
4035
  init_zod();
4036
4036
  init_observation_scopes();
@@ -4903,6 +4903,27 @@ var init_schema = __esm(() => {
4903
4903
  notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
4904
4904
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
4905
4905
  });
4906
+ ScratchConfigSchema = exports_external.object({
4907
+ enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true — but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
4908
+ volume: exports_external.string().refine((v) => v.startsWith("/"), {
4909
+ message: "scratch.volume must be an absolute host path"
4910
+ }).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist — its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
4911
+ subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
4912
+ message: "scratch.subdir must be a relative path without '..' segments"
4913
+ }).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
4914
+ });
4915
+ DiskConfigSchema = exports_external.object({
4916
+ warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 — the " + "reference fleet was at 85% when the condition was found by hand."),
4917
+ fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
4918
+ reap_report: exports_external.object({
4919
+ enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
4920
+ log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` — it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
4921
+ max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 — twice the " + "documented daily cadence, so a single missed run is not noise.")
4922
+ }).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
4923
+ }).refine((v) => v.fail_pct > v.warn_pct, {
4924
+ message: "disk.fail_pct must be greater than disk.warn_pct",
4925
+ path: ["fail_pct"]
4926
+ });
4906
4927
  HostControlConfigSchema = exports_external.object({
4907
4928
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
4908
4929
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
@@ -4977,6 +4998,8 @@ var init_schema = __esm(() => {
4977
4998
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
4978
4999
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/rfcs/notion-integration.md. Top-level Notion integration " + "config — vault key for the integration token, friendly-name → " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
4979
5000
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
5001
+ scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent — not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
5002
+ disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
4980
5003
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
4981
5004
  hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
4982
5005
  fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health — job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent → inert; the admin page renders an empty state."),
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.21.11",
4
+ "version": "0.21.13",
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": {
@@ -57,6 +57,7 @@
57
57
  "lint:claude-cli-lockstep": "node scripts/check-claude-cli-lockstep.mjs",
58
58
  "lint:changelog-entry": "node scripts/check-changelog-entry.mjs",
59
59
  "changelog:generate": "node scripts/gen-changelog-entry.mjs",
60
+ "changelog:cut": "node scripts/cut-changelog-release.mjs",
60
61
  "lint:agent-attribution-trailers": "node scripts/check-agent-attribution-trailers.mjs",
61
62
  "lint:hostd-template-guard": "bun scripts/check-hostd-template-guard.ts",
62
63
  "lint:bench-baseline-anonymised": "node scripts/check-bench-baseline-anonymised.mjs",
@@ -83,6 +84,7 @@
83
84
  "@types/bun": "^1.3.11",
84
85
  "@types/node": "^22.0.0",
85
86
  "@vitest/coverage-v8": "3.2.4",
87
+ "commonmark": "0.31.2",
86
88
  "typescript": "^5.7.0",
87
89
  "vitest": "^3.2.4"
88
90
  },
@@ -139,17 +139,21 @@ is not a rebuttal.
139
139
  `AWAITING_CHECKS` means a required workflow is not listening for
140
140
  `merge_group`; `.github/MERGE-QUEUE.md` owns that failure mode and the
141
141
  invariants that prevent it.
142
- - **Stage the CHANGELOG entry author-side, before `gh pr create`.** A PR that
143
- changes shippable code must add a `## Unreleased` entry (#4469's
142
+ - **Stage the changelog note author-side, before `gh pr create`.** A PR that
143
+ changes shippable code must add a NEW `changelog.d/` fragment file (#4469's
144
144
  `check-changelog-entry.mjs`, part of `npm run lint`, enforces it). Don't
145
- hand-write it: run `bun run changelog:generate` in the repo — it derives the
146
- entry from your conventional-commit title, appends it under `## Unreleased`
147
- (idempotently — a second run is a no-op), and respects the escape hatches. Then
148
- commit CHANGELOG.md so it lands in your own push. It runs AUTHOR-side, not in
149
- CI, because a CI commit-back with the default `GITHUB_TOKEN` cannot re-trigger
150
- the required checks and would wedge the PR (`.github/MERGE-QUEUE.md` §
151
- "Author-side changelog generation"). Opt a docs/chore/test PR out with the
152
- `no-changelog` label or a `[skip changelog]` token on its own line.
145
+ hand-edit `CHANGELOG.md`: run `bun run changelog:generate` in the repo — it
146
+ derives a per-PR fragment (`changelog.d/<pr>-<slug>.<type>.md`) from your
147
+ conventional-commit title (idempotently — a second run is a no-op) and
148
+ respects the escape hatches. Then commit the fragment so it lands in your
149
+ own push. Fragment files are per-PR, so they never conflict with another
150
+ in-flight PR editing the shared `## Unreleased` section does, which is
151
+ exactly the conflict class fragments replace (see `changelog.d/README.md`).
152
+ It runs AUTHOR-side, not in CI, because a CI commit-back with the default
153
+ `GITHUB_TOKEN` cannot re-trigger the required checks and would wedge the PR
154
+ (`.github/MERGE-QUEUE.md` § "Author-side changelog generation"). Opt a
155
+ docs/chore/test PR out with the `no-changelog` label or a `[skip changelog]`
156
+ token on its own line.
153
157
  - **A test that wouldn't fail on the bug it guards is not a test.** Assert the
154
158
  observable outcome, not that the code path executed.
155
159
  - **Prefer a deterministic mechanism over prompt discipline.** If a check, a
@@ -12,13 +12,15 @@ Switchroom is a multi-agent orchestrator built on Claude Code. It manages multip
12
12
 
13
13
  **One `switchroom.yaml` to rule them all.** All agents are configured from a single file using a three-layer cascade. See [cascade.md](cascade.md) for full merge semantics.
14
14
 
15
- **Agents as Docker containers.** Each agent runs as a long-lived `claude` process inside its own container (`switchroom-<name>`), supervised by Docker Compose with `restart: unless-stopped` and a healthcheck. The `start.sh` script sets environment variables and execs into `claude`. Claude Code handles session persistence and tool execution.
15
+ **Agents as Docker containers.** Each agent runs as a long-lived `claude` process inside its own container (`switchroom-<name>`), supervised by Docker Compose with `restart: always` (`src/agents/compose.ts`). The agent service itself has NO healthcheck — it `depends_on` `vault-broker` (`service_started`), `approval-kernel` (`service_started`), and `switchroom-auth-broker` (`service_healthy`), so it waits on the auth-broker's healthcheck before booting. Healthchecks exist on `vault-broker`, `approval-kernel`, `switchroom-auth-broker`, and `voice-sidecar` — not on the agent container. The `start.sh` script sets environment variables and execs into `claude`. Claude Code handles session persistence and tool execution.
16
16
 
17
- **Telegram as the primary interface.** The `switchroom-telegram` MCP plugin connects Claude Code to Telegram, providing 10 tools for message handling. See [telegram.md](telegram.md) for details.
17
+ **Telegram as the primary interface.** The `switchroom-telegram` MCP plugin connects Claude Code to Telegram, providing message-handling tools (see [telegram.md](telegram.md) for the current tool list and categories — don't hard-code a count here, it drifts).
18
18
 
19
19
  **Hindsight for memory.** Cross-session memory uses the Hindsight MCP server — a semantic vector store with knowledge graphs, mental models, and directives. Each agent has its own named collection.
20
20
 
21
- **Skills as reusable behavior.** Shared skills live in `~/.switchroom/skills/` (or `switchroom.skills_dir`). Scaffold symlinks selected skills into each agent's `skills/` directory. Claude Code loads them at session start.
21
+ **Skills as reusable behavior.** Shared skills live in `~/.switchroom/skills/` (or `switchroom.skills_dir`). Scaffold symlinks selected skills into each agent's `.claude/skills/` directory (`src/agents/scaffold.ts`; `migrateLegacySkillsDir` migrates any pre-existing symlinks from the old `<agentDir>/skills/` location). Claude Code loads them at session start.
22
+
23
+ **Beyond the agent containers.** The agent fleet's compose file (`generateCompose`, `src/agents/compose.ts`) emits the per-agent `switchroom-<name>` services plus three shared services every agent depends on: `vault-broker`, `approval-kernel`, and `switchroom-auth-broker` (agent `depends_on`, `src/agents/compose.ts` around `emitAgentService`'s `depends_on` block). Optionally, per-agent `voice-sidecar` services are emitted too. `hostd` (`src/cli/hostd.ts`) and `web` (`src/cli/webd.ts`) run as their OWN separate compose projects (`switchroom-hostd`, `switchroom-web`) — not part of the agent fleet compose file, and not self-healing on an image-pin bump the way agents are.
22
24
 
23
25
  ## Directory layout
24
26
 
@@ -34,10 +36,12 @@ Switchroom is a multi-agent orchestrator built on Claude Code. It manages multip
34
36
  ├── start.sh # launcher (sets env, execs claude)
35
37
  ├── settings.json # Claude Code settings
36
38
  ├── .mcp.json # MCP server config
37
- ├── CLAUDE.md # agent identity (never overwritten by reconcile)
38
- ├── skills/ # symlinks to ~/.switchroom/skills/<name>/
39
+ ├── CLAUDE.md # agent identity (reconcile rewrites content ABOVE the
40
+ # `# --- Yours ---` marker; below it always survives.
41
+ │ # `--preserve-claude-md` opts out of the rewrite.)
39
42
  ├── .claude/
40
- └── agents/ # sub-agent definition files
43
+ ├── agents/ # sub-agent definition files
44
+ │ └── skills/ # symlinks to ~/.switchroom/skills/<name>/
41
45
  └── telegram/
42
46
  ├── history.db # SQLite message buffer
43
47
  └── access.json # per-agent access control
@@ -52,7 +56,7 @@ Switchroom is a multi-agent orchestrator built on Claude Code. It manages multip
52
56
  5. MCP servers connect (Hindsight, switchroom-telegram, others)
53
57
  6. Telegram plugin polls for messages
54
58
  7. User sends message → plugin fires `UserPromptSubmit` hook → Claude responds
55
- 8. `switchroom agent reconcile <name>` — re-apply switchroom.yaml (no CLAUDE.md touch)
59
+ 8. `switchroom agent reconcile <name>` — re-apply switchroom.yaml (rewrites `.mcp.json` + `settings.json` + `start.sh` + CLAUDE.md's managed section above the `# --- Yours ---` marker; pass `--preserve-claude-md` to skip the CLAUDE.md rewrite)
56
60
 
57
61
  ## Deep dives
58
62
 
@@ -16,13 +16,16 @@ The resolved value at any field is determined by the **merge type** for that fie
16
16
 
17
17
  | Merge type | Fields | Behavior |
18
18
  |---|---|---|
19
- | **Union** | `tools.allow`, `tools.deny`, `skills` | Combine across all layers, dedup |
19
+ | **Union** | `tools.allow`, `tools.deny`, `skills`, `secrets`, `allowed_tools`, `disallowed_tools`, `extra_stable_files` | Combine across all layers, dedup-preserving-order (defaults first) |
20
20
  | **Override** | `model`, `extends`, `dangerous_mode`, most scalars | Agent wins entirely |
21
- | **Per-key merge** | `mcp_servers`, `env`, `subagents` | Agent wins on key conflict, others preserved |
22
- | **Per-field merge** | `soul`, `memory`, `session`, `channels` | Agent wins per sub-field |
23
- | **Per-event concat** | `hooks` | Defaults appended first, then agent |
21
+ | **Per-key merge** | `mcp_servers`, `env`, `bundled_skills` | Agent wins on key conflict, others preserved |
22
+ | **Per-key merge with field-level merge on conflict** | `subagents` | Agent wins per key; on a key present at both layers, fields are merged field-by-field (not a whole-definition replacement — that was the pre-#682 bug) |
23
+ | **Per-field merge** | `soul`, `session`, `session_continuity`, `channels`, `reactions`, `reaction_dispatch` | Agent wins per sub-field. `channels` and `reactions`/`reaction_dispatch` sub-arrays (e.g. `trigger_emojis`) use REPLACE semantics, not union. |
24
+ | **One-level-deep merge** | `memory` (`recall`/`retain`/`disposition` sub-objects), `litellm` (scalars + per-key `tags`) | Top-level fields override; the named sub-object merges one level deep instead of replacing wholesale, so overriding one knob doesn't drop its siblings |
25
+ | **Per-event concat** | `hooks` | Defaults appended first, then agent (no dedup — identical entries may be intentional) |
24
26
  | **Concatenate** | `schedule`, `system_prompt_append`, `claude_md_raw`, `cli_args` | Defaults prepended |
25
27
  | **Deep merge** | `settings_raw` | Recursive object merge, agent wins |
28
+ | **Replace-if-unset** | `release` | Whole-block replace; an agent-declared `release` is NOT field-merged with defaults (deliberate — a pinned agent must not silently inherit a channel/pin from the fleet, or vice versa) |
26
29
 
27
30
  ## Examples
28
31
 
@@ -65,7 +68,7 @@ Prefer TypeScript.
65
68
  Never use `any`.
66
69
  ```
67
70
 
68
- ### subagents per-key merge
71
+ ### subagents per-key merge with field-level merge on conflict
69
72
  ```yaml
70
73
  defaults:
71
74
  subagents:
@@ -77,11 +80,16 @@ agents:
77
80
  dev:
78
81
  subagents:
79
82
  worker:
80
- description: "Code implementation with tests" # replaces default worker
81
- model: sonnet
82
- tools: [Read, Edit, Write, Bash]
83
+ description: "Code implementation with tests" # overrides only this field
84
+ tools: [Read, Edit, Write, Bash] # added; model still inherited
83
85
  # researcher and reviewer inherited from defaults unchanged
84
86
  ```
87
+ `worker`'s resolved definition is `{ description: "Code implementation with
88
+ tests", model: sonnet, tools: [Read, Edit, Write, Bash] }` — the agent layer
89
+ merges field-by-field onto the matching default key, it does not replace the
90
+ whole `worker` definition (`src/config/merge.ts`, `subagents: per-key merge,
91
+ with field-level merge on conflict`; whole-def replacement was the pre-#682
92
+ bug this fixed).
85
93
 
86
94
  ### hooks per-event concat
87
95
  ```yaml
@@ -103,10 +111,21 @@ agents:
103
111
  Profiles can be defined in two places (inline takes priority):
104
112
 
105
113
  1. **Inline** in `profiles:` section of switchroom.yaml
106
- 2. **Filesystem** at `profiles/<name>/` — contains `CLAUDE.md.hbs`, `SOUL.md.hbs`, optional `skills/`
114
+ 2. **Filesystem** at `profiles/<name>/` — contains `CLAUDE.md.hbs`, plus optional `SOUL.md.hbs` and `skills/`
107
115
 
108
116
  An agent inherits from at most one profile via `extends: <name>`. Profiles themselves do not chain.
109
117
 
110
118
  ## Vault references
111
119
 
112
- Secrets in switchroom.yaml use `vault:key-name` syntax. They're resolved at scaffold/reconcile time from `~/.switchroom/vault.enc`. Vault values are written into `start.sh` as environment variables — never stored in plaintext in switchroom.yaml.
120
+ Secrets in switchroom.yaml use `vault:key-name` syntax; `vault:<key>#<filename>`
121
+ additionally inlines one named file's contents as a string from a
122
+ `kind: "files"` vault entry (`src/vault/resolver.ts:194`,
123
+ `resolveSingleReference`). At scaffold/reconcile time these are resolved
124
+ from `~/.switchroom/vault.enc` and written into `start.sh` as environment
125
+ variables — never stored in plaintext in switchroom.yaml.
126
+
127
+ In production, agent-runtime vault reads do NOT go through that scaffold-time
128
+ decrypt path — they go through the vault-broker daemon over a local socket
129
+ (`resolveVaultReferencesViaBroker`, `src/vault/resolver.ts:288`), which is
130
+ what enforces the per-agent grant/deny model (`VAULT-BROKER-DENIED`,
131
+ `vault_request_access`) documented for agents at runtime.
@@ -2,9 +2,15 @@
2
2
 
3
3
  Switchroom generates Claude Code custom sub-agent files (`.claude/agents/<name>.md`) from `switchroom.yaml`. This enables the "Opus plans, Sonnet implements" pattern: the main agent delegates to cheaper models running in the background.
4
4
 
5
- ## Default sub-agents
5
+ ## Default sub-agents (starter template, not a hard-coded default)
6
6
 
7
- Switchroom ships three default sub-agents that every agent inherits:
7
+ `subagents` is `.optional()` with no schema-level `.default()`
8
+ (`src/config/schema.ts`), and nothing in `src/agents/scaffold.ts` injects a
9
+ worker/researcher/reviewer set — an agent with no `subagents` block anywhere
10
+ in its cascade simply has none. The three-sub-agent pattern below is the
11
+ **starter template** shipped in `examples/switchroom.yaml` (`worker` at
12
+ `:111`, `researcher` at `:137`, `reviewer` at `:149`), not something
13
+ switchroom ships by default:
8
14
 
9
15
  | Sub-agent | Model | Purpose |
10
16
  |-----------|-------|---------|
@@ -12,7 +18,9 @@ Switchroom ships three default sub-agents that every agent inherits:
12
18
  | **researcher** | Haiku | Exploration — codebase search, docs, investigation |
13
19
  | **reviewer** | Sonnet | Quality review — correctness, completeness, security |
14
20
 
15
- These are defined in `defaults.subagents` and flow through the cascade to every agent.
21
+ If you want this pattern, declare it under `defaults.subagents` (or a
22
+ profile) yourself — copy it from `examples/switchroom.yaml` — and it will
23
+ then flow through the cascade to every agent.
16
24
 
17
25
  ## How delegation works
18
26
 
@@ -30,15 +38,15 @@ Each sub-agent supports the full Claude Code frontmatter spec:
30
38
 
31
39
  | Field | Description |
32
40
  |-------|-------------|
33
- | `description` | (required) When the main agent should delegate here |
41
+ | `description` | Schema-optional (a partial override, e.g. `isolation` only, need not restate it — the cascade retains the base definition's description on merge) but effectively required for a fresh, non-overriding definition: when the main agent should delegate here |
34
42
  | `model` | `sonnet`, `opus`, `haiku`, full model ID, or `inherit` |
35
43
  | `background` | Run non-blocking. Default: false |
36
44
  | `isolation` | `worktree` — own git branch for file work |
37
45
  | `tools` | Tool allowlist (inherits all if omitted) |
38
46
  | `disallowedTools` | Tool denylist |
39
47
  | `maxTurns` | Auto-stop after N turns |
40
- | `permissionMode` | `default`, `acceptEdits`, `auto`, `bypassPermissions`, `plan` |
41
- | `effort` | `low`, `medium`, `high`, `max` |
48
+ | `permissionMode` | `default`, `acceptEdits`, `auto`, `dontAsk`, `bypassPermissions`, `plan` |
49
+ | `effort` | `low`, `medium`, `high`, `xhigh`, `max` |
42
50
  | `color` | Display color in task list |
43
51
  | `memory` | `user`, `project`, or `local` for persistent learning |
44
52
  | `skills` | Skills to preload |
@@ -46,7 +54,7 @@ Each sub-agent supports the full Claude Code frontmatter spec:
46
54
 
47
55
  ## Cascade behavior
48
56
 
49
- Sub-agents are **per-key merged**. An agent overrides a specific sub-agent by declaring one with the same name:
57
+ Sub-agents are **per-key merged, with field-level merge on conflict** (`src/config/merge.ts`) — see [cascade.md](cascade.md). An agent overrides a specific sub-agent by declaring one with the same name; only the fields it sets are replaced, everything else is inherited from the base definition:
50
58
 
51
59
  ```yaml
52
60
  defaults:
@@ -66,22 +74,15 @@ agents:
66
74
  # researcher and reviewer inherited unchanged from defaults
67
75
  ```
68
76
 
69
- ## Model resolution order (highest wins)
70
-
71
- 1. `CLAUDE_CODE_SUBAGENT_MODEL` env var switchroom deliberately doesn't set this (it would override ALL sub-agents including Claude Code built-ins)
72
- 2. Per-invocation `model` parameter (user/main-agent override)
73
- 3. Sub-agent file's `model` frontmatter (what switchroom sets)
74
- 4. Main conversation's model
75
-
76
- ## Coexistence with Claude Code built-ins
77
-
78
- | Agent | Model | Origin |
79
- |-------|-------|--------|
80
- | Explore | Haiku | Claude Code built-in |
81
- | Plan | Inherit | Claude Code built-in |
82
- | general-purpose | Inherit | Claude Code built-in |
83
- | worker | Sonnet | Switchroom default |
84
- | researcher | Haiku | Switchroom default |
85
- | reviewer | Sonnet | Switchroom default |
86
-
87
- All sub-agents share the same `.claude/agents/` directory. Switchroom-generated files don't conflict with Claude Code's built-ins.
77
+ ## Model resolution and Claude Code's built-in sub-agents
78
+
79
+ How Claude Code itself resolves a sub-agent's model (env var precedence,
80
+ per-invocation overrides) and how switchroom-generated `.claude/agents/*.md`
81
+ files coexist with Claude Code's own built-in sub-agents (e.g. Explore,
82
+ Plan, general-purpose) is upstream Claude Code CLI behaviour — this repo's
83
+ `src/` has no code that reads or sets a `CLAUDE_CODE_SUBAGENT_MODEL` env var,
84
+ so switchroom's own source cannot confirm or deny any specific precedence
85
+ order or built-in roster here. Consult Anthropic's Claude Code documentation
86
+ for the authoritative answer; all switchroom does is render one `.md` file
87
+ per configured sub-agent into `.claude/agents/<name>.md` and let Claude Code
88
+ own everything downstream of that file.
@@ -2,32 +2,45 @@
2
2
 
3
3
  Switchroom ships an enhanced `switchroom-telegram` MCP plugin that replaces the official marketplace plugin. It is the default — no configuration needed.
4
4
 
5
- ## 9 MCP tools
6
-
7
- | Tool | What it does |
8
- |------|-------------|
9
- | `reply` | Send text, photos, or documents the single final-answer tool. Chunks anything over the 32768-char rich-message cap (4096 applies only to plain-text degradations). Supports threading, topic routing, file attachments. |
10
- | `react` | Add emoji reactions to messages (Telegram whitelist: 👍 👎 ❤️ 🔥 👀 🎉 etc). |
11
- | `edit_message` | Update a previously sent message. Edits are silent (no push notification). |
12
- | `delete_message` | Remove a bot-sent message (48h Telegram API limit). |
13
- | `forward_message` | Quote/resurface earlier messages with thread support. |
14
- | `send_typing` | Show typing indicator (5s auto-expire). Use during long operations. |
15
- | `download_attachment` | Fetch files attached to inbound messages. |
16
- | `get_recent_messages` | Query SQLite history buffer with pagination and thread filtering. |
5
+ ## MCP tools
6
+
7
+ The plugin's tool count changes as features land — don't hard-code a number
8
+ here; `telegram-plugin/bridge/bridge.ts`'s `TOOL_SCHEMAS` array is the source
9
+ of truth. As of this writing it defines 21 tool schemas, grouped by purpose:
10
+
11
+ | Category | Tools | What it does |
12
+ |----------|-------|-------------|
13
+ | Messaging | `reply`, `edit_message`, `delete_message`, `forward_message`, `progress_update` | `reply` is the single final-answer tool — send text, photos, or documents. Chunks anything over the 32768-char rich-message cap (4096 applies only to plain-text degradations). Supports threading, topic routing, file attachments. `edit_message` updates a previously sent message silently (no push notification). `delete_message` removes a bot-sent message (48h Telegram API limit). `forward_message` quotes/resurfaces earlier messages with thread support. `progress_update` posts a short interim status line mid-turn. |
14
+ | Interactivity | `react`, `send_typing`, `ask_user`, `send_checklist`, `update_checklist`, `send_sticker`, `send_gif` | `react` adds emoji reactions (Telegram whitelist). `send_typing` shows a typing indicator (5s auto-expire). `ask_user` blocks for a structured reply. `send_checklist`/`update_checklist` render and mutate a tappable checklist card. `send_sticker`/`send_gif` send media. |
15
+ | History & attachments | `download_attachment`, `get_recent_messages` | Fetch files attached to inbound messages; query the SQLite history buffer with pagination and thread filtering. |
16
+ | Vault & secrets | `vault_request_save`, `vault_request_access`, `request_secret` | Post approval cards for saving/granting/requesting vault-backed secrets. |
17
+ | Memory | `mental_model_propose` | Post an approval card to create/refresh a Hindsight mental model. |
18
+ | Linear (conditional) | `linear_agent_activity`, `linear_create_issue`, `linear_agent_setup` | Only registered when Linear integration is configured for the agent. |
17
19
 
18
20
  ## Emoji status lifecycle
19
21
 
20
- The plugin automatically reacts to inbound messages with a lifecycle progression:
22
+ The plugin automatically reacts to inbound messages with a state machine
23
+ (`telegram-plugin/status-reactions.ts`) tracking CURRENT TURN ACTIVITY, not
24
+ delivery state. The real state set: `queued, thinking, coding, web,
25
+ compacting, awaiting, undelivered, error, stallSoft, stallHard`. Working
26
+ states (`thinking`, `tool`, `coding`, `web`, `compacting`) cycle freely and
27
+ bidirectionally within one turn — none is "higher" than another. The only
28
+ terminal state is reached via `finalize()`, triggered by the Stop hook
29
+ (`turn_end`).
30
+
31
+ A representative progression:
21
32
 
22
33
  ```
23
- 👀 queued → 🤔 thinking → 👨‍💻 tool use 🔥 streaming → 👍 done
34
+ 👀 queued → 🤔 thinking → 👨‍💻 coding → 👍 done
24
35
  ```
25
36
 
26
- Stall watchdogs: `🥱` at 30s idle, `😨` at 90s — so the user always knows the agent is alive.
37
+ Stall watchdogs auto-promote to `🥱` (stallSoft) at 30s idle, `😨` (stallHard)
38
+ at 90s — so the user always knows the agent is alive. `🔥` is reserved for
39
+ genuine 5xx server errors, not for "streaming" — there is no streaming state.
27
40
 
28
41
  Tool-specific reactions:
29
- - `👨‍💻` for Bash/Edit/Write
30
- - `⚡` for web search/fetch
42
+ - `👨‍💻` for Bash/Edit/Write (coding)
43
+ - `⚡` for web search/fetch (web)
31
44
 
32
45
  ## Message history
33
46
 
@@ -55,6 +68,20 @@ bisecting code fences or table rows; bodies that degrade to plain text fall
55
68
  back to the legacy 4096-char cap. See
56
69
  `reference/telegram-formatting-guide.md` for the full vocabulary.
57
70
 
71
+ ## Inbound message attributes
72
+
73
+ Every inbound Telegram message arrives as a `<channel source="telegram" ...>`
74
+ tag whose attributes are built in
75
+ `telegram-plugin/gateway/inbound-router.ts:112-484`. Notable ones:
76
+
77
+ | Attribute | Meaning |
78
+ |-----------|---------|
79
+ | `reply_to_message_id` | Set when the user long-pressed a prior message and chose Reply — that message is the antecedent for "this"/"that" pronoun references. `reply_to_text`/`reply_to_role`/`reply_to_kind` accompany it. |
80
+ | `message_thread_id` | The forum topic the message came from. |
81
+ | `origin_turn_id` | Pass this back on a reply (instead of `message_thread_id`) so the answer routes to the topic this message came from, even if a message from another topic arrived mid-turn. |
82
+ | `attachment_file_id`, `attachment_kind` | Present when the inbound message has a file attachment; feed `attachment_file_id` to `download_attachment`. |
83
+ | `forwarded_from`, `forwarded_from_type`, `forwarded_from_id`, `forwarded_date` | Server-stamped forward-origin context (Bot API 7.0+ `forward_origin`) — trustworthy provenance, unlike the forwarded body text. A multi-origin burst carries numbered siblings (`forwarded_from_2`, ...). |
84
+
58
85
  ## Access control
59
86
 
60
87
  `telegram/access.json` per agent:
@@ -22,14 +22,20 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
22
22
  1. `git fetch origin`, confirm `main` is at the commit you want released.
23
23
  2. `gh pr list --state open` — confirm no PR meant for this release is still open. Ask the operator if unsure.
24
24
  3. Confirm CI on `main` is green (`gh run list --branch main --limit 3`).
25
- 4. Read `CHANGELOG.md`the `## Unreleased` section is the **continuously-maintained** staging area for this release's notes. Every PR since the last release stages its own entry there as it merges, enforced by `scripts/check-changelog-entry.mjs` (part of `npm run lint`), so by release time this section should already read as a near-complete draft — you tidy and summarise, you do NOT author it from scratch. **An empty (or missing) `## Unreleased` at release time is now an ANOMALY, not the normal state:** it means either there genuinely is nothing to release, OR the enforcement was bypassed (`no-changelog` labels / `[skip changelog]` tokens on their own line on PRs that should have staged entries). Before assuming the former, cross-check against `git log <last-tag>..origin/main` — if real shippable work landed but Unreleased is bare, investigate and reconstruct rather than shipping a hollow release (this is exactly the v0.20.12 failure mode the enforcement exists to prevent).
25
+ 4. Read the staged notes they live in TWO places, both **continuously maintained** as PRs merge and both enforced by `scripts/check-changelog-entry.mjs` (part of `npm run lint`): the `changelog.d/` **fragment files** (the primary path — every shippable PR adds its own `<pr>-<slug>.<type>.md`; see `changelog.d/README.md`) and any legacy/hand-staged entries under `## Unreleased` in `CHANGELOG.md`. Together they should already read as a near-complete draft — you assemble and tidy, you do NOT author from scratch. **Nothing staged at release time (no fragments AND an empty `## Unreleased`) is an ANOMALY, not the normal state** — `bun run changelog:cut` refuses to cut it: it means either there genuinely is nothing to release, OR the enforcement was bypassed (`no-changelog` labels / `[skip changelog]` tokens on their own line on PRs that should have staged notes). Before assuming the former, cross-check against `git log <last-tag>..origin/main` — if real shippable work landed but nothing is staged, investigate and reconstruct rather than shipping a hollow release (this is exactly the v0.20.12 failure mode the enforcement exists to prevent).
26
26
  5. Pick the next version: read the latest tag (`git tag --list 'v*' --sort=-v:refname | head -1`) and bump the patch (or minor if the operator asks). Confirm with the operator which.
27
27
 
28
- ## Step 1 — Consolidate the changelog (the release commit is CHANGELOG-only)
28
+ ## Step 1 — Assemble the changelog (the release commit is CHANGELOG + fragment deletions only)
29
29
 
30
- - The section is maintained continuously (see pre-flight 4), so this step is now a **rename + tidy**, not authorship: change the `## Unreleased` header to `## vX.Y.Z — <one-line summary>`, then read through the staged entries and lightly edit for grouping/summary. If Unreleased was empty or thin, STOP and resolve the anomaly (pre-flight 4) before proceeding — do not invent a release note.
31
- - **Re-seed a fresh empty `## Unreleased` block at the top** (header + the convention HTML comment) immediately below `# Changelog`, so the staging area exists for the next cycle and `scripts/check-changelog-entry.mjs` keeps enforcing it. (Copy the comment block from the section you just renamed.)
32
- - The release commit touches **CHANGELOG.md only**. Do NOT bump `package.json` (placeholder discipline).
30
+ - The notes are staged continuously (see pre-flight 4), so this step is **one command + a review**, not authorship:
31
+
32
+ ```bash
33
+ bun run changelog:cut -- --version vX.Y.Z --summary "<one-line summary>"
34
+ ```
35
+
36
+ `scripts/cut-changelog-release.mjs` folds every `changelog.d/` fragment (plus anything hand-staged under `## Unreleased`) into a new `## vX.Y.Z — <summary>` section grouped by category, **re-seeds a fresh empty `## Unreleased` block** (header + convention comment) below `# Changelog`, and **deletes the consumed fragment files**. Use `--dry-run` first to preview the assembled section. It FAILS LOUDLY on a double cut or an empty cut — if it refuses for "nothing to release", STOP and resolve the anomaly (pre-flight 4); do not invent a release note.
37
+ - Read through the assembled section and lightly edit for grouping/summary before committing.
38
+ - The release commit touches **CHANGELOG.md and the deleted `changelog.d/` fragments only**. Do NOT bump `package.json` (placeholder discipline). (A CHANGELOG + fragment-deletion PR changes no shippable code, so `check-changelog-entry.mjs` passes it without a staged note of its own — deleting fragments never counts as staging one.)
33
39
  - Branch protection blocks direct push to `main`, so: create a `release/vX.Y.Z` branch, push it, open a `chore: release vX.Y.Z` PR (base `main`), arm auto-merge (squash, delete-branch) on green CI.
34
40
 
35
41
  ## Step 2 — Create the DRAFT release on a PINNED SHA, then push the tag