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.
- package/dist/agent-scheduler/index.js +7 -80
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +292 -261
- package/dist/host-control/main.js +5 -0
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +10 -6
- package/telegram-plugin/bridge/bridge.ts +24 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +266 -23
- package/telegram-plugin/dist/server.js +23 -0
- package/telegram-plugin/gateway/gateway.ts +100 -24
- package/telegram-plugin/gateway/linear-activity.ts +160 -0
- package/telegram-plugin/gateway/model-command.ts +13 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +56 -15
- package/telegram-plugin/history.ts +57 -0
- package/telegram-plugin/tests/gateway-request-secret.test.ts +1 -1
- package/telegram-plugin/tests/history.test.ts +83 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +124 -0
- package/telegram-plugin/tests/model-command.test.ts +40 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +213 -5
- package/telegram-plugin/tests/obligation-store.test.ts +17 -0
|
@@ -24631,6 +24631,29 @@ 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
|
+
}
|
|
24634
24657
|
}
|
|
24635
24658
|
];
|
|
24636
24659
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
|
|
@@ -139,6 +139,7 @@ import {
|
|
|
139
139
|
recordReaction, lookupMessageRoleAndText,
|
|
140
140
|
checkpointWal as checkpointHistoryWal,
|
|
141
141
|
pruneMessagesOlderThanDays,
|
|
142
|
+
hasOutboundDeliveredSince,
|
|
142
143
|
} from '../history.js'
|
|
143
144
|
import {
|
|
144
145
|
runRegistryReaper,
|
|
@@ -466,6 +467,7 @@ import {
|
|
|
466
467
|
listGrantsViaBroker,
|
|
467
468
|
revokeGrantViaBroker,
|
|
468
469
|
} from '../../src/vault/broker/client.js'
|
|
470
|
+
import { emitLinearAgentActivity } from './linear-activity.js'
|
|
469
471
|
import {
|
|
470
472
|
approvalRequest,
|
|
471
473
|
approvalConsume,
|
|
@@ -1506,6 +1508,20 @@ const OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
|
|
|
1506
1508
|
const n = Number(raw)
|
|
1507
1509
|
return Number.isFinite(n) && n >= 0 ? n : 20 * 60_000
|
|
1508
1510
|
})()
|
|
1511
|
+
// Per-represent grace window. After a re-present fires, the obligation is
|
|
1512
|
+
// ineligible for the next represent/escalate until at least this many ms have
|
|
1513
|
+
// elapsed since markRepresented. Without this the 5s sweep can fire again
|
|
1514
|
+
// before the re-presented turn even reaches the agent, burning the represent
|
|
1515
|
+
// budget and producing back-to-back re-presents or a premature escalation.
|
|
1516
|
+
// Default 120s — generous enough for a turn to start + deliver an answer;
|
|
1517
|
+
// small enough not to delay genuine unanswered re-presents.
|
|
1518
|
+
// Kill switch: SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS=0 → no per-represent grace.
|
|
1519
|
+
const OBLIGATION_REPRESENT_GRACE_MS = (() => {
|
|
1520
|
+
const raw = process.env.SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS
|
|
1521
|
+
if (raw == null || raw === '') return 120_000
|
|
1522
|
+
const n = Number(raw)
|
|
1523
|
+
return Number.isFinite(n) && n >= 0 ? n : 120_000
|
|
1524
|
+
})()
|
|
1509
1525
|
// Marker-freshness window for the orphaned-foreground signal. The turn-active
|
|
1510
1526
|
// marker is touched on every foreground tool_use and on foreground sub-agent
|
|
1511
1527
|
// JSONL growth, so an mtime younger than this means a sub-agent is touching it
|
|
@@ -2175,23 +2191,38 @@ function hasDifferentThreadedRecentTurn(
|
|
|
2175
2191
|
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
2176
2192
|
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
2177
2193
|
* obligation discharged is the one for the SAME origin the answer routes to
|
|
2178
|
-
* (origin_turn_id the model echoed, else the
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
*
|
|
2185
|
-
*
|
|
2186
|
-
*
|
|
2194
|
+
* (origin_turn_id the model echoed, else the routed origin the gateway resolved,
|
|
2195
|
+
* else the live turn). So 713's reply closes 713's obligation even after
|
|
2196
|
+
* currentTurn flipped to 715, and 715 stays open until ITS own substantive
|
|
2197
|
+
* answer. Answers to re-presented obligations (via=quoted, no model echo) close
|
|
2198
|
+
* via the gateway-resolved routedOriginTurn. An ack does NOT close (so
|
|
2199
|
+
* ack-then-ghost is re-presented, not re-dropped). The live-turn fallback fires
|
|
2200
|
+
* only for the live turn's OWN obligation (it was the turn delivering this
|
|
2201
|
+
* reply), preserving the 713/715 invariant. No-op unless the flag is on.
|
|
2202
|
+
*
|
|
2203
|
+
* @param routedOriginTurn — the origin the reply router already resolved
|
|
2204
|
+
* (echoedTurn ?? quotedTurn); pass whenever the TURN_ORIGIN_ROUTING path ran.
|
|
2205
|
+
* Skipped when null/undefined (pre-routing paths, or DM with no quote).
|
|
2187
2206
|
*/
|
|
2188
2207
|
function closeObligationOnSubstantiveReply(
|
|
2189
2208
|
args: Record<string, unknown>,
|
|
2190
2209
|
liveTurn: CurrentTurn | null | undefined,
|
|
2210
|
+
routedOriginTurn?: CurrentTurn | null,
|
|
2191
2211
|
): void {
|
|
2192
2212
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
2193
2213
|
const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
2194
|
-
|
|
2214
|
+
// routedOriginTurn is the gateway-resolved origin (echoedTurn ?? quotedTurn).
|
|
2215
|
+
// Only pass it as routedOriginId when it DIFFERS from the echoed turn (if
|
|
2216
|
+
// echoed is present, resolveCloseTarget's first branch already handles it),
|
|
2217
|
+
// and only when it is NOT the live turn (live-turn is the fallback, not the
|
|
2218
|
+
// routed origin — passing live turn here would bypass the live-turn fallback
|
|
2219
|
+
// logic and still close correctly, but naming matters for the 713/715 case:
|
|
2220
|
+
// the routed origin on a via=quoted reply IS the origin, not "live fallback").
|
|
2221
|
+
const routedOriginId =
|
|
2222
|
+
routedOriginTurn != null && echoed == null
|
|
2223
|
+
? routedOriginTurn.turnId
|
|
2224
|
+
: null
|
|
2225
|
+
const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
|
|
2195
2226
|
if (target != null) obligationLedger.close(target)
|
|
2196
2227
|
}
|
|
2197
2228
|
|
|
@@ -5414,13 +5445,16 @@ function obligationSweep(): void {
|
|
|
5414
5445
|
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
|
|
5415
5446
|
// Grace window: skip an obligation whose handling turn ended < grace ago — its
|
|
5416
5447
|
// trailing slow/worker answer may still be landing (over-escalation fix).
|
|
5448
|
+
// Per-represent grace: skip an obligation re-presented < grace ago — prevents
|
|
5449
|
+
// the 5s sweep from immediately firing again before the re-present even lands.
|
|
5417
5450
|
const decision = obligationLedger.decideAtIdle(
|
|
5418
|
-
OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive
|
|
5451
|
+
OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0
|
|
5419
5452
|
? {
|
|
5420
5453
|
now,
|
|
5421
5454
|
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
5422
5455
|
backgroundWorkActive,
|
|
5423
5456
|
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
5457
|
+
representGraceMs: OBLIGATION_REPRESENT_GRACE_MS,
|
|
5424
5458
|
}
|
|
5425
5459
|
: undefined,
|
|
5426
5460
|
)
|
|
@@ -5449,8 +5483,22 @@ function obligationSweep(): void {
|
|
|
5449
5483
|
)
|
|
5450
5484
|
return
|
|
5451
5485
|
}
|
|
5452
|
-
// escalate — re-present ladder exhausted.
|
|
5453
|
-
//
|
|
5486
|
+
// escalate — re-present ladder exhausted. Before sending the user-visible
|
|
5487
|
+
// apology, check whether the agent has ALREADY delivered an outbound reply
|
|
5488
|
+
// to this chat since the obligation was opened. If yes, the obligation is
|
|
5489
|
+
// stale (the agent did answer, just without closing the obligation via the
|
|
5490
|
+
// normal close path) — close silently instead of alarming the user with a
|
|
5491
|
+
// false "I may have missed this". This is Fix 4: escalate only on knowledge,
|
|
5492
|
+
// not doubt. Fall back to false (safe: never suppresses) if history unavailable.
|
|
5493
|
+
if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
|
|
5494
|
+
process.stderr.write(
|
|
5495
|
+
`telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
|
|
5496
|
+
)
|
|
5497
|
+
obligationLedger.close(o.originTurnId)
|
|
5498
|
+
return
|
|
5499
|
+
}
|
|
5500
|
+
// Proceed with escalation: send ONE operator-visible nudge and close the
|
|
5501
|
+
// obligation ONLY AFTER it actually lands. This inverts the old
|
|
5454
5502
|
// close-before-send (which silently dropped the terminal whenever the send
|
|
5455
5503
|
// failed): the close is now itself an observable terminal. A transient send
|
|
5456
5504
|
// failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
|
|
@@ -6671,6 +6719,7 @@ const ALLOWED_TOOLS = new Set([
|
|
|
6671
6719
|
'vault_request_save',
|
|
6672
6720
|
'vault_request_access',
|
|
6673
6721
|
'request_secret',
|
|
6722
|
+
'linear_agent_activity',
|
|
6674
6723
|
])
|
|
6675
6724
|
|
|
6676
6725
|
async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -6716,6 +6765,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
6716
6765
|
return executeVaultRequestAccess(args)
|
|
6717
6766
|
case 'request_secret':
|
|
6718
6767
|
return executeRequestSecret(args)
|
|
6768
|
+
case 'linear_agent_activity':
|
|
6769
|
+
return executeLinearAgentActivity(args)
|
|
6719
6770
|
default:
|
|
6720
6771
|
throw new Error(`unknown tool: ${tool}`)
|
|
6721
6772
|
}
|
|
@@ -6747,6 +6798,10 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
6747
6798
|
return { content: [{ type: 'text', text: `checklist sent (id: ${sent.message_id})` }] }
|
|
6748
6799
|
}
|
|
6749
6800
|
|
|
6801
|
+
async function executeLinearAgentActivity(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6802
|
+
return emitLinearAgentActivity(args)
|
|
6803
|
+
}
|
|
6804
|
+
|
|
6750
6805
|
async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
6751
6806
|
const chat_id = args.chat_id as string
|
|
6752
6807
|
if (!chat_id) throw new Error('update_checklist: chat_id is required')
|
|
@@ -6970,6 +7025,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6970
7025
|
// heuristic is what mis-routed a late reply to whichever topic most
|
|
6971
7026
|
// recently received a message. DM: every tier is undefined → unchanged.
|
|
6972
7027
|
// Kill switch off → exact legacy resolveThreadId precedence.
|
|
7028
|
+
// Hoist the resolved origin turn so the obligation-close path (below) can
|
|
7029
|
+
// pass it into resolveCloseTarget as routedOriginId, closing re-presented
|
|
7030
|
+
// obligations even when the model omitted origin_turn_id (Fix 1/2).
|
|
7031
|
+
let replyRoutedOriginTurn: CurrentTurn | null = null
|
|
6973
7032
|
let threadId: number | undefined
|
|
6974
7033
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
6975
7034
|
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
@@ -6979,6 +7038,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6979
7038
|
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
6980
7039
|
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
|
|
6981
7040
|
const originTurn = echoedTurn ?? quotedTurn
|
|
7041
|
+
replyRoutedOriginTurn = originTurn ?? null
|
|
6982
7042
|
threadId = resolveAnswerThreadWithLog(
|
|
6983
7043
|
chat_id,
|
|
6984
7044
|
Number.isFinite(explicit as number) ? (explicit as number) : undefined,
|
|
@@ -7220,7 +7280,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
7220
7280
|
text: decision.mergedText,
|
|
7221
7281
|
disableNotification,
|
|
7222
7282
|
})
|
|
7223
|
-
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
7283
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
|
|
7224
7284
|
}
|
|
7225
7285
|
outboundDedup.record(
|
|
7226
7286
|
chat_id,
|
|
@@ -7573,7 +7633,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
7573
7633
|
finalizeStatusReaction(chat_id, threadId, 'done')
|
|
7574
7634
|
// PR2: close this origin's obligation on a SUBSTANTIVE final answer
|
|
7575
7635
|
// (after finalize so the reaction guard test's anchor window is stable).
|
|
7576
|
-
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
7636
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
|
|
7577
7637
|
}
|
|
7578
7638
|
// v0.13.30 follow-up — release the buffer gate on EVERY reply
|
|
7579
7639
|
// finalize, not just on `isFinalAnswerReply`. The narrow
|
|
@@ -7633,20 +7693,36 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7633
7693
|
// topic and a late stream-reply can't be stolen by a successor turn. DM:
|
|
7634
7694
|
// every tier undefined → unchanged. Kill switch off → legacy live-turn
|
|
7635
7695
|
// injection only.
|
|
7696
|
+
// Origin resolution is hoisted UNCONDITIONALLY (outside the
|
|
7697
|
+
// message_thread_id==null guard below) so the obligation-close path has
|
|
7698
|
+
// the correct routedOriginTurn even when the model explicitly passes
|
|
7699
|
+
// message_thread_id (forum-topic streams). Without this hoist, Fix 1
|
|
7700
|
+
// is a no-op for forum-topic streams — the origin is never resolved and
|
|
7701
|
+
// closeObligationOnSubstantiveReply falls through to the live-turn
|
|
7702
|
+
// fallback. Matches executeReply's unconditional resolution. Thread
|
|
7703
|
+
// injection still stays scoped to the message_thread_id==null branch —
|
|
7704
|
+
// only the obligation-close input changes.
|
|
7705
|
+
let streamRoutedOriginTurn: CurrentTurn | null = null
|
|
7706
|
+
// Track whether the origin was found via echo (for the routing log below).
|
|
7707
|
+
let streamOriginVia: 'echo' | 'quoted' | null = null
|
|
7708
|
+
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
7709
|
+
// Origin precedence: model echo first, then the framework-owned quoted
|
|
7710
|
+
// message_id as a deterministic fallback (mirrors executeReply).
|
|
7711
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
7712
|
+
const quotedTurn =
|
|
7713
|
+
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
7714
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
7715
|
+
streamRoutedOriginTurn = originTurn ?? null
|
|
7716
|
+
streamOriginVia = originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted'
|
|
7717
|
+
}
|
|
7636
7718
|
if (args.message_thread_id == null) {
|
|
7637
7719
|
let injected: number | undefined
|
|
7638
7720
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
7639
|
-
// Origin precedence: model echo first, then the framework-owned quoted
|
|
7640
|
-
// message_id as a deterministic fallback (mirrors executeReply).
|
|
7641
|
-
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
7642
|
-
const quotedTurn =
|
|
7643
|
-
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
7644
|
-
const originTurn = echoedTurn ?? quotedTurn
|
|
7645
7721
|
injected = resolveAnswerThreadWithLog(
|
|
7646
7722
|
String(args.chat_id),
|
|
7647
7723
|
undefined,
|
|
7648
|
-
|
|
7649
|
-
|
|
7724
|
+
streamRoutedOriginTurn,
|
|
7725
|
+
streamOriginVia,
|
|
7650
7726
|
turn,
|
|
7651
7727
|
'stream_reply',
|
|
7652
7728
|
)
|
|
@@ -7925,7 +8001,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7925
8001
|
disableNotification: args.disable_notification === true,
|
|
7926
8002
|
done: args.done === true,
|
|
7927
8003
|
})
|
|
7928
|
-
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
8004
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn)
|
|
7929
8005
|
// #1744 follow-up — stream_reply edge case. The first-emit gate at
|
|
7930
8006
|
// L5178 only clears silent-end state on the FIRST emit of a stream.
|
|
7931
8007
|
// If a stream's first emit was ack-shaped (disable_notification:true,
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
/** Log sink — stderr in production. */
|
|
37
|
+
log?: (line: string) => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ToolTextResult = { content: Array<{ type: string; text: string }> }
|
|
41
|
+
|
|
42
|
+
/** Default token resolver: vault broker get on `linear/<agent>/token`. */
|
|
43
|
+
export async function defaultResolveLinearToken(agent: string): Promise<LinearTokenResult> {
|
|
44
|
+
const key = `linear/${agent}/token`
|
|
45
|
+
const token = readVaultTokenFile(agent) ?? undefined
|
|
46
|
+
const result = await getViaBrokerStructured(key, token ? { token } : {})
|
|
47
|
+
if (result.kind === 'ok' && result.entry.kind === 'string') {
|
|
48
|
+
return { ok: true, token: result.entry.value }
|
|
49
|
+
}
|
|
50
|
+
if (result.kind === 'unreachable') return { ok: false, reason: 'unreachable' }
|
|
51
|
+
if (result.kind === 'not_found') return { ok: false, reason: 'not_found' }
|
|
52
|
+
if (result.kind === 'denied') return { ok: false, reason: 'denied' }
|
|
53
|
+
return { ok: false, reason: 'unknown' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Emit a Linear AgentActivity. Validates args, resolves the token, POSTs
|
|
58
|
+
* the `agentActivityCreate` mutation, and returns an MCP text result. Never
|
|
59
|
+
* throws on a vault/network failure — returns actionable error text so the
|
|
60
|
+
* agent can recover (e.g. via vault_request_access).
|
|
61
|
+
*/
|
|
62
|
+
export async function emitLinearAgentActivity(
|
|
63
|
+
args: Record<string, unknown>,
|
|
64
|
+
deps: LinearActivityDeps = {},
|
|
65
|
+
): Promise<ToolTextResult> {
|
|
66
|
+
const log = deps.log ?? ((s) => process.stderr.write(s))
|
|
67
|
+
|
|
68
|
+
const sessionId = args.agent_session_id as string | undefined
|
|
69
|
+
if (!sessionId) throw new Error('linear_agent_activity: agent_session_id is required')
|
|
70
|
+
const type = args.type as string | undefined
|
|
71
|
+
if (!type || !['thought', 'message', 'complete', 'error'].includes(type)) {
|
|
72
|
+
throw new Error('linear_agent_activity: type must be one of thought|message|complete|error')
|
|
73
|
+
}
|
|
74
|
+
const body = args.body as string | undefined
|
|
75
|
+
if (type !== 'complete' && (body == null || body === '')) {
|
|
76
|
+
throw new Error(`linear_agent_activity: body is required for type='${type}'`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const agent = deps.agent ?? process.env.SWITCHROOM_AGENT_NAME ?? '-'
|
|
80
|
+
const resolveToken = deps.resolveToken ?? defaultResolveLinearToken
|
|
81
|
+
const tokenResult = await resolveToken(agent)
|
|
82
|
+
if (!tokenResult.ok) {
|
|
83
|
+
if (tokenResult.reason === 'denied' || tokenResult.reason === 'not_found') {
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text:
|
|
89
|
+
`linear_agent_activity failed: no Linear token (vault ${tokenResult.reason}). ` +
|
|
90
|
+
`Call vault_request_access for key 'linear/${agent}/token' (scope read), then retry.`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: `linear_agent_activity failed: vault broker ${tokenResult.reason} resolving 'linear/${agent}/token'.`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// AgentActivity content discriminated by type. thought/message/error carry
|
|
106
|
+
// a body; complete is terminal with an optional summary body.
|
|
107
|
+
const content: Record<string, unknown> = { type }
|
|
108
|
+
if (body != null && body !== '') content.body = body
|
|
109
|
+
|
|
110
|
+
const mutation =
|
|
111
|
+
'mutation AgentActivityCreate($input: AgentActivityCreateInput!) { ' +
|
|
112
|
+
'agentActivityCreate(input: $input) { success agentActivity { id } } }'
|
|
113
|
+
const variables = { input: { agentSessionId: sessionId, content } }
|
|
114
|
+
|
|
115
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
116
|
+
let resp: Response
|
|
117
|
+
try {
|
|
118
|
+
resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': 'application/json',
|
|
122
|
+
Authorization: tokenResult.token,
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({ query: mutation, variables }),
|
|
125
|
+
})
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return {
|
|
128
|
+
content: [{ type: 'text', text: `linear_agent_activity failed: request error: ${(err as Error).message}` }],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!resp.ok) {
|
|
133
|
+
const txt = await resp.text().catch(() => '')
|
|
134
|
+
return {
|
|
135
|
+
content: [
|
|
136
|
+
{ type: 'text', text: `linear_agent_activity failed: Linear API ${resp.status}${txt ? ` — ${txt.slice(0, 200)}` : ''}` },
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let json: { data?: { agentActivityCreate?: { success?: boolean } }; errors?: Array<{ message?: string }> }
|
|
142
|
+
try {
|
|
143
|
+
json = (await resp.json()) as typeof json
|
|
144
|
+
} catch {
|
|
145
|
+
return { content: [{ type: 'text', text: 'linear_agent_activity failed: malformed Linear API response' }] }
|
|
146
|
+
}
|
|
147
|
+
if (json.errors && json.errors.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{ type: 'text', text: `linear_agent_activity failed: ${json.errors.map((e) => e.message ?? 'error').join('; ').slice(0, 300)}` },
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (json.data?.agentActivityCreate?.success === false) {
|
|
155
|
+
return { content: [{ type: 'text', text: 'linear_agent_activity failed: Linear reported success=false' }] }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
log(`telegram gateway: linear_agent_activity: emitted type=${type} session=${sessionId} agent=${agent}\n`)
|
|
159
|
+
return { content: [{ type: 'text', text: `Linear ${type} emitted on session ${sessionId}` }] }
|
|
160
|
+
}
|
|
@@ -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
|
|
@@ -55,6 +55,12 @@ export interface Obligation {
|
|
|
55
55
|
* that re-stamps this once, and representCount is capped, so the ladder still
|
|
56
56
|
* terminates. Durable (part of the snapshot) so the grace survives restart. */
|
|
57
57
|
lastTurnEndedAt?: number
|
|
58
|
+
/** Wall-clock ms this obligation was most recently re-presented. Drives the
|
|
59
|
+
* per-represent grace: a freshly re-presented obligation is skipped until
|
|
60
|
+
* at least `representGraceMs` has elapsed, preventing immediate second
|
|
61
|
+
* re-present/escalate when the sweep fires < 5s later. Durable (part of the
|
|
62
|
+
* snapshot) so the grace window survives a restart. */
|
|
63
|
+
lastRepresentedAt?: number
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/** What the gateway should do for the oldest open obligation at an idle boundary. */
|
|
@@ -202,20 +208,30 @@ export class ObligationLedger {
|
|
|
202
208
|
* pathologically-stuck/leaked worker cannot suppress the escalation forever —
|
|
203
209
|
* once openedAt+backgroundGraceMs passes, the obligation is acted on regardless
|
|
204
210
|
* of work state, and the FSM still terminates.
|
|
211
|
+
*
|
|
212
|
+
* PER-REPRESENT GRACE (opts.representGraceMs > 0): an obligation that was just
|
|
213
|
+
* re-presented is ineligible until at least `representGraceMs` ms have elapsed
|
|
214
|
+
* since `lastRepresentedAt`. Without this, the 5s sweep can fire again before
|
|
215
|
+
* the re-presented turn even reaches the agent, burning the represent budget
|
|
216
|
+
* immediately and producing back-to-back escalations on the same message.
|
|
205
217
|
*/
|
|
206
218
|
decideAtIdle(opts?: {
|
|
207
219
|
now: number
|
|
208
220
|
graceMs: number
|
|
209
221
|
backgroundWorkActive?: boolean
|
|
210
222
|
backgroundGraceMs?: number
|
|
223
|
+
representGraceMs?: number
|
|
211
224
|
}): LedgerDecision {
|
|
212
|
-
const useEligible =
|
|
225
|
+
const useEligible =
|
|
226
|
+
opts != null &&
|
|
227
|
+
(opts.graceMs > 0 || opts.backgroundWorkActive === true || (opts.representGraceMs ?? 0) > 0)
|
|
213
228
|
const o = useEligible
|
|
214
229
|
? this.oldestEligible(
|
|
215
230
|
opts!.now,
|
|
216
231
|
opts!.graceMs,
|
|
217
232
|
opts!.backgroundWorkActive === true,
|
|
218
233
|
opts!.backgroundGraceMs ?? 0,
|
|
234
|
+
opts!.representGraceMs ?? 0,
|
|
219
235
|
)
|
|
220
236
|
: this.oldest()
|
|
221
237
|
if (o === undefined) return { action: 'none' }
|
|
@@ -224,24 +240,30 @@ export class ObligationLedger {
|
|
|
224
240
|
}
|
|
225
241
|
|
|
226
242
|
/** The oldest open obligation that is currently ELIGIBLE to act on — i.e. NOT
|
|
227
|
-
* within
|
|
243
|
+
* within any grace window:
|
|
228
244
|
* - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
|
|
229
245
|
* obligation with no lastTurnEndedAt can't have a trailing answer, so it is
|
|
230
|
-
* always eligible on this axis);
|
|
246
|
+
* always eligible on this axis);
|
|
231
247
|
* - background-work grace: when `backgroundWorkActive`, it was opened <
|
|
232
248
|
* `backgroundGraceMs` ago (genuine in-flight autonomous work — bounded by
|
|
233
|
-
* the ceiling so a stale/leaked worker can't suppress escalation forever)
|
|
249
|
+
* the ceiling so a stale/leaked worker can't suppress escalation forever);
|
|
250
|
+
* - per-represent grace: it was re-presented < `representGraceMs` ago (prevents
|
|
251
|
+
* a 5s sweep tick from immediately firing again on the same obligation before
|
|
252
|
+
* the re-presented turn even reaches the agent). */
|
|
234
253
|
private oldestEligible(
|
|
235
254
|
now: number,
|
|
236
255
|
graceMs: number,
|
|
237
256
|
backgroundWorkActive: boolean,
|
|
238
257
|
backgroundGraceMs: number,
|
|
258
|
+
representGraceMs: number,
|
|
239
259
|
): Obligation | undefined {
|
|
240
260
|
let best: Obligation | undefined
|
|
241
261
|
for (const o of this.open.values()) {
|
|
242
262
|
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
|
|
243
263
|
if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
|
|
244
264
|
continue // in-flight autonomous work, bounded by the ceiling
|
|
265
|
+
if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
|
|
266
|
+
continue // per-represent grace: sweep fired before re-presented turn landed
|
|
245
267
|
if (best === undefined || o.openedAt < best.openedAt) best = o
|
|
246
268
|
}
|
|
247
269
|
return best
|
|
@@ -259,29 +281,48 @@ export class ObligationLedger {
|
|
|
259
281
|
/**
|
|
260
282
|
* Decide which obligation a substantive reply discharges — DETERMINISTICALLY,
|
|
261
283
|
* holding for any model behavior:
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
284
|
+
*
|
|
285
|
+
* 1. `echoedTurnId` (model echoed origin_turn_id back) → authoritative; close
|
|
286
|
+
* exactly that (a no-op via close() if it isn't actually open).
|
|
287
|
+
* 2. `routedOriginId` (gateway-resolved origin from quote/via=quoted or
|
|
288
|
+
* via=live routing) → treat as the definitive target when present; this
|
|
289
|
+
* makes answers to re-presented messages close their obligation even when
|
|
290
|
+
* no model echo was provided and even with >1 open obligation (the routed
|
|
291
|
+
* origin IS the answer's origin — this is deterministic, not a guess).
|
|
292
|
+
* The 713/715 invariant still holds: the gateway only passes a routedOriginId
|
|
293
|
+
* that it has positively resolved as the reply's origin (quote resolution,
|
|
294
|
+
* via=quoted); it never passes the LIVE turn id here when a different
|
|
295
|
+
* obligation is the resolved origin.
|
|
296
|
+
* 3. else, close the live turn's own obligation when that turn itself is open —
|
|
297
|
+
* this is unambiguous (the reply happened IN that turn, so the turn's own
|
|
298
|
+
* obligation IS the right target). The 713/715 wrong-close protection is
|
|
299
|
+
* preserved by ordering: routed/echoed origin (steps 1/2) wins first;
|
|
300
|
+
* live-turn fallback (step 3) only fires when no routed origin resolved, AND
|
|
301
|
+
* only for the live turn's OWN obligation (not another open obligation). A
|
|
302
|
+
* reply answering message A landing while currentTurn=B must STILL not close B
|
|
303
|
+
* — only steps 1/2 can close A in that case. With multiple open obligations
|
|
304
|
+
* and no routed origin, the LIVE turn's own obligation is the safe default
|
|
305
|
+
* (relaxed from size==1 which wrongly blocked it when a second message arrived
|
|
306
|
+
* meanwhile). Returns the id to close, or null.
|
|
270
307
|
*/
|
|
271
308
|
resolveCloseTarget(
|
|
272
309
|
echoedTurnId: string | null | undefined,
|
|
273
310
|
liveTurnId: string | null | undefined,
|
|
311
|
+
routedOriginId?: string | null,
|
|
274
312
|
): string | null {
|
|
275
313
|
if (echoedTurnId != null) return echoedTurnId
|
|
276
|
-
if (
|
|
314
|
+
if (routedOriginId != null) return routedOriginId
|
|
315
|
+
if (liveTurnId != null && this.open.has(liveTurnId)) return liveTurnId
|
|
277
316
|
return null
|
|
278
317
|
}
|
|
279
318
|
|
|
280
|
-
/** Record that an obligation was just re-presented (bumps representCount
|
|
281
|
-
|
|
319
|
+
/** Record that an obligation was just re-presented (bumps representCount, stamps
|
|
320
|
+
* lastRepresentedAt for the per-represent grace window). */
|
|
321
|
+
markRepresented(originTurnId: string, now = Date.now()): number {
|
|
282
322
|
const o = this.open.get(originTurnId)
|
|
283
323
|
if (o === undefined) return 0
|
|
284
324
|
o.representCount += 1
|
|
325
|
+
o.lastRepresentedAt = now
|
|
285
326
|
this.persist()
|
|
286
327
|
return o.representCount
|
|
287
328
|
}
|