switchroom 0.15.12 → 0.15.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/agent-scheduler/index.js +324 -14
  2. package/dist/auth-broker/index.js +61 -4
  3. package/dist/cli/notion-write-pretool.mjs +61 -4
  4. package/dist/cli/switchroom.js +402 -113
  5. package/dist/host-control/main.js +61 -4
  6. package/dist/vault/approvals/kernel-server.js +62 -5
  7. package/dist/vault/broker/server.js +62 -5
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +30 -13
  10. package/profiles/_shared/agent-self-service.md.hbs +37 -0
  11. package/telegram-plugin/bridge/bridge.ts +38 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +31 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +536 -53
  14. package/telegram-plugin/dist/server.js +31 -1
  15. package/telegram-plugin/gateway/gateway.ts +169 -6
  16. package/telegram-plugin/gateway/ipc-protocol.ts +31 -1
  17. package/telegram-plugin/gateway/ipc-server.ts +29 -0
  18. package/telegram-plugin/gateway/linear-activity.ts +145 -0
  19. package/telegram-plugin/runtime-metrics.ts +14 -0
  20. package/telegram-plugin/scoped-approval.ts +253 -0
  21. package/telegram-plugin/tests/bridge-liveness-override.test.ts +21 -0
  22. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +54 -0
  23. package/telegram-plugin/tests/linear-agent-activity.test.ts +1 -1
  24. package/telegram-plugin/tests/linear-create-issue.test.ts +211 -0
  25. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +13 -0
  26. package/telegram-plugin/tests/runtime-metrics.test.ts +9 -0
  27. package/telegram-plugin/tests/scoped-approval.test.ts +254 -0
  28. package/telegram-plugin/tests/send-outbound-wiring.test.ts +63 -0
@@ -13724,11 +13724,29 @@ var PollSpecSchema = exports_external.discriminatedUnion("type", [
13724
13724
  HttpDiffPollSchema,
13725
13725
  TelegramReactionsPollSchema
13726
13726
  ]);
13727
+ var TelegramMessageActionSchema = exports_external.object({
13728
+ type: exports_external.literal("telegram-message"),
13729
+ text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
13730
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
13731
+ });
13732
+ var WebhookActionSchema = exports_external.object({
13733
+ type: exports_external.literal("webhook"),
13734
+ url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
13735
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
13736
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
13737
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
13738
+ 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 keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
13739
+ });
13740
+ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
13741
+ TelegramMessageActionSchema,
13742
+ WebhookActionSchema
13743
+ ]);
13727
13744
  var ScheduleEntrySchema = exports_external.object({
13728
13745
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
13729
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
13730
- kind: exports_external.enum(["poll", "prompt"]).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. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
13746
+ 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)."),
13747
+ 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)."),
13731
13748
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
13749
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
13732
13750
  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."),
13733
13751
  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."),
13734
13752
  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,13 +13763,41 @@ var ScheduleEntrySchema = exports_external.object({
13745
13763
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
13746
13764
  });
13747
13765
  }
13748
- if (kind === "prompt" && entry.poll) {
13766
+ if (kind !== "poll" && entry.poll) {
13749
13767
  ctx.addIssue({
13750
13768
  code: exports_external.ZodIssueCode.custom,
13751
13769
  path: ["poll"],
13752
13770
  message: "`poll` is only valid when kind: poll."
13753
13771
  });
13754
13772
  }
13773
+ if (kind === "action" && !entry.action) {
13774
+ ctx.addIssue({
13775
+ code: exports_external.ZodIssueCode.custom,
13776
+ path: ["action"],
13777
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
13778
+ });
13779
+ }
13780
+ if (kind !== "action" && entry.action) {
13781
+ ctx.addIssue({
13782
+ code: exports_external.ZodIssueCode.custom,
13783
+ path: ["action"],
13784
+ message: "`action` is only valid when kind: action."
13785
+ });
13786
+ }
13787
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
13788
+ ctx.addIssue({
13789
+ code: exports_external.ZodIssueCode.custom,
13790
+ path: ["prompt"],
13791
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
13792
+ });
13793
+ }
13794
+ if (kind === "action" && entry.prompt !== undefined) {
13795
+ ctx.addIssue({
13796
+ code: exports_external.ZodIssueCode.custom,
13797
+ path: ["prompt"],
13798
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
13799
+ });
13800
+ }
13755
13801
  });
