switchroom 0.15.21 → 0.15.23

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.
@@ -11001,11 +11001,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
11001
11001
  var ScheduleEntrySchema = exports_external.object({
11002
11002
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11003
11003
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
11004
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
11004
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
11005
11005
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11006
11006
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11007
11007
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
11008
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
11008
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
11009
11009
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
11010
11010
  topic: exports_external.union([
11011
11011
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -11517,8 +11517,8 @@ var WebServiceConfigSchema = exports_external.object({
11517
11517
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
11518
11518
  });
11519
11519
  var HostdConfigSchema = exports_external.object({
11520
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11521
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11520
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11521
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
11522
11522
  });
11523
11523
  var CronEgressSchema = exports_external.object({
11524
11524
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -11555,7 +11555,7 @@ var SwitchroomConfigSchema = exports_external.object({
11555
11555
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/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."),
11556
11556
  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."),
11557
11557
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
11558
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
11558
+ 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)."),
11559
11559
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
11560
11560
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11561
11561
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
@@ -11001,11 +11001,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
11001
11001
  var ScheduleEntrySchema = exports_external.object({
11002
11002
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11003
11003
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
11004
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
11004
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
11005
11005
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11006
11006
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11007
11007
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
11008
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
11008
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
11009
11009
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
11010
11010
  topic: exports_external.union([
11011
11011
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -11517,8 +11517,8 @@ var WebServiceConfigSchema = exports_external.object({
11517
11517
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
11518
11518
  });
11519
11519
  var HostdConfigSchema = exports_external.object({
11520
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11521
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11520
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11521
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
11522
11522
  });
11523
11523
  var CronEgressSchema = exports_external.object({
11524
11524
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -11555,7 +11555,7 @@ var SwitchroomConfigSchema = exports_external.object({
11555
11555
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/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."),
11556
11556
  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."),
11557
11557
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
11558
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
11558
+ 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)."),
11559
11559
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
11560
11560
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11561
11561
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
@@ -11749,11 +11749,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
11749
11749
  var ScheduleEntrySchema = exports_external.object({
11750
11750
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11751
11751
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
11752
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
11752
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
11753
11753
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11754
11754
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11755
11755
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
11756
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
11756
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
11757
11757
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
11758
11758
  topic: exports_external.union([
11759
11759
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -12265,8 +12265,8 @@ var WebServiceConfigSchema = exports_external.object({
12265
12265
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false \u2014 existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
12266
12266
  });
12267
12267
  var HostdConfigSchema = exports_external.object({
12268
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
12269
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
12268
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
12269
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
12270
12270
  });
12271
12271
  var CronEgressSchema = exports_external.object({
12272
12272
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -12303,7 +12303,7 @@ var SwitchroomConfigSchema = exports_external.object({
12303
12303
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "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."),
12304
12304
  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."),
12305
12305
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
12306
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a \u2014 disabled by default)."),
12306
+ 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)."),
12307
12307
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container \u2014 then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
12308
12308
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
12309
12309
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
@@ -13565,11 +13565,11 @@ var init_schema = __esm(() => {
13565
13565
  ScheduleEntrySchema = exports_external.object({
13566
13566
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
13567
13567
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
13568
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
13568
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
13569
13569
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
13570
13570
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
13571
13571
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
13572
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
13572
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
13573
13573
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
13574
13574
  topic: exports_external.union([
13575
13575
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -14081,8 +14081,8 @@ var init_schema = __esm(() => {
14081
14081
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false \u2014 existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
14082
14082
  });
14083
14083
  HostdConfigSchema = exports_external.object({
14084
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
14085
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
14084
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
14085
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
14086
14086
  });
14087
14087
  CronEgressSchema = exports_external.object({
14088
14088
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -14119,7 +14119,7 @@ var init_schema = __esm(() => {
14119
14119
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "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."),
14120
14120
  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."),
14121
14121
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
14122
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a \u2014 disabled by default)."),
14122
+ 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)."),
14123
14123
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container \u2014 then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
14124
14124
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
14125
14125
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
@@ -50424,7 +50424,7 @@ var init_server4 = __esm(() => {
50424
50424
  },
50425
50425
  {
50426
50426
  name: "config_propose_edit",
50427
- description: "Propose a unified-diff patch against /state/config/switchroom.yaml " + "(RFC admin-agent-config-edit). When fully shipped the host validates " + "the patch (applies cleanly + post-patch yaml parses against the " + "config schema) and raises a Telegram approval card in the OPERATOR's " + "primary chat \u2014 NOT yours; the requesting agent's chat is not the " + "approval surface. Admin-only at the wire layer. " + "Current status (PR 1a \u2014 skeleton): the tool is registered but the " + "feature is OFF by default; calling it returns " + "E_CONFIG_EDIT_DISABLED until the operator sets " + "hostd.config_edit_enabled=true in switchroom.yaml. Even when enabled " + "in this PR, the call returns E_NOT_IMPLEMENTED \u2014 the validation " + "pipeline (PR 1b) and apply path (PR 1c) ship in follow-up PRs.",
50427
+ description: "Propose a unified-diff patch against /state/config/switchroom.yaml. " + "The host validates the patch (applies cleanly + post-patch yaml parses " + "against the config schema + no secret leak), raises a Telegram approval " + "card in the OPERATOR's primary chat (NOT yours \u2014 the requesting agent's " + "chat is not the approval surface), and on Allow applies it in place and " + "reconciles (rolling back if reconcile fails); returns " + `result:"completed" on success. Use this \u2014 behind the operator's tap \u2014 ` + "to amend config instead of asking the operator to hand-edit the yaml. " + "Admin agents may propose ANY field; non-admin agents are confined to " + "their own agents.<self>.tools.allow. Requires " + "hostd.config_edit_enabled=true (operator opt-in; default off) \u2014 returns " + "E_CONFIG_EDIT_DISABLED otherwise. An applied edit is not live in the " + "running agent until it restarts (the approval card names which agents " + "to bounce).",
50428
50428
  inputSchema: {
50429
50429
  type: "object",
50430
50430
  required: ["unified_diff", "reason", "target_path"],
@@ -50432,7 +50432,7 @@ var init_server4 = __esm(() => {
50432
50432
  unified_diff: {
50433
50433
  type: "string",
50434
50434
  minLength: 1,
50435
- description: "Unified diff against switchroom.yaml. Must have \u22653 lines " + "context (enforced in PR 1b); no path-traversal or multi-file " + "diffs. LF-only, \u22641 MB."
50435
+ description: "Unified diff against switchroom.yaml. Any context level (a " + "zero-context diff is fine); single-file, no path-traversal. " + "LF-only, \u22641 MB."
50436
50436
  },
50437
50437
  reason: {
50438
50438
  type: "string",
@@ -50477,8 +50477,8 @@ var {
50477
50477
  } = import__.default;
50478
50478
 
50479
50479
  // src/build-info.ts
50480
- var VERSION = "0.15.21";
50481
- var COMMIT_SHA = "36706e85";
50480
+ var VERSION = "0.15.23";
50481
+ var COMMIT_SHA = "4c70a87a";
50482
50482
 
50483
50483
  // src/cli/agent.ts
50484
50484
  init_source();
@@ -76387,9 +76387,8 @@ async function stepGoogleWorkspace(config, nonInteractive) {
76387
76387
  console.log(source_default.gray(` Step 2 \u2014 enable the account on ${source_default.bold(firstName)}:`));
76388
76388
  console.log(source_default.cyan(` ${enableCmd}`));
76389
76389
  console.log();
76390
- console.log(source_default.gray(` (\`account add\` is a stub today \u2014 Phase 3b.2d wires the OAuth flow.`));
76391
- console.log(source_default.gray(` Until then, use the v0.6.0 fallback:`));
76392
- console.log(source_default.gray(` ${fallbackCmd})`));
76390
+ console.log(source_default.gray(` (\`account add\` runs the OAuth flow in your browser. Older`));
76391
+ console.log(source_default.gray(` single-agent alternative: ${fallbackCmd})`));
76393
76392
  } else {
76394
76393
  console.log(source_default.gray(` ${STEP_DONE} Skipped \u2014 connect later with:`));
76395
76394
  console.log(source_default.cyan(` ${accountAddCmd}`));
@@ -84003,7 +84002,7 @@ function scheduleRemove(opts) {
84003
84002
  }
84004
84003
  function registerAgentConfigWriteCommands(program3) {
84005
84004
  const schedule = program3.command("schedule").description("Add / remove an agent's scheduled cron entries (overlay-backed)");
84006
- schedule.command("add").description("Append a schedule entry to the agent's overlay dir").requiredOption("--cron <expr>", "Cron expression").requiredOption("--prompt <text>", "Prompt to fire at the scheduled time").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").option("--secrets <list>", "Comma-separated vault keys (REJECTED for agent-authored overlays)").option("--name <slug>", "Optional human-readable name (a-z 0-9 -)").option("--model <id>", "Cheap-cron tier hint: a known-cheap model (sonnet/haiku) routes this fire to a fresh, minimal-context cron session (Tier 1) instead of the agent's full live session (Tier 2), cutting token cost. Inert unless SWITCHROOM_CHEAP_CRON is on.").option("--context <mode>", "Tier hint: 'fresh' (minimal-context cheap session) or 'agent' (full live session). Unset \u2192 inferred from --model.").option("--stage-on-reject", "When a security gate trips (secrets/quota/min-interval), stage the entry under .pending/ for operator approval instead of rejecting with exit 9. Used by the MCP path; operator CLI defaults to off.").action(async (opts) => {
84005
+ schedule.command("add").description("Append a schedule entry to the agent's overlay dir").requiredOption("--cron <expr>", "Cron expression").requiredOption("--prompt <text>", "Prompt to fire at the scheduled time").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").option("--secrets <list>", "Comma-separated vault keys (REJECTED for agent-authored overlays)").option("--name <slug>", "Optional human-readable name (a-z 0-9 -)").option("--model <id>", "Cheap-cron tier hint: a known-cheap model (sonnet/haiku) routes this fire to a fresh, minimal-context cron session (Tier 1) instead of the agent's full live session (Tier 2), cutting token cost. Active by default; SWITCHROOM_CHEAP_CRON=0 is the kill-switch.").option("--context <mode>", "Tier hint: 'fresh' (minimal-context cheap session) or 'agent' (full live session). Unset \u2192 inferred from --model.").option("--stage-on-reject", "When a security gate trips (secrets/quota/min-interval), stage the entry under .pending/ for operator approval instead of rejecting with exit 9. Used by the MCP path; operator CLI defaults to off.").action(async (opts) => {
84007
84006
  if (opts.context && opts.context !== "fresh" && opts.context !== "agent") {
84008
84007
  emitError("E_INVALID_PROMPT", "--context must be 'fresh' or 'agent'");
84009
84008
  process.exit(1);
@@ -85969,8 +85968,14 @@ services:
85969
85968
  # resolve there. Mounting the file directly forces docker to
85970
85969
  # follow the symlink at mount time and bind the underlying file
85971
85970
  # to the container path. Mirrors how the agent containers expose
85972
- # the config (also at /state/config/switchroom.yaml).
85973
- - ${hostHome}/.switchroom/switchroom.yaml:/state/config/switchroom.yaml:ro
85971
+ # the config (also at /state/config/switchroom.yaml) \u2014 but RW here,
85972
+ # not ro: hostd is the SANCTIONED config writer (config_propose_edit
85973
+ # applies an operator-approved diff in place via O_RDWR). With :ro the
85974
+ # write fails EROFS and every config_propose_edit rolls back
85975
+ # (E_RECONCILE_FAILED_ROLLED_BACK) \u2014 agents could never amend the yaml.
85976
+ # Agents themselves still mount it ro; only hostd, the tap-gated writer,
85977
+ # gets rw. The in-place writer preserves the file's owner/mode.
85978
+ - ${hostHome}/.switchroom/switchroom.yaml:/state/config/switchroom.yaml:rw
85974
85979
  # docker.sock is the whole reason hostd exists \u2014 agents shouldn't
85975
85980
  # have it, but hostd (auditing every shell-out via run-hook.sh's
85976
85981
  # pattern) is the controlled chokepoint.
@@ -13736,11 +13736,11 @@ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
13736
13736
  var ScheduleEntrySchema = exports_external.object({
13737
13737
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
13738
13738
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
13739
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
13739
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
13740
13740
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
13741
13741
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
13742
13742
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
13743
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
13743
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
13744
13744
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
13745
13745
  topic: exports_external.union([
13746
13746
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -14252,8 +14252,8 @@ var WebServiceConfigSchema = exports_external.object({
14252
14252
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
14253
14253
  });
14254
14254
  var HostdConfigSchema = exports_external.object({
14255
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
14256
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
14255
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
14256
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
14257
14257
  });
14258
14258
  var CronEgressSchema = exports_external.object({
14259
14259
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -14290,7 +14290,7 @@ var SwitchroomConfigSchema = exports_external.object({
14290
14290
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/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."),
14291
14291
  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."),
14292
14292
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
14293
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
14293
+ 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)."),
14294
14294
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
14295
14295
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
14296
14296
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
@@ -20817,6 +20817,85 @@ function stripCallerAllow(cfg, caller) {
20817
20817
  return clone;
20818
20818
  }
20819
20819
 
20820
+ // src/host-control/config-blast-radius.ts
20821
+ function toObject2(v) {
20822
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
20823
+ }
20824
+ function changedConfigPaths(before, after, prefix = "") {
20825
+ if (deepEqual(before, after))
20826
+ return [];
20827
+ const bObj = before && typeof before === "object" && !Array.isArray(before);
20828
+ const aObj = after && typeof after === "object" && !Array.isArray(after);
20829
+ if (bObj && aObj) {
20830
+ const keys = new Set([
20831
+ ...Object.keys(before),
20832
+ ...Object.keys(after)
20833
+ ]);
20834
+ const out = [];
20835
+ for (const k of keys) {
20836
+ out.push(...changedConfigPaths(before[k], after[k], prefix ? `${prefix}.${k}` : k));
20837
+ }
20838
+ return out;
20839
+ }
20840
+ return [prefix || "<root>"];
20841
+ }
20842
+ function deepEqual(a, b) {
20843
+ if (a === b)
20844
+ return true;
20845
+ if (typeof a !== typeof b)
20846
+ return false;
20847
+ if (a && b && typeof a === "object") {
20848
+ if (Array.isArray(a) !== Array.isArray(b))
20849
+ return false;
20850
+ if (Array.isArray(a) && Array.isArray(b)) {
20851
+ if (a.length !== b.length)
20852
+ return false;
20853
+ return a.every((x, i) => deepEqual(x, b[i]));
20854
+ }
20855
+ const ao = a;
20856
+ const bo = b;
20857
+ const keys = new Set([...Object.keys(ao), ...Object.keys(bo)]);
20858
+ for (const k of keys)
20859
+ if (!deepEqual(ao[k], bo[k]))
20860
+ return false;
20861
+ return true;
20862
+ }
20863
+ return false;
20864
+ }
20865
+ function classifyBlastRadius(beforeYaml, afterYaml) {
20866
+ let before;
20867
+ let after;
20868
+ try {
20869
+ before = toObject2($parse(beforeYaml));
20870
+ after = toObject2($parse(afterYaml));
20871
+ } catch {
20872
+ return { agents: [], fleetWide: true, changedPaths: ["<unparseable>"] };
20873
+ }
20874
+ const changedPaths = changedConfigPaths(before, after).sort();
20875
+ if (changedPaths.length === 0) {
20876
+ return { agents: [], fleetWide: false, changedPaths: [] };
20877
+ }
20878
+ const agents = new Set;
20879
+ let fleetWide = false;
20880
+ for (const path2 of changedPaths) {
20881
+ if (path2 === "<root>") {
20882
+ fleetWide = true;
20883
+ continue;
20884
+ }
20885
+ const segs = path2.split(".");
20886
+ if (segs[0] === "agents" && segs.length >= 2) {
20887
+ agents.add(segs[1]);
20888
+ } else {
20889
+ fleetWide = true;
20890
+ }
20891
+ }
20892
+ return {
20893
+ agents: fleetWide ? [] : [...agents].sort(),
20894
+ fleetWide,
20895
+ changedPaths
20896
+ };
20897
+ }
20898
+
20820
20899
  // src/host-control/server.ts
20821
20900
  function resolveDigests(imageRefs) {
20822
20901
  const out = new Map;
@@ -21509,7 +21588,12 @@ class HostdServer {
21509
21588
  const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(["apply"]));
21510
21589
  const recRes = await runner({ requestId: approvalId });
21511
21590
  if (recRes.exit_code === 0) {
21512
- await approval.finalize({ outcome: "applied" });
21591
+ const blast = classifyBlastRadius(snapshot, postApply);
21592
+ await approval.finalize({
21593
+ outcome: "applied",
21594
+ affectedAgents: blast.agents,
21595
+ fleetWide: blast.fleetWide
21596
+ });
21513
21597
  return {
21514
21598
  v: 1,
21515
21599
  request_id: req.request_id,
@@ -21893,7 +21977,9 @@ class SocketApprovalGateway {
21893
21977
  type: "request_config_finalize",
21894
21978
  requestId: req.requestId,
21895
21979
  outcome: outcome.outcome,
21896
- ...outcome.detail ? { detail: outcome.detail } : {}
21980
+ ...outcome.detail ? { detail: outcome.detail } : {},
21981
+ ...outcome.affectedAgents ? { affectedAgents: outcome.affectedAgents } : {},
21982
+ ...outcome.fleetWide !== undefined ? { fleetWide: outcome.fleetWide } : {}
21897
21983
  }) + `
21898
21984
  `);
21899
21985
  client2.end();
@@ -11344,11 +11344,11 @@ var init_schema = __esm(() => {
11344
11344
  ScheduleEntrySchema = exports_external.object({
11345
11345
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11346
11346
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
11347
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
11347
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
11348
11348
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11349
11349
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11350
11350
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
11351
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
11351
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
11352
11352
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
11353
11353
  topic: exports_external.union([
11354
11354
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -11860,8 +11860,8 @@ var init_schema = __esm(() => {
11860
11860
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
11861
11861
  });
11862
11862
  HostdConfigSchema = exports_external.object({
11863
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11864
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11863
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11864
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
11865
11865
  });
11866
11866
  CronEgressSchema = exports_external.object({
11867
11867
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -11898,7 +11898,7 @@ var init_schema = __esm(() => {
11898
11898
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/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."),
11899
11899
  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."),
11900
11900
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
11901
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
11901
+ 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)."),
11902
11902
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
11903
11903
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11904
11904
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
@@ -11344,11 +11344,11 @@ var init_schema = __esm(() => {
11344
11344
  ScheduleEntrySchema = exports_external.object({
11345
11345
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11346
11346
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
11347
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
11347
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
11348
11348
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11349
11349
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11350
11350
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
11351
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
11351
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
11352
11352
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
11353
11353
  topic: exports_external.union([
11354
11354
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -11860,8 +11860,8 @@ var init_schema = __esm(() => {
11860
11860
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false — existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
11861
11861
  });
11862
11862
  HostdConfigSchema = exports_external.object({
11863
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11864
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11863
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11864
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
11865
11865
  });
11866
11866
  CronEgressSchema = exports_external.object({
11867
11867
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -11898,7 +11898,7 @@ var init_schema = __esm(() => {
11898
11898
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/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."),
11899
11899
  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."),
11900
11900
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
11901
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a — disabled by default)."),
11901
+ 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)."),
11902
11902
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container — then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
11903
11903
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11904
11904
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.21",
3
+ "version": "0.15.23",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "test:vitest": "vitest run",
28
28
  "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
29
29
  "test:watch": "vitest",
30
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
30
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs",
31
31
  "lint:tsc": "tsc --noEmit",
32
32
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
33
33
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
@@ -131,9 +131,13 @@ A config-summary greeting card is sent automatically by the SessionStart hook
131
131
  {{#if admin}}
132
132
  ## Admin surface
133
133
 
134
- You're `admin: true`. Fleet operations live on the `hostd` MCP server: `agent_restart` / `agent_start` / `agent_stop` (lifecycle of any peer), `agent_logs` (peer container logs), `agent_exec` (read-only inspection inside any peer — argv[0] must be on the safe-command allowlist), `update_check` / `update_apply`. Treat these like a root shell on the host: confirm intent before destructive actions, refuse if unsure who's asking.
134
+ You're `admin: true`. Fleet operations live on the `hostd` MCP server: `agent_restart` / `agent_start` / `agent_stop` (lifecycle of any peer), `agent_logs` (peer container logs), `agent_exec` (read-only inspection inside any peer — argv[0] must be on the safe-command allowlist), `update_check` / `update_apply`, `get_status` (look up a prior mutation's outcome by request_id), and `config_propose_edit`. Treat these like a root shell on the host: confirm intent before destructive actions, refuse if unsure who's asking.
135
135
 
136
- Only `update_check` (a read-only dry-run) runs immediately. Every mutating / host verb `update_apply`, `agent_exec`, `agent_restart` / `agent_start` / `agent_stop`, `agent_logs` pauses for an **operator approval card in Telegram before it executes**: a human must tap approve. This is deliberate (you are a prompt-injectable process; the human in the loop is the safety boundary, not your own judgement). Expect the call to block until approved or denied; if denied, don't retryrelay the denial and stop.
136
+ **Editing `switchroom.yaml` use `config_propose_edit`, don't hand the operator a snippet.** When `hostd.config_edit_enabled` is on (it returns `E_CONFIG_EDIT_DISABLED` if not), this is the sanctioned way to amend config: you propose a unified diff, the operator gets a Telegram approval card, and on Allow the host applies + reconciles it. As admin you may propose any field. An applied edit isn't live in the affected agent until it restarts — the result card names which agents to bounce (use `agent_restart`, or tell the operator). The vault is different: `vault remove` / secret deletes stay operator-only host-CLI opsthere's no agent tool for those, so for those you DO hand the operator the command.
137
+
138
+ After a tapped `update_apply`, call `get_status` with its request_id to confirm what actually rolled out before reporting success — don't assume.
139
+
140
+ Only `update_check` (a read-only dry-run) and `get_status` run immediately. Every mutating / host verb — `update_apply`, `agent_exec`, `agent_restart` / `agent_start` / `agent_stop`, `agent_logs`, `config_propose_edit` — pauses for an **operator approval card in Telegram before it executes**: a human must tap approve. This is deliberate (you are a prompt-injectable process; the human in the loop is the safety boundary, not your own judgement). Expect the call to block until approved or denied; if denied, don't retry — relay the denial and stop.
137
141
  {{else}}
138
142
  ## Admin operations
139
143
 
@@ -23847,11 +23847,11 @@ var init_schema = __esm(() => {
23847
23847
  ScheduleEntrySchema = exports_external.object({
23848
23848
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
23849
23849
  prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
23850
- kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
23850
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt tiering is on by default " + "(SWITCHROOM_CHEAP_CRON=0 is the kill-switch); an action is model-free " + "regardless (the kill-switch governs model tiering, not deterministic " + "actions)."),
23851
23851
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
23852
23852
  action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
23853
23853
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
23854
- context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
23854
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). On by default; " + "SWITCHROOM_CHEAP_CRON=0 is the kill-switch."),
23855
23855
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
23856
23856
  topic: exports_external.union([
23857
23857
  exports_external.string().min(1, "topic alias must be non-empty"),
@@ -24363,8 +24363,8 @@ var init_schema = __esm(() => {
24363
24363
  managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false \u2014 existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
24364
24364
  });
24365
24365
  HostdConfigSchema = exports_external.object({
24366
- config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
24367
- config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
24366
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true, admin agents can propose unified-diff " + "patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
24367
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Configurable now, but the rate limiter is not yet enforced " + "(no `E_RATE_LIMITED` is currently raised); the field is reserved so " + "operators can pin the cap ahead of the limiter going live.")
24368
24368
  });
24369
24369
  CronEgressSchema = exports_external.object({
24370
24370
  allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
@@ -24401,7 +24401,7 @@ var init_schema = __esm(() => {
24401
24401
  notion_workspace: NotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "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."),
24402
24402
  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."),
24403
24403
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/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)."),
24404
- 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. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a \u2014 disabled by default)."),
24404
+ 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)."),
24405
24405
  web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container \u2014 then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
24406
24406
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
24407
24407
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
@@ -31013,6 +31013,7 @@ __export(exports_config_approval_handler, {
31013
31013
  parseConfigApprovalCallback: () => parseConfigApprovalCallback,
31014
31014
  handleRequestConfigFinalize: () => handleRequestConfigFinalize,
31015
31015
  handleRequestConfigApproval: () => handleRequestConfigApproval,
31016
+ buildLiveNote: () => buildLiveNote,
31016
31017
  buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
31017
31018
  _resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
31018
31019
  _peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
@@ -31156,6 +31157,22 @@ async function resolvePendingConfigApproval(requestId, verdict, deps) {
31156
31157
  }
31157
31158
  return true;
31158
31159
  }
31160
+ function buildLiveNote(affectedAgents, fleetWide) {
31161
+ if (fleetWide) {
31162
+ return `
31163
+
31164
+ \u26a0\ufe0f Shared config changed \u2014 affects all agents. Not live until they ` + `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`;
31165
+ }
31166
+ const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
31167
+ if (agents.length === 0)
31168
+ return "";
31169
+ const list2 = agents.map(escapeHtml12).join(", ");
31170
+ const cmds = agents.map((a) => `/restart ${escapeHtml12(a)}`).join(" \u00b7 ");
31171
+ return `
31172
+
31173
+ \uD83D\uDD04 Not live until restart \u2014 affects: <b>${list2}</b>
31174
+ ${cmds}`;
31175
+ }
31159
31176
  async function handleRequestConfigFinalize(_client, msg, deps) {
31160
31177
  const entry = pending.get(msg.requestId);
31161
31178
  if (!entry) {
@@ -31163,8 +31180,9 @@ async function handleRequestConfigFinalize(_client, msg, deps) {
31163
31180
  return;
31164
31181
  }
31165
31182
  pending.delete(msg.requestId);
31183
+ const liveNote = msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
31166
31184
  const body = msg.outcome === "applied" ? `\u2705 <b>Applied</b>${msg.detail ? `
31167
- ${escapeHtml12(msg.detail)}` : ""}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
31185
+ ${escapeHtml12(msg.detail)}` : ""}${liveNote}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
31168
31186
  ${escapeHtml12(msg.detail)}` : ""}`;
31169
31187
  try {
31170
31188
  await deps.editCard({
@@ -47641,6 +47659,16 @@ function validateClientMessage(msg) {
47641
47659
  return false;
47642
47660
  if (m.detail !== undefined && (typeof m.detail !== "string" || m.detail.length > 500))
47643
47661
  return false;
47662
+ if (m.affectedAgents !== undefined) {
47663
+ if (!Array.isArray(m.affectedAgents) || m.affectedAgents.length > 64)
47664
+ return false;
47665
+ for (const a of m.affectedAgents) {
47666
+ if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a))
47667
+ return false;
47668
+ }
47669
+ }
47670
+ if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean")
47671
+ return false;
47644
47672
  return true;
47645
47673
  }
47646
47674
  case "request_drive_approval": {
@@ -54392,11 +54420,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54392
54420
  }
54393
54421
 
54394
54422
  // ../src/build-info.ts
54395
- var VERSION = "0.15.21";
54396
- var COMMIT_SHA = "36706e85";
54397
- var COMMIT_DATE = "2026-06-14T01:34:05Z";
54398
- var LATEST_PR = 2345;
54399
- var COMMITS_AHEAD_OF_TAG = 0;
54423
+ var VERSION = "0.15.23";
54424
+ var COMMIT_SHA = "4c70a87a";
54425
+ var COMMIT_DATE = "2026-06-14T15:15:27+10:00";
54426
+ var LATEST_PR = null;
54427
+ var COMMITS_AHEAD_OF_TAG = 2;
54400
54428
 
54401
54429
  // gateway/boot-version.ts
54402
54430
  function formatRelativeAgo(iso) {
@@ -13,6 +13,7 @@
13
13
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
14
  import {
15
15
  buildConfigApprovalCardBody,
16
+ buildLiveNote,
16
17
  handleRequestConfigApproval,
17
18
  handleRequestConfigFinalize,
18
19
  parseConfigApprovalCallback,
@@ -266,6 +267,29 @@ describe("timeout path", () => {
266
267
  });
267
268
  });
268
269
 
270
+ describe("buildLiveNote", () => {
271
+ it("names specific affected agents + the per-agent restart command", () => {
272
+ const note = buildLiveNote(["clerk", "gymbro"], false);
273
+ expect(note).toContain("clerk, gymbro");
274
+ expect(note).toContain("/restart clerk");
275
+ expect(note).toContain("/restart gymbro");
276
+ expect(note).toContain("Not live until restart");
277
+ });
278
+ it("guides to a full rollout when fleet-wide (no per-agent list)", () => {
279
+ const note = buildLiveNote([], true);
280
+ expect(note).toContain("all agents");
281
+ expect(note).toContain("switchroom rollout");
282
+ expect(note).not.toContain("/restart");
283
+ });
284
+ it("is empty when nothing is runtime-affected", () => {
285
+ expect(buildLiveNote([], false)).toBe("");
286
+ expect(buildLiveNote(undefined, undefined)).toBe("");
287
+ });
288
+ it("HTML-escapes agent names", () => {
289
+ expect(buildLiveNote(["a<b>"], false)).toContain("a&lt;b&gt;");
290
+ });
291
+ });
292
+
269
293
  describe("handleRequestConfigFinalize", () => {
270
294
  it("edits the card to '✅ Applied' on success", async () => {
271
295
  const { client, deps, editCalls } = fakeDeps();
@@ -377,6 +377,27 @@ export async function resolvePendingConfigApproval(
377
377
  return true;
378
378
  }
379
379
 
380
+ /**
381
+ * The "make it live" note appended to an Applied card. claude loads config at
382
+ * boot, so an applied edit is inert in the running agents until they restart —
383
+ * this names exactly what must bounce (and the command) instead of letting the
384
+ * change silently not take effect. Fleet-wide (shared config) → guide to a full
385
+ * rollout, never a per-agent list. Empty when nothing runtime-affected.
386
+ */
387
+ export function buildLiveNote(affectedAgents?: string[], fleetWide?: boolean): string {
388
+ if (fleetWide) {
389
+ return (
390
+ `\n\n⚠️ Shared config changed — affects all agents. Not live until they ` +
391
+ `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`
392
+ );
393
+ }
394
+ const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
395
+ if (agents.length === 0) return "";
396
+ const list = agents.map(escapeHtml).join(", ");
397
+ const cmds = agents.map((a) => `/restart ${escapeHtml(a)}`).join(" · ");
398
+ return `\n\n🔄 Not live until restart — affects: <b>${list}</b>\n${cmds}`;
399
+ }
400
+
380
401
  /** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
381
402
  export async function handleRequestConfigFinalize(
382
403
  _client: Pick<IpcClient, "send">,
@@ -393,9 +414,15 @@ export async function handleRequestConfigFinalize(
393
414
  // Clean up the pending entry — finalize is the terminal transition.
394
415
  pending.delete(msg.requestId);
395
416
 
417
+ // On apply, tell the operator what must restart for the edit to go LIVE —
418
+ // claude loads config at boot, so an applied edit is inert until restart.
419
+ // Specific agents → name them + the one-liner to bounce them; shared config
420
+ // → guide to a full rollout (never silently leave the change un-live).
421
+ const liveNote =
422
+ msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
396
423
  const body =
397
424
  msg.outcome === "applied"
398
- ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`
425
+ ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}${liveNote}`
399
426
  : `⚠️ <b>Reconcile failed; rolled back</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`;
400
427
  try {
401
428
  await deps.editCard({
@@ -391,6 +391,14 @@ export interface RequestConfigFinalizeMessage {
391
391
  outcome: "applied" | "reconcile_failed_rolled_back";
392
392
  /** Optional short diagnostic appended to the card body. */
393
393
  detail?: string;
394
+ /**
395
+ * On `applied`: agents that must restart for the edit to go live (claude
396
+ * loads config at boot). Empty when `fleetWide`. The finalize card offers a
397
+ * one-tap restart of these. Computed host-side by classifyBlastRadius.
398
+ */
399
+ affectedAgents?: string[];
400
+ /** On `applied`: a shared/inherited key changed → all agents affected. */
401
+ fleetWide?: boolean;
394
402
  }
395
403
 
396
404
  /**
@@ -307,6 +307,16 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
307
307
  if (m.detail !== undefined
308
308
  && (typeof m.detail !== "string"
309
309
  || (m.detail as string).length > 500)) return false;
310
+ // affectedAgents (optional): a bounded list of kebab-case agent names —
311
+ // they drive a restart button, so validate shape + charclass even though
312
+ // the sender (hostd) is trusted (defense in depth).
313
+ if (m.affectedAgents !== undefined) {
314
+ if (!Array.isArray(m.affectedAgents) || (m.affectedAgents as unknown[]).length > 64) return false;
315
+ for (const a of m.affectedAgents as unknown[]) {
316
+ if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a)) return false;
317
+ }
318
+ }
319
+ if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean") return false;
310
320
  return true;
311
321
  }
312
322
  case "request_drive_approval": {
@@ -332,4 +332,32 @@ describe('validateClientMessage', () => {
332
332
  expect(validateClientMessage({ ...valid, ttlMs: '300000' })).toBe(false)
333
333
  })
334
334
  })
335
+
336
+ describe('request_config_finalize — affectedAgents / fleetWide (#2346)', () => {
337
+ const valid = { type: 'request_config_finalize', requestId: 'r1', outcome: 'applied' }
338
+
339
+ it('accepts the bare message and the new optional fields', () => {
340
+ expect(validateClientMessage(valid)).toBe(true)
341
+ expect(validateClientMessage({ ...valid, affectedAgents: ['clerk', 'gymbro'], fleetWide: false })).toBe(true)
342
+ expect(validateClientMessage({ ...valid, affectedAgents: [], fleetWide: true })).toBe(true)
343
+ })
344
+
345
+ it('rejects a non-array affectedAgents', () => {
346
+ expect(validateClientMessage({ ...valid, affectedAgents: 'clerk' })).toBe(false)
347
+ })
348
+
349
+ it('rejects affectedAgents with an unsafe / non-kebab name (defense in depth)', () => {
350
+ expect(validateClientMessage({ ...valid, affectedAgents: ['../etc'] })).toBe(false)
351
+ expect(validateClientMessage({ ...valid, affectedAgents: ['has space'] })).toBe(false)
352
+ expect(validateClientMessage({ ...valid, affectedAgents: [42] })).toBe(false)
353
+ })
354
+
355
+ it('rejects an over-long affectedAgents list', () => {
356
+ expect(validateClientMessage({ ...valid, affectedAgents: Array.from({ length: 65 }, (_, i) => `a${i}`) })).toBe(false)
357
+ })
358
+
359
+ it('rejects a non-boolean fleetWide', () => {
360
+ expect(validateClientMessage({ ...valid, fleetWide: 'yes' })).toBe(false)
361
+ })
362
+ })
335
363
  })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * UAT — `/effort` command (#2336, #2342): show + tap-to-switch the
3
+ * Claude reasoning effort for the live session. The effort sibling of
4
+ * `/model`; the picker-driven menu is the same shape.
5
+ *
6
+ * Verified live on test-harness v0.15.21. Switches are session-only
7
+ * (revert on restart), so the tap test restores the original level.
8
+ *
9
+ * Self-skips green on an unwired host (spinUp can't resolve the chat).
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { spinUp } from "../harness.js";
13
+
14
+ const AGENT = "test-harness";
15
+ const T = 30_000;
16
+
17
+ describe("uat: /effort — show, tap-switch, bad-arg", () => {
18
+ it(
19
+ "bare /effort shows the effort menu with a tap keyboard",
20
+ async () => {
21
+ const sc = await spinUp({ agent: AGENT });
22
+ try {
23
+ await sc.sendDM("/effort");
24
+ const menu = await sc.expectMessage(/Effort —/, { from: "bot", timeout: T });
25
+ expect(menu.text).toMatch(/faster → smarter|low · medium · high/i);
26
+ expect(menu.text).toMatch(/switchroom\.yaml/i);
27
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
28
+ const labels = (kb ?? []).flat().map((b) => b.text);
29
+ expect(labels.some((t) => /low/i.test(t)), "low button present").toBe(true);
30
+ expect(labels.some((t) => /max/i.test(t)), "max button present").toBe(true);
31
+ } finally {
32
+ await sc.tearDown();
33
+ }
34
+ },
35
+ 60_000,
36
+ );
37
+
38
+ it(
39
+ "tapping a level switches the live session, then restores",
40
+ async () => {
41
+ const sc = await spinUp({ agent: AGENT });
42
+ try {
43
+ await sc.sendDM("/effort");
44
+ const menu = await sc.expectMessage(/Effort —/, { from: "bot", timeout: T });
45
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
46
+ const flat = (kb ?? []).flat();
47
+ // The current level is prefixed with ✅; pick a DIFFERENT one.
48
+ const current = flat.find((b) => /✅/.test(b.text));
49
+ const target = flat.find(
50
+ (b) => b.callbackData && !/✅/.test(b.text) && /medium|high/i.test(b.text),
51
+ );
52
+ expect(target, "a non-current effort button to tap").toBeDefined();
53
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, target!.callbackData!);
54
+ // The card edits in place to prepend a confirmation line.
55
+ await new Promise((r) => setTimeout(r, 4000));
56
+ const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
57
+ expect(after?.text ?? "").toMatch(/Effort →|Switched|effort/i);
58
+
59
+ // Restore the original level so test-harness isn't left changed.
60
+ if (current?.callbackData) {
61
+ const kb2 = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
62
+ const restore = (kb2 ?? [])
63
+ .flat()
64
+ .find((b) => b.callbackData === current.callbackData);
65
+ if (restore?.callbackData) {
66
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, restore.callbackData);
67
+ }
68
+ }
69
+ } finally {
70
+ await sc.tearDown();
71
+ }
72
+ },
73
+ 90_000,
74
+ );
75
+
76
+ it(
77
+ "/effort bogus → reply (error/help), never silence",
78
+ async () => {
79
+ const sc = await spinUp({ agent: AGENT });
80
+ try {
81
+ await sc.sendDM("/effort definitely-not-a-level");
82
+ const reply = await sc.expectMessage(/\S/, { from: "bot", timeout: T });
83
+ expect(reply.text.length).toBeGreaterThan(0);
84
+ } finally {
85
+ await sc.tearDown();
86
+ }
87
+ },
88
+ 60_000,
89
+ );
90
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * End-to-end UAT — agent AUTO-RESUMES after a vault grant approval,
3
+ * under the live `telegram-id` (single-factor) posture. Regression gate
4
+ * for the mid-turn-strand fix (#2340).
5
+ *
6
+ * THE BUG (#2340, clerk 2026-06-13): the gateway injects a synthetic
7
+ * "✅ approved — resume your task" inbound after the operator taps
8
+ * Approve. That inject used a raw `sendToAgent` and only buffered on a
9
+ * disconnected bridge. When the approval landed WHILE the agent's
10
+ * grant-requesting turn was still finishing, the socket write succeeded
11
+ * (`delivered=true`) but claude was mid-turn, so the channel
12
+ * notification stranded in its TUI composer (the #1556 race) and the
13
+ * agent sat idle until manually poked. Fix: route the resume through
14
+ * the same turn-gate as normal inbounds (buffer mid-turn, flush at
15
+ * turn-end). This scenario proves the agent resumes on its own.
16
+ *
17
+ * Posture: the live fleet runs `vault.broker.approvalAuth: telegram-id`
18
+ * (broker auto-unlocked), so tapping Approve mints silently — NO
19
+ * passphrase prompt. The sibling `vault-grant-auto-resume-dm.test.ts`
20
+ * covers the (now-legacy) passphrase posture and stays skipped.
21
+ *
22
+ * No sacrificial key needed: `vault_request_access` mints an ACL grant
23
+ * for the key *pattern*; the card → approve → resume cycle fires
24
+ * whether or not the key holds a value. We assert the RESUME (a fresh
25
+ * bot turn after the tap, with no driver nudge), not a secret value —
26
+ * so nothing sensitive is read or leaked.
27
+ *
28
+ * Self-skips green when the driver can't resolve the chat (unwired
29
+ * host), matching the other opt-in scenarios — uat/** is excluded from
30
+ * gating CI anyway. Mutates host vault state (mints a short grant on
31
+ * test-harness); harmless + TTL-expiring.
32
+ */
33
+
34
+ import { describe, expect, it } from "vitest";
35
+ import { spinUp } from "../harness.js";
36
+
37
+ const AGENT = "test-harness";
38
+ const KEY = "uat/resume-probe";
39
+
40
+ describe("uat: agent auto-resumes after vault grant approval — telegram-id (#2340)", () => {
41
+ it(
42
+ "fires card → operator taps Approve → agent emits a NEW turn with no nudge",
43
+ async () => {
44
+ const sc = await spinUp({ agent: AGENT });
45
+ try {
46
+ // 1. Ask the agent to request access then resume. Steer it to
47
+ // end its turn after the tool call so the approval lands at
48
+ // a turn boundary — the exact window #2340 fixes.
49
+ await sc.sendDM(
50
+ `Call your vault_request_access MCP tool with key="${KEY}", ` +
51
+ `scope="read", reason="UAT #2340 resume gate". After the tool ` +
52
+ `returns "waiting for operator", END YOUR TURN. When the ` +
53
+ `operator approves, you should AUTOMATICALLY resume: confirm ` +
54
+ `the grant landed and that you saw the approval for ${KEY}.`,
55
+ );
56
+
57
+ // 2. Wait for the approval card.
58
+ const card = await sc.expectMessage(/wants vault access/i, {
59
+ from: "bot",
60
+ timeout: 120_000,
61
+ });
62
+
63
+ // 3. Find + tap the Approve button.
64
+ const kb = await sc.driver.getKeyboard(sc.botUserId, card.messageId);
65
+ const approve = kb!
66
+ .flat()
67
+ .find((b) => b.callbackData !== undefined && /approve/i.test(b.text));
68
+ expect(approve, "Approve button present on the card").toBeDefined();
69
+ const tapAtMsgId = card.messageId;
70
+ await sc.driver.pressButton(sc.botUserId, card.messageId, approve!.callbackData!);
71
+
72
+ // 4. Single-factor: card edits to "Granted" with no passphrase
73
+ // prompt. Anchor on the grant confirmation.
74
+ await sc.expectMessage(/Granted|already has|access to/i, {
75
+ from: "bot",
76
+ timeout: 30_000,
77
+ });
78
+
79
+ // 5. THE #2340 ASSERTION: the agent auto-resumes — a NEW bot
80
+ // turn referencing the approval/grant/key, WITHOUT the driver
81
+ // sending anything else. Pre-fix this stranded mid-turn and
82
+ // timed out. The resume reply must be a message newer than
83
+ // the card we tapped (not the card edit).
84
+ const resume = await sc.expectMessage(
85
+ (m) =>
86
+ m.messageId > tapAtMsgId &&
87
+ /(approv|grant|access|resume|landed|✅)/i.test(m.text),
88
+ { from: "bot", timeout: 150_000 },
89
+ );
90
+ expect(resume.text.length).toBeGreaterThan(0);
91
+ } finally {
92
+ await sc.tearDown();
93
+ }
94
+ },
95
+ 360_000,
96
+ );
97
+ });
@@ -34,10 +34,13 @@ describe("uat: /model command — show, switch, bad-name", () => {
34
34
  const sc = await spinUp({ agent: AGENT });
35
35
  try {
36
36
  await sc.sendDM("/model");
37
- // v2 (picker-driven menu): "Now: <model>"; v1 / fallback path:
38
- // "Configured: <model>". Either proves the gateway handled the
39
- // command rather than forwarding it to claude as plain text.
40
- const shape = /Now:|Configured:/i;
37
+ // v2 (picker-driven menu) renders the live model as
38
+ // "Default (new sessions): <model>" (shipped wording, verified
39
+ // live on test-harness v0.15.21); "Now: <model>" was the
40
+ // pre-ship wording; v1 / fallback path renders "Configured:
41
+ // <model>". Any of these proves the gateway handled the command
42
+ // rather than forwarding it to claude as plain text.
43
+ const shape = /Default \(new sessions\):|Now:|Configured:/i;
41
44
  const reply = await sc.expectMessage(shape, {
42
45
  from: "bot",
43
46
  timeout: REPLY_TIMEOUT_MS,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * UAT — `/model` v2 dashboard BUTTON TAP (#2263, #2270, #2271). The
3
+ * existing jtbd-model-command scenario covers the bare dashboard +
4
+ * typed-arg forms; this one exercises the genuinely new path the
5
+ * mtcute driver can now drive: tapping a model button to switch the
6
+ * live session via the picker, and the menu never leaving a dead card
7
+ * (#2270 — keeps buttons + clears the toast).
8
+ *
9
+ * Switches are session-only (revert on restart); the test taps a
10
+ * different model then restores the original so test-harness isn't
11
+ * left changed.
12
+ *
13
+ * Self-skips green on an unwired host.
14
+ */
15
+ import { describe, expect, it } from "vitest";
16
+ import { spinUp } from "../harness.js";
17
+
18
+ const AGENT = "test-harness";
19
+
20
+ describe("uat: /model dashboard button tap switches the live session", () => {
21
+ it(
22
+ "tap a non-current model → switch confirmation; then restore",
23
+ async () => {
24
+ const sc = await spinUp({ agent: AGENT });
25
+ try {
26
+ await sc.sendDM("/model");
27
+ const menu = await sc.expectMessage(/Default \(new sessions\):|Now:/i, {
28
+ from: "bot",
29
+ timeout: 30_000,
30
+ });
31
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
32
+ const flat = (kb ?? []).flat().filter((b) => b.callbackData);
33
+ // Model buttons carry mdl:s:<tag>; the current one is prefixed
34
+ // ✅. Refresh (mdl:r) is excluded — pick a non-current model.
35
+ const originalLabel = flat
36
+ .find((b) => /✅/.test(b.text))
37
+ ?.text.replace(/^✅\s*/, "");
38
+ const target = flat.find(
39
+ (b) => /mdl:s:/.test(b.callbackData!) && !/✅/.test(b.text),
40
+ );
41
+ expect(target, "a non-current model button to tap").toBeDefined();
42
+
43
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, target!.callbackData!);
44
+ await new Promise((r) => setTimeout(r, 6000));
45
+ // #2270: the card never goes dead — it edits in place to a
46
+ // confirmation and KEEPS a keyboard.
47
+ const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
48
+ expect(after?.text ?? "").toMatch(/Set model to|Switched|model/i);
49
+ const kbAfter = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
50
+ expect(
51
+ (kbAfter ?? []).flat().length,
52
+ "menu keeps its buttons after a tap (no dead card, #2270)",
53
+ ).toBeGreaterThan(0);
54
+
55
+ // Restore the original model: tap the button whose label now
56
+ // matches the original (it's no longer the ✅ row).
57
+ if (originalLabel) {
58
+ const restore = (kbAfter ?? [])
59
+ .flat()
60
+ .find((b) => b.callbackData?.startsWith("mdl:s:") && b.text.replace(/^✅\s*/, "") === originalLabel);
61
+ if (restore?.callbackData) {
62
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, restore.callbackData);
63
+ }
64
+ }
65
+ } finally {
66
+ await sc.tearDown();
67
+ }
68
+ },
69
+ 120_000,
70
+ );
71
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * UAT — `/whoami` (#2341): the operator's read-only view of THIS
3
+ * agent's sandbox (same data the agent's `config whoami` MCP tool and
4
+ * the `switchroom config whoami` host CLI report). Read-only, like
5
+ * `/version`; mutates nothing.
6
+ *
7
+ * Verified live on test-harness v0.15.21. Self-skips green on an
8
+ * unwired host.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { spinUp } from "../harness.js";
12
+
13
+ const AGENT = "test-harness";
14
+
15
+ describe("uat: /whoami shows the agent sandbox card", () => {
16
+ it(
17
+ "renders tier, model, tools, MCP, and powers",
18
+ async () => {
19
+ const sc = await spinUp({ agent: AGENT });
20
+ try {
21
+ await sc.sendDM("/whoami");
22
+ // Header: "👤 <agent> · <tier>"
23
+ const reply = await sc.expectMessage(/👤\s*test-harness/i, {
24
+ from: "bot",
25
+ timeout: 30_000,
26
+ });
27
+ // The card's load-bearing fields — proves whoami resolved the
28
+ // sandbox (tier/model/tools/mcp/powers), not just echoed a stub.
29
+ expect(reply.text).toMatch(/Model:/i);
30
+ expect(reply.text).toMatch(/Tools:/i);
31
+ expect(reply.text).toMatch(/Powers:/i);
32
+ // Tier marker present in the header (standard / admin / root).
33
+ expect(reply.text).toMatch(/·\s*(standard|admin|root)/i);
34
+ } finally {
35
+ await sc.tearDown();
36
+ }
37
+ },
38
+ 60_000,
39
+ );
40
+ });