switchroom 0.15.10 → 0.15.12

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.
@@ -13882,6 +13882,11 @@ var TelegramChannelSchema = exports_external.object({
13882
13882
  }).optional().describe("Per-source rate limit for the webhook ingest path (#714). " + "Off by default — when this key is absent the handler skips " + "rate-limit checks entirely. Opt in by setting `rpm` to an " + "integer requests-per-minute (token bucket per (agent, source); " + "burst equal to rpm). When enabled, exceeding the limit returns " + "429 with Retry-After header; first throttle event per " + "(agent, source) per 60s window is written to " + "<agent>/telegram/issues.jsonl. " + "Cascades from defaults.channels.telegram.webhook_rate_limit."),
13883
13883
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
13884
13884
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
13885
+ linear_agent: exports_external.object({
13886
+ enabled: exports_external.boolean(),
13887
+ 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.")
13889
+ }).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."),
13885
13890
  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."),
13886
13891
  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`."),
13887
13892
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
@@ -11480,6 +11480,11 @@ var init_schema = __esm(() => {
11480
11480
  }).optional().describe("Per-source rate limit for the webhook ingest path (#714). " + "Off by default — when this key is absent the handler skips " + "rate-limit checks entirely. Opt in by setting `rpm` to an " + "integer requests-per-minute (token bucket per (agent, source); " + "burst equal to rpm). When enabled, exceeding the limit returns " + "429 with Retry-After header; first throttle event per " + "(agent, source) per 60s window is written to " + "<agent>/telegram/issues.jsonl. " + "Cascades from defaults.channels.telegram.webhook_rate_limit."),
11481
11481
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11482
11482
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11483
+ linear_agent: exports_external.object({
11484
+ enabled: exports_external.boolean(),
11485
+ 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.")
11487
+ }).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."),
11483
11488
  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."),
11484
11489
  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`."),
11485
11490
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
@@ -11480,6 +11480,11 @@ var init_schema = __esm(() => {
11480
11480
  }).optional().describe("Per-source rate limit for the webhook ingest path (#714). " + "Off by default — when this key is absent the handler skips " + "rate-limit checks entirely. Opt in by setting `rpm` to an " + "integer requests-per-minute (token bucket per (agent, source); " + "burst equal to rpm). When enabled, exceeding the limit returns " + "429 with Retry-After header; first throttle event per " + "(agent, source) per 60s window is written to " + "<agent>/telegram/issues.jsonl. " + "Cascades from defaults.channels.telegram.webhook_rate_limit."),
11481
11481
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11482
11482
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11483
+ linear_agent: exports_external.object({
11484
+ enabled: exports_external.boolean(),
11485
+ 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.")
11487
+ }).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."),
11483
11488
  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."),
