@vellumai/assistant 0.4.46 → 0.4.48

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 (194) hide show
  1. package/ARCHITECTURE.md +5 -5
  2. package/docs/architecture/security.md +5 -5
  3. package/package.json +1 -1
  4. package/src/__tests__/browser-fill-credential.test.ts +5 -2
  5. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +2 -1
  6. package/src/__tests__/channel-readiness-routes.test.ts +20 -19
  7. package/src/__tests__/cli.test.ts +23 -0
  8. package/src/__tests__/credential-broker-browser-fill.test.ts +23 -22
  9. package/src/__tests__/credential-broker-server-use.test.ts +22 -21
  10. package/src/__tests__/credential-broker.test.ts +2 -1
  11. package/src/__tests__/credential-metadata-store.test.ts +240 -18
  12. package/src/__tests__/credential-resolve.test.ts +5 -4
  13. package/src/__tests__/credential-security-e2e.test.ts +8 -8
  14. package/src/__tests__/credential-security-invariants.test.ts +104 -6
  15. package/src/__tests__/credential-vault-unit.test.ts +22 -20
  16. package/src/__tests__/credential-vault.test.ts +284 -12
  17. package/src/__tests__/credentials-cli.test.ts +11 -6
  18. package/src/__tests__/gateway-only-enforcement.test.ts +4 -2
  19. package/src/__tests__/gemini-image-service.test.ts +75 -45
  20. package/src/__tests__/gemini-provider.test.ts +9 -6
  21. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -33
  22. package/src/__tests__/guardian-action-copy-generator.test.ts +0 -20
  23. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -28
  24. package/src/__tests__/guardian-action-followup-store.test.ts +1 -1
  25. package/src/__tests__/guardian-grant-minting.test.ts +35 -0
  26. package/src/__tests__/integration-status.test.ts +53 -21
  27. package/src/__tests__/managed-proxy-context.test.ts +5 -3
  28. package/src/__tests__/media-generate-image.test.ts +63 -2
  29. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -3
  30. package/src/__tests__/messaging-send-tool.test.ts +4 -6
  31. package/src/__tests__/provider-fail-open-selection.test.ts +3 -1
  32. package/src/__tests__/provider-managed-proxy-integration.test.ts +70 -6
  33. package/src/__tests__/schema-transforms.test.ts +226 -0
  34. package/src/__tests__/script-proxy-injection-runtime.test.ts +23 -13
  35. package/src/__tests__/script-proxy-policy-runtime.test.ts +1 -1
  36. package/src/__tests__/script-proxy-session-manager.test.ts +1 -1
  37. package/src/__tests__/secret-onetime-send.test.ts +5 -3
  38. package/src/__tests__/session-messaging-secret-redirect.test.ts +5 -4
  39. package/src/__tests__/skills-uninstall.test.ts +2 -2
  40. package/src/__tests__/skills.test.ts +0 -9
  41. package/src/__tests__/slack-channel-config.test.ts +9 -8
  42. package/src/__tests__/slack-share-routes.test.ts +11 -6
  43. package/src/__tests__/telegram-bot-username-resolution.test.ts +3 -0
  44. package/src/__tests__/twilio-config.test.ts +2 -1
  45. package/src/__tests__/twilio-provider.test.ts +4 -2
  46. package/src/__tests__/twilio-routes.test.ts +5 -4
  47. package/src/calls/call-domain.ts +7 -4
  48. package/src/calls/twilio-config.ts +2 -1
  49. package/src/calls/twilio-provider.ts +2 -1
  50. package/src/calls/twilio-rest.ts +2 -1
  51. package/src/cli/commands/browser-relay.ts +40 -15
  52. package/src/cli/commands/credentials.ts +9 -8
  53. package/src/cli/commands/oauth.ts +1 -1
  54. package/src/cli.ts +3 -2
  55. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -4
  56. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +29 -32
  57. package/src/config/bundled-skills/gmail/SKILL.md +4 -4
  58. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +54 -61
  59. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +25 -28
  60. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +14 -17
  61. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +39 -44
  62. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +61 -58
  63. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +50 -49
  64. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +11 -13
  65. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +148 -146
  66. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +4 -7
  67. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +175 -173
  68. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +4 -7
  69. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +71 -76
  70. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +32 -38
  71. package/src/config/bundled-skills/google-calendar/SKILL.md +2 -2
  72. package/src/config/bundled-skills/google-calendar/calendar-client.ts +70 -29
  73. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +9 -10
  74. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +5 -6
  75. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +4 -5
  76. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +14 -15
  77. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +37 -37
  78. package/src/config/bundled-skills/google-calendar/tools/shared.ts +4 -9
  79. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +24 -3
  80. package/src/config/bundled-skills/messaging/SKILL.md +6 -6
  81. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +62 -63
  82. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +15 -16
  83. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +4 -5
  84. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +6 -7
  85. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +4 -5
  86. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +14 -15
  87. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +4 -5
  88. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +128 -128
  89. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +33 -34
  90. package/src/config/bundled-skills/messaging/tools/shared.ts +11 -11
  91. package/src/config/bundled-skills/slack/tools/shared.ts +4 -10
  92. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +4 -5
  93. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +15 -16
  94. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +4 -5
  95. package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +4 -5
  96. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +4 -5
  97. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +95 -92
  98. package/src/config/loader.ts +6 -0
  99. package/src/daemon/computer-use-session.ts +7 -1
  100. package/src/daemon/guardian-action-generators.ts +4 -5
  101. package/src/daemon/handlers/config-slack-channel.ts +37 -20
  102. package/src/daemon/handlers/config-telegram.ts +33 -20
  103. package/src/daemon/lifecycle.ts +9 -1
  104. package/src/daemon/message-types/integrations.ts +1 -0
  105. package/src/daemon/ride-shotgun-handler.ts +3 -1
  106. package/src/daemon/session-messaging.ts +3 -1
  107. package/src/daemon/session-tool-setup.ts +18 -2
  108. package/src/daemon/session.ts +1 -1
  109. package/src/email/providers/index.ts +2 -1
  110. package/src/instrument.ts +15 -1
  111. package/src/media/app-icon-generator.ts +30 -4
  112. package/src/media/avatar-router.ts +26 -3
  113. package/src/media/gemini-image-service.ts +28 -2
  114. package/src/memory/guardian-action-store.ts +1 -1
  115. package/src/memory/schema/guardian.ts +1 -1
  116. package/src/messaging/provider.ts +16 -10
  117. package/src/messaging/providers/gmail/adapter.ts +40 -23
  118. package/src/messaging/providers/gmail/client.ts +203 -122
  119. package/src/messaging/providers/gmail/people-client.ts +26 -18
  120. package/src/messaging/providers/slack/adapter.ts +29 -19
  121. package/src/messaging/providers/slack/client.ts +265 -78
  122. package/src/messaging/providers/telegram-bot/adapter.ts +5 -4
  123. package/src/messaging/providers/whatsapp/adapter.ts +6 -3
  124. package/src/messaging/registry.ts +2 -1
  125. package/src/oauth/byo-connection.test.ts +436 -0
  126. package/src/oauth/byo-connection.ts +112 -0
  127. package/src/oauth/connect-orchestrator.ts +27 -0
  128. package/src/oauth/connection-resolver.ts +34 -0
  129. package/src/oauth/connection.ts +38 -0
  130. package/src/oauth/platform-connection.test.ts +163 -0
  131. package/src/oauth/platform-connection.ts +110 -0
  132. package/src/oauth/provider-base-urls.ts +21 -0
  133. package/src/oauth/provider-profiles.ts +1 -1
  134. package/src/oauth/token-persistence.ts +20 -20
  135. package/src/permissions/checker.ts +5 -1
  136. package/src/prompts/system-prompt.ts +49 -12
  137. package/src/providers/gemini/client.ts +15 -6
  138. package/src/providers/managed-proxy/constants.ts +2 -2
  139. package/src/providers/managed-proxy/context.ts +5 -1
  140. package/src/providers/ratelimit.ts +17 -0
  141. package/src/providers/registry.ts +2 -2
  142. package/src/runtime/AGENTS.md +17 -0
  143. package/src/runtime/channel-invite-transports/telegram.ts +2 -1
  144. package/src/runtime/channel-readiness-service.ts +168 -195
  145. package/src/runtime/channel-readiness-types.ts +4 -0
  146. package/src/runtime/guardian-action-conversation-turn.ts +1 -3
  147. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  148. package/src/runtime/guardian-action-message-composer.ts +3 -23
  149. package/src/runtime/http-server.ts +9 -4
  150. package/src/runtime/http-types.ts +0 -1
  151. package/src/runtime/middleware/rate-limiter.ts +74 -20
  152. package/src/runtime/routes/channel-readiness-routes.ts +2 -0
  153. package/src/runtime/routes/diagnostics-routes.ts +11 -9
  154. package/src/runtime/routes/guardian-approval-interception.ts +20 -5
  155. package/src/runtime/routes/integrations/slack/share.ts +3 -2
  156. package/src/runtime/routes/integrations/twilio.ts +6 -5
  157. package/src/runtime/routes/secret-routes.ts +3 -2
  158. package/src/runtime/routes/settings-routes.ts +75 -17
  159. package/src/runtime/telegram-streaming-delivery.test.ts +132 -0
  160. package/src/runtime/telegram-streaming-delivery.ts +11 -1
  161. package/src/schedule/integration-status.ts +5 -4
  162. package/src/security/credential-key.ts +170 -0
  163. package/src/security/token-manager.ts +36 -7
  164. package/src/tools/apps/definitions.ts +0 -5
  165. package/src/tools/assets/materialize.ts +0 -5
  166. package/src/tools/assets/search.ts +0 -5
  167. package/src/tools/browser/headless-browser.ts +1 -67
  168. package/src/tools/claude-code/claude-code.ts +0 -5
  169. package/src/tools/computer-use/request-computer-control.ts +0 -5
  170. package/src/tools/credentials/broker.ts +6 -4
  171. package/src/tools/credentials/metadata-store.ts +72 -20
  172. package/src/tools/credentials/resolve.ts +2 -1
  173. package/src/tools/credentials/vault.ts +77 -16
  174. package/src/tools/filesystem/edit.ts +1 -6
  175. package/src/tools/filesystem/read.ts +0 -5
  176. package/src/tools/filesystem/write.ts +1 -6
  177. package/src/tools/host-filesystem/edit.ts +1 -6
  178. package/src/tools/host-filesystem/read.ts +1 -6
  179. package/src/tools/host-filesystem/write.ts +1 -6
  180. package/src/tools/mcp/mcp-tool-factory.ts +18 -1
  181. package/src/tools/memory/definitions.ts +0 -5
  182. package/src/tools/network/web-fetch.ts +0 -5
  183. package/src/tools/network/web-search.ts +0 -5
  184. package/src/tools/schema-transforms.ts +99 -0
  185. package/src/tools/skills/load.ts +0 -5
  186. package/src/tools/swarm/delegate.ts +0 -5
  187. package/src/tools/system/avatar-generator.ts +0 -5
  188. package/src/tools/ui-surface/definitions.ts +0 -15
  189. package/src/tools/watch/screen-watch.ts +0 -5
  190. package/src/version.ts +10 -0
  191. package/src/watcher/providers/github.ts +51 -52
  192. package/src/watcher/providers/gmail.ts +88 -80
  193. package/src/watcher/providers/google-calendar.ts +93 -86
  194. package/src/watcher/providers/linear.ts +87 -93
