@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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. 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
+ }