switchroom 0.13.54 → 0.13.56

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.
@@ -13876,6 +13876,16 @@ var MicrosoftWorkspaceConfigSchema = exports_external.object({
13876
13876
  authority: exports_external.string().url().optional().describe("Microsoft authority endpoint. Defaults to " + "'https://login.microsoftonline.com/common' which accepts both " + "personal MSA and work/school tenants. Override only for " + "single-tenant deployments."),
13877
13877
  org_mode: exports_external.boolean().optional().describe("Opt-in to Teams + SharePoint surfaces (RFC §6.4). When true, " + "the v1 scope set adds Sites.ReadWrite.All AND the launcher " + "spawns softeria with --org-mode. Defaults to false — personal " + "MSA + standard work surfaces only. Flipping for an existing " + "consented account requires re-running 'auth microsoft account " + "add --replace' to consent the additional scope.")
13878
13878
  }).optional();
13879
+ var NotionWorkspaceConfigSchema = exports_external.object({
13880
+ vault_key: exports_external.string().min(1).default("notion/integration-token").describe("Vault key holding the Notion internal-integration token. Default " + "`notion/integration-token`. Override only for non-standard vault " + "layouts. The broker's --allow ACL on this key is the authoritative " + "list of which agents may receive the token."),
13881
+ databases: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
13882
+ message: "notion_workspace.databases friendly names must match " + "/^[a-z0-9][a-z0-9_-]{0,62}$/ — lowercase letters, digits, " + "hyphens, underscores. Got: '%s'."
13883
+ }), exports_external.string().regex(/^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$/, {
13884
+ message: "notion_workspace.databases values must be Notion database " + "UUIDs (32 hex characters, optional dashes)."
13885
+ })).default({}).describe("Friendly-name → Notion database UUID map. Operator-managed; agents " + "reference databases by friendly name only — they never see or type " + "UUIDs. Populate via `switchroom notion list-dbs` (PR 4) after " + "vault-putting the integration token and sharing DBs with the " + "integration in Notion's UI."),
13886
+ mcp_version: exports_external.string().min(1).optional().describe("Optional pin for the upstream `@notionhq/notion-mcp-server` npm " + "package version. Default is the build-time `NOTION_MCP_PINNED_VERSION` " + "constant. Override only when reproducing operator-specific bugs."),
13887
+ rate_limit_rps: exports_external.number().int().positive().max(10).optional().describe("Optional global rate-limit budget in requests per second across all " + "switchroom agents sharing this integration token. Defaults to 3 " + "(Notion's documented public-API limit). Lower it if you also use " + "the integration token from outside switchroom and need to share " + "budget. Higher than 10 is rejected — if you think you need it, " + "your usage probably needs a workspace-tier upgrade with Notion.")
13888
+ }).optional();
13879
13889
  var AgentGoogleWorkspaceConfigSchema = exports_external.object({
13880
13890
  account: exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
13881
13891
  message: "google_workspace.account must be a Google account email like " + "'alice@example.com' (colons not allowed)"
@@ -13889,6 +13899,13 @@ var AgentMicrosoftWorkspaceConfigSchema = exports_external.object({
13889
13899
  }).transform((v) => v.trim().toLowerCase()).optional().describe("RFC #1873: the Microsoft account this agent uses for the M365 MCP. " + "Must be a key in top-level `microsoft_accounts:` with this agent " + "listed in its `enabled_for[]`. Read by the auth-broker " + "(get-credentials, provider=microsoft) and by the scaffold to " + "decide whether to emit the `ms-365` MCP entry. Normalized to " + "lowercase so it matches the microsoft_accounts key (which is " + "also normalized)."),
13890
13900
  org_mode: exports_external.boolean().optional().describe("Per-agent org_mode override (RFC #1873 §6.4). When set, replaces " + "the top-level microsoft_workspace.org_mode for this agent. " + "Defaults to top-level value (which defaults to false).")
13891
13901
  }).optional();
