@hellcoder/companion 0.96.0
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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// ─── Linear Agent Session Bridge ──────────────────────────────────────────────
|
|
2
|
+
// Bridges Linear Agent Interaction SDK sessions with Companion CLI sessions.
|
|
3
|
+
// When Linear sends an AgentSessionEvent webhook, this module:
|
|
4
|
+
// 1. Acknowledges immediately (post a "thought" activity within 10s)
|
|
5
|
+
// 2. Finds the right Companion agent to handle it (by oauthClientId)
|
|
6
|
+
// 3. Launches a CLI session via AgentExecutor
|
|
7
|
+
// 4. Relays CLI output back to Linear as agent activities
|
|
8
|
+
// 5. Relays TodoWrite → Linear plan checklist
|
|
9
|
+
// 6. Periodically flushes intermediate progress as ephemeral thoughts
|
|
10
|
+
|
|
11
|
+
import type { AgentExecutor } from "./agent-executor.js";
|
|
12
|
+
import type { WsBridge } from "./ws-bridge.js";
|
|
13
|
+
import type { BrowserIncomingMessage } from "./session-types.js";
|
|
14
|
+
import type { AgentConfig } from "./agent-types.js";
|
|
15
|
+
import * as agentStore from "./agent-store.js";
|
|
16
|
+
import * as linearAgent from "./linear-agent.js";
|
|
17
|
+
import type { AgentSessionEventPayload, AgentPlanItem, LinearOAuthCredentials } from "./linear-agent.js";
|
|
18
|
+
import { buildLinearOAuthSystemPrompt } from "./linear-prompt-builder.js";
|
|
19
|
+
import { getSettings } from "./settings-manager.js";
|
|
20
|
+
import { companionBus } from "./event-bus.js";
|
|
21
|
+
import { findOAuthConnectionByClientId, getOAuthConnection, updateOAuthConnection } from "./linear-oauth-connections.js";
|
|
22
|
+
|
|
23
|
+
/** Interval (ms) for flushing intermediate progress as ephemeral thoughts. */
|
|
24
|
+
const PROGRESS_FLUSH_INTERVAL_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
/** Safely extract the content array from an assistant-type message. */
|
|
27
|
+
function getAssistantContent(msg: BrowserIncomingMessage): unknown[] | null {
|
|
28
|
+
if (msg.type !== "assistant") return null;
|
|
29
|
+
// Assistant messages carry content blocks at msg.message.content
|
|
30
|
+
const raw = msg as Record<string, unknown>;
|
|
31
|
+
const message = raw.message;
|
|
32
|
+
if (!message || typeof message !== "object") return null;
|
|
33
|
+
const content = (message as Record<string, unknown>).content;
|
|
34
|
+
return Array.isArray(content) ? content : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Extract text from assistant message content blocks */
|
|
38
|
+
function extractTextFromAssistant(msg: BrowserIncomingMessage): string {
|
|
39
|
+
const content = getAssistantContent(msg);
|
|
40
|
+
if (!content) return "";
|
|
41
|
+
return content
|
|
42
|
+
.filter((b): b is { type: string; text: string } =>
|
|
43
|
+
typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "text" && typeof (b as Record<string, unknown>).text === "string")
|
|
44
|
+
.map((b) => b.text)
|
|
45
|
+
.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Extract text deltas from stream events. */
|
|
49
|
+
function extractTextDeltaFromStreamEvent(msg: BrowserIncomingMessage): string {
|
|
50
|
+
if (msg.type !== "stream_event") return "";
|
|
51
|
+
const event = msg.event as Record<string, unknown> | undefined;
|
|
52
|
+
if (!event || event.type !== "content_block_delta") return "";
|
|
53
|
+
const delta = event.delta as Record<string, unknown> | undefined;
|
|
54
|
+
if (!delta || delta.type !== "text_delta" || typeof delta.text !== "string") return "";
|
|
55
|
+
return delta.text;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Extract all tool use blocks from assistant message content (with raw input for plan extraction) */
|
|
59
|
+
function extractToolUses(msg: BrowserIncomingMessage): Array<{ id?: string; name: string; input: string; rawInput?: Record<string, unknown> }> {
|
|
60
|
+
const content = getAssistantContent(msg);
|
|
61
|
+
if (!content) return [];
|
|
62
|
+
return content
|
|
63
|
+
.filter((b): b is { type: string; id?: string; name: string; input?: Record<string, unknown> } =>
|
|
64
|
+
typeof b === "object" && b !== null
|
|
65
|
+
&& (b as Record<string, unknown>).type === "tool_use"
|
|
66
|
+
&& typeof (b as Record<string, unknown>).name === "string")
|
|
67
|
+
.map((toolBlock) => ({
|
|
68
|
+
id: typeof toolBlock.id === "string" ? toolBlock.id : undefined,
|
|
69
|
+
name: toolBlock.name,
|
|
70
|
+
input: toolBlock.input ? JSON.stringify(toolBlock.input).slice(0, 200) : "",
|
|
71
|
+
rawInput: toolBlock.input,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract tool_result blocks from assistant message content. */
|
|
76
|
+
function extractToolResults(msg: BrowserIncomingMessage): Array<{ tool_use_id: string; content: string }> {
|
|
77
|
+
const content = getAssistantContent(msg);
|
|
78
|
+
if (!content) return [];
|
|
79
|
+
return content
|
|
80
|
+
.filter((b): b is { type: string; tool_use_id: string; content?: unknown } =>
|
|
81
|
+
typeof b === "object" && b !== null
|
|
82
|
+
&& (b as Record<string, unknown>).type === "tool_result"
|
|
83
|
+
&& typeof (b as Record<string, unknown>).tool_use_id === "string")
|
|
84
|
+
.map((block) => ({
|
|
85
|
+
tool_use_id: block.tool_use_id,
|
|
86
|
+
content: typeof block.content === "string"
|
|
87
|
+
? block.content.slice(0, 500)
|
|
88
|
+
: Array.isArray(block.content)
|
|
89
|
+
? (block.content as Array<{ type?: string; text?: string }>)
|
|
90
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
91
|
+
.map((c) => c.text)
|
|
92
|
+
.join("\n")
|
|
93
|
+
.slice(0, 500)
|
|
94
|
+
: "",
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Map TodoWrite status values to Linear plan item status values. */
|
|
99
|
+
function mapTodoStatus(status: string): AgentPlanItem["status"] {
|
|
100
|
+
if (status === "in_progress") return "inProgress";
|
|
101
|
+
if (status === "completed") return "completed";
|
|
102
|
+
if (status === "canceled") return "canceled";
|
|
103
|
+
return "pending";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Build an enriched prompt from the webhook payload's structured data. */
|
|
107
|
+
export function buildPrompt(payload: AgentSessionEventPayload): string {
|
|
108
|
+
const parts: string[] = [];
|
|
109
|
+
const issue = payload.agentSession?.issue;
|
|
110
|
+
const comment = payload.agentSession?.comment;
|
|
111
|
+
|
|
112
|
+
if (issue) {
|
|
113
|
+
parts.push(`[Linear Issue ${issue.identifier}] ${issue.title}`);
|
|
114
|
+
parts.push(`URL: ${issue.url}`);
|
|
115
|
+
if (issue.description) {
|
|
116
|
+
parts.push(`\nDescription:\n${issue.description}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (comment?.body) {
|
|
121
|
+
parts.push(`\nUser comment:\n${comment.body}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (payload.previousComments?.length) {
|
|
125
|
+
const commentLines = payload.previousComments.map((c) => `- ${c.body}`).join("\n");
|
|
126
|
+
parts.push(`\nThread context (${payload.previousComments.length} previous comments):\n${commentLines}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (payload.guidance) {
|
|
130
|
+
parts.push(`\nAgent guidance:\n${payload.guidance}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const promptContext = payload.promptContext ?? "";
|
|
134
|
+
|
|
135
|
+
// If we have structured context, prepend it before the XML prompt context
|
|
136
|
+
if (parts.length > 0) {
|
|
137
|
+
return parts.join("\n") + "\n\n---\n\n" + promptContext;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return promptContext;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export class LinearAgentBridge {
|
|
144
|
+
private agentExecutor: AgentExecutor;
|
|
145
|
+
private wsBridge: WsBridge;
|
|
146
|
+
|
|
147
|
+
/** Maps Linear agent session IDs to Companion session info */
|
|
148
|
+
private sessionMap = new Map<string, { companionSessionId: string; agentId: string }>();
|
|
149
|
+
/** Maps Companion session IDs back to Linear agent session IDs */
|
|
150
|
+
private reverseMap = new Map<string, string>();
|
|
151
|
+
/** Track active session unsubscribers for cleanup */
|
|
152
|
+
private sessionCleanups = new Map<string, Array<() => void>>();
|
|
153
|
+
|
|
154
|
+
constructor(agentExecutor: AgentExecutor, wsBridge: WsBridge) {
|
|
155
|
+
this.agentExecutor = agentExecutor;
|
|
156
|
+
this.wsBridge = wsBridge;
|
|
157
|
+
this.restoreSessionMaps();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Restore Linear<->Companion session mappings from persisted session state. */
|
|
161
|
+
private restoreSessionMaps(): void {
|
|
162
|
+
const mappings = this.wsBridge.getLinearSessionMappings();
|
|
163
|
+
for (const { sessionId, linearSessionId } of mappings) {
|
|
164
|
+
// Try to find the agent for this session from the session's execution history
|
|
165
|
+
// Fallback: find any enabled Linear agent
|
|
166
|
+
const agentId = this.findAnyLinearAgentId() || "";
|
|
167
|
+
this.sessionMap.set(linearSessionId, { companionSessionId: sessionId, agentId });
|
|
168
|
+
this.reverseMap.set(sessionId, linearSessionId);
|
|
169
|
+
}
|
|
170
|
+
if (mappings.length > 0) {
|
|
171
|
+
console.log(`[linear-agent-bridge] Restored ${mappings.length} session mapping(s) from disk`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Handle an incoming AgentSessionEvent from Linear. */
|
|
176
|
+
async handleEvent(payload: AgentSessionEventPayload): Promise<void> {
|
|
177
|
+
if (payload.action === "created") {
|
|
178
|
+
await this.handleCreated(payload);
|
|
179
|
+
} else if (payload.action === "prompted") {
|
|
180
|
+
await this.handlePrompted(payload);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Handle a new agent session (user mentioned or assigned the agent). */
|
|
185
|
+
private async handleCreated(payload: AgentSessionEventPayload): Promise<void> {
|
|
186
|
+
const linearSessionId = payload.agentSession?.id;
|
|
187
|
+
const enrichedPrompt = buildPrompt(payload);
|
|
188
|
+
|
|
189
|
+
if (!linearSessionId) {
|
|
190
|
+
console.error("[linear-agent-bridge] No session ID found in payload:", JSON.stringify(payload));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(`[linear-agent-bridge] New agent session: ${linearSessionId}`);
|
|
195
|
+
|
|
196
|
+
// 1. Find the right Companion agent by OAuth client ID
|
|
197
|
+
const agent = this.findLinearAgentByClientId(payload.oauthClientId);
|
|
198
|
+
if (!agent) {
|
|
199
|
+
// Can't post activity without credentials — just log
|
|
200
|
+
console.error(`[linear-agent-bridge] No agent configured for oauthClientId: ${payload.oauthClientId}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const creds = this.getCredentials(agent);
|
|
205
|
+
const onTokensRefreshed = this.createTokenRefreshCallback(agent.id);
|
|
206
|
+
const oauthConn = agent.triggers?.linear?.oauthConnectionId
|
|
207
|
+
? getOAuthConnection(agent.triggers.linear.oauthConnectionId)
|
|
208
|
+
: null;
|
|
209
|
+
const linearAccessEnv = oauthConn?.accessToken
|
|
210
|
+
? {
|
|
211
|
+
LINEAR_OAUTH_ACCESS_TOKEN: oauthConn.accessToken,
|
|
212
|
+
LINEAR_API_KEY: oauthConn.accessToken,
|
|
213
|
+
}
|
|
214
|
+
: undefined;
|
|
215
|
+
const linearSystemPrompt = oauthConn?.accessToken
|
|
216
|
+
? buildLinearOAuthSystemPrompt({ name: oauthConn.name })
|
|
217
|
+
: undefined;
|
|
218
|
+
|
|
219
|
+
// 2. Immediately acknowledge with a thought (must be within 10s)
|
|
220
|
+
linearAgent.postActivity(creds, linearSessionId, {
|
|
221
|
+
type: "thought",
|
|
222
|
+
body: "Starting Companion session...",
|
|
223
|
+
ephemeral: true,
|
|
224
|
+
}, onTokensRefreshed).catch((err) => console.error("[linear-agent-bridge] Failed to post initial thought:", err));
|
|
225
|
+
|
|
226
|
+
// 3. Launch the CLI session with enriched prompt
|
|
227
|
+
try {
|
|
228
|
+
const sessionInfo = await this.agentExecutor.executeAgent(agent.id, enrichedPrompt, {
|
|
229
|
+
force: true,
|
|
230
|
+
triggerType: "linear",
|
|
231
|
+
additionalEnv: linearAccessEnv,
|
|
232
|
+
systemPrompt: linearSystemPrompt,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!sessionInfo) {
|
|
236
|
+
// Check if the agent is already running (overlap prevention)
|
|
237
|
+
const agentData = agentStore.getAgent(agent.id);
|
|
238
|
+
const isOverlap = agentData?.lastSessionId && this.wsBridge.getSession(agentData.lastSessionId);
|
|
239
|
+
await linearAgent.postActivity(creds, linearSessionId, {
|
|
240
|
+
type: "error",
|
|
241
|
+
body: isOverlap
|
|
242
|
+
? `Agent "${agent.name}" is currently busy with another session. Please wait for it to complete.`
|
|
243
|
+
: "Failed to start Companion session. Check The Companion for details.",
|
|
244
|
+
}, onTokensRefreshed);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const companionSessionId = sessionInfo.sessionId;
|
|
249
|
+
|
|
250
|
+
// 4. Map sessions and persist (include agentId for follow-up credential lookup)
|
|
251
|
+
this.sessionMap.set(linearSessionId, { companionSessionId, agentId: agent.id });
|
|
252
|
+
this.reverseMap.set(companionSessionId, linearSessionId);
|
|
253
|
+
this.wsBridge.setLinearSessionId(companionSessionId, linearSessionId);
|
|
254
|
+
|
|
255
|
+
// 5. Set external URL linking back to Companion
|
|
256
|
+
const settings = getSettings();
|
|
257
|
+
const baseUrl = settings.publicUrl || "http://localhost:3456";
|
|
258
|
+
linearAgent.updateSessionUrls(
|
|
259
|
+
creds,
|
|
260
|
+
linearSessionId,
|
|
261
|
+
[{ label: "Companion Session", url: `${baseUrl}/#/session/${companionSessionId}` }],
|
|
262
|
+
onTokensRefreshed,
|
|
263
|
+
).catch((err) => console.error("[linear-agent-bridge] Failed to set external URLs:", err));
|
|
264
|
+
|
|
265
|
+
// 6. Set up response relay (pass agentId for credential lookup)
|
|
266
|
+
this.setupRelay(linearSessionId, companionSessionId, agent.id);
|
|
267
|
+
|
|
268
|
+
await linearAgent.postActivity(creds, linearSessionId, {
|
|
269
|
+
type: "thought",
|
|
270
|
+
body: `Agent "${agent.name}" session started. Working on it...`,
|
|
271
|
+
}, onTokensRefreshed);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error("[linear-agent-bridge] Failed to start session:", err);
|
|
274
|
+
await linearAgent.postActivity(creds, linearSessionId, {
|
|
275
|
+
type: "error",
|
|
276
|
+
body: `Failed to start session: ${err instanceof Error ? err.message : String(err)}`,
|
|
277
|
+
}, onTokensRefreshed);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Handle a follow-up prompt in an existing agent session. */
|
|
282
|
+
private async handlePrompted(payload: AgentSessionEventPayload): Promise<void> {
|
|
283
|
+
const linearSessionId = payload.agentSession?.id;
|
|
284
|
+
|
|
285
|
+
// Extract follow-up message from multiple possible locations:
|
|
286
|
+
// 1. agentActivity.content.body — the nested content from the prompted activity
|
|
287
|
+
// 2. agentActivity.body — direct body (alternative format)
|
|
288
|
+
// 3. agentSession.comment.body — the comment that triggered the follow-up
|
|
289
|
+
// 4. promptContext — the full XML context (last resort)
|
|
290
|
+
const message = (
|
|
291
|
+
payload.agentActivity?.content?.body
|
|
292
|
+
|| payload.agentActivity?.body
|
|
293
|
+
|| payload.agentSession?.comment?.body
|
|
294
|
+
|| payload.promptContext
|
|
295
|
+
|| ""
|
|
296
|
+
).trim();
|
|
297
|
+
|
|
298
|
+
if (!linearSessionId) {
|
|
299
|
+
console.error("[linear-agent-bridge] No session ID found in prompted payload:", JSON.stringify(payload));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Skip empty follow-ups — no point injecting a blank message
|
|
304
|
+
if (!message) {
|
|
305
|
+
console.log(`[linear-agent-bridge] Ignoring empty follow-up for ${linearSessionId}`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const mapping = this.sessionMap.get(linearSessionId);
|
|
310
|
+
if (!mapping) {
|
|
311
|
+
// Session not found — might have expired. Create a new one with the follow-up message.
|
|
312
|
+
console.log(`[linear-agent-bridge] No session mapping for ${linearSessionId}, creating new`);
|
|
313
|
+
await this.handleCreated({
|
|
314
|
+
...payload,
|
|
315
|
+
action: "created",
|
|
316
|
+
promptContext: message,
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const { companionSessionId, agentId } = mapping;
|
|
322
|
+
|
|
323
|
+
console.log(`[linear-agent-bridge] Follow-up for session ${linearSessionId} → ${companionSessionId}`);
|
|
324
|
+
|
|
325
|
+
// Check if the Companion session is still alive before injecting
|
|
326
|
+
const session = this.wsBridge.getSession(companionSessionId);
|
|
327
|
+
if (!session) {
|
|
328
|
+
console.log(`[linear-agent-bridge] Session ${companionSessionId} is dead, creating new`);
|
|
329
|
+
// Clean up stale mapping
|
|
330
|
+
this.sessionMap.delete(linearSessionId);
|
|
331
|
+
this.reverseMap.delete(companionSessionId);
|
|
332
|
+
this.cleanupRelay(companionSessionId);
|
|
333
|
+
// Start a new session with the follow-up message as prompt context
|
|
334
|
+
await this.handleCreated({
|
|
335
|
+
...payload,
|
|
336
|
+
action: "created",
|
|
337
|
+
promptContext: message,
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Look up agent for credentials
|
|
343
|
+
const agent = agentStore.getAgent(agentId);
|
|
344
|
+
const creds = agent ? this.getCredentials(agent) : null;
|
|
345
|
+
const onTokensRefreshed = this.createTokenRefreshCallback(agentId);
|
|
346
|
+
|
|
347
|
+
// Post acknowledgement
|
|
348
|
+
if (creds) {
|
|
349
|
+
linearAgent.postActivity(creds, linearSessionId, {
|
|
350
|
+
type: "thought",
|
|
351
|
+
body: "Processing follow-up...",
|
|
352
|
+
ephemeral: true,
|
|
353
|
+
}, onTokensRefreshed).catch((err) => console.error("[linear-agent-bridge] Failed to post thought:", err));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Re-establish relay for the new turn (resets pendingText accumulator).
|
|
357
|
+
// setupRelay calls cleanupRelay internally first, so old listeners are removed.
|
|
358
|
+
this.setupRelay(linearSessionId, companionSessionId, agentId);
|
|
359
|
+
|
|
360
|
+
// Inject user message into the running Companion session
|
|
361
|
+
this.wsBridge.injectUserMessage(companionSessionId, message);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Set up bidirectional relay between a Companion session and a Linear agent session. */
|
|
365
|
+
private setupRelay(linearSessionId: string, companionSessionId: string, agentId: string): void {
|
|
366
|
+
// Clean up any existing relay
|
|
367
|
+
this.cleanupRelay(companionSessionId);
|
|
368
|
+
|
|
369
|
+
// Look up current agent credentials for this relay session
|
|
370
|
+
const agent = agentStore.getAgent(agentId);
|
|
371
|
+
const creds = agent ? this.getCredentials(agent) : null;
|
|
372
|
+
if (!creds) {
|
|
373
|
+
console.error(`[linear-agent-bridge] Cannot setup relay — agent ${agentId} not found`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const cleanups: Array<() => void> = [];
|
|
378
|
+
let pendingText = "";
|
|
379
|
+
let streamedTextForCurrentMessage = "";
|
|
380
|
+
// Track pending tool uses by ID so we can post results when they come back
|
|
381
|
+
const pendingToolUseIds = new Map<string, string>(); // tool_use_id → tool name
|
|
382
|
+
const onTokensRefreshed = this.createTokenRefreshCallback(agentId);
|
|
383
|
+
|
|
384
|
+
const appendPendingText = (text: string) => {
|
|
385
|
+
if (!text) return;
|
|
386
|
+
pendingText += (pendingText ? "\n" : "") + text;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const unsubStream = companionBus.on("message:stream_event", ({ sessionId, message }) => {
|
|
390
|
+
if (sessionId !== companionSessionId) return;
|
|
391
|
+
const delta = extractTextDeltaFromStreamEvent(message);
|
|
392
|
+
if (!delta) return;
|
|
393
|
+
|
|
394
|
+
if (!streamedTextForCurrentMessage) {
|
|
395
|
+
appendPendingText(delta);
|
|
396
|
+
} else {
|
|
397
|
+
pendingText += delta;
|
|
398
|
+
}
|
|
399
|
+
streamedTextForCurrentMessage += delta;
|
|
400
|
+
});
|
|
401
|
+
cleanups.push(unsubStream);
|
|
402
|
+
|
|
403
|
+
// Relay assistant messages → Linear activities
|
|
404
|
+
const unsubAssistant = companionBus.on("message:assistant", ({ sessionId, message: msg }) => {
|
|
405
|
+
if (sessionId !== companionSessionId) return;
|
|
406
|
+
const text = extractTextFromAssistant(msg);
|
|
407
|
+
if (text) {
|
|
408
|
+
if (streamedTextForCurrentMessage && text.startsWith(streamedTextForCurrentMessage)) {
|
|
409
|
+
const suffix = text.slice(streamedTextForCurrentMessage.length);
|
|
410
|
+
if (suffix) {
|
|
411
|
+
pendingText += suffix;
|
|
412
|
+
}
|
|
413
|
+
} else if (!streamedTextForCurrentMessage || text !== streamedTextForCurrentMessage) {
|
|
414
|
+
appendPendingText(text);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
streamedTextForCurrentMessage = "";
|
|
418
|
+
|
|
419
|
+
// Relay all tool use blocks as action activities (supports parallel tool calls)
|
|
420
|
+
for (const tool of extractToolUses(msg)) {
|
|
421
|
+
// Track tool use IDs for result matching (id is on the block itself)
|
|
422
|
+
if (tool.id) {
|
|
423
|
+
pendingToolUseIds.set(tool.id, tool.name);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
linearAgent.postActivity(creds, linearSessionId, {
|
|
427
|
+
type: "action",
|
|
428
|
+
action: tool.name,
|
|
429
|
+
parameter: tool.input || undefined,
|
|
430
|
+
ephemeral: true,
|
|
431
|
+
}, onTokensRefreshed).catch((err) => console.error("[linear-agent-bridge] Failed to post action:", err));
|
|
432
|
+
|
|
433
|
+
// Relay TodoWrite → Linear plan checklist
|
|
434
|
+
if (tool.name === "TodoWrite" && tool.rawInput) {
|
|
435
|
+
const todos = (tool.rawInput as { todos?: unknown[] }).todos;
|
|
436
|
+
if (Array.isArray(todos)) {
|
|
437
|
+
const planItems: AgentPlanItem[] = todos
|
|
438
|
+
.filter((t): t is { content: string; status: string } =>
|
|
439
|
+
typeof t === "object" && t !== null
|
|
440
|
+
&& typeof (t as Record<string, unknown>).content === "string"
|
|
441
|
+
&& typeof (t as Record<string, unknown>).status === "string")
|
|
442
|
+
.map((t) => ({
|
|
443
|
+
content: t.content,
|
|
444
|
+
status: mapTodoStatus(t.status),
|
|
445
|
+
}));
|
|
446
|
+
if (planItems.length > 0) {
|
|
447
|
+
linearAgent.updateSessionPlan(creds, linearSessionId, planItems, onTokensRefreshed)
|
|
448
|
+
.catch((err) => console.error("[linear-agent-bridge] Failed to update plan:", err));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Relay tool results back to Linear as action activities with result field
|
|
455
|
+
for (const result of extractToolResults(msg)) {
|
|
456
|
+
const toolName = pendingToolUseIds.get(result.tool_use_id);
|
|
457
|
+
if (toolName && result.content) {
|
|
458
|
+
pendingToolUseIds.delete(result.tool_use_id);
|
|
459
|
+
linearAgent.postActivity(creds, linearSessionId, {
|
|
460
|
+
type: "action",
|
|
461
|
+
action: toolName,
|
|
462
|
+
result: result.content,
|
|
463
|
+
ephemeral: true,
|
|
464
|
+
}, onTokensRefreshed).catch((err) => console.error("[linear-agent-bridge] Failed to post tool result:", err));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
cleanups.push(unsubAssistant);
|
|
469
|
+
|
|
470
|
+
// Intermediate progress flush — post accumulated text as ephemeral thoughts
|
|
471
|
+
// every PROGRESS_FLUSH_INTERVAL_MS so Linear doesn't look stalled.
|
|
472
|
+
let lastFlushedLength = 0;
|
|
473
|
+
const progressTimer = setInterval(() => {
|
|
474
|
+
if (pendingText.length > lastFlushedLength) {
|
|
475
|
+
const newText = pendingText.slice(lastFlushedLength);
|
|
476
|
+
lastFlushedLength = pendingText.length;
|
|
477
|
+
linearAgent.postActivity(creds, linearSessionId, {
|
|
478
|
+
type: "thought",
|
|
479
|
+
body: newText.slice(0, 2000),
|
|
480
|
+
ephemeral: true,
|
|
481
|
+
}, onTokensRefreshed).catch((err) => console.error("[linear-agent-bridge] Failed to post progress:", err));
|
|
482
|
+
}
|
|
483
|
+
}, PROGRESS_FLUSH_INTERVAL_MS);
|
|
484
|
+
cleanups.push(() => clearInterval(progressTimer));
|
|
485
|
+
|
|
486
|
+
// Relay turn completion → post accumulated text as a response activity.
|
|
487
|
+
// Do NOT clean up session mappings or relay — the Linear agent session
|
|
488
|
+
// is long-lived and supports multi-turn follow-ups via "prompted" events.
|
|
489
|
+
const unsubResult = companionBus.on("message:result", async ({ sessionId }) => {
|
|
490
|
+
if (sessionId !== companionSessionId) return;
|
|
491
|
+
if (pendingText) {
|
|
492
|
+
try {
|
|
493
|
+
await linearAgent.postActivity(creds, linearSessionId, {
|
|
494
|
+
type: "response",
|
|
495
|
+
body: pendingText,
|
|
496
|
+
}, onTokensRefreshed);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
console.error("[linear-agent-bridge] Failed to post response:", err);
|
|
499
|
+
}
|
|
500
|
+
pendingText = "";
|
|
501
|
+
lastFlushedLength = 0;
|
|
502
|
+
streamedTextForCurrentMessage = "";
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
cleanups.push(unsubResult);
|
|
506
|
+
|
|
507
|
+
// Auto-cleanup relay when the Companion session exits, restoring the
|
|
508
|
+
// implicit cleanup that the old per-session WsBridge listener Maps provided.
|
|
509
|
+
const unsubExited = companionBus.on("session:exited", ({ sessionId }) => {
|
|
510
|
+
if (sessionId === companionSessionId) {
|
|
511
|
+
this.cleanupRelay(companionSessionId);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
cleanups.push(unsubExited);
|
|
515
|
+
|
|
516
|
+
this.sessionCleanups.set(companionSessionId, cleanups);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Clean up listeners for a session. */
|
|
520
|
+
private cleanupRelay(companionSessionId: string): void {
|
|
521
|
+
const cleanups = this.sessionCleanups.get(companionSessionId);
|
|
522
|
+
if (cleanups) {
|
|
523
|
+
cleanups.forEach((fn) => fn());
|
|
524
|
+
this.sessionCleanups.delete(companionSessionId);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Extract Linear OAuth credentials from an agent's config.
|
|
529
|
+
* Prefers the new `oauthConnectionId` model, falls back to inline credentials. */
|
|
530
|
+
private getCredentials(agent: AgentConfig): LinearOAuthCredentials {
|
|
531
|
+
const linear = agent.triggers?.linear;
|
|
532
|
+
|
|
533
|
+
// New model: resolve from OAuth connection
|
|
534
|
+
if (linear?.oauthConnectionId) {
|
|
535
|
+
const conn = getOAuthConnection(linear.oauthConnectionId);
|
|
536
|
+
if (conn) {
|
|
537
|
+
return {
|
|
538
|
+
clientId: conn.oauthClientId,
|
|
539
|
+
clientSecret: conn.oauthClientSecret,
|
|
540
|
+
webhookSecret: conn.webhookSecret,
|
|
541
|
+
accessToken: conn.accessToken,
|
|
542
|
+
refreshToken: conn.refreshToken,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
console.warn(
|
|
546
|
+
`[linear-agent-bridge] OAuth connection "${linear.oauthConnectionId}" referenced by agent not found — falling back to inline credentials`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Legacy fallback: inline credentials
|
|
551
|
+
return {
|
|
552
|
+
clientId: linear?.oauthClientId || "",
|
|
553
|
+
clientSecret: linear?.oauthClientSecret || "",
|
|
554
|
+
webhookSecret: linear?.webhookSecret || "",
|
|
555
|
+
accessToken: linear?.accessToken || "",
|
|
556
|
+
refreshToken: linear?.refreshToken || "",
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Create a callback that persists refreshed tokens back to the appropriate store. */
|
|
561
|
+
private createTokenRefreshCallback(agentId: string): (tokens: { accessToken: string; refreshToken: string }) => void {
|
|
562
|
+
return (tokens) => {
|
|
563
|
+
const agent = agentStore.getAgent(agentId);
|
|
564
|
+
if (!agent?.triggers?.linear) return;
|
|
565
|
+
|
|
566
|
+
// New model: update the OAuth connection
|
|
567
|
+
if (agent.triggers.linear.oauthConnectionId) {
|
|
568
|
+
updateOAuthConnection(agent.triggers.linear.oauthConnectionId, {
|
|
569
|
+
accessToken: tokens.accessToken,
|
|
570
|
+
refreshToken: tokens.refreshToken,
|
|
571
|
+
status: "connected",
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Legacy fallback: update agent inline
|
|
577
|
+
agentStore.updateAgent(agentId, {
|
|
578
|
+
triggers: {
|
|
579
|
+
...agent.triggers,
|
|
580
|
+
linear: {
|
|
581
|
+
...agent.triggers.linear,
|
|
582
|
+
accessToken: tokens.accessToken,
|
|
583
|
+
refreshToken: tokens.refreshToken,
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Find the agent configured for a specific Linear OAuth client ID.
|
|
591
|
+
* Checks both new `oauthConnectionId` model and legacy inline credentials. */
|
|
592
|
+
private findLinearAgentByClientId(oauthClientId: string | undefined): AgentConfig | null {
|
|
593
|
+
if (!oauthClientId) return null;
|
|
594
|
+
const agents = agentStore.listAgents();
|
|
595
|
+
|
|
596
|
+
// New model: find agents via OAuth connection reference
|
|
597
|
+
const oauthConn = findOAuthConnectionByClientId(oauthClientId);
|
|
598
|
+
if (oauthConn) {
|
|
599
|
+
const agent = agents.find(
|
|
600
|
+
(a) => a.enabled && a.triggers?.linear?.enabled
|
|
601
|
+
&& a.triggers.linear.oauthConnectionId === oauthConn.id,
|
|
602
|
+
);
|
|
603
|
+
if (agent) return agent;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Legacy fallback: inline oauthClientId
|
|
607
|
+
const legacyAgent = agents.find(
|
|
608
|
+
(a) => a.enabled && a.triggers?.linear?.enabled
|
|
609
|
+
&& a.triggers.linear.oauthClientId === oauthClientId,
|
|
610
|
+
);
|
|
611
|
+
return legacyAgent || null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Find any enabled Linear agent's ID (for backward compat on session restore). */
|
|
615
|
+
private findAnyLinearAgentId(): string | null {
|
|
616
|
+
const agents = agentStore.listAgents();
|
|
617
|
+
const agent = agents.find((a) => a.enabled && a.triggers?.linear?.enabled);
|
|
618
|
+
return agent?.id || null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Clean up all session mappings and listeners. */
|
|
622
|
+
shutdown(): void {
|
|
623
|
+
for (const [companionSessionId] of this.sessionCleanups) {
|
|
624
|
+
this.cleanupRelay(companionSessionId);
|
|
625
|
+
}
|
|
626
|
+
this.sessionMap.clear();
|
|
627
|
+
this.reverseMap.clear();
|
|
628
|
+
}
|
|
629
|
+
}
|