11484
11489
  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`."),
11485
11490
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.10",
3
+ "version": "0.15.12",
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": {
@@ -17,15 +17,19 @@
17
17
  set -u
18
18
 
19
19
  # Runtime kill-switch. The fork is baked into start.sh whenever {{name}} has a
20
- # context:fresh cron entry (a config property), but the session only actually
21
- # runs when SWITCHROOM_CHEAP_CRON is on at runtime. Exit 78 (EX_CONFIG) so the
22
- # supervisor does NOT respawn-loop when the feature is off a clean idle.
20
+ # cron entry the value-gate routes to a cheap session, but the session only
21
+ # actually runs when cheap-cron is enabled at runtime. Cheap-cron is ON by
22
+ # DEFAULT (matches isCheapCronEnabled in src/scheduler/cron-routing.tsonly
23
+ # SWITCHROOM_CHEAP_CRON=0/false/off disables it); the old "off unless =1" gate
24
+ # here meant the cron session quarantined itself even with cheap-by-default on,
25
+ # so every Tier-1 fire fell back to the main session and saved nothing. Exit 78
26
+ # (EX_CONFIG) on the explicit kill-switch so the supervisor cleanly idles.
23
27
  case "${SWITCHROOM_CHEAP_CRON:-}" in
24
- 1 | true | on | ON) : ;;
25
- *)
26
- echo "cron-session: SWITCHROOM_CHEAP_CRON off — not starting (exit 78, no respawn)" >&2
28
+ 0 | false | off | OFF | False | FALSE)
29
+ echo "cron-session: SWITCHROOM_CHEAP_CRON disabled (=0) — not starting (exit 78, no respawn)" >&2
27
30
  exit 78
28
31
  ;;
32
+ *) : ;;
29
33
  esac
30
34
 
31
35
  CRON_NAME="{{name}}-cron"
@@ -455,6 +455,30 @@ const TOOL_SCHEMAS = [
455
455
  required: ['chat_id', 'key'],
456
456
  },
457
457
  },
458
+ {
459
+ name: 'linear_agent_activity',
460
+ description:
461
+ 'Emit a structured Linear AgentActivity against an agent session (#2298). Use this ONLY inside a turn that was woken by a Linear agent session (the inbound carries meta.source="linear" and meta.agent_session_id) — pass that agent_session_id back here. Linear renders activities as status chips + a timeline on the issue, so the human sees acknowledge → work → result. Emit a `thought` within ~10s of being woken so the session does not look dead, then `message`(s) as you make progress, and finally exactly one terminal `complete` (work done) or `error` (you could not proceed). body is required for thought/message/error and optional for complete. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns an error instructing you to vault_request_access for `linear/<agent>/token`.',
462
+ inputSchema: {
463
+ type: 'object',
464
+ properties: {
465
+ agent_session_id: {
466
+ type: 'string',
467
+ description: 'The Linear AgentSession id — copy it verbatim from the woken turn\'s meta.agent_session_id.',
468
+ },
469
+ type: {
470
+ type: 'string',
471
+ enum: ['thought', 'message', 'complete', 'error'],
472
+ description: 'Activity kind. thought = visible reasoning ack (emit within ~10s); message = progress update; complete = terminal success; error = terminal failure.',
473
+ },
474
+ body: {
475
+ type: 'string',
476
+ description: 'Activity text (Markdown). Required for thought/message/error; optional for complete (a closing summary).',
477
+ },
478
+ },
479
+ required: ['agent_session_id', 'type'],
480
+ },
481
+ },
458
482
  ]
459
483
 
460
484
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }))
@@ -24934,6 +24934,29 @@ var TOOL_SCHEMAS = [
24934
24934
  },
24935
24935
  required: ["chat_id", "key"]
24936
24936
  }
24937
+ },
24938
+ {
24939
+ name: "linear_agent_activity",
24940
+ description: 'Emit a structured Linear AgentActivity against an agent session (#2298). Use this ONLY inside a turn that was woken by a Linear agent session (the inbound carries meta.source="linear" and meta.agent_session_id) \u2014 pass that agent_session_id back here. Linear renders activities as status chips + a timeline on the issue, so the human sees acknowledge \u2192 work \u2192 result. Emit a `thought` within ~10s of being woken so the session does not look dead, then `message`(s) as you make progress, and finally exactly one terminal `complete` (work done) or `error` (you could not proceed). body is required for thought/message/error and optional for complete. Resolves the agent\'s Linear app token from the vault; on VAULT-BROKER-DENIED it returns an error instructing you to vault_request_access for `linear/<agent>/token`.',
24941
+ inputSchema: {
24942
+ type: "object",
24943
+ properties: {
24944
+ agent_session_id: {
24945
+ type: "string",
24946
+ description: "The Linear AgentSession id \u2014 copy it verbatim from the woken turn's meta.agent_session_id."
24947
+ },
24948
+ type: {
24949
+ type: "string",
24950
+ enum: ["thought", "message", "complete", "error"],
24951
+ description: "Activity kind. thought = visible reasoning ack (emit within ~10s); message = progress update; complete = terminal success; error = terminal failure."
24952
+ },
24953
+ body: {
24954
+ type: "string",
24955
+ description: "Activity text (Markdown). Required for thought/message/error; optional for complete (a closing summary)."
24956
+ }
24957
+ },
24958
+ required: ["agent_session_id", "type"]
24959
+ }
24937
24960
  }
24938
24961
  ];
24939
24962
  mcp.setRequestHandler(ListToolsRequestSchema2, async () => ({ tools: TOOL_SCHEMAS }));
@@ -23993,6 +23993,11 @@ var init_schema = __esm(() => {
23993
23993
  }).optional().describe("Per-source rate limit for the webhook ingest path (#714). " + "Off by default \u2014 when this key is absent the handler skips " + "rate-limit checks entirely. Opt in by setting `rpm` to an " + "integer requests-per-minute (token bucket per (agent, source); " + "burst equal to rpm). When enabled, exceeding the limit returns " + "429 with Retry-After header; first throttle event per " + "(agent, source) per 60s window is written to " + "<agent>/telegram/issues.jsonl. " + "Cascades from defaults.channels.telegram.webhook_rate_limit."),
23994
23994
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
23995
23995
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge \u2014 the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
23996
+ linear_agent: exports_external.object({
23997
+ enabled: exports_external.boolean(),
23998
+ 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."),
23999
+ workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational \u2014 used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
24000
+ }).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 \u2014 opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
23996
24001
  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 \u2014 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."),
23997
24002
  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 \u2014 set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
23998
24003
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot \u2192 alerts, hostd \u2192 admin, etc.). " + "Cascades per-key through defaults \u2192 profile \u2192 agent.")
@@ -28268,6 +28273,7 @@ __export(exports_history, {
28268
28273
  pruneMessagesOlderThanDays: () => pruneMessagesOlderThanDays,
28269
28274
  lookupMessageRoleAndText: () => lookupMessageRoleAndText,
28270
28275
  initHistory: () => initHistory,
28276
+ hasOutboundDeliveredSince: () => hasOutboundDeliveredSince,
28271
28277
  getRecentOutboundCount: () => getRecentOutboundCount,
28272
28278
  getLatestInboundMessageId: () => getLatestInboundMessageId,
28273
28279
  deleteFromHistory: () => deleteFromHistory,
@@ -28462,6 +28468,26 @@ function getRecentOutboundCount(chatId, withinSeconds) {
28462
28468
  const row = requireDb().prepare("SELECT COUNT(*) as cnt FROM messages WHERE chat_id = ? AND role = ? AND ts >= ?").get(chatId, "assistant", cutoff);
28463
28469
  return row?.cnt ?? 0;
28464
28470
  }
28471
+ function hasOutboundDeliveredSince(chatId, sinceMs, threadId) {
28472
+ try {
28473
+ const cutoffSec = Math.floor(sinceMs / 1000);
28474
+ const params = [chatId, cutoffSec];
28475
+ let sql = "SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= 200";
28476
+ if (threadId !== undefined) {
28477
+ if (threadId === null) {
28478
+ sql += " AND thread_id IS NULL";
28479
+ } else {
28480
+ sql += " AND thread_id = ?";
28481
+ params.push(threadId);
28482
+ }
28483
+ }
28484
+ sql += " LIMIT 1";
28485
+ const row = requireDb().prepare(sql).get(...params);
28486
+ return row != null;
28487
+ } catch {
28488
+ return false;
28489
+ }
28490
+ }
28465
28491
  function query(opts) {
28466
28492
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT));
28467
28493
  const params = [opts.chat_id];
@@ -44899,7 +44925,7 @@ function labelTag(label) {
44899
44925
  }
44900
44926
 
44901
44927
  // gateway/model-command.ts
44902
- var MODEL_ALIASES = ["opus", "sonnet", "haiku", "default"];
44928
+ var MODEL_ALIASES = ["opus", "sonnet", "haiku", "fable", "default"];
44903
44929
  var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/;
44904
44930
  function isValidModelArg(arg) {
44905
44931
  return MODEL_ARG_RE.test(arg);
@@ -47005,6 +47031,53 @@ function injectWebhookInbound(agent, prompt, ctx, deps = {}) {
47005
47031
  `)).catch((err) => log(`webhook-dispatch: agent='${agent}' inject failed: ${String(err)}
47006
47032
  `));