13902
+ var AgentNotionWorkspaceConfigSchema = exports_external.object({
13903
+ databases: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
13904
+ message: "notion_workspace.databases entries must be friendly names " + "matching /^[a-z0-9][a-z0-9_-]{0,62}$/ — these must appear as " + "keys in top-level notion_workspace.databases."
13905
+ })).min(1, {
13906
+ message: "notion_workspace.databases must list at least one friendly " + "name. An empty list rejects every Notion tool call — if you " + "want to remove this agent's Notion access, delete the entire " + "notion_workspace block instead."
13907
+ }).optional().describe("Optional per-agent allowlist of database friendly names this " + "agent may read/write. Each name must exist as a key in top-level " + "notion_workspace.databases. Omit the field (or leave it undefined) " + "to grant access to every DB the upstream integration can see — " + "appropriate for an admin/orchestrator agent. Set the list to " + "narrow access for specialist agents.")
13908
+ }).optional();
13892
13909
  var ReactionsSchema = exports_external.object({
13893
13910
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-trigger path. When false, " + "reactions are still persisted via recordReaction but never " + "dispatched to the agent as synthetic inbound turns. Default true."),
13894
13911
  trigger_emojis: exports_external.array(exports_external.string()).optional().describe("Emoji allowlist that triggers a synthetic inbound when reacted " + "to a bot message. Default ['\uD83D\uDC4E', '❌', '\uD83D\uDC4D', '✅']. Cascade " + "mode: REPLACE (not union) — setting this at a layer replaces " + "lower layers entirely, so an operator can narrow to [] to " + "disable triggering without flipping `enabled`."),
@@ -14025,6 +14042,7 @@ var AgentSchema = exports_external.object({
14025
14042
  drive: AgentGoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Per-agent " + "google_workspace overrides (currently approvers + tier). When set, " + "replaces the top-level approvers list for this agent. " + "google_client_id/secret are not per-agent — they live at the top level."),
14026
14043
  google_workspace: AgentGoogleWorkspaceConfigSchema.describe("RFC G canonical key. Per-agent Google Workspace overrides — currently " + "approvers (replaces, does not extend the top-level list) and tier " + "(`core` | `extended` | `complete`, replaces top-level default). " + "google_client_id/secret are not per-agent — they live at the top level. " + "Mutually exclusive with `drive:` on the same agent (loader fails fast " + "if both are set)."),
14027
14044
  microsoft_workspace: AgentMicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Per-agent Microsoft Workspace " + "override — pins the Microsoft account this agent reads via the " + "auth-broker (must be a key in top-level `microsoft_accounts:` with " + "this agent in its `enabled_for[]`) and optionally overrides org_mode. " + "microsoft_client_id/secret are not per-agent."),
14045
+ notion_workspace: AgentNotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Per-agent Notion access. " + "Presence opts the agent IN (launcher scaffolded, MCP entry emitted, " + "broker grants the integration token). Optional `databases:` filter " + "narrows which DBs this agent may read/write — names must resolve in " + "top-level notion_workspace.databases. Absence opts the agent OUT."),
14028
14046
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
14029
14047
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
14030
14048
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
@@ -14148,6 +14166,7 @@ var SwitchroomConfigSchema = exports_external.object({
14148
14166
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
14149
14167
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
14150
14168
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
14169
+ 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."),
14151
14170
  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."),
14152
14171
  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)."),
14153
14172
  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)."),
@@ -14653,6 +14672,34 @@ function mergeAgentConfig(defaultsIn, agentIn) {
14653
14672
  mergeAgentConfig.notifiedWorkerIsolationMove = false;
14654
14673
  })(mergeAgentConfig ||= {});
14655
14674
 
14675
+ // src/config/notion-workspace-acl.ts
14676
+ function validateNotionWorkspaceConfig(config) {
14677
+ const issues = [];
14678
+ const dbMap = config.notion_workspace?.databases ?? {};
14679
+ const known = new Set(Object.keys(dbMap));
14680
+ for (const [agentName, agentRaw] of Object.entries(config.agents ?? {})) {
14681
+ if (!agentRaw)
14682
+ continue;
14683
+ const dbFilter = agentRaw.notion_workspace?.databases;
14684
+ if (dbFilter === undefined)
14685
+ continue;
14686
+ if (dbFilter.length === 0) {
14687
+ issues.push(` agents.${agentName}.notion_workspace.databases is an empty ` + `list. Delete the entire notion_workspace block to remove Notion ` + `access, or list at least one database friendly name.`);
14688
+ continue;
14689
+ }
14690
+ if (config.notion_workspace === undefined) {
14691
+ issues.push(` agents.${agentName}.notion_workspace is set but the top-level ` + `notion_workspace block is missing. Configure the integration ` + `globally first (vault_key + databases map), then grant per-agent ` + `access.`);
14692
+ continue;
14693
+ }
14694
+ for (const name of dbFilter) {
14695
+ if (!known.has(name)) {
14696
+ issues.push(` agents.${agentName}.notion_workspace.databases references ` + `unknown database "${name}". Add it to top-level ` + `notion_workspace.databases (run \`switchroom notion list-dbs\` ` + `to see the UUIDs the integration can read), or remove the ` + `reference.`);
14697
+ }
14698
+ }
14699
+ }
14700
+ return issues;
14701
+ }
14702
+
14656
14703
  // src/config/loader.ts
14657
14704
  class ConfigError extends Error {
14658
14705
  details;
@@ -14772,6 +14819,10 @@ function loadConfig(configPath) {
14772
14819
  }
14773
14820
  applyAgentOverlays(config);
14774
14821
  validateAllCronTopicAliases(config, filePath);
14822
+ const notionIssues = validateNotionWorkspaceConfig(config);
14823
+ if (notionIssues.length > 0) {
14824
+ throw new ConfigError(`Invalid notion_workspace configuration in ${filePath}`, notionIssues);
14825
+ }
14775
14826
  return config;
14776
14827
  }
14777
14828
  function validateAllCronTopicAliases(config, filePath) {
@@ -11259,7 +11259,7 @@ var init_dist = __esm(() => {
11259
11259
  });
11260
11260
 
11261
11261
  // src/config/schema.ts
11262
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
11262
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
11263
11263
  var init_schema = __esm(() => {
11264
11264
  init_zod();
11265
11265
  CodeRepoEntrySchema = exports_external.object({
@@ -11444,6 +11444,16 @@ var init_schema = __esm(() => {
11444
11444
  authority: exports_external.string().url().optional().describe("Microsoft authority endpoint. Defaults to " + "'https://login.microsoftonline.com/common' which accepts both " + "personal MSA and work/school tenants. Override only for " + "single-tenant deployments."),
11445
11445
  org_mode: exports_external.boolean().optional().describe("Opt-in to Teams + SharePoint surfaces (RFC §6.4). When true, " + "the v1 scope set adds Sites.ReadWrite.All AND the launcher " + "spawns softeria with --org-mode. Defaults to false — personal " + "MSA + standard work surfaces only. Flipping for an existing " + "consented account requires re-running 'auth microsoft account " + "add --replace' to consent the additional scope.")
11446
11446
  }).optional();
11447
+ NotionWorkspaceConfigSchema = exports_external.object({
11448
+ vault_key: exports_external.string().min(1).default("notion/integration-token").describe("Vault key holding the Notion internal-integration token. Default " + "`notion/integration-token`. Override only for non-standard vault " + "layouts. The broker's --allow ACL on this key is the authoritative " + "list of which agents may receive the token."),
11449
+ databases: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11450
+ message: "notion_workspace.databases friendly names must match " + "/^[a-z0-9][a-z0-9_-]{0,62}$/ — lowercase letters, digits, " + "hyphens, underscores. Got: '%s'."
11451
+ }), exports_external.string().regex(/^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$/, {
11452
+ message: "notion_workspace.databases values must be Notion database " + "UUIDs (32 hex characters, optional dashes)."
11453
+ })).default({}).describe("Friendly-name → Notion database UUID map. Operator-managed; agents " + "reference databases by friendly name only — they never see or type " + "UUIDs. Populate via `switchroom notion list-dbs` (PR 4) after " + "vault-putting the integration token and sharing DBs with the " + "integration in Notion's UI."),
11454
+ mcp_version: exports_external.string().min(1).optional().describe("Optional pin for the upstream `@notionhq/notion-mcp-server` npm " + "package version. Default is the build-time `NOTION_MCP_PINNED_VERSION` " + "constant. Override only when reproducing operator-specific bugs."),
11455
+ rate_limit_rps: exports_external.number().int().positive().max(10).optional().describe("Optional global rate-limit budget in requests per second across all " + "switchroom agents sharing this integration token. Defaults to 3 " + "(Notion's documented public-API limit). Lower it if you also use " + "the integration token from outside switchroom and need to share " + "budget. Higher than 10 is rejected — if you think you need it, " + "your usage probably needs a workspace-tier upgrade with Notion.")
11456
+ }).optional();
11447
11457
  AgentGoogleWorkspaceConfigSchema = exports_external.object({
11448
11458
  account: exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11449
11459
  message: "google_workspace.account must be a Google account email like " + "'alice@example.com' (colons not allowed)"
@@ -11457,6 +11467,13 @@ var init_schema = __esm(() => {
11457
11467
  }).transform((v) => v.trim().toLowerCase()).optional().describe("RFC #1873: the Microsoft account this agent uses for the M365 MCP. " + "Must be a key in top-level `microsoft_accounts:` with this agent " + "listed in its `enabled_for[]`. Read by the auth-broker " + "(get-credentials, provider=microsoft) and by the scaffold to " + "decide whether to emit the `ms-365` MCP entry. Normalized to " + "lowercase so it matches the microsoft_accounts key (which is " + "also normalized)."),
11458
11468
  org_mode: exports_external.boolean().optional().describe("Per-agent org_mode override (RFC #1873 §6.4). When set, replaces " + "the top-level microsoft_workspace.org_mode for this agent. " + "Defaults to top-level value (which defaults to false).")
11459
11469
  }).optional();
11470
+ AgentNotionWorkspaceConfigSchema = exports_external.object({
11471
+ databases: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11472
+ message: "notion_workspace.databases entries must be friendly names " + "matching /^[a-z0-9][a-z0-9_-]{0,62}$/ — these must appear as " + "keys in top-level notion_workspace.databases."
11473
+ })).min(1, {
11474
+ message: "notion_workspace.databases must list at least one friendly " + "name. An empty list rejects every Notion tool call — if you " + "want to remove this agent's Notion access, delete the entire " + "notion_workspace block instead."
11475
+ }).optional().describe("Optional per-agent allowlist of database friendly names this " + "agent may read/write. Each name must exist as a key in top-level " + "notion_workspace.databases. Omit the field (or leave it undefined) " + "to grant access to every DB the upstream integration can see — " + "appropriate for an admin/orchestrator agent. Set the list to " + "narrow access for specialist agents.")
11476
+ }).optional();
11460
11477
  ReactionsSchema = exports_external.object({
11461
11478
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-trigger path. When false, " + "reactions are still persisted via recordReaction but never " + "dispatched to the agent as synthetic inbound turns. Default true."),
11462
11479
  trigger_emojis: exports_external.array(exports_external.string()).optional().describe("Emoji allowlist that triggers a synthetic inbound when reacted " + "to a bot message. Default ['\uD83D\uDC4E', '❌', '\uD83D\uDC4D', '✅']. Cascade " + "mode: REPLACE (not union) — setting this at a layer replaces " + "lower layers entirely, so an operator can narrow to [] to " + "disable triggering without flipping `enabled`."),
@@ -11593,6 +11610,7 @@ var init_schema = __esm(() => {
11593
11610
  drive: AgentGoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Per-agent " + "google_workspace overrides (currently approvers + tier). When set, " + "replaces the top-level approvers list for this agent. " + "google_client_id/secret are not per-agent — they live at the top level."),
11594
11611
  google_workspace: AgentGoogleWorkspaceConfigSchema.describe("RFC G canonical key. Per-agent Google Workspace overrides — currently " + "approvers (replaces, does not extend the top-level list) and tier " + "(`core` | `extended` | `complete`, replaces top-level default). " + "google_client_id/secret are not per-agent — they live at the top level. " + "Mutually exclusive with `drive:` on the same agent (loader fails fast " + "if both are set)."),
11595
11612
  microsoft_workspace: AgentMicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Per-agent Microsoft Workspace " + "override — pins the Microsoft account this agent reads via the " + "auth-broker (must be a key in top-level `microsoft_accounts:` with " + "this agent in its `enabled_for[]`) and optionally overrides org_mode. " + "microsoft_client_id/secret are not per-agent."),
11613
+ notion_workspace: AgentNotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Per-agent Notion access. " + "Presence opts the agent IN (launcher scaffolded, MCP entry emitted, " + "broker grants the integration token). Optional `databases:` filter " + "narrows which DBs this agent may read/write — names must resolve in " + "top-level notion_workspace.databases. Absence opts the agent OUT."),
11596
11614
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11597
11615
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11598
11616
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
@@ -11716,6 +11734,7 @@ var init_schema = __esm(() => {
11716
11734
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
11717
11735
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11718
11736
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
11737
+ 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."),
11719
11738
  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."),
11720
11739
  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)."),
11721
11740
  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)."),
@@ -11912,6 +11931,34 @@ var init_overlay_loader = __esm(() => {
11912
11931
  OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
11913
11932
  });
11914
11933
 
11934
+ // src/config/notion-workspace-acl.ts
11935
+ function validateNotionWorkspaceConfig(config) {
11936
+ const issues = [];
11937
+ const dbMap = config.notion_workspace?.databases ?? {};
11938
+ const known = new Set(Object.keys(dbMap));
11939
+ for (const [agentName, agentRaw] of Object.entries(config.agents ?? {})) {
11940
+ if (!agentRaw)
11941
+ continue;
11942
+ const dbFilter = agentRaw.notion_workspace?.databases;
11943
+ if (dbFilter === undefined)
11944
+ continue;
11945
+ if (dbFilter.length === 0) {
11946
+ issues.push(` agents.${agentName}.notion_workspace.databases is an empty ` + `list. Delete the entire notion_workspace block to remove Notion ` + `access, or list at least one database friendly name.`);
11947
+ continue;
11948
+ }
11949
+ if (config.notion_workspace === undefined) {
11950
+ issues.push(` agents.${agentName}.notion_workspace is set but the top-level ` + `notion_workspace block is missing. Configure the integration ` + `globally first (vault_key + databases map), then grant per-agent ` + `access.`);
11951
+ continue;
11952
+ }
11953
+ for (const name of dbFilter) {
11954
+ if (!known.has(name)) {
11955
+ issues.push(` agents.${agentName}.notion_workspace.databases references ` + `unknown database "${name}". Add it to top-level ` + `notion_workspace.databases (run \`switchroom notion list-dbs\` ` + `to see the UUIDs the integration can read), or remove the ` + `reference.`);
11956
+ }
11957
+ }
11958
+ }
11959
+ return issues;
11960
+ }
11961
+
11915
11962
  // src/config/loader.ts
11916
11963
  var exports_loader = {};
11917
11964
  __export(exports_loader, {
@@ -12034,6 +12081,10 @@ function loadConfig(configPath) {
12034
12081
  }
12035
12082
  applyAgentOverlays(config);
12036
12083
  validateAllCronTopicAliases(config, filePath);
12084
+ const notionIssues = validateNotionWorkspaceConfig(config);
12085
+ if (notionIssues.length > 0) {
12086
+ throw new ConfigError(`Invalid notion_workspace configuration in ${filePath}`, notionIssues);
12087
+ }
12037
12088
  return config;
12038
12089
  }
12039
12090
  function validateAllCronTopicAliases(config, filePath) {
@@ -11259,7 +11259,7 @@ var init_zod = __esm(() => {
11259
11259
  });
11260
11260
 
11261
11261
  // src/config/schema.ts
11262
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
11262
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
11263
11263
  var init_schema = __esm(() => {
11264
11264
  init_zod();
11265
11265
  CodeRepoEntrySchema = exports_external.object({
@@ -11444,6 +11444,16 @@ var init_schema = __esm(() => {
11444
11444
  authority: exports_external.string().url().optional().describe("Microsoft authority endpoint. Defaults to " + "'https://login.microsoftonline.com/common' which accepts both " + "personal MSA and work/school tenants. Override only for " + "single-tenant deployments."),
11445
11445
  org_mode: exports_external.boolean().optional().describe("Opt-in to Teams + SharePoint surfaces (RFC §6.4). When true, " + "the v1 scope set adds Sites.ReadWrite.All AND the launcher " + "spawns softeria with --org-mode. Defaults to false — personal " + "MSA + standard work surfaces only. Flipping for an existing " + "consented account requires re-running 'auth microsoft account " + "add --replace' to consent the additional scope.")
11446
11446
  }).optional();
11447
+ NotionWorkspaceConfigSchema = exports_external.object({
11448
+ vault_key: exports_external.string().min(1).default("notion/integration-token").describe("Vault key holding the Notion internal-integration token. Default " + "`notion/integration-token`. Override only for non-standard vault " + "layouts. The broker's --allow ACL on this key is the authoritative " + "list of which agents may receive the token."),
11449
+ databases: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11450
+ message: "notion_workspace.databases friendly names must match " + "/^[a-z0-9][a-z0-9_-]{0,62}$/ — lowercase letters, digits, " + "hyphens, underscores. Got: '%s'."
11451
+ }), exports_external.string().regex(/^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$/, {
11452
+ message: "notion_workspace.databases values must be Notion database " + "UUIDs (32 hex characters, optional dashes)."
11453
+ })).default({}).describe("Friendly-name → Notion database UUID map. Operator-managed; agents " + "reference databases by friendly name only — they never see or type " + "UUIDs. Populate via `switchroom notion list-dbs` (PR 4) after " + "vault-putting the integration token and sharing DBs with the " + "integration in Notion's UI."),
11454
+ mcp_version: exports_external.string().min(1).optional().describe("Optional pin for the upstream `@notionhq/notion-mcp-server` npm " + "package version. Default is the build-time `NOTION_MCP_PINNED_VERSION` " + "constant. Override only when reproducing operator-specific bugs."),
11455
+ rate_limit_rps: exports_external.number().int().positive().max(10).optional().describe("Optional global rate-limit budget in requests per second across all " + "switchroom agents sharing this integration token. Defaults to 3 " + "(Notion's documented public-API limit). Lower it if you also use " + "the integration token from outside switchroom and need to share " + "budget. Higher than 10 is rejected — if you think you need it, " + "your usage probably needs a workspace-tier upgrade with Notion.")
11456
+ }).optional();
11447
11457
  AgentGoogleWorkspaceConfigSchema = exports_external.object({
11448
11458
  account: exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11449
11459
  message: "google_workspace.account must be a Google account email like " + "'alice@example.com' (colons not allowed)"
@@ -11457,6 +11467,13 @@ var init_schema = __esm(() => {
11457
11467
  }).transform((v) => v.trim().toLowerCase()).optional().describe("RFC #1873: the Microsoft account this agent uses for the M365 MCP. " + "Must be a key in top-level `microsoft_accounts:` with this agent " + "listed in its `enabled_for[]`. Read by the auth-broker " + "(get-credentials, provider=microsoft) and by the scaffold to " + "decide whether to emit the `ms-365` MCP entry. Normalized to " + "lowercase so it matches the microsoft_accounts key (which is " + "also normalized)."),
11458
11468
  org_mode: exports_external.boolean().optional().describe("Per-agent org_mode override (RFC #1873 §6.4). When set, replaces " + "the top-level microsoft_workspace.org_mode for this agent. " + "Defaults to top-level value (which defaults to false).")
11459
11469
  }).optional();
11470
+ AgentNotionWorkspaceConfigSchema = exports_external.object({
11471
+ databases: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
11472
+ message: "notion_workspace.databases entries must be friendly names " + "matching /^[a-z0-9][a-z0-9_-]{0,62}$/ — these must appear as " + "keys in top-level notion_workspace.databases."
11473
+ })).min(1, {
11474
+ message: "notion_workspace.databases must list at least one friendly " + "name. An empty list rejects every Notion tool call — if you " + "want to remove this agent's Notion access, delete the entire " + "notion_workspace block instead."
11475
+ }).optional().describe("Optional per-agent allowlist of database friendly names this " + "agent may read/write. Each name must exist as a key in top-level " + "notion_workspace.databases. Omit the field (or leave it undefined) " + "to grant access to every DB the upstream integration can see — " + "appropriate for an admin/orchestrator agent. Set the list to " + "narrow access for specialist agents.")
11476
+ }).optional();
11460
11477
  ReactionsSchema = exports_external.object({
11461
11478
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-trigger path. When false, " + "reactions are still persisted via recordReaction but never " + "dispatched to the agent as synthetic inbound turns. Default true."),
11462
11479
  trigger_emojis: exports_external.array(exports_external.string()).optional().describe("Emoji allowlist that triggers a synthetic inbound when reacted " + "to a bot message. Default ['\uD83D\uDC4E', '❌', '\uD83D\uDC4D', '✅']. Cascade " + "mode: REPLACE (not union) — setting this at a layer replaces " + "lower layers entirely, so an operator can narrow to [] to " + "disable triggering without flipping `enabled`."),
@@ -11593,6 +11610,7 @@ var init_schema = __esm(() => {
11593
11610
  drive: AgentGoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Per-agent " + "google_workspace overrides (currently approvers + tier). When set, " + "replaces the top-level approvers list for this agent. " + "google_client_id/secret are not per-agent — they live at the top level."),
11594
11611
  google_workspace: AgentGoogleWorkspaceConfigSchema.describe("RFC G canonical key. Per-agent Google Workspace overrides — currently " + "approvers (replaces, does not extend the top-level list) and tier " + "(`core` | `extended` | `complete`, replaces top-level default). " + "google_client_id/secret are not per-agent — they live at the top level. " + "Mutually exclusive with `drive:` on the same agent (loader fails fast " + "if both are set)."),
11595
11612
  microsoft_workspace: AgentMicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Per-agent Microsoft Workspace " + "override — pins the Microsoft account this agent reads via the " + "auth-broker (must be a key in top-level `microsoft_accounts:` with " + "this agent in its `enabled_for[]`) and optionally overrides org_mode. " + "microsoft_client_id/secret are not per-agent."),
11613
+ notion_workspace: AgentNotionWorkspaceConfigSchema.describe("RFC docs/rfcs/notion-integration.md. Per-agent Notion access. " + "Presence opts the agent IN (launcher scaffolded, MCP entry emitted, " + "broker grants the integration token). Optional `databases:` filter " + "narrows which DBs this agent may read/write — names must resolve in " + "top-level notion_workspace.databases. Absence opts the agent OUT."),
11596
11614
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11597
11615
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11598
11616
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
@@ -11716,6 +11734,7 @@ var init_schema = __esm(() => {
11716
11734
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
11717
11735
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11718
11736
  microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration — OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
11737
+ 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."),
11719
11738
  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."),
11720
11739
  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)."),
11721
11740
  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)."),
@@ -11912,6 +11931,34 @@ var init_overlay_loader = __esm(() => {
11912
11931
  OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
11913
11932
  });
11914
11933
 
11934
+ // src/config/notion-workspace-acl.ts
11935
+ function validateNotionWorkspaceConfig(config) {
11936
+ const issues = [];
11937
+ const dbMap = config.notion_workspace?.databases ?? {};
11938
+ const known = new Set(Object.keys(dbMap));
11939
+ for (const [agentName, agentRaw] of Object.entries(config.agents ?? {})) {
11940
+ if (!agentRaw)
11941
+ continue;
11942
+ const dbFilter = agentRaw.notion_workspace?.databases;
11943
+ if (dbFilter === undefined)
11944
+ continue;
11945
+ if (dbFilter.length === 0) {
11946
+ issues.push(` agents.${agentName}.notion_workspace.databases is an empty ` + `list. Delete the entire notion_workspace block to remove Notion ` + `access, or list at least one database friendly name.`);
11947
+ continue;
11948
+ }
11949
+ if (config.notion_workspace === undefined) {
11950
+ issues.push(` agents.${agentName}.notion_workspace is set but the top-level ` + `notion_workspace block is missing. Configure the integration ` + `globally first (vault_key + databases map), then grant per-agent ` + `access.`);
11951
+ continue;
11952
+ }
11953
+ for (const name of dbFilter) {
11954
+ if (!known.has(name)) {
11955
+ issues.push(` agents.${agentName}.notion_workspace.databases references ` + `unknown database "${name}". Add it to top-level ` + `notion_workspace.databases (run \`switchroom notion list-dbs\` ` + `to see the UUIDs the integration can read), or remove the ` + `reference.`);
11956
+ }
11957
+ }
11958
+ }
11959
+ return issues;
11960
+ }
11961
+
11915
11962
  // src/config/loader.ts
11916
11963
  var exports_loader = {};
11917
11964
  __export(exports_loader, {
@@ -12034,6 +12081,10 @@ function loadConfig(configPath) {
12034
12081
  }
12035
12082
  applyAgentOverlays(config);
12036
12083
  validateAllCronTopicAliases(config, filePath);
12084
+ const notionIssues = validateNotionWorkspaceConfig(config);
12085
+ if (notionIssues.length > 0) {
12086
+ throw new ConfigError(`Invalid notion_workspace configuration in ${filePath}`, notionIssues);
12087
+ }
12037
12088
  return config;
12038
12089
  }
12039
12090
  function validateAllCronTopicAliases(config, filePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.54",
3
+ "version": "0.13.56",
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": {
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: notion
3
+ version: 0.1.0
4
+ description: |
5
+ Use when the user wants to read, search, write, or update content in
6
+ their Notion workspace. The agent has access via the `notion` MCP
7
+ server (`@notionhq/notion-mcp-server`) configured by the operator
8
+ with a per-database allowlist.
9
+
10
+ Triggers on phrasings including: "add this to Notion", "what's on my
11
+ Notion tasks", "find that page about X in Notion", "create a Notion
12
+ page for this", "update the notion entry for X", "search Notion
13
+ for…", "append this to my Notion notes", "show me what's in the
14
+ essays database", "log this in Notion".
15
+
16
+ Do NOT use to create new Notion databases — that's disabled in v1
17
+ (operators create DBs via Notion's UI). Do NOT use to scan or
18
+ enumerate the operator's whole workspace ad-hoc — the per-database
19
+ allowlist scopes what the agent can see, and brute-forcing past it
20
+ is a defence-in-depth violation.
21
+
22
+ Also do not use this skill to file bugs against a GitHub repo
23
+ (that's `file-bug`) or to search the web (Notion search is workspace-
24
+ scoped only).
25
+ ---
26
+
27
+ # Notion
28
+
29
+ This skill is your interface to the operator's Notion workspace. The
30
+ operator has registered an integration in Notion's settings and
31
+ shared specific pages/databases with it. Your access is gated at two
32
+ layers:
33
+
34
+ 1. **Notion's upstream share-list** — the integration can only see
35
+ pages and databases the operator explicitly shared with it in
36
+ Notion's UI.
37
+ 2. **switchroom's per-agent allowlist** — within what the upstream
38
+ integration can see, the operator can further restrict YOU to a
39
+ subset via `agents.<your-name>.notion_workspace.databases:`.
40
+
41
+ Both layers are enforced at the broker / hook level. You don't have
42
+ to compute the intersection — if you call a tool against a
43
+ disallowed database, you'll get a clean block reason naming the DB.
44
+
45
+ ## Tool surface
46
+
47
+ The Notion MCP exposes the standard set:
48
+
49
+ - **Search.** `search` finds pages or databases by title/content
50
+ across what the integration can see. **In switchroom v1, `search`
51
+ is BLOCKED for any agent with a non-empty
52
+ `notion_workspace.databases:` allowlist** — the post-filter that
53
+ would strip out-of-allowlist results isn't wired yet. Use
54
+ `query_database` against your allowed DBs instead. Admin-shaped
55
+ agents (no per-DB filter) can search normally.
56
+ - **Database queries.** `query_database` runs a filter+sort against
57
+ a database. You need the database's UUID (operator gives these
58
+ via friendly names in `notion_workspace.databases` — ask the
59
+ operator for "what's the UUID for X" if needed).
60
+ - **Page reads.** `get_page` and `get_block_children` walk a page's
61
+ structure. Both go through the allowlist gate.
62
+ - **Page writes.** `create_page` (with `parent.database_id`),
63
+ `update_page`, `update_block`, `append_block_children`,
64
+ `delete_block`, `create_comment`. The allowlist gate runs first;
65
+ blocked tool calls return a reason you can surface to the operator.
66
+ - **Database writes.** `update_database` — schema changes,
67
+ rename, etc. Gated.
68
+
69
+ ## Tools NOT available
70
+
71
+ - **`create_database`** — disabled in v1 (operators create
72
+ databases via Notion's UI). If the user asks you to "create a new
73
+ database for X", say so and offer to populate an existing one or
74
+ prompt the operator to make the DB first.
75
+ - **`delete_database`** — same posture.
76
+
77
+ ## Common workflows
78
+
79
+ ### "What's on my tasks?"
80
+
81
+ 1. Ask the operator (or use `config_get` to check your own
82
+ `notion_workspace.databases`) for the friendly name of the tasks
83
+ DB. The friendly name resolves to a UUID via
84
+ `notion_workspace.databases` in switchroom.yaml.
85
+ 2. `query_database` with that UUID. Default filter: `not done`.
86
+ 3. Format results as a brief markdown list. Don't dump the full
87
+ property soup — operators want titles + status, not raw IDs.
88
+
89
+ ### "Add `<thing>` to Notion"
90
+
91
+ 1. Default destination is the database whose friendly name matches
92
+ the user's intent (`tasks`, `notes`, `essays`). Ask if it's
93
+ ambiguous.
94
+ 2. Use `create_page` with `parent.database_id: <uuid>`. The page's
95
+ properties need to match the target DB's schema — call
96
+ `retrieve_database` first if you're unsure of the property
97
+ names.
98
+ 3. After creation, confirm to the user with the page title and
99
+ the Notion URL.
100
+
101
+ ### "Find that thing about X"
102
+
103
+ 1. If you have full search access (no `databases:` filter): `search`
104
+ with the query, take the top result unless ambiguous.
105
+ 2. If `search` is blocked for you (the hook will return a clear
106
+ message naming the v1 limitation): `query_database` against
107
+ each of your allowed DBs in turn, with a title-contains or
108
+ property filter. List your allowed DBs by checking
109
+ `notion_workspace.databases:` in your config via `config_get`.
110
+
111
+ ### "Update the page about X"
112
+
113
+ 1. `search` or `query_database` to find the page.
114
+ 2. `update_page` (for properties — status, tags, dates) or
115
+ `append_block_children` (for body content).
116
+ 3. **Be careful** with `update_block` and `delete_block` — these
117
+ modify the page's body irreversibly. Prefer `append_block_children`
118
+ when the user's intent is "add a note to this page", not
119
+ "replace what's there".
120
+
121
+ ## Limits and behaviours
122
+
123
+ - **Rate limit**: Notion's public API is ~3 rps per integration.
124
+ Multi-step turns with many writes may slow down (the hook makes
125
+ resolver calls per write). If you see "Notion API failed: 429",
126
+ back off and retry once.
127
+ - **No `create_database`**: if the user asks for a new database,
128
+ say "I can write into existing DBs but the operator creates new
129
+ ones in Notion's UI." Then offer the closest existing DB.
130
+ - **Standalone pages denied**: pages without a database parent
131
+ (workspace root pages, personal sub-pages) are hard-denied in
132
+ v1. The block reason names this when it fires; pass it through
133
+ to the user.
134
+
135
+ ## When the allowlist denies you
136
+
137
+ If a tool call returns "DB <uuid> is not in your allowlist", that's
138
+ the operator's intended scope — don't try to work around it. Surface
139
+ the message to the user honestly: "I'm not configured to access that
140
+ database. You can add it to my allowlist with
141
+ `agents.<my-name>.notion_workspace.databases` in switchroom.yaml."
142
+
143
+ ## Authoring small notes vs full pages
144
+
145
+ For one-line "log this thought" intents, prefer `append_block_children`
146
+ to an existing daily-notes / inbox page rather than creating a new
147
+ page per note. Operators usually have an inbox DB; ask if you're
148
+ unsure.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Ack-first state flag — load-bearing for the ack-first-pretool hook
3
+ * (see `src/cli/ack-first-pretool.ts` and
4
+ * `reference/conversational-pacing.md` beat 1).
5
+ *
6
+ * The gateway is the source of truth for "has the model called reply
7
+ * yet this turn?". The PreToolUse hook runs as a short-lived child
8
+ * process so it can't read gateway in-memory state; the hand-off is
9
+ * a single file inside `$TELEGRAM_STATE_DIR`:
10
+ *
11
+ * - `markAckSent()` touches the file on the first reply per turn.
12
+ * - `clearAckSent()` removes it at turn_started.
13
+ * - The hook checks `existsSync(path)` → allow / block.
14
+ *
15
+ * Per-agent isolation is built-in: `$TELEGRAM_STATE_DIR` is the
16
+ * agent's per-container state dir (~/.switchroom/agents/<name>/telegram,
17
+ * bind-mounted into /state/agent/home/.switchroom/agents/<name>/telegram
18
+ * inside the container).
19
+ *
20
+ * All operations are best-effort: a write or unlink failure logs to
21
+ * stderr and returns; the gate is informational UX, not a safety
22
+ * primitive, so a broken state-dir must never wedge reply itself.
23
+ */
24
+
25
+ import { closeSync, existsSync, openSync, unlinkSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ export const ACK_SENT_MARKER = "ack-sent.flag";
29
+
30
+ function markerPath(): string | null {
31
+ const dir = process.env.TELEGRAM_STATE_DIR;
32
+ if (!dir) return null;
33
+ return join(dir, ACK_SENT_MARKER);
34
+ }
35
+
36
+ /** Create the ack-sent marker. Idempotent (no-op if it exists). */
37
+ export function markAckSent(): void {
38
+ const path = markerPath();
39
+ if (path == null) return;
40
+ if (existsSync(path)) return;
41
+ try {
42
+ const fd = openSync(path, "w");
43
+ closeSync(fd);
44
+ } catch (err) {
45
+ process.stderr.write(
46
+ `ack-flag: markAckSent failed path=${path}: ${err}\n`,
47
+ );
48
+ }
49
+ }
50
+
51
+ /** Remove the ack-sent marker. Idempotent. */
52
+ export function clearAckSent(): void {
53
+ const path = markerPath();
54
+ if (path == null) return;
55
+ try {
56
+ unlinkSync(path);
57
+ } catch (err: unknown) {
58
+ // ENOENT is the common case (turn started before any prior turn
59
+ // ran a reply); silently swallow.
60
+ const code = (err as { code?: string } | undefined)?.code;
61
+ if (code === "ENOENT") return;
62
+ process.stderr.write(
63
+ `ack-flag: clearAckSent failed path=${path}: ${err}\n`,
64
+ );
65
+ }
66
+ }