switchroom 0.15.11 → 0.15.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +16 -0
- package/dist/auth-broker/index.js +16 -0
- package/dist/cli/notion-write-pretool.mjs +16 -0
- package/dist/cli/switchroom.js +356 -186
- package/dist/host-control/main.js +16 -0
- package/dist/vault/approvals/kernel-server.js +16 -0
- package/dist/vault/broker/server.js +16 -0
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +37 -0
- package/telegram-plugin/bridge/bridge.ts +55 -0
- package/telegram-plugin/dist/bridge/bridge.js +53 -0
- package/telegram-plugin/dist/gateway/gateway.js +628 -50
- package/telegram-plugin/dist/server.js +53 -0
- package/telegram-plugin/gateway/gateway.ts +130 -5
- package/telegram-plugin/gateway/linear-activity.ts +305 -0
- package/telegram-plugin/gateway/model-command.ts +13 -5
- package/telegram-plugin/scoped-approval.ts +253 -0
- package/telegram-plugin/tests/gateway-request-secret.test.ts +1 -1
- package/telegram-plugin/tests/linear-agent-activity.test.ts +124 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +211 -0
- package/telegram-plugin/tests/model-command.test.ts +40 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +13 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +254 -0
|
@@ -24631,6 +24631,59 @@ var init_bridge = __esm(async () => {
|
|
|
24631
24631
|
},
|
|
24632
24632
|
required: ["chat_id", "key"]
|
|
24633
24633
|
}
|
|
24634
|
+
},
|
|
24635
|
+
{
|
|
24636
|
+
name: "linear_agent_activity",
|
|
24637
|
+
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`.',
|
|
24638
|
+
inputSchema: {
|
|
24639
|
+
type: "object",
|
|
24640
|
+
properties: {
|
|
24641
|
+
agent_session_id: {
|
|
24642
|
+
type: "string",
|
|
24643
|
+
description: "The Linear AgentSession id \u2014 copy it verbatim from the woken turn's meta.agent_session_id."
|
|
24644
|
+
},
|
|
24645
|
+
type: {
|
|
24646
|
+
type: "string",
|
|
24647
|
+
enum: ["thought", "message", "complete", "error"],
|
|
24648
|
+
description: "Activity kind. thought = visible reasoning ack (emit within ~10s); message = progress update; complete = terminal success; error = terminal failure."
|
|
24649
|
+
},
|
|
24650
|
+
body: {
|
|
24651
|
+
type: "string",
|
|
24652
|
+
description: "Activity text (Markdown). Required for thought/message/error; optional for complete (a closing summary)."
|
|
24653
|
+
}
|
|
24654
|
+
},
|
|
24655
|
+
required: ["agent_session_id", "type"]
|
|
24656
|
+
}
|
|
24657
|
+
},
|
|
24658
|
+
{
|
|
24659
|
+
name: "linear_create_issue",
|
|
24660
|
+
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.',
|
|
24661
|
+
inputSchema: {
|
|
24662
|
+
type: "object",
|
|
24663
|
+
properties: {
|
|
24664
|
+
title: {
|
|
24665
|
+
type: "string",
|
|
24666
|
+
description: 'Issue title \u2014 a crisp, imperative one-liner (e.g. "Fix duplicate webhook retries on Brevo sync").'
|
|
24667
|
+
},
|
|
24668
|
+
body: {
|
|
24669
|
+
type: "string",
|
|
24670
|
+
description: "Issue description (Markdown). Capture the ask, relevant context from the message/thread, and any acceptance criteria you can infer."
|
|
24671
|
+
},
|
|
24672
|
+
team_id: {
|
|
24673
|
+
type: "string",
|
|
24674
|
+
description: "Optional Linear team id. Omit to auto-resolve when the workspace has a single team; required only when the workspace has multiple teams."
|
|
24675
|
+
},
|
|
24676
|
+
dedup_key: {
|
|
24677
|
+
type: "string",
|
|
24678
|
+
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>".'
|
|
24679
|
+
},
|
|
24680
|
+
priority: {
|
|
24681
|
+
type: "number",
|
|
24682
|
+
description: "Optional Linear priority (0 none, 1 urgent, 2 high, 3 normal, 4 low)."
|
|
24683
|
+
}
|
|
24684
|
+
},
|
|
24685
|
+
required: ["title", "body"]
|
|
24686
|
+
}
|
|
24634
24687
|
}
|
|
24635
24688
|
];
|
|
24636
24689
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
|
|
@@ -420,6 +420,14 @@ import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.
|
|
|
420
420
|
import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
|
|
421
421
|
import { formatPermissionCardBody, describeGrant, naturalAction, formatPermissionResumeMessage } from '../permission-title.js'
|
|
422
422
|
import { resolveScopedAllowChoices, isRulePersisted } from '../permission-rule.js'
|
|
423
|
+
import {
|
|
424
|
+
type ScopedGrantStore,
|
|
425
|
+
scopedApprovalTtlMs,
|
|
426
|
+
resolveTimeBox,
|
|
427
|
+
recordScopedGrant,
|
|
428
|
+
lookupScopedGrant,
|
|
429
|
+
sweepScopedGrants,
|
|
430
|
+
} from '../scoped-approval.js'
|
|
423
431
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
424
432
|
import {
|
|
425
433
|
readClaudeJsonOverage,
|
|
@@ -467,6 +475,7 @@ import {
|
|
|
467
475
|
listGrantsViaBroker,
|
|
468
476
|
revokeGrantViaBroker,
|
|
469
477
|
} from '../../src/vault/broker/client.js'
|
|
478
|
+
import { emitLinearAgentActivity, createLinearIssue } from './linear-activity.js'
|
|
470
479
|
import {
|
|
471
480
|
approvalRequest,
|
|
472
481
|
approvalConsume,
|
|
@@ -3650,6 +3659,14 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
|
3650
3659
|
}
|
|
3651
3660
|
}
|
|
3652
3661
|
|
|
3662
|
+
// "⏱ 30 min" scoped-approval store (the middle tier between Allow-once and
|
|
3663
|
+
// 🔁 Always). Operator-tapped, gateway-side ONLY (never pushed to the
|
|
3664
|
+
// bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
|
|
3665
|
+
// agent name for per-agent isolation. All policy lives in
|
|
3666
|
+
// ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
|
|
3667
|
+
const scopedGrants: ScopedGrantStore = new Map()
|
|
3668
|
+
const selfAgentName = (): string => process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
3669
|
+
|
|
3653
3670
|
// `ask_user` MCP tool — open prompts awaiting a user button-tap.
|
|
3654
3671
|
// Keyed by askId (8 hex chars from generateAskId). Each entry holds
|
|
3655
3672
|
// the deferred promise that resolves the originating tool call, the
|
|
@@ -4245,6 +4262,9 @@ const pendingStateReaper = setInterval(() => {
|
|
|
4245
4262
|
for (const [k, v] of vaultPassphraseCache) {
|
|
4246
4263
|
if (now > v.expiresAt) vaultPassphraseCache.delete(k)
|
|
4247
4264
|
}
|
|
4265
|
+
// Drop expired "⏱ 30 min" scoped grants. (Lookup already fails closed on
|
|
4266
|
+
// expiry; this just keeps the map from accumulating dead entries.)
|
|
4267
|
+
sweepScopedGrants(scopedGrants, now)
|
|
4248
4268
|
for (const [k, v] of deferredSecrets) {
|
|
4249
4269
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
4250
4270
|
}
|
|
@@ -5590,10 +5610,18 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
|
|
|
5590
5610
|
* rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
|
|
5591
5611
|
* never approach Telegram's 64-byte ceiling.
|
|
5592
5612
|
*/
|
|
5593
|
-
function buildPermissionActionRow(
|
|
5613
|
+
function buildPermissionActionRow(
|
|
5614
|
+
requestId: string,
|
|
5615
|
+
showAlways: boolean,
|
|
5616
|
+
showTimeBox = false,
|
|
5617
|
+
): InlineKeyboard {
|
|
5594
5618
|
const kb = new InlineKeyboard()
|
|
5595
5619
|
.text('❌ Deny', `perm:deny:${requestId}`)
|
|
5596
5620
|
.text('✅ Allow once', `perm:allow:${requestId}`)
|
|
5621
|
+
// "⏱ 30 min" sits between once and always. Only shown for a narrow,
|
|
5622
|
+
// non-destructive scope (resolveTimeBox decides); broad/MCP/destructive
|
|
5623
|
+
// requests get once/always only.
|
|
5624
|
+
if (showTimeBox) kb.text('⏱ 30 min', `perm:tmb:${requestId}`)
|
|
5597
5625
|
if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
|
|
5598
5626
|
return kb
|
|
5599
5627
|
}
|
|
@@ -5977,6 +6005,32 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5977
6005
|
|
|
5978
6006
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
5979
6007
|
const { requestId, toolName, description, inputPreview } = msg
|
|
6008
|
+
// "⏱ 30 min" short-circuit: if the operator tapped a live scoped grant
|
|
6009
|
+
// covering this exact request, auto-allow without posting a card. CRITICAL:
|
|
6010
|
+
// dispatch WITHOUT a `rule` so the bridge does NOT cache it untimed
|
|
6011
|
+
// (sessionAllowRules has no TTL — a `rule` here would silently promote the
|
|
6012
|
+
// 30-min window to session-forever). The fixed window lives only in
|
|
6013
|
+
// scopedGrants. lookupScopedGrant fails closed on expiry and on a
|
|
6014
|
+
// destructive Bash command matching a family grant. No card, no pending
|
|
6015
|
+
// entry, no awaiting reaction — the turn just continues, silently.
|
|
6016
|
+
const scopedTtl = scopedApprovalTtlMs()
|
|
6017
|
+
if (scopedTtl > 0) {
|
|
6018
|
+
const hit = lookupScopedGrant(scopedGrants, selfAgentName(), toolName, inputPreview, Date.now())
|
|
6019
|
+
if (hit) {
|
|
6020
|
+
// Silent auto-allow — no card was posted and the turn was never parked
|
|
6021
|
+
// on the awaiting glyph, so we intentionally OMIT the resume-glyph flip
|
|
6022
|
+
// and the agent-voiced resume message (posting "got it, continuing" on
|
|
6023
|
+
// every auto-allowed call is the exact noise this tier removes). The
|
|
6024
|
+
// `no-card-verdict` sentinel below exempts this callsite from the
|
|
6025
|
+
// resume-guard pairing test.
|
|
6026
|
+
dispatchPermissionVerdict({ type: 'permission', requestId, behavior: 'allow' }) // no-card-verdict
|
|
6027
|
+
process.stderr.write(
|
|
6028
|
+
`telegram gateway: scoped-approval auto-allow tool=${toolName} rule="${hit}" ` +
|
|
6029
|
+
`request=${requestId} (time-boxed window)\n`,
|
|
6030
|
+
)
|
|
6031
|
+
return
|
|
6032
|
+
}
|
|
6033
|
+
}
|
|
5980
6034
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
5981
6035
|
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
5982
6036
|
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
@@ -5995,8 +6049,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5995
6049
|
// any file ⚠️). The "🔁 Always…" button only appears when we can
|
|
5996
6050
|
// synthesize a meaningful rule for this tool; unknown tools get the
|
|
5997
6051
|
// two-button row only.
|
|
5998
|
-
const
|
|
5999
|
-
const
|
|
6052
|
+
const scopeChoices = resolveScopedAllowChoices(toolName, inputPreview)
|
|
6053
|
+
const showAlways = scopeChoices != null
|
|
6054
|
+
// Offer "⏱ 30 min" only for a narrow, non-destructive scope, and only
|
|
6055
|
+
// when the tier is enabled (TTL > 0).
|
|
6056
|
+
const showTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
6057
|
+
resolveTimeBox(toolName, inputPreview, scopeChoices) != null
|
|
6058
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox)
|
|
6000
6059
|
// Route the card to the SAME place the post-verdict resume message
|
|
6001
6060
|
// lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
|
|
6002
6061
|
// there's an active turn — so a supergroup agent's card appears IN the
|
|
@@ -6718,6 +6777,8 @@ const ALLOWED_TOOLS = new Set([
|
|
|
6718
6777
|
'vault_request_save',
|
|
6719
6778
|
'vault_request_access',
|
|
6720
6779
|
'request_secret',
|
|
6780
|
+
'linear_agent_activity',
|
|
6781
|
+
'linear_create_issue',
|
|
6721
6782
|
])
|
|
6722
6783
|
|
|
6723
6784
|
async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -6763,6 +6824,10 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
6763
6824
|
return executeVaultRequestAccess(args)
|
|
6764
6825
|
case 'request_secret':
|
|
6765
6826
|
return executeRequestSecret(args)
|
|
6827
|
+
case 'linear_agent_activity':
|
|
6828
|
+
return executeLinearAgentActivity(args)
|
|
6829
|
+
case 'linear_create_issue':
|
|
6830
|
+
return executeLinearCreateIssue(args)
|
|
6766
6831
|
default:
|
|
6767
6832
|
throw new Error(`unknown tool: ${tool}`)
|
|
6768
6833
|
}
|
|
@@ -6794,6 +6859,14 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
6794
6859
|
return { content: [{ type: 'text', text: `checklist sent (id: ${sent.message_id})` }] }
|
|
6795
6860
|
}
|
|
6796
6861
|
|
|
6862
|
+
async function executeLinearAgentActivity(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6863
|
+
return emitLinearAgentActivity(args)
|
|
6864
|
+
}
|
|
6865
|
+
|
|
6866
|
+
async function executeLinearCreateIssue(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6867
|
+
return createLinearIssue(args)
|
|
6868
|
+
}
|
|
6869
|
+
|
|
6797
6870
|
async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6798
6871
|
const chat_id = args.chat_id as string
|
|
6799
6872
|
if (!chat_id) throw new Error('update_checklist: chat_id is required')
|
|
@@ -18954,7 +19027,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18954
19027
|
}
|
|
18955
19028
|
|
|
18956
19029
|
// Permission request buttons.
|
|
18957
|
-
const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
|
|
19030
|
+
const m = /^perm:(allow|deny|always|asn|asb|back|tmb):([a-km-z]{5})$/.exec(data)
|
|
18958
19031
|
if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
|
|
18959
19032
|
const access = loadAccess()
|
|
18960
19033
|
const senderId = String(ctx.from.id)
|
|
@@ -18969,7 +19042,10 @@ bot.on('callback_query:data', async ctx => {
|
|
|
18969
19042
|
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
18970
19043
|
let keyboard: InlineKeyboard
|
|
18971
19044
|
if (behavior === 'back') {
|
|
18972
|
-
|
|
19045
|
+
const backChoices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
19046
|
+
const backTimeBox = scopedApprovalTtlMs() > 0 &&
|
|
19047
|
+
resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null
|
|
19048
|
+
keyboard = buildPermissionActionRow(request_id, true, backTimeBox)
|
|
18973
19049
|
} else {
|
|
18974
19050
|
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
18975
19051
|
if (choices == null) {
|
|
@@ -19193,6 +19269,55 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19193
19269
|
return
|
|
19194
19270
|
}
|
|
19195
19271
|
|
|
19272
|
+
// "⏱ 30 min" — record a fixed-window scoped grant for the NARROW scope,
|
|
19273
|
+
// then allow the in-flight call. Mirrors the asn/asb structure: dispatch
|
|
19274
|
+
// the verdict immediately, then edit the card. CRITICAL: the verdict
|
|
19275
|
+
// carries NO `rule` (unlike asn/asb), so the bridge does not cache it
|
|
19276
|
+
// untimed — the window lives only in scopedGrants, gateway-side.
|
|
19277
|
+
if (behavior === 'tmb') {
|
|
19278
|
+
const details = pendingPermissions.get(request_id)
|
|
19279
|
+
if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
|
|
19280
|
+
const ttl = scopedApprovalTtlMs()
|
|
19281
|
+
if (ttl <= 0) { await ctx.answerCallbackQuery({ text: 'Time-boxed approvals are disabled.' }).catch(() => {}); return }
|
|
19282
|
+
const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
|
|
19283
|
+
const tb = resolveTimeBox(details.tool_name, details.input_preview, choices)
|
|
19284
|
+
if (!tb) { await ctx.answerCallbackQuery({ text: 'This action can\'t be time-boxed.' }).catch(() => {}); return }
|
|
19285
|
+
const agentName = selfAgentName()
|
|
19286
|
+
if (!agentName) { await ctx.answerCallbackQuery({ text: 'Time-box needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {}); return }
|
|
19287
|
+
|
|
19288
|
+
pendingPermissions.delete(request_id)
|
|
19289
|
+
// (1) Allow the in-flight call NOW — no `rule` (keeps the window
|
|
19290
|
+
// strictly gateway-side; the bridge must not cache it untimed).
|
|
19291
|
+
dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior: 'allow' })
|
|
19292
|
+
// (2) Record the fixed-window grant so matching calls auto-allow.
|
|
19293
|
+
recordScopedGrant(scopedGrants, agentName, tb.rule, Date.now(), ttl)
|
|
19294
|
+
resumeReactionAfterVerdict()
|
|
19295
|
+
postPermissionResumeMessage({
|
|
19296
|
+
behavior: 'allow',
|
|
19297
|
+
action: naturalAction(details.tool_name, details.input_preview),
|
|
19298
|
+
})
|
|
19299
|
+
process.stderr.write(
|
|
19300
|
+
`telegram gateway: scoped-approval granted rule="${tb.rule}" agent=${agentName} ` +
|
|
19301
|
+
`ttl_ms=${ttl} (request_id=${request_id})\n`,
|
|
19302
|
+
)
|
|
19303
|
+
|
|
19304
|
+
const mins = Math.max(1, Math.round(ttl / 60_000))
|
|
19305
|
+
// Honest card: state the real BREADTH (e.g. "any `git` command"), not
|
|
19306
|
+
// just the rule, plus the window — consent covers both (access-model
|
|
19307
|
+
// honest-card contract).
|
|
19308
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
19309
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
19310
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
19311
|
+
: ''
|
|
19312
|
+
const editLabel = `⏱ <b>Allowed for ${mins} min — ${escapeHtmlForTg(tb.breadth)}</b> · re-asks after that, and now for anything else`
|
|
19313
|
+
await finalizeCallback(ctx, {
|
|
19314
|
+
ackText: `⏱ Allowed for ${mins} min`.slice(0, 200),
|
|
19315
|
+
newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
|
|
19316
|
+
parseMode: 'HTML',
|
|
19317
|
+
})
|
|
19318
|
+
return
|
|
19319
|
+
}
|
|
19320
|
+
|
|
19196
19321
|
// Forward permission decision to connected bridges. Capture the work
|
|
19197
19322
|
// phrase BEFORE deleting the pending entry — postPermissionResumeMessage
|
|
19198
19323
|
// (fired in synthInbound below) names the resumed work.
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear AgentActivity emission (#2298).
|
|
3
|
+
*
|
|
4
|
+
* The `linear_agent_activity` MCP tool lets an agent that was woken by a
|
|
5
|
+
* Linear agent session respond with structured activities (thought /
|
|
6
|
+
* message / complete / error) that Linear renders as status chips + a
|
|
7
|
+
* timeline on the issue. This module owns the pure logic — token
|
|
8
|
+
* resolution + the `agentActivityCreate` GraphQL POST — behind injectable
|
|
9
|
+
* deps so it is testable without a vault broker or the network. The
|
|
10
|
+
* gateway wires it into `executeToolCall`.
|
|
11
|
+
*
|
|
12
|
+
* The agent's Linear app token is resolved from the vault under
|
|
13
|
+
* `linear/<agent>/token` via the broker (never an inline literal, never an
|
|
14
|
+
* env file). On a vault denial the tool returns actionable text telling the
|
|
15
|
+
* agent to `vault_request_access` for that key rather than failing opaquely.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
getViaBrokerStructured,
|
|
20
|
+
readVaultTokenFile,
|
|
21
|
+
} from '../../src/vault/broker/client.js'
|
|
22
|
+
|
|
23
|
+
export const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql'
|
|
24
|
+
|
|
25
|
+
export type LinearTokenResult =
|
|
26
|
+
| { ok: true; token: string }
|
|
27
|
+
| { ok: false; reason: 'denied' | 'unreachable' | 'not_found' | 'unknown' }
|
|
28
|
+
|
|
29
|
+
export interface LinearActivityDeps {
|
|
30
|
+
/** Resolve the Linear app token for `agent` from the vault. */
|
|
31
|
+
resolveToken?: (agent: string) => Promise<LinearTokenResult>
|
|
32
|
+
/** Injectable fetch (tests). */
|
|
33
|
+
fetchImpl?: typeof fetch
|
|
34
|
+
/** Agent slug (defaults to SWITCHROOM_AGENT_NAME). */
|
|
35
|
+
agent?: string
|
|
36
|
+
/** Default Linear team id for captured issues (multi-team workspaces);
|
|
37
|
+
* defaults to SWITCHROOM_LINEAR_DEFAULT_TEAM_ID. Tests inject directly. */
|
|
38
|
+
defaultTeamId?: string
|
|
39
|
+
/** Log sink — stderr in production. */
|
|
40
|
+
log?: (line: string) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ToolTextResult = { content: Array<{ type: string; text: string }> }
|
|
44
|
+
|
|
45
|
+
/** Default token resolver: vault broker get on `linear/<agent>/token`. */
|
|
46
|
+
export async function defaultResolveLinearToken(agent: string): Promise<LinearTokenResult> {
|
|
47
|
+
const key = `linear/${agent}/token`
|
|
48
|
+
const token = readVaultTokenFile(agent) ?? undefined
|
|
49
|
+
const result = await getViaBrokerStructured(key, token ? { token } : {})
|
|
50
|
+
if (result.kind === 'ok' && result.entry.kind === 'string') {
|
|
51
|
+
return { ok: true, token: result.entry.value }
|
|
52
|
+
}
|
|
53
|
+
if (result.kind === 'unreachable') return { ok: false, reason: 'unreachable' }
|
|
54
|
+
if (result.kind === 'not_found') return { ok: false, reason: 'not_found' }
|
|
55
|
+
if (result.kind === 'denied') return { ok: false, reason: 'denied' }
|
|
56
|
+
return { ok: false, reason: 'unknown' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Emit a Linear AgentActivity. Validates args, resolves the token, POSTs
|
|
61
|
+
* the `agentActivityCreate` mutation, and returns an MCP text result. Never
|
|
62
|
+
* throws on a vault/network failure — returns actionable error text so the
|
|
63
|
+
* agent can recover (e.g. via vault_request_access).
|
|
64
|
+
*/
|
|
65
|
+
export async function emitLinearAgentActivity(
|
|
66
|
+
args: Record<string, unknown>,
|
|
67
|
+
deps: LinearActivityDeps = {},
|
|
68
|
+
): Promise<ToolTextResult> {
|
|
69
|
+
const log = deps.log ?? ((s) => process.stderr.write(s))
|
|
70
|
+
|
|
71
|
+
const sessionId = args.agent_session_id as string | undefined
|
|
72
|
+
if (!sessionId) throw new Error('linear_agent_activity: agent_session_id is required')
|
|
73
|
+
const type = args.type as string | undefined
|
|
74
|
+
if (!type || !['thought', 'message', 'complete', 'error'].includes(type)) {
|
|
75
|
+
throw new Error('linear_agent_activity: type must be one of thought|message|complete|error')
|
|
76
|
+
}
|
|
77
|
+
const body = args.body as string | undefined
|
|
78
|
+
if (type !== 'complete' && (body == null || body === '')) {
|
|
79
|
+
throw new Error(`linear_agent_activity: body is required for type='${type}'`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const agent = deps.agent ?? process.env.SWITCHROOM_AGENT_NAME ?? '-'
|
|
83
|
+
const resolveToken = deps.resolveToken ?? defaultResolveLinearToken
|
|
84
|
+
const tokenResult = await resolveToken(agent)
|
|
85
|
+
if (!tokenResult.ok) {
|
|
86
|
+
if (tokenResult.reason === 'denied' || tokenResult.reason === 'not_found') {
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: 'text',
|
|
91
|
+
text:
|
|
92
|
+
`linear_agent_activity failed: no Linear token (vault ${tokenResult.reason}). ` +
|
|
93
|
+
`Call vault_request_access for key 'linear/${agent}/token' (scope read), then retry.`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: `linear_agent_activity failed: vault broker ${tokenResult.reason} resolving 'linear/${agent}/token'.`,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// AgentActivity content discriminated by type. thought/message/error carry
|
|
109
|
+
// a body; complete is terminal with an optional summary body.
|
|
110
|
+
const content: Record<string, unknown> = { type }
|
|
111
|
+
if (body != null && body !== '') content.body = body
|
|
112
|
+
|
|
113
|
+
const mutation =
|
|
114
|
+
'mutation AgentActivityCreate($input: AgentActivityCreateInput!) { ' +
|
|
115
|
+
'agentActivityCreate(input: $input) { success agentActivity { id } } }'
|
|
116
|
+
const variables = { input: { agentSessionId: sessionId, content } }
|
|
117
|
+
|
|
118
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
119
|
+
let resp: Response
|
|
120
|
+
try {
|
|
121
|
+
resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
Authorization: tokenResult.token,
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify({ query: mutation, variables }),
|
|
128
|
+
})
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: 'text', text: `linear_agent_activity failed: request error: ${(err as Error).message}` }],
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!resp.ok) {
|
|
136
|
+
const txt = await resp.text().catch(() => '')
|
|
137
|
+
return {
|
|
138
|
+
content: [
|
|
139
|
+
{ type: 'text', text: `linear_agent_activity failed: Linear API ${resp.status}${txt ? ` — ${txt.slice(0, 200)}` : ''}` },
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let json: { data?: { agentActivityCreate?: { success?: boolean } }; errors?: Array<{ message?: string }> }
|
|
145
|
+
try {
|
|
146
|
+
json = (await resp.json()) as typeof json
|
|
147
|
+
} catch {
|
|
148
|
+
return { content: [{ type: 'text', text: 'linear_agent_activity failed: malformed Linear API response' }] }
|
|
149
|
+
}
|
|
150
|
+
if (json.errors && json.errors.length > 0) {
|
|
151
|
+
return {
|
|
152
|
+
content: [
|
|
153
|
+
{ type: 'text', text: `linear_agent_activity failed: ${json.errors.map((e) => e.message ?? 'error').join('; ').slice(0, 300)}` },
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (json.data?.agentActivityCreate?.success === false) {
|
|
158
|
+
return { content: [{ type: 'text', text: 'linear_agent_activity failed: Linear reported success=false' }] }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
log(`telegram gateway: linear_agent_activity: emitted type=${type} session=${sessionId} agent=${agent}\n`)
|
|
162
|
+
return { content: [{ type: 'text', text: `Linear ${type} emitted on session ${sessionId}` }] }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Hidden marker appended to a captured issue's description so a re-capture of
|
|
166
|
+
* the same Telegram message can be detected (dedup backstop; the gateway-side
|
|
167
|
+
* seen-set is the primary, race-free guard). */
|
|
168
|
+
export function captureDedupMarker(dedupKey: string): string {
|
|
169
|
+
return `\n\n<!-- switchroom-capture: ${dedupKey} -->`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a Linear issue (capture-on-reaction → Linear). Mirrors
|
|
174
|
+
* emitLinearAgentActivity: validates, resolves the agent's Linear app token,
|
|
175
|
+
* POSTs `issueCreate`, returns an MCP text result; never throws on vault/
|
|
176
|
+
* network failure. The issue is filed AS the agent's app actor.
|
|
177
|
+
*
|
|
178
|
+
* Team: Linear requires a teamId. If `team_id` is omitted we auto-resolve —
|
|
179
|
+
* the zero-config single-team case uses the workspace's only team; a
|
|
180
|
+
* multi-team workspace returns actionable text asking for an explicit team_id.
|
|
181
|
+
*
|
|
182
|
+
* Dedup: when `dedup_key` is given we (a) best-effort search for a prior
|
|
183
|
+
* capture carrying the same marker and short-circuit to "already filed", and
|
|
184
|
+
* (b) embed the marker in the new issue's description as a durable backstop.
|
|
185
|
+
*/
|
|
186
|
+
export async function createLinearIssue(
|
|
187
|
+
args: Record<string, unknown>,
|
|
188
|
+
deps: LinearActivityDeps = {},
|
|
189
|
+
): Promise<ToolTextResult> {
|
|
190
|
+
const log = deps.log ?? ((s) => process.stderr.write(s))
|
|
191
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
192
|
+
|
|
193
|
+
const title = args.title as string | undefined
|
|
194
|
+
if (!title || title.trim() === '') throw new Error('linear_create_issue: title is required')
|
|
195
|
+
const body = (args.body as string | undefined) ?? ''
|
|
196
|
+
// Explicit team_id wins; otherwise the operator's configured default team
|
|
197
|
+
// (scaffold injects SWITCHROOM_LINEAR_DEFAULT_TEAM_ID for multi-team
|
|
198
|
+
// workspaces); otherwise we auto-resolve below (zero-config single team).
|
|
199
|
+
const teamIdArg =
|
|
200
|
+
(args.team_id as string | undefined) ??
|
|
201
|
+
(deps.defaultTeamId ?? process.env.SWITCHROOM_LINEAR_DEFAULT_TEAM_ID) ??
|
|
202
|
+
undefined
|
|
203
|
+
const dedupKey = (args.dedup_key as string | undefined) ?? undefined
|
|
204
|
+
const priority = typeof args.priority === 'number' ? (args.priority as number) : undefined
|
|
205
|
+
|
|
206
|
+
const agent = deps.agent ?? process.env.SWITCHROOM_AGENT_NAME ?? '-'
|
|
207
|
+
const resolveToken = deps.resolveToken ?? defaultResolveLinearToken
|
|
208
|
+
const tokenResult = await resolveToken(agent)
|
|
209
|
+
if (!tokenResult.ok) {
|
|
210
|
+
const hint =
|
|
211
|
+
tokenResult.reason === 'denied' || tokenResult.reason === 'not_found'
|
|
212
|
+
? ` Call vault_request_access for key 'linear/${agent}/token' (scope read), then retry.`
|
|
213
|
+
: ''
|
|
214
|
+
return {
|
|
215
|
+
content: [
|
|
216
|
+
{ type: 'text', text: `Couldn't file to Linear: no token (vault ${tokenResult.reason}).${hint}` },
|
|
217
|
+
],
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const token = tokenResult.token
|
|
221
|
+
|
|
222
|
+
const gql = async (query: string, variables: Record<string, unknown>): Promise<{ ok: true; data: any } | { ok: false; text: string }> => {
|
|
223
|
+
let resp: Response
|
|
224
|
+
try {
|
|
225
|
+
resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json', Authorization: token },
|
|
228
|
+
body: JSON.stringify({ query, variables }),
|
|
229
|
+
})
|
|
230
|
+
} catch (err) {
|
|
231
|
+
return { ok: false, text: `request error: ${(err as Error).message}` }
|
|
232
|
+
}
|
|
233
|
+
if (!resp.ok) {
|
|
234
|
+
const txt = await resp.text().catch(() => '')
|
|
235
|
+
return { ok: false, text: `Linear API ${resp.status}${txt ? ` — ${txt.slice(0, 200)}` : ''}` }
|
|
236
|
+
}
|
|
237
|
+
let json: { data?: any; errors?: Array<{ message?: string }> }
|
|
238
|
+
try {
|
|
239
|
+
json = (await resp.json()) as typeof json
|
|
240
|
+
} catch {
|
|
241
|
+
return { ok: false, text: 'malformed Linear API response' }
|
|
242
|
+
}
|
|
243
|
+
if (json.errors && json.errors.length > 0) {
|
|
244
|
+
return { ok: false, text: json.errors.map((e) => e.message ?? 'error').join('; ').slice(0, 300) }
|
|
245
|
+
}
|
|
246
|
+
return { ok: true, data: json.data }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Dedup backstop: search for a prior capture of the same Telegram message.
|
|
250
|
+
if (dedupKey) {
|
|
251
|
+
const search = await gql(
|
|
252
|
+
'query($term: String!) { searchIssues(term: $term) { nodes { id url title } } }',
|
|
253
|
+
{ term: dedupKey },
|
|
254
|
+
)
|
|
255
|
+
if (search.ok) {
|
|
256
|
+
const hit = (search.data?.searchIssues?.nodes ?? [])[0] as { url?: string } | undefined
|
|
257
|
+
if (hit?.url) {
|
|
258
|
+
log(`telegram gateway: linear_create_issue: dedup hit key=${dedupKey} agent=${agent}\n`)
|
|
259
|
+
return { content: [{ type: 'text', text: `Already filed: ${hit.url}` }] }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// a failed search is non-fatal — fall through to create (gateway seen-set is primary).
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Resolve the team.
|
|
266
|
+
let teamId = teamIdArg
|
|
267
|
+
if (!teamId) {
|
|
268
|
+
const teams = await gql('query { teams(first: 50) { nodes { id key name } } }', {})
|
|
269
|
+
if (!teams.ok) {
|
|
270
|
+
return { content: [{ type: 'text', text: `Couldn't file to Linear: ${teams.text}` }] }
|
|
271
|
+
}
|
|
272
|
+
const nodes = (teams.data?.teams?.nodes ?? []) as Array<{ id: string; key: string; name: string }>
|
|
273
|
+
if (nodes.length === 0) {
|
|
274
|
+
return { content: [{ type: 'text', text: 'Couldn\'t file to Linear: the workspace has no teams.' }] }
|
|
275
|
+
}
|
|
276
|
+
if (nodes.length > 1) {
|
|
277
|
+
const list = nodes.map((t) => `${t.key} (${t.name})`).join(', ')
|
|
278
|
+
return {
|
|
279
|
+
content: [
|
|
280
|
+
{ type: 'text', text: `Couldn't file to Linear: multiple teams (${list}) — set a default team (linear_agent.default_team_id) or pass team_id.` },
|
|
281
|
+
],
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
teamId = nodes[0].id
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const description = dedupKey ? `${body}${captureDedupMarker(dedupKey)}` : body
|
|
288
|
+
const input: Record<string, unknown> = { teamId, title, description }
|
|
289
|
+
if (priority !== undefined) input.priority = priority
|
|
290
|
+
|
|
291
|
+
const create = await gql(
|
|
292
|
+
'mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier url } } }',
|
|
293
|
+
{ input },
|
|
294
|
+
)
|
|
295
|
+
if (!create.ok) {
|
|
296
|
+
return { content: [{ type: 'text', text: `Couldn't file to Linear: ${create.text}` }] }
|
|
297
|
+
}
|
|
298
|
+
const issue = create.data?.issueCreate?.issue as { identifier?: string; url?: string } | undefined
|
|
299
|
+
if (create.data?.issueCreate?.success === false || !issue?.url) {
|
|
300
|
+
return { content: [{ type: 'text', text: 'Couldn\'t file to Linear: issue not created (success=false).' }] }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
log(`telegram gateway: linear_create_issue: filed ${issue.identifier} agent=${agent}${dedupKey ? ` dedup=${dedupKey}` : ''}\n`)
|
|
304
|
+
return { content: [{ type: 'text', text: `Filed: ${title} → ${issue.url}` }] }
|
|
305
|
+
}
|
|
@@ -33,12 +33,20 @@ import {
|
|
|
33
33
|
} from '../../src/agents/model-picker.js'
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Aliases the claude CLI resolves natively
|
|
37
|
-
* the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Aliases the claude CLI resolves natively (`claude --help`: "an alias for
|
|
37
|
+
* the latest model (e.g. 'fable', 'opus', or 'sonnet')"). Listed in help
|
|
38
|
+
* text only — the handler does NOT restrict to these (a full model id like
|
|
39
|
+
* `claude-opus-4-8` passes through and claude itself validates it, so new
|
|
40
|
+
* aliases/models work without a switchroom release).
|
|
41
|
+
*
|
|
42
|
+
* `fable` is the latest flagship (Fable 5) — kept selectable here on
|
|
43
|
+
* purpose. NB the alias is NOT the full codename: `claude-fable-5` (a
|
|
44
|
+
* pinned pre-launch id) was retired server-side and 4xx'd the whole fleet
|
|
45
|
+
* on 2026-06-13, while the `fable` alias keeps resolving to the current
|
|
46
|
+
* model. Aliases are the durable way to pick a model — see the model
|
|
47
|
+
* regression tests.
|
|
40
48
|
*/
|
|
41
|
-
export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'default'] as const
|
|
49
|
+
export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
|
|
42
50
|
|
|
43
51
|
/**
|
|
44
52
|
* Shape gate for the model argument. This string is typed literally
|