47007
47033
  }
47034
+ function parseLinearAgentSession(payload) {
47035
+ const type = String(payload.type ?? "").toLowerCase();
47036
+ if (type !== "agentsessionevent")
47037
+ return null;
47038
+ const action = String(payload.action ?? "").toLowerCase();
47039
+ const session = payload.agentSession ?? payload.agent_session ?? {};
47040
+ const sessionId = String(session.id ?? payload.agentSessionId ?? payload.agent_session_id ?? "");
47041
+ if (!sessionId)
47042
+ return null;
47043
+ const explicitTrigger = String(session.trigger ?? payload.trigger ?? "").toLowerCase();
47044
+ const comment = session.comment;
47045
+ const trigger = explicitTrigger === "mention" || explicitTrigger === "delegation" ? explicitTrigger : comment ? "mention" : "delegation";
47046
+ const promptContext = typeof payload.promptContext === "string" ? payload.promptContext : typeof session.promptContext === "string" ? session.promptContext : undefined;
47047
+ const issue = session.issue ?? {};
47048
+ const issueId = String(issue.identifier ?? "");
47049
+ const issueTitle = String(issue.title ?? "");
47050
+ const commentBody = typeof comment?.body === "string" ? comment.body : "";
47051
+ const summaryParts = [];
47052
+ summaryParts.push(trigger === "mention" ? `You were @mentioned in Linear` : `An issue was delegated to you in Linear`);
47053
+ if (issueId || issueTitle) {
47054
+ summaryParts.push(`Issue ${issueId}${issueTitle ? `: ${issueTitle}` : ""}`.trim());
47055
+ }
47056
+ if (commentBody)
47057
+ summaryParts.push(commentBody);
47058
+ const summary = summaryParts.join(`
47059
+ `);
47060
+ return { sessionId, trigger, action, promptContext, summary };
47061
+ }
47062
+ function buildLinearInbound(session, ctx, now) {
47063
+ const content = session.promptContext && session.promptContext.trim().length > 0 ? session.promptContext : session.summary;
47064
+ return {
47065
+ type: "inbound",
47066
+ chatId: ctx.chatId,
47067
+ ...ctx.threadId !== undefined ? { threadId: ctx.threadId } : {},
47068
+ messageId: now,
47069
+ user: "linear",
47070
+ userId: 0,
47071
+ ts: now,
47072
+ text: content,
47073
+ meta: {
47074
+ source: "linear",
47075
+ agent_session_id: session.sessionId,
47076
+ linear_trigger: session.trigger,
47077
+ linear_event: `agent_session.${session.action || "event"}`
47078
+ }
47079
+ };
47080
+ }
47008
47081
  function evaluateDispatch(args, deps = {}) {
47009
47082
  const log = deps.log ?? ((s) => process.stderr.write(s));
47010
47083
  const now = (deps.now ?? Date.now)();
@@ -47116,6 +47189,38 @@ function recordWebhookEvent(rec, deps = {}) {
47116
47189
  }
47117
47190
  log(`webhook-gateway: agent='${agent}' source='${rec.source}' event='${rec.event_type}' recorded ts=${now}
47118
47191
  `);
47192
+ if (rec.source === "linear") {
47193
+ try {
47194
+ const session = parseLinearAgentSession(rec.payload);
47195
+ if (session) {
47196
+ const config = (deps.loadConfig ?? loadConfig)();
47197
+ const rawAgent = config.agents?.[agent];
47198
+ const linearAgent = rawAgent ? resolveAgentConfig(config.defaults, config.profiles, rawAgent).channels?.telegram?.linear_agent : undefined;
47199
+ if (!linearAgent?.enabled) {
47200
+ log(`linear-agent: agent='${agent}' session='${session.sessionId}' ignored \u2014 linear_agent not enabled
47201
+ `);
47202
+ } else {
47203
+ const target = resolveChannelTarget(config, agent);
47204
+ if (!target) {
47205
+ log(`linear-agent: agent='${agent}' session='${session.sessionId}' skipped \u2014 no chat target (forum_chat_id / chat_id unset)
47206
+ `);
47207
+ } else {
47208
+ const inbound = buildLinearInbound(session, {
47209
+ chatId: target.chatId,
47210
+ ...target.threadId !== undefined ? { threadId: target.threadId } : {}
47211
+ }, now);
47212
+ const ok = deps.inject ? deps.inject(agent, inbound) : false;
47213
+ log(`linear-agent: agent='${agent}' session='${session.sessionId}' trigger='${session.trigger}' ` + `${ok ? "injected" : "NOT injected (no inject sink)"}
47214
+ `);
47215
+ return { status: "ok", ts: now, dispatched: ok ? 1 : 0 };
47216
+ }
47217
+ }
47218
+ }
47219
+ } catch (err) {
47220
+ log(`linear-agent: agent='${agent}' session inject error (event recorded): ${err.message}
47221
+ `);
47222
+ }
47223
+ }
47119
47224
  let dispatched = 0;
47120
47225
  try {
47121
47226
  const config = (deps.loadConfig ?? loadConfig)();
@@ -48305,21 +48410,23 @@ class ObligationLedger {
48305
48410
  return best;
48306
48411
  }
48307
48412
  decideAtIdle(opts) {
48308
- const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true);
48309
- const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0) : this.oldest();
48413
+ const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true || (opts.representGraceMs ?? 0) > 0);
48414
+ const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0, opts.representGraceMs ?? 0) : this.oldest();
48310
48415
  if (o === undefined)