13756
13802
  var AgentSoulSchema = exports_external.object({
13757
13803
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -13885,7 +13931,8 @@ var TelegramChannelSchema = exports_external.object({
13885
13931
  linear_agent: exports_external.object({
13886
13932
  enabled: exports_external.boolean(),
13887
13933
  token: exports_external.string().describe("vault:<key> reference to the Linear OAuth app token (actor=app). " + "Resolved at runtime via the vault broker (canonically " + "vault:linear/<agent>/token). Never an inline literal."),
13888
- workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
13934
+ workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace."),
13935
+ default_team_id: exports_external.string().optional().describe("Optional Linear team id new captured issues file into when the " + "agent doesn't pass an explicit team_id. Unnecessary for a " + "single-team workspace (auto-resolved); set it only when the " + "workspace has multiple teams. Manage via " + "`switchroom linear-agent set-team <agent> <team>`.")
13889
13936
  }).optional().describe("Linear first-class agent integration (#2298). When enabled, the " + "agent appears in a Linear workspace as an app actor (own name/" + "avatar, @-mentionable, delegate-assignable). Linear AgentSessionEvent " + "webhooks (mention / delegation) wake the agent instantly via the " + "same gateway inject path as webhook_dispatch, tagged " + 'meta.source="linear" with the agent_session_id, and the agent ' + "responds with structured AgentActivity (thought/message/complete/" + "error) via the linear_agent_activity MCP tool. Builds the " + "session-lifecycle layer on top of the plain webhook_sources:[linear] " + "+ webhook_dispatch support (#2272). The OAuth app token is stored in " + "the vault and referenced here as vault:linear/<agent>/token; run " + "`switchroom linear-agent setup <agent>` to provision it. Off by " + "default — opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
13890
13937
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
13891
13938
  default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
@@ -14769,6 +14816,16 @@ function mergeAgentConfig(defaultsIn, agentIn) {
14769
14816
  }
14770
14817
  merged.reaction_dispatch = combined;
14771
14818
  }
14819
+ const linearEnabled = merged.channels?.telegram?.linear_agent?.enabled === true;
14820
+ if (linearEnabled) {
14821
+ const rd = merged.reaction_dispatch;
14822
+ if (!rd || rd.emojis === undefined) {
14823
+ merged.reaction_dispatch = {
14824
+ enabled: rd?.enabled ?? true,
14825
+ emojis: ["\uD83D\uDC68‍\uD83D\uDCBB", "\uD83D\uDCCC"]
14826
+ };
14827
+ }
14828
+ }
14772
14829
  if (defaults.resources || merged.resources) {
14773
14830
  const d = defaults.resources ?? {};
14774
14831
  const a = merged.resources ?? {};
@@ -4305,6 +4305,16 @@ function mergeAgentConfig(defaultsIn, agentIn) {
4305
4305
  }
4306
4306
  merged.reaction_dispatch = combined;
4307
4307
  }
4308
+ const linearEnabled = merged.channels?.telegram?.linear_agent?.enabled === true;
4309
+ if (linearEnabled) {
4310
+ const rd = merged.reaction_dispatch;
4311
+ if (!rd || rd.emojis === undefined) {
4312
+ merged.reaction_dispatch = {
4313
+ enabled: rd?.enabled ?? true,
4314
+ emojis: ["\uD83D\uDC68‍\uD83D\uDCBB", "\uD83D\uDCCC"]
4315
+ };
4316
+ }
4317
+ }
4308
4318
  if (defaults.resources || merged.resources) {
4309
4319
  const d = defaults.resources ?? {};
4310
4320
  const a = merged.resources ?? {};
@@ -11289,7 +11299,7 @@ var init_dist = __esm(() => {
11289
11299
  });
11290
11300
 
11291
11301
  // src/config/schema.ts
11292
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11302
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11293
11303
  var init_schema = __esm(() => {
11294
11304
  init_zod();
11295
11305
  CodeRepoEntrySchema = exports_external.object({
@@ -11322,11 +11332,29 @@ var init_schema = __esm(() => {
11322
11332
  HttpDiffPollSchema,
11323
11333
  TelegramReactionsPollSchema
11324
11334
  ]);
11335
+ TelegramMessageActionSchema = exports_external.object({
11336
+ type: exports_external.literal("telegram-message"),
11337
+ text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
11338
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
11339
+ });
11340
+ WebhookActionSchema = exports_external.object({
11341
+ type: exports_external.literal("webhook"),
11342
+ url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
11343
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
11344
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
11345
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
11346
+ 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 keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
11347
+ });
11348
+ ActionSpecSchema = exports_external.discriminatedUnion("type", [
11349
+ TelegramMessageActionSchema,
11350
+ WebhookActionSchema
11351
+ ]);
11325
11352
  ScheduleEntrySchema = exports_external.object({
11326
11353
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11327
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
11328
- kind: exports_external.enum(["poll", "prompt"]).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. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
11354
+ 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)."),
11355
+ 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)."),
11329
11356
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11357
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11330
11358
  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."),
11331
11359
  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."),
11332
11360
  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."),
@@ -11343,13 +11371,41 @@ var init_schema = __esm(() => {
11343
11371
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11344
11372
  });
11345
11373
  }
11346
- if (kind === "prompt" && entry.poll) {
11374
+ if (kind !== "poll" && entry.poll) {
11347
11375
  ctx.addIssue({
11348
11376
  code: exports_external.ZodIssueCode.custom,
11349
11377
  path: ["poll"],
11350
11378
  message: "`poll` is only valid when kind: poll."
11351
11379
  });
11352
11380
  }
11381
+ if (kind === "action" && !entry.action) {
11382
+ ctx.addIssue({
11383
+ code: exports_external.ZodIssueCode.custom,
11384
+ path: ["action"],
11385
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
11386
+ });
11387
+ }
11388
+ if (kind !== "action" && entry.action) {
11389
+ ctx.addIssue({
11390
+ code: exports_external.ZodIssueCode.custom,
11391
+ path: ["action"],
11392
+ message: "`action` is only valid when kind: action."
11393
+ });
11394
+ }
11395
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
11396
+ ctx.addIssue({
11397
+ code: exports_external.ZodIssueCode.custom,
11398
+ path: ["prompt"],
11399
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
11400
+ });
11401
+ }
11402
+ if (kind === "action" && entry.prompt !== undefined) {
11403
+ ctx.addIssue({
11404
+ code: exports_external.ZodIssueCode.custom,
11405
+ path: ["prompt"],
11406
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
11407
+ });
11408
+ }
11353
11409
  });
11354
11410
  AgentSoulSchema = exports_external.object({
11355
11411
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11483,7 +11539,8 @@ var init_schema = __esm(() => {
11483
11539
  linear_agent: exports_external.object({
11484
11540
  enabled: exports_external.boolean(),
11485
11541
  token: exports_external.string().describe("vault:<key> reference to the Linear OAuth app token (actor=app). " + "Resolved at runtime via the vault broker (canonically " + "vault:linear/<agent>/token). Never an inline literal."),
11486
- workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
11542
+ workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace."),
11543
+ default_team_id: exports_external.string().optional().describe("Optional Linear team id new captured issues file into when the " + "agent doesn't pass an explicit team_id. Unnecessary for a " + "single-team workspace (auto-resolved); set it only when the " + "workspace has multiple teams. Manage via " + "`switchroom linear-agent set-team <agent> <team>`.")
11487
11544
  }).optional().describe("Linear first-class agent integration (#2298). When enabled, the " + "agent appears in a Linear workspace as an app actor (own name/" + "avatar, @-mentionable, delegate-assignable). Linear AgentSessionEvent " + "webhooks (mention / delegation) wake the agent instantly via the " + "same gateway inject path as webhook_dispatch, tagged " + 'meta.source="linear" with the agent_session_id, and the agent ' + "responds with structured AgentActivity (thought/message/complete/" + "error) via the linear_agent_activity MCP tool. Builds the " + "session-lifecycle layer on top of the plain webhook_sources:[linear] " + "+ webhook_dispatch support (#2272). The OAuth app token is stored in " + "the vault and referenced here as vault:linear/<agent>/token; run " + "`switchroom linear-agent setup <agent>` to provision it. Off by " + "default — opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
11488
11545
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11489
11546
  default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
@@ -339,6 +339,16 @@ function mergeAgentConfig(defaultsIn, agentIn) {
339
339
  }
340
340
  merged.reaction_dispatch = combined;
341
341
  }
342
+ const linearEnabled = merged.channels?.telegram?.linear_agent?.enabled === true;
343
+ if (linearEnabled) {
344
+ const rd = merged.reaction_dispatch;
345
+ if (!rd || rd.emojis === undefined) {
346
+ merged.reaction_dispatch = {
347
+ enabled: rd?.enabled ?? true,
348
+ emojis: ["\uD83D\uDC68‍\uD83D\uDCBB", "\uD83D\uDCCC"]
349
+ };
350
+ }
351
+ }
342
352
  if (defaults.resources || merged.resources) {
343
353
  const d = defaults.resources ?? {};
344
354
  const a = merged.resources ?? {};
@@ -11289,7 +11299,7 @@ var init_zod = __esm(() => {
11289
11299
  });
11290
11300
 
11291
11301
  // src/config/schema.ts
11292
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11302
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11293
11303
  var init_schema = __esm(() => {
11294
11304
  init_zod();
11295
11305
  CodeRepoEntrySchema = exports_external.object({
@@ -11322,11 +11332,29 @@ var init_schema = __esm(() => {
11322
11332
  HttpDiffPollSchema,
11323
11333
  TelegramReactionsPollSchema
11324
11334
  ]);
11335
+ TelegramMessageActionSchema = exports_external.object({
11336
+ type: exports_external.literal("telegram-message"),
11337
+ text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
11338
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
11339
+ });
11340
+ WebhookActionSchema = exports_external.object({
11341
+ type: exports_external.literal("webhook"),
11342
+ url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
11343
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
11344
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
11345
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
11346
+ 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 keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
11347
+ });
11348
+ ActionSpecSchema = exports_external.discriminatedUnion("type", [
11349
+ TelegramMessageActionSchema,
11350
+ WebhookActionSchema
11351
+ ]);
11325
11352
  ScheduleEntrySchema = exports_external.object({
11326
11353
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11327
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
11328
- kind: exports_external.enum(["poll", "prompt"]).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. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
11354
+ 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)."),
11355
+ 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)."),
11329
11356
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11357
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11330
11358
  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."),
11331
11359
  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."),
11332
11360
  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."),
@@ -11343,13 +11371,41 @@ var init_schema = __esm(() => {
11343
11371
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11344
11372
  });
11345
11373
  }
11346
- if (kind === "prompt" && entry.poll) {
11374
+ if (kind !== "poll" && entry.poll) {
11347
11375
  ctx.addIssue({
11348
11376
  code: exports_external.ZodIssueCode.custom,
11349
11377
  path: ["poll"],
11350
11378
  message: "`poll` is only valid when kind: poll."
11351
11379
  });
11352
11380
  }
11381
+ if (kind === "action" && !entry.action) {
11382
+ ctx.addIssue({
11383
+ code: exports_external.ZodIssueCode.custom,
11384
+ path: ["action"],
11385
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
11386
+ });
11387
+ }
11388
+ if (kind !== "action" && entry.action) {
11389
+ ctx.addIssue({
11390
+ code: exports_external.ZodIssueCode.custom,
11391
+ path: ["action"],
11392
+ message: "`action` is only valid when kind: action."
11393
+ });
11394
+ }
11395
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
11396
+ ctx.addIssue({
11397
+ code: exports_external.ZodIssueCode.custom,
11398
+ path: ["prompt"],
11399
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
11400
+ });
11401
+ }
11402
+ if (kind === "action" && entry.prompt !== undefined) {
11403
+ ctx.addIssue({
11404
+ code: exports_external.ZodIssueCode.custom,
11405
+ path: ["prompt"],
11406
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
11407
+ });
11408
+ }
11353
11409
  });
11354
11410
  AgentSoulSchema = exports_external.object({
11355
11411
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11483,7 +11539,8 @@ var init_schema = __esm(() => {
11483
11539
  linear_agent: exports_external.object({
11484
11540
  enabled: exports_external.boolean(),
11485
11541
  token: exports_external.string().describe("vault:<key> reference to the Linear OAuth app token (actor=app). " + "Resolved at runtime via the vault broker (canonically " + "vault:linear/<agent>/token). Never an inline literal."),
11486
- workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
11542
+ workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace."),
11543
+ default_team_id: exports_external.string().optional().describe("Optional Linear team id new captured issues file into when the " + "agent doesn't pass an explicit team_id. Unnecessary for a " + "single-team workspace (auto-resolved); set it only when the " + "workspace has multiple teams. Manage via " + "`switchroom linear-agent set-team <agent> <team>`.")
11487
11544
  }).optional().describe("Linear first-class agent integration (#2298). When enabled, the " + "agent appears in a Linear workspace as an app actor (own name/" + "avatar, @-mentionable, delegate-assignable). Linear AgentSessionEvent " + "webhooks (mention / delegation) wake the agent instantly via the " + "same gateway inject path as webhook_dispatch, tagged " + 'meta.source="linear" with the agent_session_id, and the agent ' + "responds with structured AgentActivity (thought/message/complete/" + "error) via the linear_agent_activity MCP tool. Builds the " + "session-lifecycle layer on top of the plain webhook_sources:[linear] " + "+ webhook_dispatch support (#2272). The OAuth app token is stored in " + "the vault and referenced here as vault:linear/<agent>/token; run " + "`switchroom linear-agent setup <agent>` to provision it. Off by " + "default — opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
11488
11545
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11489
11546
  default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.12",
3
+ "version": "0.15.14",
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": {
@@ -5,10 +5,15 @@
5
5
  # container, dedicated to cheap cron fires. It registers to the SAME gateway
6
6
  # as a DISTINCT bridge identity `<name>-cron` (start.sh's gateway sidecar
7
7
  # routes meta.session=cron fires here and gates all status surfaces off this
8
- # identity — see telegram-plugin/gateway/cron-session.ts). Minimal context:
9
- # its own CLAUDE_CONFIG_DIR (separate transcript) + a TRIMMED .mcp.json (only
10
- # switchroom-telegram) it pays a fraction of the main session's schema +
11
- # cache-read tax, at the cheap cron model.
8
+ # identity — see telegram-plugin/gateway/cron-session.ts).
9
+ #
10
+ # Tier-1 (#2307) is "a fresh conversation that STILL has the agent's memory +
11
+ # tools, on a cheaper model." It loads the agent's FULL .mcp.json (its own
12
+ # .claude-cron/.mcp.json — same hindsight bank baked from the BASE name, same
13
+ # tools) with ENABLE_TOOL_SEARCH so the schema set DEFERS (low context cost).
14
+ # The saving is dropping the accumulated main-session conversation context +
15
+ # the cheaper model — NOT lobotomising the session. Its CLAUDE_CONFIG_DIR is
16
+ # separate (own transcript) so each fire starts fresh and /clear-friendly.
12
17
  #
13
18
  # Forked as a supervised sidecar by start.sh ONLY when {{name}} actually has a
14
19
  # Tier-1 (context: fresh) cron entry AND SWITCHROOM_CHEAP_CRON is on — so the
@@ -35,12 +40,13 @@ esac
35
40
  CRON_NAME="{{name}}-cron"
36
41
  CRON_CONFIG_DIR="{{agentDir}}/.claude-cron"
37
42
  MAIN_CONFIG_DIR="{{agentDir}}/.claude"
38
- # TRIMMED MCP config (scaffold writes .claude-cron/.mcp.json with ONLY
39
- # switchroom-telegram) the cron session pays a fraction of the main session's
40
- # ~31k-token MCP schema tax. The switchroom-telegram BRIDGE reads
41
- # SWITCHROOM_AGENT_NAME from the process env (exported below), NOT its .mcp.json
42
- # env block, so it registers as the cron identity. Fall back to the full
43
- # .mcp.json if the trimmed file is missing (older scaffold) so it still boots.
43
+ # Cron .mcp.json (scaffold writes .claude-cron/.mcp.json with the agent's FULL
44
+ # filtered MCP set same hindsight bank, same tools — but the switchroom-
45
+ # telegram entry carries a DISTINCT SWITCHROOM_BRIDGE_ALIVE_PATH so its liveness
46
+ # file can't mask the main bridge). The bridge reads SWITCHROOM_AGENT_NAME from
47
+ # the process env (exported below), NOT its .mcp.json env block, so it registers
48
+ # as the cron identity. Fall back to the main .mcp.json if the cron file is
49
+ # missing (older scaffold) so it still boots.
44
50
  CRON_MCP_CONFIG="{{agentDir}}/.claude-cron/.mcp.json"
45
51
  [ -f "$CRON_MCP_CONFIG" ] || CRON_MCP_CONFIG="{{agentDir}}/.mcp.json"
46
52
  CRON_SOCKET="switchroom-${CRON_NAME}"
@@ -63,12 +69,23 @@ fi
63
69
  export SWITCHROOM_AGENT_NAME="$CRON_NAME"
64
70
  export CLAUDE_CONFIG_DIR="$CRON_CONFIG_DIR"
65
71
 
66
- CRON_APPEND_PROMPT="You are the cheap background cron worker for {{name}}. You handle scheduled tasks only. Do the task, reply once with the result, and keep it brief. You do not have {{name}}'s full memory or persona — if a task needs them, say so rather than guessing."
72
+ # Defer the (now full) MCP schema set via tool-search so the cron session keeps
73
+ # its low-context cost — the heavy servers load on demand; switchroom-telegram
74
+ # + hindsight stay alwaysLoad. Normally inherited from start.sh's env; set here
75
+ # as a belt-and-braces default, honouring the operator kill-switch + any value
76
+ # already in the env.
77
+ if [ "${SWITCHROOM_DISABLE_TOOL_SEARCH:-}" != "1" ]; then
78
+ export ENABLE_TOOL_SEARCH="${ENABLE_TOOL_SEARCH:-auto}"
79
+ fi
80
+
81
+ CRON_APPEND_PROMPT="You are the cheap background cron worker for {{name}}. You run scheduled tasks in a fresh, low-context conversation on a cheaper model — but you SHARE {{name}}'s memory (hindsight) and tools, so use them: recall what you need, look things up, reply with real substance. Do the scheduled task, reply once with the result, and keep it tight."
67
82
 
68
83
  # Launch the cron claude in its OWN tmux session/socket so it never contends
69
84
  # with the main session's pane. Interactive (no -p). --strict-mcp-config pins
70
- # it to the trimmed config (switchroom-telegram only). Fresh each boot (no
71
- # --continue) — minimal context by construction.
85
+ # it to the cron .mcp.json (full set, tool-search-deferred). Fresh each boot
86
+ # (no --continue) — low context by construction. cd into the workspace so
87
+ # claude's project key matches the pre-seeded trust state (.claude-cron).
88
+ cd "{{agentDir}}" || exit 1
72
89
  exec tmux -L "$CRON_SOCKET" \
73
90
  new-session -A -s "$CRON_NAME" -x 400 -y 50 \
74
91
  claude \
@@ -123,6 +123,43 @@ After a successful `schedule_add`, confirm to the user with:
123
123
  After a failed write (any `E_*` code from the rails above), surface the
124
124
  specific error verbatim, explain which rail tripped, and offer the
125
125
  closest legal alternative.
126
+ {{#if linearAgentEnabled}}
127
+
128
+ ## Capture to Linear (👨‍💻 / 📌 reaction → new issue)
129
+
130
+ You're connected to Linear as a first-class app actor. The operator has the
131
+ **capture reactions** wired: when they react to a Telegram message with **👨‍💻**
132
+ (laptop) or **📌** (pushpin), you're woken with a turn whose inbound is shaped
133
+ `<channel … event="reaction" emoji="👨‍💻" message_id="…" chat_id="…">[the
134
+ reacted message text]</channel>`. That reaction means: **"turn this into a
135
+ Linear issue."**
136
+
137
+ When you get such a reaction turn:
138
+
139
+ 1. **Read the reacted message + nearby context.** The `[reacted text]` is the
140
+ seed. Pull the surrounding thread (`get_recent_messages`) when the ask needs
141
+ it — the issue should stand on its own without the operator re-explaining.
142
+ 2. **Call `linear_create_issue`** with:
143
+ - `title` — a crisp, imperative one-liner ("Fix duplicate Brevo webhook
144
+ retries"), not a restatement of the chat.
145
+ - `body` — the ask, the context you gathered, and any acceptance criteria
146
+ you can reasonably infer. Markdown.
147
+ - `dedup_key` — set it to `"<chat_id>:<message_id>"` from the reaction event.
148
+ This makes a re-react of the same message idempotent (it returns the
149
+ existing issue instead of filing a duplicate).
150
+ - `team_id` — omit it. A single-team workspace auto-resolves; you'll only be
151
+ asked for one if the workspace has multiple teams (then tell the operator
152
+ to set a default via `switchroom linear-agent set-team`).
153
+ 3. **Reply in plain text, not an emoji.** On success the tool returns
154
+ `Filed: <title> → <url>` — reply that link so the operator can click
155
+ through ("📋 Filed: <url>"). On failure (vault denial, multi-team, API
156
+ error) the tool returns actionable text — relay it plainly ("Couldn't file
157
+ it — …") and, if it's a vault denial, follow the `vault_request_access`
158
+ instruction it gives you, then retry.
159
+
160
+ Don't acknowledge with only a reaction or a bare "done" — the operator wants
161
+ the link (or the honest reason it didn't file).
162
+ {{/if}}
126
163
 
127
164
  ### Don't lie about scheduling
128
165
 
@@ -479,6 +479,37 @@ const TOOL_SCHEMAS = [
479
479
  required: ['agent_session_id', 'type'],
480
480
  },
481
481
  },
482
+ {
483
+ name: 'linear_create_issue',
484
+ description:
485
+ 'File a new Linear issue from a Telegram message the operator flagged for capture (#2312). Use this when a turn was triggered by a capture reaction (the inbound carries event="reaction" with a capture emoji like 👨‍💻 or 📌) — turn the reacted message + any relevant thread context into a well-formed issue. Write a crisp imperative title and a body that captures the ask, the context, and any acceptance criteria you can infer; the agent files it AS its own Linear app actor. Team is auto-resolved when the workspace has a single team; if there are multiple it returns text asking for an explicit team_id. Pass dedup_key (e.g. the chat_id:message_id of the reacted message) so a re-react of the same message does not file a duplicate. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns text instructing you to vault_request_access for `linear/<agent>/token`. Returns "Filed: <title> → <url>" on success — reply that link to the operator in plain text.',
486
+ inputSchema: {
487
+ type: 'object',
488
+ properties: {
489
+ title: {
490
+ type: 'string',
491
+ description: 'Issue title — a crisp, imperative one-liner (e.g. "Fix duplicate webhook retries on Brevo sync").',
492
+ },
493
+ body: {
494
+ type: 'string',
495
+ description: 'Issue description (Markdown). Capture the ask, relevant context from the message/thread, and any acceptance criteria you can infer.',
496
+ },
497
+ team_id: {
498
+ type: 'string',
499
+ description: 'Optional Linear team id. Omit to auto-resolve when the workspace has a single team; required only when the workspace has multiple teams.',
500
+ },
501
+ dedup_key: {
502
+ type: 'string',
503
+ description: 'Optional idempotency key (use the reacted message identity, e.g. "<chat_id>:<message_id>"). A prior capture with the same key short-circuits to "Already filed: <url>".',
504
+ },
505
+ priority: {
506
+ type: 'number',
507
+ description: 'Optional Linear priority (0 none, 1 urgent, 2 high, 3 normal, 4 low).',
508
+ },
509
+ },
510
+ required: ['title', 'body'],
511
+ },
512
+ },
482
513
  ]
483
514
 
484
515
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }))
@@ -796,7 +827,13 @@ async function main(): Promise<void> {
796
827
  onPermission,
797
828
  onStatus,
798
829
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}\n`),
799
- livenessFilePath: join(STATE_DIR, ".bridge-alive"),
830
+ // #2307 Tier-1: the cron-session bridge shares the agent's STATE_DIR
831
+ // (access.json / history / gateway.sock) but writes its liveness file to a
832
+ // DISTINCT path (SWITCHROOM_BRIDGE_ALIVE_PATH, set in the cron .mcp.json) so
833
+ // a live <agent>-cron bridge can't mask a dead MAIN bridge in the
834
+ // dashboard/doctor liveness probe (RISK #2). Unset ⟹ the main bridge's
835
+ // canonical STATE_DIR/.bridge-alive, exactly as before.
836
+ livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join(STATE_DIR, ".bridge-alive"),
800
837
  })
801
838
  if (ipc.isConnected()) {
802
839
  process.stderr.write(`telegram bridge: connected to gateway at ${SOCKET_PATH}\n`)
@@ -24957,6 +24957,36 @@ var TOOL_SCHEMAS = [
24957
24957
  },
24958
24958
  required: ["agent_session_id", "type"]
24959
24959
  }
24960
+ },
24961
+ {
24962
+ name: "linear_create_issue",
24963
+ description: 'File a new Linear issue from a Telegram message the operator flagged for capture (#2312). Use this when a turn was triggered by a capture reaction (the inbound carries event="reaction" with a capture emoji like \uD83D\uDC68\u200D\uD83D\uDCBB or \uD83D\uDCCC) \u2014 turn the reacted message + any relevant thread context into a well-formed issue. Write a crisp imperative title and a body that captures the ask, the context, and any acceptance criteria you can infer; the agent files it AS its own Linear app actor. Team is auto-resolved when the workspace has a single team; if there are multiple it returns text asking for an explicit team_id. Pass dedup_key (e.g. the chat_id:message_id of the reacted message) so a re-react of the same message does not file a duplicate. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns text instructing you to vault_request_access for `linear/<agent>/token`. Returns "Filed: <title> \u2192 <url>" on success \u2014 reply that link to the operator in plain text.',
24964
+ inputSchema: {
24965
+ type: "object",
24966
+ properties: {
24967
+ title: {
24968
+ type: "string",
24969
+ description: 'Issue title \u2014 a crisp, imperative one-liner (e.g. "Fix duplicate webhook retries on Brevo sync").'
24970
+ },
24971
+ body: {
24972
+ type: "string",
24973
+ description: "Issue description (Markdown). Capture the ask, relevant context from the message/thread, and any acceptance criteria you can infer."
24974
+ },
24975
+ team_id: {
24976
+ type: "string",
24977
+ description: "Optional Linear team id. Omit to auto-resolve when the workspace has a single team; required only when the workspace has multiple teams."
24978
+ },
24979
+ dedup_key: {
24980
+ type: "string",
24981
+ description: 'Optional idempotency key (use the reacted message identity, e.g. "<chat_id>:<message_id>"). A prior capture with the same key short-circuits to "Already filed: <url>".'
24982
+ },
24983
+ priority: {
24984
+ type: "number",
24985
+ description: "Optional Linear priority (0 none, 1 urgent, 2 high, 3 normal, 4 low)."
24986
+ }
24987
+ },
24988
+ required: ["title", "body"]
24989
+ }
24960
24990
  }
24961
24991
  ];
24962
24992
  mcp.setRequestHandler(ListToolsRequestSchema2, async () => ({ tools: TOOL_SCHEMAS }));
@@ -25176,7 +25206,7 @@ async function main() {
25176
25206
  onStatus,
25177
25207
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}
25178
25208
  `),
25179
- livenessFilePath: join4(STATE_DIR, ".bridge-alive")
25209
+ livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join4(STATE_DIR, ".bridge-alive")
25180
25210
  });
25181
25211
  if (ipc.isConnected()) {
25182
25212
  process.stderr.write(`telegram bridge: connected to gateway at ${SOCKET_PATH}