@@ -11,7 +11,9 @@ import {
11
11
  listEvents,
12
12
  } from "../../config/bundled-skills/google-calendar/calendar-client.js";
13
13
  import type { CalendarEvent } from "../../config/bundled-skills/google-calendar/types.js";
14
- import { withValidToken } from "../../security/token-manager.js";
14
+ import type { OAuthConnection } from "../../oauth/connection.js";
15
+ import { resolveOAuthConnection } from "../../oauth/connection-resolver.js";
16
+ import { GOOGLE_CALENDAR_BASE_URL } from "../../oauth/provider-base-urls.js";
15
17
  import { getLogger } from "../../util/logger.js";
16
18
  import type {
17
19
  FetchResult,
@@ -21,8 +23,6 @@ import type {
21
23
 
22
24
  const log = getLogger("watcher:google-calendar");
23
25
 
24
- const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3";
25
-
26
26
  /** The credential service — calendar shares OAuth tokens with Gmail. */
27
27
  const CREDENTIAL_SERVICE = "integration:gmail";
28
28
 
@@ -69,7 +69,7 @@ interface SyncResponse {
69
69
  * Returns all accumulated events and the final nextSyncToken.
70
70
  */
71
71
  async function incrementalSync(
72
- token: string,
72
+ connection: OAuthConnection,
73
73
  syncToken: string,
74
74
  ): Promise<SyncResponse> {
75
75
  let allItems: CalendarEvent[] = [];
@@ -77,27 +77,32 @@ async function incrementalSync(
77
77
  let nextSyncToken: string | undefined;
78
78
 
79
79
  do {
80
- const params = new URLSearchParams({ syncToken });
81
- if (pageToken) params.set("pageToken", pageToken);
82
- const url = `${CALENDAR_API_BASE}/calendars/primary/events?${params}`;
83
- const resp = await fetch(url, {
84
- headers: { Authorization: `Bearer ${token}` },
80
+ const query: Record<string, string> = { syncToken };
81
+ if (pageToken) query.pageToken = pageToken;
82
+
83
+ const resp = await connection.request({
84
+ method: "GET",
85
+ path: "/calendars/primary/events",
86
+ query,
87
+ baseUrl: GOOGLE_CALENDAR_BASE_URL,
85
88
  });
86
89
 
87
- if (!resp.ok) {
88
- const body = await resp.text().catch(() => "");
90
+ if (resp.status < 200 || resp.status >= 300) {
91
+ const bodyStr =
92
+ typeof resp.body === "string"
93
+ ? resp.body
94
+ : JSON.stringify(resp.body ?? "");
89
95
  if (resp.status === 410) {
90
- throw new SyncTokenExpiredError(body);
96
+ throw new SyncTokenExpiredError(bodyStr);
91
97
  }
92
- // Throw CalendarApiError so withValidToken can detect 401s via the status property
93
98
  throw new CalendarApiError(
94
99
  resp.status,
95
- resp.statusText,
96
- `Calendar Sync API ${resp.status}: ${body}`,
100
+ "",
101
+ `Calendar Sync API ${resp.status}: ${bodyStr}`,
97
102
  );
98
103
  }
99
104
 
100
- const page = (await resp.json()) as SyncResponse;
105
+ const page = resp.body as SyncResponse;
101
106
  if (page.items) allItems = allItems.concat(page.items);
102
107
  pageToken = page.nextPageToken;
103
108
  nextSyncToken = page.nextSyncToken;
@@ -119,16 +124,48 @@ export const googleCalendarProvider: WatcherProvider = {
119
124
  requiredCredentialService: CREDENTIAL_SERVICE,
120
125
 
121
126
  async getInitialWatermark(credentialService: string): Promise<string> {
122
- return withValidToken(credentialService, async (token) => {
123
- // Do a full sync with a narrow window to get the initial syncToken.
124
- // The API may paginate even for small result sets, so follow nextPageToken
125
- // until we reach the final page that carries the nextSyncToken.
127
+ const connection = resolveOAuthConnection(credentialService);
128
+
129
+ // Do a full sync with a narrow window to get the initial syncToken.
130
+ // The API may paginate even for small result sets, so follow nextPageToken
131
+ // until we reach the final page that carries the nextSyncToken.
132
+ const now = new Date().toISOString();
133
+ let pageToken: string | undefined;
134
+ let syncToken: string | undefined;
135
+
136
+ do {
137
+ const result = await listEvents(connection, "primary", {
138
+ timeMin: now,
139
+ maxResults: 250,
140
+ singleEvents: true,
141
+ pageToken,
142
+ });
143
+ syncToken = result.nextSyncToken;
144
+ pageToken = result.nextPageToken;
145
+ } while (pageToken && !syncToken);
146
+
147
+ if (!syncToken) {
148
+ throw new Error("Calendar API did not return a syncToken");
149
+ }
150
+ return syncToken;
151
+ },
152
+
153
+ async fetchNew(
154
+ credentialService: string,
155
+ watermark: string | null,
156
+ _config: Record<string, unknown>,
157
+ _watcherKey: string,
158
+ ): Promise<FetchResult> {
159
+ const connection = resolveOAuthConnection(credentialService);
160
+
161
+ if (!watermark) {
162
+ // No watermark — paginate through to get the initial syncToken, return no items
126
163
  const now = new Date().toISOString();
127
164
  let pageToken: string | undefined;
128
165
  let syncToken: string | undefined;
129
166
 
130
167
  do {
131
- const result = await listEvents(token, "primary", {
168
+ const result = await listEvents(connection, "primary", {
132
169
  timeMin: now,
133
170
  maxResults: 250,
134
171
  singleEvents: true,
@@ -138,82 +175,52 @@ export const googleCalendarProvider: WatcherProvider = {
138
175
  pageToken = result.nextPageToken;
139
176
  } while (pageToken && !syncToken);
140
177
 
141
- if (!syncToken) {
142
- throw new Error("Calendar API did not return a syncToken");
178
+ return { items: [], watermark: syncToken ?? "" };
179
+ }
180
+
181
+ try {
182
+ const syncResp = await incrementalSync(connection, watermark);
183
+ const newWatermark = syncResp.nextSyncToken ?? watermark;
184
+
185
+ if (!syncResp.items || syncResp.items.length === 0) {
186
+ return { items: [], watermark: newWatermark };
143
187
  }
144
- return syncToken;
145
- });
146
- },
147
188
 
148
- async fetchNew(
149
- credentialService: string,
150
- watermark: string | null,
151
- _config: Record<string, unknown>,
152
- _watcherKey: string,
153
- ): Promise<FetchResult> {
154
- return withValidToken(credentialService, async (token) => {
155
- if (!watermark) {
156
- // No watermark — paginate through to get the initial syncToken, return no items
157
- const now = new Date().toISOString();
158
- let pageToken: string | undefined;
159
- let syncToken: string | undefined;
160
-
161
- do {
162
- const result = await listEvents(token, "primary", {
163
- timeMin: now,
164
- maxResults: 250,
165
- singleEvents: true,
166
- pageToken,
167
- });
168
- syncToken = result.nextSyncToken;
169
- pageToken = result.nextPageToken;
170
- } while (pageToken && !syncToken);
171
-
172
- return { items: [], watermark: syncToken ?? "" };
189
+ // Convert events to watcher items, distinguishing new vs updated
190
+ const items: WatcherItem[] = [];
191
+ for (const event of syncResp.items) {
192
+ if (event.status === "cancelled") continue;
193
+
194
+ const eventType =
195
+ event.created === event.updated
196
+ ? "new_calendar_event"
197
+ : "updated_calendar_event";
198
+ items.push(eventToItem(event, eventType));
173
199
  }
174
200
 
175
- try {
176
- const syncResp = await incrementalSync(token, watermark);
177
- const newWatermark = syncResp.nextSyncToken ?? watermark;
178
-
179
- if (!syncResp.items || syncResp.items.length === 0) {
180
- return { items: [], watermark: newWatermark };
181
- }
182
-
183
- // Convert events to watcher items, distinguishing new vs updated
184
- const items: WatcherItem[] = [];
185
- for (const event of syncResp.items) {
186
- if (event.status === "cancelled") continue;
187
-
188
- const eventType =
189
- event.created === event.updated
190
- ? "new_calendar_event"
191
- : "updated_calendar_event";
192
- items.push(eventToItem(event, eventType));
193
- }
194
-
195
- log.info(
196
- { count: items.length, watermark: newWatermark },
197
- "Calendar: fetched event changes",
198
- );
199
- return { items, watermark: newWatermark };
200
- } catch (err) {
201
- if (err instanceof SyncTokenExpiredError) {
202
- log.warn("Calendar syncToken expired, falling back to recent events");
203
- return fallbackFetch(token);
204
- }
205
- throw err;
201
+ log.info(
202
+ { count: items.length, watermark: newWatermark },
203
+ "Calendar: fetched event changes",
204
+ );
205
+ return { items, watermark: newWatermark };
206
+ } catch (err) {
207
+ if (err instanceof SyncTokenExpiredError) {
208
+ log.warn("Calendar syncToken expired, falling back to recent events");
209
+ return fallbackFetch(connection);
206
210
  }
207
- });
211
+ throw err;
212
+ }
208
213
  },
209
214
  };
210
215
 
211
216
  /**
212
217
  * Fallback when syncToken expires: list upcoming events from today.
213
218
  */
214
- async function fallbackFetch(token: string): Promise<FetchResult> {
219
+ async function fallbackFetch(
220
+ connection: OAuthConnection,
221
+ ): Promise<FetchResult> {
215
222
  const now = new Date().toISOString();
216
- const result = await listEvents(token, "primary", {
223
+ const result = await listEvents(connection, "primary", {
217
224
  timeMin: now,
218
225
  maxResults: 25,
219
226
  singleEvents: true,
@@ -229,7 +236,7 @@ async function fallbackFetch(token: string): Promise<FetchResult> {
229
236
  let syncToken: string | undefined;
230
237
 
231
238
  do {
232
- const syncResult = await listEvents(token, "primary", {
239
+ const syncResult = await listEvents(connection, "primary", {
233
240
  timeMin: now,
234
241
  maxResults: 250,
235
242
  singleEvents: true,
@@ -13,7 +13,8 @@
13
13
  * and issues.
14
14
  */
15
15
 
16
- import { withValidToken } from "../../security/token-manager.js";
16
+ import type { OAuthConnection } from "../../oauth/connection.js";
17
+ import { resolveOAuthConnection } from "../../oauth/connection-resolver.js";
17
18
  import { getLogger } from "../../util/logger.js";
18
19
  import { truncate } from "../../util/truncate.js";
19
20
  import type {
@@ -24,8 +25,6 @@ import type {
24
25
 
25
26
  const log = getLogger("watcher:linear");
26
27
 
27
- const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
28
-
29
28
  // ── GraphQL response types ────────────────────────────────────────────────────
30
29
 
31
30
  interface LinearNotification {
@@ -89,27 +88,23 @@ interface LinearViewer {
89
88
  // ── GraphQL helpers ───────────────────────────────────────────────────────────
90
89
 
91
90
  async function graphql<T>(
92
- token: string,
91
+ connection: OAuthConnection,
93
92
  query: string,
94
93
  variables?: Record<string, unknown>,
95
94
  ): Promise<T> {
96
- const resp = await fetch(LINEAR_GRAPHQL_URL, {
95
+ const resp = await connection.request({
97
96
  method: "POST",
98
- headers: {
99
- // Linear accepts both personal API keys and OAuth tokens; the Bearer scheme
100
- // is required for all token types per Linear's API docs.
101
- Authorization: `Bearer ${token}`,
102
- "Content-Type": "application/json",
103
- },
104
- body: JSON.stringify({ query, variables }),
97
+ path: "/graphql",
98
+ body: { query, variables },
105
99
  });
106
100
 
107
- if (!resp.ok) {
108
- const body = await resp.text().catch(() => "");
101
+ if (resp.status >= 400) {
102
+ const body =
103
+ typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body);
109
104
  throw new Error(`Linear API ${resp.status}: ${body}`);
110
105
  }
111
106
 
112
- const result = (await resp.json()) as {
107
+ const result = resp.body as {
113
108
  data?: T;
114
109
  errors?: Array<{ message: string }>;
115
110
  };
@@ -128,9 +123,9 @@ async function graphql<T>(
128
123
  }
129
124
 
130
125
  /** Fetch the authenticated user's ID and name. */
131
- async function fetchViewer(token: string): Promise<LinearViewer> {
126
+ async function fetchViewer(connection: OAuthConnection): Promise<LinearViewer> {
132
127
  const data = await graphql<{ viewer: LinearViewer }>(
133
- token,
128
+ connection,
134
129
  `
135
130
  query {
136
131
  viewer {
@@ -150,7 +145,7 @@ async function fetchViewer(token: string): Promise<LinearViewer> {
150
145
  * between polls.
151
146
  */
152
147
  async function fetchNotifications(
153
- token: string,
148
+ connection: OAuthConnection,
154
149
  since: string,
155
150
  ): Promise<LinearNotification[]> {
156
151
  const allNodes: LinearNotification[] = [];
@@ -165,7 +160,7 @@ async function fetchNotifications(
165
160
 
166
161
  do {
167
162
  const data: NotificationsResponse = await graphql<NotificationsResponse>(
168
- token,
163
+ connection,
169
164
  `
170
165
  query FetchNotifications($after: DateTime, $cursor: String) {
171
166
  notifications(
@@ -242,7 +237,7 @@ async function fetchNotifications(
242
237
  * `pageInfo.hasNextPage` is false so updates beyond the first 50 aren't skipped.
243
238
  */
244
239
  async function fetchAssignedIssueUpdates(
245
- token: string,
240
+ connection: OAuthConnection,
246
241
  viewerId: string,
247
242
  since: string,
248
243
  ): Promise<LinearIssue[]> {
@@ -258,7 +253,7 @@ async function fetchAssignedIssueUpdates(
258
253
 
259
254
  do {
260
255
  const data: IssuesResponse = await graphql<IssuesResponse>(
261
- token,
256
+ connection,
262
257
  `
263
258
  query FetchAssignedIssues(
264
259
  $assigneeId: ID
@@ -319,7 +314,7 @@ async function fetchAssignedIssueUpdates(
319
314
  * complete set — needed for accurate eviction and reassignment detection.
320
315
  */
321
316
  async function fetchAllAssignedIssueIds(
322
- token: string,
317
+ connection: OAuthConnection,
323
318
  viewerId: string,
324
319
  ): Promise<Set<string>> {
325
320
  const ids = new Set<string>();
@@ -334,7 +329,7 @@ async function fetchAllAssignedIssueIds(
334
329
 
335
330
  do {
336
331
  const data: IdsResponse = await graphql<IdsResponse>(
337
- token,
332
+ connection,
338
333
  `
339
334
  query FetchAllAssignedIssueIds($assigneeId: ID, $cursor: String) {
340
335
  issues(
@@ -517,87 +512,86 @@ export const linearProvider: WatcherProvider = {
517
512
  _config: Record<string, unknown>,
518
513
  watcherKey: string,
519
514
  ): Promise<FetchResult> {
520
- return withValidToken(credentialService, async (token) => {
521
- const since = watermark ?? new Date().toISOString();
515
+ const connection = resolveOAuthConnection(credentialService);
516
+ const since = watermark ?? new Date().toISOString();
522
517
 
523
- // Resolve the authenticated viewer's ID once per poll for the assigned-issues query
524
- const viewer = await fetchViewer(token);
518
+ // Resolve the authenticated viewer's ID once per poll for the assigned-issues query
519
+ const viewer = await fetchViewer(connection);
525
520
 
526
- // Fetch notifications (assignments, mentions, status changes via notification feed)
527
- const notifications = await fetchNotifications(token, since);
521
+ // Fetch notifications (assignments, mentions, status changes via notification feed)
522
+ const notifications = await fetchNotifications(connection, since);
528
523
 
529
- // Only surface notification types that warrant attention
530
- const relevantTypes = new Set([
531
- "issueAssignedToYou",
532
- "issueMentionedYou",
533
- "issueCommentMentionedYou",
534
- "issueStatusChanged",
535
- ]);
524
+ // Only surface notification types that warrant attention
525
+ const relevantTypes = new Set([
526
+ "issueAssignedToYou",
527
+ "issueMentionedYou",
528
+ "issueCommentMentionedYou",
529
+ "issueStatusChanged",
530
+ ]);
536
531
 
537
- const items: WatcherItem[] = [];
532
+ const items: WatcherItem[] = [];
538
533
 
539
- for (const n of notifications) {
540
- if (!relevantTypes.has(n.type)) continue;
541
- items.push(notificationToItem(n));
542
- }
534
+ for (const n of notifications) {
535
+ if (!relevantTypes.has(n.type)) continue;
536
+ items.push(notificationToItem(n));
537
+ }
543
538
 
544
- // Fetch the complete set of currently assigned issue IDs (no updatedAt
545
- // filter) so we can accurately evict stale cache entries and guard against
546
- // false-positive status change events on reassignment.
547
- const currentAssignedIds = await fetchAllAssignedIssueIds(
548
- token,
549
- viewer.id,
550
- );
551
- const previousAssignedIds = lastSeenAssignedIdsByWatcher.get(watcherKey);
552
-
553
- // Also poll assigned issues directly for status changes not covered by
554
- // notifications (e.g., bulk team updates). We only emit an event when the
555
- // state ID differs from what we recorded on the previous poll — any other
556
- // field update (title, description, etc.) does not constitute a status change.
557
- // On first sight of an issue we seed the map without emitting, so we don't
558
- // fire false-positive events after a daemon restart.
559
- const assignedIssues = await fetchAssignedIssueUpdates(
560
- token,
561
- viewer.id,
562
- since,
563
- );
564
- const stateCache = getStateCache(watcherKey);
565
- for (const issue of assignedIssues) {
566
- const previousStateId = stateCache.get(issue.id);
567
- // Only emit a status change if: (1) we have a cached state that differs,
568
- // AND (2) the issue was also assigned in the previous poll. Condition (2)
569
- // prevents false-positive events when an issue is unassigned, changes
570
- // state while unassigned, and is then reassigned.
571
- const wasPreviouslySeen = previousAssignedIds?.has(issue.id) ?? false;
572
- if (
573
- previousStateId !== undefined &&
574
- previousStateId !== issue.state.id &&
575
- wasPreviouslySeen
576
- ) {
577
- items.push(issueToStatusChangeItem(issue, previousStateId));
578
- }
579
- stateCache.set(issue.id, issue.state.id);
539
+ // Fetch the complete set of currently assigned issue IDs (no updatedAt
540
+ // filter) so we can accurately evict stale cache entries and guard against
541
+ // false-positive status change events on reassignment.
542
+ const currentAssignedIds = await fetchAllAssignedIssueIds(
543
+ connection,
544
+ viewer.id,
545
+ );
546
+ const previousAssignedIds = lastSeenAssignedIdsByWatcher.get(watcherKey);
547
+
548
+ // Also poll assigned issues directly for status changes not covered by
549
+ // notifications (e.g., bulk team updates). We only emit an event when the
550
+ // state ID differs from what we recorded on the previous poll — any other
551
+ // field update (title, description, etc.) does not constitute a status change.
552
+ // On first sight of an issue we seed the map without emitting, so we don't
553
+ // fire false-positive events after a daemon restart.
554
+ const assignedIssues = await fetchAssignedIssueUpdates(
555
+ connection,
556
+ viewer.id,
557
+ since,
558
+ );
559
+ const stateCache = getStateCache(watcherKey);
560
+ for (const issue of assignedIssues) {
561
+ const previousStateId = stateCache.get(issue.id);
562
+ // Only emit a status change if: (1) we have a cached state that differs,
563
+ // AND (2) the issue was also assigned in the previous poll. Condition (2)
564
+ // prevents false-positive events when an issue is unassigned, changes
565
+ // state while unassigned, and is then reassigned.
566
+ const wasPreviouslySeen = previousAssignedIds?.has(issue.id) ?? false;
567
+ if (
568
+ previousStateId !== undefined &&
569
+ previousStateId !== issue.state.id &&
570
+ wasPreviouslySeen
571
+ ) {
572
+ items.push(issueToStatusChangeItem(issue, previousStateId));
580
573
  }
574
+ stateCache.set(issue.id, issue.state.id);
575
+ }
581
576
 
582
- // Evict cached state for issues that left the assigned set so stale
583
- // entries don't accumulate and don't cause false-positive events if the
584
- // issue is later reassigned.
585
- if (previousAssignedIds) {
586
- for (const id of previousAssignedIds) {
587
- if (!currentAssignedIds.has(id)) {
588
- stateCache.delete(id);
589
- }
577
+ // Evict cached state for issues that left the assigned set so stale
578
+ // entries don't accumulate and don't cause false-positive events if the
579
+ // issue is later reassigned.
580
+ if (previousAssignedIds) {
581
+ for (const id of previousAssignedIds) {
582
+ if (!currentAssignedIds.has(id)) {
583
+ stateCache.delete(id);
590
584
  }
591
585
  }
592
- lastSeenAssignedIdsByWatcher.set(watcherKey, currentAssignedIds);
586
+ }
587
+ lastSeenAssignedIdsByWatcher.set(watcherKey, currentAssignedIds);
593
588
 
594
- const newWatermark = new Date().toISOString();
595
- log.info(
596
- { count: items.length, viewer: viewer.name, watermark: newWatermark },
597
- "Linear: fetched new notifications",
598
- );
589
+ const newWatermark = new Date().toISOString();
590
+ log.info(
591
+ { count: items.length, viewer: viewer.name, watermark: newWatermark },
592
+ "Linear: fetched new notifications",
593
+ );
599
594
 
600
- return { items, watermark: newWatermark };
601
- });
595
+ return { items, watermark: newWatermark };
602
596
  },
603
597
  };