48311
48416
  return { action: "none" };
48312
48417
  if (o.representCount >= this.maxRepresents)
48313
48418
  return { action: "escalate", obligation: o };
48314
48419
  return { action: "represent", obligation: o };
48315
48420
  }
48316
- oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs) {
48421
+ oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs, representGraceMs) {
48317
48422
  let best;
48318
48423
  for (const o of this.open.values()) {
48319
48424
  if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs)
48320
48425
  continue;
48321
48426
  if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
48322
48427
  continue;
48428
+ if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
48429
+ continue;
48323
48430
  if (best === undefined || o.openedAt < best.openedAt)
48324
48431
  best = o;
48325
48432
  }
@@ -48332,18 +48439,21 @@ class ObligationLedger {
48332
48439
  o.lastTurnEndedAt = ts;
48333
48440
  this.persist();
48334
48441
  }
48335
- resolveCloseTarget(echoedTurnId, liveTurnId) {
48442
+ resolveCloseTarget(echoedTurnId, liveTurnId, routedOriginId) {
48336
48443
  if (echoedTurnId != null)
48337
48444
  return echoedTurnId;
48338
- if (liveTurnId != null && this.open.size === 1 && this.open.has(liveTurnId))
48445
+ if (routedOriginId != null)
48446
+ return routedOriginId;
48447
+ if (liveTurnId != null && this.open.has(liveTurnId))
48339
48448
  return liveTurnId;
48340
48449
  return null;
48341
48450
  }
48342
- markRepresented(originTurnId) {
48451
+ markRepresented(originTurnId, now = Date.now()) {
48343
48452
  const o = this.open.get(originTurnId);
48344
48453
  if (o === undefined)
48345
48454
  return 0;
48346
48455
  o.representCount += 1;
48456
+ o.lastRepresentedAt = now;
48347
48457
  this.persist();
48348
48458
  return o.representCount;
48349
48459
  }
@@ -53767,10 +53877,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
53767
53877
  }
53768
53878
 
53769
53879
  // ../src/build-info.ts
53770
- var VERSION = "0.15.10";
53771
- var COMMIT_SHA = "bb9182e1";
53772
- var COMMIT_DATE = "2026-06-13T01:33:32Z";
53773
- var LATEST_PR = 2302;
53880
+ var VERSION = "0.15.12";
53881
+ var COMMIT_SHA = "18b7b6e6";
53882
+ var COMMIT_DATE = "2026-06-13T04:55:14Z";
53883
+ var LATEST_PR = 2311;
53774
53884
  var COMMITS_AHEAD_OF_TAG = 0;
53775
53885
 
53776
53886
  // gateway/boot-version.ts
@@ -54061,6 +54171,110 @@ async function revokeGrantViaBroker(id, opts) {
54061
54171
  return { kind: "error", msg: "unexpected broker response" };
54062
54172
  }
54063
54173
 
54174
+ // gateway/linear-activity.ts
54175
+ init_client2();
54176
+ var LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
54177
+ async function defaultResolveLinearToken(agent) {
54178
+ const key = `linear/${agent}/token`;
54179
+ const token = readVaultTokenFile(agent) ?? undefined;
54180
+ const result = await getViaBrokerStructured(key, token ? { token } : {});
54181
+ if (result.kind === "ok" && result.entry.kind === "string") {
54182
+ return { ok: true, token: result.entry.value };
54183
+ }
54184
+ if (result.kind === "unreachable")
54185
+ return { ok: false, reason: "unreachable" };
54186
+ if (result.kind === "not_found")
54187
+ return { ok: false, reason: "not_found" };
54188
+ if (result.kind === "denied")
54189
+ return { ok: false, reason: "denied" };
54190
+ return { ok: false, reason: "unknown" };
54191
+ }
54192
+ async function emitLinearAgentActivity(args, deps = {}) {
54193
+ const log = deps.log ?? ((s) => process.stderr.write(s));
54194
+ const sessionId = args.agent_session_id;
54195
+ if (!sessionId)
54196
+ throw new Error("linear_agent_activity: agent_session_id is required");
54197
+ const type = args.type;
54198
+ if (!type || !["thought", "message", "complete", "error"].includes(type)) {
54199
+ throw new Error("linear_agent_activity: type must be one of thought|message|complete|error");
54200
+ }
54201
+ const body = args.body;
54202
+ if (type !== "complete" && (body == null || body === "")) {
54203
+ throw new Error(`linear_agent_activity: body is required for type='${type}'`);
54204
+ }
54205
+ const agent = deps.agent ?? process.env.SWITCHROOM_AGENT_NAME ?? "-";
54206
+ const resolveToken = deps.resolveToken ?? defaultResolveLinearToken;
54207
+ const tokenResult = await resolveToken(agent);
54208
+ if (!tokenResult.ok) {
54209
+ if (tokenResult.reason === "denied" || tokenResult.reason === "not_found") {
54210
+ return {
54211
+ content: [
54212
+ {
54213
+ type: "text",
54214
+ text: `linear_agent_activity failed: no Linear token (vault ${tokenResult.reason}). ` + `Call vault_request_access for key 'linear/${agent}/token' (scope read), then retry.`
54215
+ }
54216
+ ]
54217
+ };
54218
+ }
54219
+ return {
54220
+ content: [
54221
+ {
54222
+ type: "text",
54223
+ text: `linear_agent_activity failed: vault broker ${tokenResult.reason} resolving 'linear/${agent}/token'.`
54224
+ }
54225
+ ]
54226
+ };
54227
+ }
54228
+ const content = { type };
54229
+ if (body != null && body !== "")
54230
+ content.body = body;
54231
+ const mutation = "mutation AgentActivityCreate($input: AgentActivityCreateInput!) { " + "agentActivityCreate(input: $input) { success agentActivity { id } } }";
54232
+ const variables = { input: { agentSessionId: sessionId, content } };
54233
+ const fetchImpl = deps.fetchImpl ?? fetch;
54234
+ let resp;
54235
+ try {
54236
+ resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
54237
+ method: "POST",
54238
+ headers: {
54239
+ "Content-Type": "application/json",
54240
+ Authorization: tokenResult.token
54241
+ },
54242
+ body: JSON.stringify({ query: mutation, variables })
54243
+ });
54244
+ } catch (err) {
54245
+ return {
54246
+ content: [{ type: "text", text: `linear_agent_activity failed: request error: ${err.message}` }]
54247
+ };
54248
+ }
54249
+ if (!resp.ok) {
54250
+ const txt = await resp.text().catch(() => "");
54251
+ return {
54252
+ content: [
54253
+ { type: "text", text: `linear_agent_activity failed: Linear API ${resp.status}${txt ? ` \u2014 ${txt.slice(0, 200)}` : ""}` }
54254
+ ]
54255
+ };
54256
+ }
54257
+ let json;
54258
+ try {
54259
+ json = await resp.json();
54260
+ } catch {
54261
+ return { content: [{ type: "text", text: "linear_agent_activity failed: malformed Linear API response" }] };
54262
+ }
54263
+ if (json.errors && json.errors.length > 0) {
54264
+ return {
54265
+ content: [
54266
+ { type: "text", text: `linear_agent_activity failed: ${json.errors.map((e) => e.message ?? "error").join("; ").slice(0, 300)}` }
54267
+ ]
54268
+ };
54269
+ }
54270
+ if (json.data?.agentActivityCreate?.success === false) {
54271
+ return { content: [{ type: "text", text: "linear_agent_activity failed: Linear reported success=false" }] };
54272
+ }
54273
+ log(`telegram gateway: linear_agent_activity: emitted type=${type} session=${sessionId} agent=${agent}
54274
+ `);
54275
+ return { content: [{ type: "text", text: `Linear ${type} emitted on session ${sessionId}` }] };
54276
+ }
54277
+
54064
54278
  // vault-approval-posture.ts
