switchroom 0.21.12 → 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.12";
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.12",
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": {
@@ -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:
@@ -30,15 +30,15 @@ description: >
30
30
 
31
31
  # Agent Status
32
32
 
33
- When the user asks about agent status, what's running, uptime, or wants to see agent info, answer by running (or telling them to run) `switchroom agent list` — this is the canonical command for showing running agents, their uptime, and current state.
33
+ When the user asks about agent status, what's running, uptime, or wants to see agent info, answer by running (or telling them to run) `switchroom status` — this is the canonical command for showing running agents, their uptime, and current state.
34
34
 
35
35
  ## Step 1 — Always mention `switchroom status` in your response
36
36
 
37
- The answer to "what agents are running", "show me agent info", "list all switchroom agents", or any uptime question is the `switchroom status` command (since v0.13.51). Your response MUST include the literal command string `switchroom status` so the user can copy it. If you have Bash tool access, run it and include the output. If you do not have Bash access, or the command fails in the current environment, still tell the user explicitly:
37
+ The answer to "what agents are running", "show me agent info", "list all switchroom agents", or any uptime question is the `switchroom status` command (since v0.13.53). Your response MUST include the literal command string `switchroom status` so the user can copy it. If you have Bash tool access, run it and include the output. If you do not have Bash access, or the command fails in the current environment, still tell the user explicitly:
38
38
 
39
39
  > Run `switchroom status` from your switchroom project directory to see running agents (uptime + scheduler), known auth accounts, and per-agent MCP connection state.
40
40
 
41
- Do not respond with a PATH-not-found bailout or a "no config found" diagnosis without first giving the user the command — the eval environment may not have a config on cwd, but on the user's actual machine `switchroom status` is the right command. (Pre-v0.13.51 the canonical command was `switchroom agent list` — that still works but only shows the Fleet section.)
41
+ Do not respond with a PATH-not-found bailout or a "no config found" diagnosis without first giving the user the command — the eval environment may not have a config on cwd, but on the user's actual machine `switchroom status` is the right command. (Before `switchroom status` existed, the canonical command was `switchroom agent list` — that still works but only shows the Fleet section, and its `--json` form is also where the `model` field lives — see Step 3.)
42
42
 
43
43
  ## Step 2 — Try to run it
44
44
 
@@ -48,30 +48,29 @@ If you have Bash tool access, run:
48
48
  switchroom status --json 2>/dev/null || switchroom status
49
49
  ```
50
50
 
51
- `switchroom status` returns three sections: **Fleet** (per-agent uptime + scheduler), **Accounts** (broker-known auth accounts with active marker), and **MCPs** (per-agent MCP connection state). If you want to skip the MCP probe (slower because it does a docker exec per agent), pass `--no-mcp`.
51
+ `switchroom status --json` returns three top-level keys: **`fleet`** (per-agent `name`, `status`, `started_at`, `topic`, `scheduler` — no `model`), **`accounts`** (broker-known auth accounts with an `active` marker), and **`mcps`** (per-agent MCP connection state, probed via `docker exec <agent> claude mcp list`). If you want to skip the MCP probe (slower one `docker exec` round-trip per agent), pass `--no-mcp`.
52
52
 
53
- If `switchroom status` fails (e.g. command not found, no config in cwd), fall back to `switchroom agent list` (older command, Fleet-only). Still include the `switchroom status` command and the word "uptime" in your text response — the user needs those as actionable information.
53
+ If `switchroom status` fails (e.g. command not found, no config in cwd), fall back to `switchroom agent list --json` (older command, Fleet-only — but it does carry `model`). Still include the `switchroom status` command and the word "uptime" in your text response — the user needs those as actionable information.
54
54
 
55
55
  ## Step 3 — For each agent, report running state and uptime
56
56
 
57
57
  When you have real output, for each agent show:
58
58
  - **Name** and topic
59
- - **Status**: running / stopped / error (from the docker-compose container state)
59
+ - **Status**: `active` (running) / `inactive`, `exited`, `dead` (not running) — other docker-container states pass through verbatim (`restarting`, `paused`, `created`). This is the normalized container state, not raw docker-compose text.
60
60
  - **Uptime**: how long it's been running (for running agents, always include the word "uptime" and the duration)
61
- - **Model**: which Claude model it's using
62
- - **Memory**: Hindsight collection name (if configured)
63
- - **PID** if available
61
+ - **Model**: which Claude model it's using. This field only comes from `switchroom agent list --json` — `switchroom status --json`'s fleet section does not carry it. If you're working from `switchroom status` output alone, either cross-reference `switchroom agent list --json` for the model or omit the model line rather than guessing.
62
+
63
+ Don't report a PID or a Hindsight collection/bank name — neither `switchroom status --json` nor `switchroom agent list --json` emits either field at the fleet level, so there is nothing real to show without a slower per-agent `switchroom agent status <name> --json` call (out of scope for a fleet-wide snapshot).
64
64
 
65
65
  Every running agent must have its uptime reported so the user can see how long each has been up. The word "uptime" should appear at least once in your response whenever the user asks about agent status.
66
66
 
67
67
  ## Step 4 — Format the output
68
68
 
69
- Format as a clean summary — one section per agent. Use bold agent names, inline code for model/collection names.
69
+ Format as a clean summary — one section per agent. Use bold agent names, inline code for the model name.
70
70
 
71
71
  ## Step 5 — Highlight anything suspicious
72
72
 
73
- - Agents that are stopped but should be running
74
- - Agents in error/failed state
73
+ - Agents that are `inactive`/`exited`/`dead` but should be running
75
74
  - Agents with very recent restarts (< 5 min uptime — may be crash-looping)
76
75
 
77
76
  ## Step 6 — One-line summary
@@ -81,16 +80,16 @@ End with a one-line summary: "X of Y agents running."
81
80
  ## Example Output Shape
82
81
 
83
82
  ```
84
- assistant — running (2h 14m)
85
- model: claude-sonnet-5 collection: general
83
+ assistant — active (2h 14m)
84
+ model: claude-sonnet-5
86
85
 
87
- dev — running (45m)
88
- model: claude-opus-5 collection: coding
86
+ dev — active (45m)
87
+ model: claude-opus-5
89
88
 
90
- coach — stopped
89
+ coach — inactive
91
90
  last run: 3 days ago
92
91
 
93
92
  3 of 3 agents configured, 2 running.
94
93
  ```
95
94
 
96
- If the user wants more detail on a specific agent, suggest `switchroom agent logs <name>` (covered by the `switchroom-cli` skill).
95
+ If the user wants recent log output for a specific agent, suggest `switchroom agent logs <name>` (covered by the `switchroom-cli` skill). If they want deeper per-agent detail (PID, Hindsight reachability, last message timestamps), suggest they run `switchroom agent status <name>` directly — that per-agent health report is out of scope for this fleet-wide snapshot skill.