54065
54279
  function resolveVaultApprovalPosture(broker) {
54066
54280
  if (broker?.approvalAuth === "telegram-id") {
@@ -55003,6 +55217,13 @@ var OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
55003
55217
  const n = Number(raw);
55004
55218
  return Number.isFinite(n) && n >= 0 ? n : 1200000;
55005
55219
  })();
55220
+ var OBLIGATION_REPRESENT_GRACE_MS = (() => {
55221
+ const raw = process.env.SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS;
55222
+ if (raw == null || raw === "")
55223
+ return 120000;
55224
+ const n = Number(raw);
55225
+ return Number.isFinite(n) && n >= 0 ? n : 120000;
55226
+ })();
55006
55227
  var TURN_ACTIVE_MARKER_FRESH_MS = 90000;
55007
55228
  var AUTOCLASSIFY_MIDTURN_SHADOW = process.env.SWITCHROOM_AUTOCLASSIFY_MIDTURN_SHADOW !== "0";
55008
55229
  var lastAgentOutputAt = new Map;
@@ -55158,11 +55379,12 @@ function hasDifferentThreadedRecentTurn(chatId, liveThreadId) {
55158
55379
  }
55159
55380
  return false;
55160
55381
  }
55161
- function closeObligationOnSubstantiveReply(args, liveTurn) {
55382
+ function closeObligationOnSubstantiveReply(args, liveTurn, routedOriginTurn) {
55162
55383
  if (!OBLIGATION_LEDGER_ENABLED)
55163
55384
  return;
55164
55385
  const echoed = findTurnByOriginId(args.origin_turn_id);
55165
- const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId);
55386
+ const routedOriginId = routedOriginTurn != null && echoed == null ? routedOriginTurn.turnId : null;
55387
+ const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId);
55166
55388
  if (target != null)
55167
55389
  obligationLedger.close(target);
55168
55390
  }
@@ -56538,11 +56760,12 @@ function obligationSweep() {
56538
56760
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
56539
56761
  const now = Date.now();
56540
56762
  const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now);
56541
- const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive ? {
56763
+ const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0 ? {
56542
56764
  now,
56543
56765
  graceMs: OBLIGATION_ESCALATE_GRACE_MS,
56544
56766
  backgroundWorkActive,
56545
- backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS
56767
+ backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
56768
+ representGraceMs: OBLIGATION_REPRESENT_GRACE_MS
56546
56769
  } : undefined);
56547
56770
  const o = decision.obligation;
56548
56771
  if (decision.action === "none" || o == null) {
@@ -56562,6 +56785,12 @@ function obligationSweep() {
56562
56785
  `);
56563
56786
  return;
56564
56787
  }
56788
+ if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
56789
+ process.stderr.write(`telegram gateway: obligation closed silently \u2014 outbound delivered since open origin=${o.originTurnId}
56790
+ `);
56791
+ obligationLedger.close(o.originTurnId);
56792
+ return;
56793
+ }
56565
56794
  driveEscalation({
56566
56795
  escId: o.originTurnId,
56567
56796
  inFlight: obligationEscalateInFlight,
@@ -57253,7 +57482,8 @@ var ALLOWED_TOOLS = new Set([
57253
57482
  "send_gif",
57254
57483
  "vault_request_save",
57255
57484
  "vault_request_access",
57256
- "request_secret"
57485
+ "request_secret",
57486
+ "linear_agent_activity"
57257
57487
  ]);
57258
57488
  async function executeToolCall(tool, args) {
57259
57489
  if (!ALLOWED_TOOLS.has(tool)) {
@@ -57298,6 +57528,8 @@ async function executeToolCall(tool, args) {
57298
57528
  return executeVaultRequestAccess(args);
57299
57529
  case "request_secret":
57300
57530
  return executeRequestSecret(args);
57531
+ case "linear_agent_activity":
57532
+ return executeLinearAgentActivity(args);
57301
57533
  default:
57302
57534
  throw new Error(`unknown tool: ${tool}`);
57303
57535
  }
@@ -57328,6 +57560,9 @@ async function executeSendChecklist(args) {
57328
57560
  `);
57329
57561
  return { content: [{ type: "text", text: `checklist sent (id: ${sent.message_id})` }] };
57330
57562
  }
57563
+ async function executeLinearAgentActivity(args) {
57564
+ return emitLinearAgentActivity(args);
57565
+ }
57331
57566
  async function executeUpdateChecklist(args) {
57332
57567
  const chat_id = args.chat_id;
57333
57568
  if (!chat_id)
@@ -57444,12 +57679,14 @@ ${url}`;
57444
57679
  effectiveText = text;
57445
57680
  }
57446
57681
  assertAllowedChat(chat_id);
57682
+ let replyRoutedOriginTurn = null;
57447
57683
  let threadId;
57448
57684
  if (TURN_ORIGIN_ROUTING_ENABLED) {
57449
57685
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
57450
57686
  const echoedTurn = findTurnByOriginId(args.origin_turn_id);
57451
57687
  const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null;
57452
57688
  const originTurn = echoedTurn ?? quotedTurn;
57689
+ replyRoutedOriginTurn = originTurn ?? null;
57453
57690
  threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "reply");
57454
57691
  } else {
57455
57692
  threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
@@ -57583,7 +57820,7 @@ ${url}`;
57583
57820
  disableNotification
57584
57821
  });
57585
57822
  if (turn2.finalAnswerSubstantive)
57586
- closeObligationOnSubstantiveReply(args, turn2);
57823
+ closeObligationOnSubstantiveReply(args, turn2, replyRoutedOriginTurn);
57587
57824
  }
57588
57825
  outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
57589
57826
  silentAnchorEditDone = true;
@@ -57786,7 +58023,7 @@ ${url}`;
57786
58023
  turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification });
57787
58024
  finalizeStatusReaction(chat_id, threadId, "done");
57788
58025
  if (turn.finalAnswerSubstantive)
57789
- closeObligationOnSubstantiveReply(args, turn);
58026
+ closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn);
57790
58027
  }
57791
58028
  releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
57792
58029
  if (turn?.finalAnswerDelivered === true) {
@@ -57806,13 +58043,19 @@ async function executeStreamReply(args) {
57806
58043
  throw new Error("stream_reply: chat_id is required");
57807
58044
  if (args.text == null || args.text === "")
57808
58045
  throw new Error("stream_reply: text is required and cannot be empty");
58046
+ let streamRoutedOriginTurn = null;
58047
+ let streamOriginVia = null;
58048
+ if (TURN_ORIGIN_ROUTING_ENABLED) {
58049
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id);
58050
+ const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
58051
+ const originTurn = echoedTurn ?? quotedTurn;
58052
+ streamRoutedOriginTurn = originTurn ?? null;
58053
+ streamOriginVia = originTurn == null ? null : echoedTurn != null ? "echo" : "quoted";
58054
+ }
57809
58055
  if (args.message_thread_id == null) {
57810
58056
  let injected;
57811
58057
  if (TURN_ORIGIN_ROUTING_ENABLED) {
57812
- const echoedTurn = findTurnByOriginId(args.origin_turn_id);
57813
- const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
57814
- const originTurn = echoedTurn ?? quotedTurn;
57815
- injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "stream_reply");
58058
+ injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, streamRoutedOriginTurn, streamOriginVia, turn, "stream_reply");
57816
58059
  } else {
57817
58060
  injected = turn?.sessionThreadId;
57818
58061
  }
@@ -57955,7 +58198,7 @@ async function executeStreamReply(args) {
57955
58198
  done: args.done === true
57956
58199
  });
57957
58200
  if (turn.finalAnswerSubstantive)
57958
- closeObligationOnSubstantiveReply(args, turn);
58201
+ closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn);
57959
58202
  const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
57960
58203
  clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
57961
58204
  }