@calltelemetry/openclaw-linear 0.9.14 → 0.9.16

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.
@@ -2,7 +2,7 @@
2
2
  * Recorded API responses from sub-issue decomposition smoke test.
3
3
  * Auto-generated — do not edit manually.
4
4
  * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts
5
- * Last recorded: 2026-02-22T03:40:54.418Z
5
+ * Last recorded: 2026-02-24T05:15:32.100Z
6
6
  */
7
7
 
8
8
  export const RECORDED = {
@@ -44,20 +44,20 @@ export const RECORDED = {
44
44
  }
45
45
  ],
46
46
  "createParent": {
47
- "id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
48
- "identifier": "UAT-438"
47
+ "id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
48
+ "identifier": "UAT-638"
49
49
  },
50
50
  "createSubIssue1": {
51
- "id": "4caa7593-5a51-4795-b98c-29e532589dfe",
52
- "identifier": "UAT-439"
51
+ "id": "a1702cad-3206-4a2b-bb0c-c6ad47df8983",
52
+ "identifier": "UAT-639"
53
53
  },
54
54
  "createSubIssue2": {
55
- "id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
56
- "identifier": "UAT-440"
55
+ "id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
56
+ "identifier": "UAT-640"
57
57
  },
58
58
  "subIssue1Details": {
59
- "id": "4caa7593-5a51-4795-b98c-29e532589dfe",
60
- "identifier": "UAT-439",
59
+ "id": "a1702cad-3206-4a2b-bb0c-c6ad47df8983",
60
+ "identifier": "UAT-639",
61
61
  "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
62
62
  "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
63
63
  "estimate": 2,
@@ -75,6 +75,7 @@ export const RECORDED = {
75
75
  },
76
76
  "team": {
77
77
  "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
78
+ "key": "UAT",
78
79
  "name": "UAT",
79
80
  "issueEstimationType": "tShirt"
80
81
  },
@@ -83,16 +84,16 @@ export const RECORDED = {
83
84
  },
84
85
  "project": null,
85
86
  "parent": {
86
- "id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
87
- "identifier": "UAT-438"
87
+ "id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
88
+ "identifier": "UAT-638"
88
89
  },
89
90
  "relations": {
90
91
  "nodes": []
91
92
  }
92
93
  },
93
94
  "subIssue2Details": {
94
- "id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
95
- "identifier": "UAT-440",
95
+ "id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
96
+ "identifier": "UAT-640",
96
97
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
97
98
  "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
98
99
  "estimate": 3,
@@ -110,6 +111,7 @@ export const RECORDED = {
110
111
  },
111
112
  "team": {
112
113
  "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
114
+ "key": "UAT",
113
115
  "name": "UAT",
114
116
  "issueEstimationType": "tShirt"
115
117
  },
@@ -118,18 +120,18 @@ export const RECORDED = {
118
120
  },
119
121
  "project": null,
120
122
  "parent": {
121
- "id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
122
- "identifier": "UAT-438"
123
+ "id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
124
+ "identifier": "UAT-638"
123
125
  },
124
126
  "relations": {
125
127
  "nodes": []
126
128
  }
127
129
  },
128
130
  "parentDetails": {
129
- "id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
130
- "identifier": "UAT-438",
131
+ "id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
132
+ "identifier": "UAT-638",
131
133
  "title": "[SMOKE TEST] Sub-Issue Parent: Search Feature",
132
- "description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-22T03:40:52.229Z",
134
+ "description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-24T05:15:30.285Z",
133
135
  "estimate": null,
134
136
  "state": {
135
137
  "name": "Backlog",
@@ -145,17 +147,12 @@ export const RECORDED = {
145
147
  },
146
148
  "team": {
147
149
  "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
150
+ "key": "UAT",
148
151
  "name": "UAT",
149
152
  "issueEstimationType": "tShirt"
150
153
  },
151
154
  "comments": {
152
- "nodes": [
153
- {
154
- "body": "This thread is for an agent session with ctclaw.",
155
- "user": null,
156
- "createdAt": "2026-02-22T03:40:53.165Z"
157
- }
158
- ]
155
+ "nodes": []
159
156
  },
160
157
  "project": null,
161
158
  "parent": null,
@@ -164,11 +161,11 @@ export const RECORDED = {
164
161
  }
165
162
  },
166
163
  "createRelation": {
167
- "id": "185dfd6c-362e-48a4-b717-e900407ced84"
164
+ "id": "139541b2-b088-4290-9ded-5b0167a42741"
168
165
  },
169
166
  "subIssue1WithRelation": {
170
- "id": "4caa7593-5a51-4795-b98c-29e532589dfe",
171
- "identifier": "UAT-439",
167
+ "id": "a1702cad-3206-4a2b-bb0c-c6ad47df8983",
168
+ "identifier": "UAT-639",
172
169
  "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
173
170
  "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
174
171
  "estimate": 2,
@@ -186,30 +183,25 @@ export const RECORDED = {
186
183
  },
187
184
  "team": {
188
185
  "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
186
+ "key": "UAT",
189
187
  "name": "UAT",
190
188
  "issueEstimationType": "tShirt"
191
189
  },
192
190
  "comments": {
193
- "nodes": [
194
- {
195
- "body": "This thread is for an agent session with ctclaw.",
196
- "user": null,
197
- "createdAt": "2026-02-22T03:40:53.603Z"
198
- }
199
- ]
191
+ "nodes": []
200
192
  },
201
193
  "project": null,
202
194
  "parent": {
203
- "id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
204
- "identifier": "UAT-438"
195
+ "id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
196
+ "identifier": "UAT-638"
205
197
  },
206
198
  "relations": {
207
199
  "nodes": [
208
200
  {
209
201
  "type": "blocks",
210
202
  "relatedIssue": {
211
- "id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
212
- "identifier": "UAT-440",
203
+ "id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
204
+ "identifier": "UAT-640",
213
205
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
214
206
  }
215
207
  }
@@ -217,8 +209,8 @@ export const RECORDED = {
217
209
  }
218
210
  },
219
211
  "subIssue2WithRelation": {
220
- "id": "0519065b-95eb-4752-966b-2099bbc5f3d1",
221
- "identifier": "UAT-440",
212
+ "id": "d988eb3a-7d45-4d9b-8ee2-4d7580dd183e",
213
+ "identifier": "UAT-640",
222
214
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
223
215
  "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
224
216
  "estimate": 3,
@@ -236,22 +228,17 @@ export const RECORDED = {
236
228
  },
237
229
  "team": {
238
230
  "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
231
+ "key": "UAT",
239
232
  "name": "UAT",
240
233
  "issueEstimationType": "tShirt"
241
234
  },
242
235
  "comments": {
243
- "nodes": [
244
- {
245
- "body": "This thread is for an agent session with ctclaw.",
246
- "user": null,
247
- "createdAt": "2026-02-22T03:40:53.840Z"
248
- }
249
- ]
236
+ "nodes": []
250
237
  },
251
238
  "project": null,
252
239
  "parent": {
253
- "id": "933fa3c0-981a-4d9e-8b2c-7c5c4d5b4349",
254
- "identifier": "UAT-438"
240
+ "id": "f236a0f3-a365-4e05-9b84-7c965da87c03",
241
+ "identifier": "UAT-638"
255
242
  },
256
243
  "relations": {
257
244
  "nodes": []
@@ -228,7 +228,7 @@ describe("runAgent subprocess", () => {
228
228
  const noisyOutput = [
229
229
  "[plugins] Dispatch gateway methods registered",
230
230
  "[plugins] Linear agent extension registered (agent: zoe)",
231
- '[plugins] code_run: default backend=codex, aliases={"claude":"claude"}',
231
+ '[plugins] cli tools registered: cli_codex, cli_claude, cli_gemini (agent default: cli_codex)',
232
232
  JSON.stringify({ payloads: [{ text: "clean response" }], meta: {} }),
233
233
  ].join("\n");
234
234
  (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
@@ -79,6 +79,8 @@ export async function runAgent(params: {
79
79
  * Subprocess fallback is blocked — only the embedded runner is safe.
80
80
  */
81
81
  readOnly?: boolean;
82
+ /** Additional tools to deny (merged with config + readOnly denies) */
83
+ toolsDeny?: string[];
82
84
  }): Promise<AgentRunResult> {
83
85
  const maxAttempts = 2;
84
86
 
@@ -138,8 +140,9 @@ async function runAgentOnce(params: {
138
140
  timeoutMs?: number;
139
141
  streaming?: AgentStreamCallbacks;
140
142
  readOnly?: boolean;
143
+ toolsDeny?: string[];
141
144
  }): Promise<AgentRunResult> {
142
- const { api, agentId, sessionId, streaming, readOnly } = params;
145
+ const { api, agentId, sessionId, streaming, readOnly, toolsDeny } = params;
143
146
 
144
147
  // Inject current timestamp into every LLM request
145
148
  const message = `${buildDateContext()}\n\n${params.message}`;
@@ -153,7 +156,7 @@ async function runAgentOnce(params: {
153
156
  // Try embedded runner first (has streaming callbacks)
154
157
  if (streaming) {
155
158
  try {
156
- return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs, readOnly);
159
+ return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs, readOnly, toolsDeny);
157
160
  } catch (err) {
158
161
  // Read-only mode MUST NOT fall back to subprocess — subprocess runs a
159
162
  // full agent with no way to enforce the tool deny policy.
@@ -211,11 +214,13 @@ async function runEmbedded(
211
214
  streaming: AgentStreamCallbacks,
212
215
  inactivityMs: number,
213
216
  readOnly?: boolean,
217
+ toolsDeny?: string[],
214
218
  ): Promise<AgentRunResult> {
215
219
  const ext = await getExtensionAPI();
216
220
 
217
221
  // Load config so we can resolve agent dirs and providers correctly.
218
- let config = await api.runtime.config.loadConfig();
222
+ const origConfig = await api.runtime.config.loadConfig();
223
+ let config = origConfig;
219
224
  let configAny = config as Record<string, any>;
220
225
 
221
226
  // ── Read-only enforcement ──────────────────────────────────────────
@@ -231,6 +236,17 @@ async function runEmbedded(
231
236
  api.logger.info(`Read-only mode: tools.deny = [${configAny.tools.deny.join(", ")}]`);
232
237
  }
233
238
 
239
+ // ── Additional toolsDeny entries ─────────────────────────────────────
240
+ if (toolsDeny?.length) {
241
+ if (config === origConfig) {
242
+ configAny = JSON.parse(JSON.stringify(origConfig));
243
+ config = configAny as typeof config;
244
+ }
245
+ if (!configAny.tools) configAny.tools = {};
246
+ const existing: string[] = Array.isArray(configAny.tools.deny) ? configAny.tools.deny : [];
247
+ configAny.tools.deny = [...new Set([...existing, ...toolsDeny])];
248
+ }
249
+
234
250
  // Resolve workspace and agent dirs from config (ext API ignores agentId).
235
251
  const dirs = resolveAgentDirs(agentId, configAny);
236
252
  const { workspaceDir, agentDir } = dirs;
@@ -333,13 +349,30 @@ async function runEmbedded(
333
349
  const phase = String(data.phase ?? "");
334
350
  const toolName = String(data.name ?? "tool");
335
351
  const meta = typeof data.meta === "string" ? data.meta : "";
336
- const input = typeof data.input === "string" ? data.input : "";
352
+ const rawInput = data.input;
353
+ const input = typeof rawInput === "string" ? rawInput : "";
354
+
355
+ // Parse structured input for richer detail on cli_* tools
356
+ let inputObj: Record<string, any> | null = null;
357
+ if (rawInput && typeof rawInput === "object") {
358
+ inputObj = rawInput as Record<string, any>;
359
+ } else if (input.startsWith("{")) {
360
+ try { inputObj = JSON.parse(input); } catch {}
361
+ }
337
362
 
338
363
  // Tool execution start — emit action with tool name + available context
339
364
  if (phase === "start") {
340
365
  lastToolAction = toolName;
341
- const detail = input || meta || toolName;
342
- emit({ type: "action", action: `Running ${toolName}`, parameter: detail.slice(0, 300) });
366
+
367
+ // cli_codex / cli_claude / cli_gemini: show working dir and prompt excerpt
368
+ if (toolName.startsWith("cli_") && inputObj) {
369
+ const prompt = String(inputObj.prompt ?? "").slice(0, 250);
370
+ const workDir = inputObj.workingDir ? ` in ${inputObj.workingDir}` : "";
371
+ emit({ type: "action", action: `Running ${toolName}${workDir}`, parameter: prompt });
372
+ } else {
373
+ const detail = input || meta || toolName;
374
+ emit({ type: "action", action: `Running ${toolName}`, parameter: detail.slice(0, 300) });
375
+ }
343
376
  }
344
377
 
345
378
  // Tool execution update — partial progress (keeps Linear UI alive for long tools)
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
2
- import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH } from "./linear-api.js";
2
+ import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, refreshTokenProactively } from "./linear-api.js";
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
5
  // Mocks
@@ -621,3 +621,190 @@ describe("LinearAgentApi", () => {
621
621
  });
622
622
  });
623
623
  });
624
+
625
+ // ===========================================================================
626
+ // refreshTokenProactively
627
+ // ===========================================================================
628
+
629
+ describe("refreshTokenProactively", () => {
630
+ beforeEach(() => {
631
+ delete process.env.LINEAR_CLIENT_ID;
632
+ delete process.env.LINEAR_CLIENT_SECRET;
633
+ });
634
+
635
+ it("skips refresh when token is still valid (not near expiry)", async () => {
636
+ const profileStore = {
637
+ profiles: {
638
+ "linear:default": {
639
+ accessToken: "still-good",
640
+ refreshToken: "r-tok",
641
+ expiresAt: Date.now() + 10 * 3_600_000, // 10 hours from now
642
+ },
643
+ },
644
+ };
645
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
646
+
647
+ const result = await refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" });
648
+
649
+ expect(result.refreshed).toBe(false);
650
+ expect(result.reason).toBe("token still valid");
651
+ });
652
+
653
+ it("skips refresh when credentials are missing", async () => {
654
+ const profileStore = {
655
+ profiles: {
656
+ "linear:default": {
657
+ accessToken: "expired-tok",
658
+ refreshToken: "r-tok",
659
+ expiresAt: Date.now() - 1000, // expired
660
+ },
661
+ },
662
+ };
663
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
664
+
665
+ // No clientId or clientSecret provided
666
+ const result = await refreshTokenProactively();
667
+
668
+ expect(result.refreshed).toBe(false);
669
+ expect(result.reason).toContain("missing credentials");
670
+ });
671
+
672
+ it("skips refresh when auth-profiles.json is not readable", async () => {
673
+ // Default mockReadFileSync throws ENOENT (from outer beforeEach)
674
+
675
+ const result = await refreshTokenProactively();
676
+
677
+ expect(result.refreshed).toBe(false);
678
+ expect(result.reason).toBe("auth-profiles.json not readable");
679
+ });
680
+
681
+ it("skips refresh when no linear:default profile exists", async () => {
682
+ mockReadFileSync.mockReturnValue(JSON.stringify({ profiles: {} }));
683
+
684
+ const result = await refreshTokenProactively();
685
+
686
+ expect(result.refreshed).toBe(false);
687
+ expect(result.reason).toBe("no linear:default profile found");
688
+ });
689
+
690
+ it("refreshes expired token and persists to file", async () => {
691
+ const profileStore = {
692
+ profiles: {
693
+ "linear:default": {
694
+ accessToken: "old-tok",
695
+ access: "old-tok",
696
+ refreshToken: "old-refresh",
697
+ refresh: "old-refresh",
698
+ expiresAt: Date.now() - 1000, // expired
699
+ expires: Date.now() - 1000,
700
+ },
701
+ },
702
+ };
703
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
704
+
705
+ mockRefreshLinearToken.mockResolvedValue({
706
+ access_token: "proactive-new-tok",
707
+ refresh_token: "proactive-new-refresh",
708
+ expires_in: 3600,
709
+ });
710
+
711
+ const result = await refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" });
712
+
713
+ expect(result.refreshed).toBe(true);
714
+ expect(result.reason).toBe("token refreshed successfully");
715
+
716
+ // Verify refreshLinearToken was called with correct args
717
+ // (may have stale calls from outer tests, so check the latest call)
718
+ const calls = mockRefreshLinearToken.mock.calls;
719
+ const lastCall = calls[calls.length - 1];
720
+ expect(lastCall).toEqual(["cid", "csecret", "old-refresh"]);
721
+
722
+ // Verify it wrote back to the file
723
+ const writeCalls = mockWriteFileSync.mock.calls;
724
+ expect(writeCalls.length).toBeGreaterThanOrEqual(1);
725
+ // Get the LAST write call (which is ours)
726
+ const lastWrite = writeCalls[writeCalls.length - 1];
727
+ expect(lastWrite[0]).toBe(AUTH_PROFILES_PATH);
728
+ const writtenData = JSON.parse(lastWrite[1]);
729
+ const profile = writtenData.profiles["linear:default"];
730
+ // Tokens should NOT be the old values
731
+ expect(profile.accessToken).not.toBe("old-tok");
732
+ expect(profile.refreshToken).not.toBe("old-refresh");
733
+ // accessToken and access should match each other
734
+ expect(profile.accessToken).toBe(profile.access);
735
+ expect(profile.refreshToken).toBe(profile.refresh);
736
+ expect(profile.expiresAt).toBeGreaterThan(Date.now());
737
+ expect(profile.expiresAt).toBe(profile.expires);
738
+ });
739
+
740
+ it("refreshes token that is within the 1-hour buffer", async () => {
741
+ const profileStore = {
742
+ profiles: {
743
+ "linear:default": {
744
+ accessToken: "almost-expired-tok",
745
+ refreshToken: "r-tok",
746
+ expiresAt: Date.now() + 30 * 60 * 1000, // 30 min from now (within 1h buffer)
747
+ },
748
+ },
749
+ };
750
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
751
+
752
+ mockRefreshLinearToken.mockResolvedValue({
753
+ access_token: "buffer-refreshed-tok",
754
+ expires_in: 3600,
755
+ });
756
+
757
+ const result = await refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" });
758
+
759
+ expect(result.refreshed).toBe(true);
760
+ expect(result.reason).toBe("token refreshed successfully");
761
+ });
762
+
763
+ it("propagates refresh error to caller", async () => {
764
+ const profileStore = {
765
+ profiles: {
766
+ "linear:default": {
767
+ accessToken: "expired-tok",
768
+ refreshToken: "bad-refresh",
769
+ expiresAt: Date.now() - 1000,
770
+ },
771
+ },
772
+ };
773
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
774
+
775
+ mockRefreshLinearToken.mockRejectedValue(new Error("Linear token refresh failed (400): invalid_grant"));
776
+
777
+ await expect(
778
+ refreshTokenProactively({ clientId: "cid", clientSecret: "csecret" }),
779
+ ).rejects.toThrow(/Linear token refresh failed/);
780
+ });
781
+
782
+ it("uses env vars when pluginConfig credentials are missing", async () => {
783
+ const profileStore = {
784
+ profiles: {
785
+ "linear:default": {
786
+ accessToken: "expired-tok",
787
+ refreshToken: "r-tok",
788
+ expiresAt: Date.now() - 1000,
789
+ },
790
+ },
791
+ };
792
+ mockReadFileSync.mockReturnValue(JSON.stringify(profileStore));
793
+
794
+ process.env.LINEAR_CLIENT_ID = "env-cid";
795
+ process.env.LINEAR_CLIENT_SECRET = "env-csecret";
796
+
797
+ mockRefreshLinearToken.mockResolvedValue({
798
+ access_token: "env-refreshed",
799
+ expires_in: 3600,
800
+ });
801
+
802
+ const result = await refreshTokenProactively(); // no pluginConfig
803
+
804
+ expect(result.refreshed).toBe(true);
805
+ // Verify env vars were used
806
+ const calls = mockRefreshLinearToken.mock.calls;
807
+ const lastCall = calls[calls.length - 1];
808
+ expect(lastCall).toEqual(["env-cid", "env-csecret", "r-tok"]);
809
+ });
810
+ });
@@ -41,8 +41,10 @@ export function resolveLinearToken(pluginConfig?: Record<string, unknown>): {
41
41
  return { accessToken: fromConfig, source: "config" };
42
42
  }
43
43
 
44
- // 2. Auth profile store (from OAuth flow) — preferred because OAuth tokens
45
- // carry app:assignable/app:mentionable scopes needed for Agent Sessions
44
+ // 2. Auth profile store (from OAuth flow) — OAuth tokens carry
45
+ // app:assignable/app:mentionable scopes needed for Agent Sessions.
46
+ // Token refresh is handled by the 6-hour proactive timer; if it's
47
+ // expired here, fail loudly so we know the refresh is broken.
46
48
  try {
47
49
  const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
48
50
  const store = JSON.parse(raw);
@@ -59,7 +61,7 @@ export function resolveLinearToken(pluginConfig?: Record<string, unknown>): {
59
61
  // Profile store doesn't exist or is unreadable
60
62
  }
61
63
 
62
- // 3. Env var fallback (personal API key — works for comments but not Agent Sessions)
64
+ // 3. Env var fallback
63
65
  const fromEnv = process.env.LINEAR_ACCESS_TOKEN ?? process.env.LINEAR_API_KEY;
64
66
  if (fromEnv) {
65
67
  return { accessToken: fromEnv, source: "env" };
@@ -311,7 +313,7 @@ export class LinearAgentApi {
311
313
  creator: { name: string; email: string | null } | null;
312
314
  assignee: { name: string } | null;
313
315
  labels: { nodes: Array<{ id: string; name: string }> };
314
- team: { id: string; name: string; issueEstimationType: string };
316
+ team: { id: string; key: string; name: string; issueEstimationType: string };
315
317
  comments: { nodes: Array<{ body: string; user: { name: string } | null; createdAt: string }> };
316
318
  project: { id: string; name: string } | null;
317
319
  parent: { id: string; identifier: string } | null;
@@ -329,7 +331,7 @@ export class LinearAgentApi {
329
331
  creator { name email }
330
332
  assignee { name }
331
333
  labels { nodes { id name } }
332
- team { id name issueEstimationType }
334
+ team { id key name issueEstimationType }
333
335
  comments(last: 10) {
334
336
  nodes {
335
337
  body
@@ -685,4 +687,111 @@ export class LinearAgentApi {
685
687
  );
686
688
  return data.webhookDelete.success;
687
689
  }
690
+
691
+ /**
692
+ * Get repository suggestions from Linear for an issue.
693
+ * Uses Linear's ML to rank candidate repos by relevance to the issue.
694
+ */
695
+ async getRepositorySuggestions(
696
+ issueId: string,
697
+ agentSessionId: string,
698
+ candidates: Array<{ hostname: string; repositoryFullName: string }>,
699
+ ): Promise<Array<{ repositoryFullName: string; hostname: string; confidence: number }>> {
700
+ if (candidates.length === 0) return [];
701
+ try {
702
+ const data = await this.gql<{
703
+ issueRepositorySuggestions: {
704
+ suggestions: Array<{ repositoryFullName: string; hostname: string; confidence: number }>;
705
+ };
706
+ }>(
707
+ `query RepoSuggestions($issueId: String!, $agentSessionId: String!, $candidateRepositories: [CandidateRepository!]!) {
708
+ issueRepositorySuggestions(issueId: $issueId, agentSessionId: $agentSessionId, candidateRepositories: $candidateRepositories) {
709
+ suggestions {
710
+ repositoryFullName
711
+ hostname
712
+ confidence
713
+ }
714
+ }
715
+ }`,
716
+ { issueId, agentSessionId, candidateRepositories: candidates },
717
+ );
718
+ return data.issueRepositorySuggestions?.suggestions ?? [];
719
+ } catch {
720
+ // Best-effort — if the API doesn't support this or fails, return empty
721
+ return [];
722
+ }
723
+ }
724
+ }
725
+
726
+ // ---------------------------------------------------------------------------
727
+ // Proactive token refresh (standalone, no API call required)
728
+ // ---------------------------------------------------------------------------
729
+
730
+ const PROACTIVE_BUFFER_MS = 3_600_000; // 1 hour before expiry
731
+
732
+ /**
733
+ * Proactively refresh the Linear OAuth token if it's expired or about to expire.
734
+ * Returns true if refreshed, false if skipped (not needed or can't refresh).
735
+ *
736
+ * This is a standalone function that can be called from a timer without making
737
+ * a Linear API request. It reads/writes auth-profiles.json directly.
738
+ */
739
+ export async function refreshTokenProactively(
740
+ pluginConfig?: Record<string, unknown>,
741
+ ): Promise<{ refreshed: boolean; reason: string }> {
742
+ // 1. Read auth-profiles.json, get linear:default profile
743
+ let store: any;
744
+ try {
745
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
746
+ store = JSON.parse(raw);
747
+ } catch {
748
+ return { refreshed: false, reason: "auth-profiles.json not readable" };
749
+ }
750
+
751
+ const profile = store?.profiles?.["linear:default"];
752
+ if (!profile) {
753
+ return { refreshed: false, reason: "no linear:default profile found" };
754
+ }
755
+
756
+ // 2. Check if token is expired or will expire within 1 hour
757
+ const expiresAt = profile.expiresAt ?? profile.expires;
758
+ if (typeof expiresAt === "number" && Date.now() < expiresAt - PROACTIVE_BUFFER_MS) {
759
+ return { refreshed: false, reason: "token still valid" };
760
+ }
761
+
762
+ // 3. Resolve credentials
763
+ const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
764
+ const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
765
+ const refreshToken = profile.refreshToken ?? profile.refresh;
766
+
767
+ if (!clientId || !clientSecret || !refreshToken) {
768
+ return { refreshed: false, reason: "missing credentials (clientId, clientSecret, or refreshToken)" };
769
+ }
770
+
771
+ // 4. Refresh the token
772
+ const result = await refreshLinearToken(clientId, clientSecret, refreshToken);
773
+
774
+ // 5. Persist updated tokens back to auth-profiles.json (same pattern as persistToken())
775
+ const newAccessToken = result.access_token;
776
+ const newRefreshToken = result.refresh_token ?? refreshToken;
777
+ const newExpiresAt = Date.now() + result.expires_in * 1000;
778
+
779
+ try {
780
+ // Re-read to avoid clobbering concurrent writes
781
+ const freshRaw = readFileSync(AUTH_PROFILES_PATH, "utf8");
782
+ const freshStore = JSON.parse(freshRaw);
783
+ if (freshStore.profiles?.["linear:default"]) {
784
+ freshStore.profiles["linear:default"].accessToken = newAccessToken;
785
+ freshStore.profiles["linear:default"].access = newAccessToken;
786
+ freshStore.profiles["linear:default"].refreshToken = newRefreshToken;
787
+ freshStore.profiles["linear:default"].refresh = newRefreshToken;
788
+ freshStore.profiles["linear:default"].expiresAt = newExpiresAt;
789
+ freshStore.profiles["linear:default"].expires = newExpiresAt;
790
+ writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(freshStore, null, 2), "utf8");
791
+ }
792
+ } catch {
793
+ // Best-effort persistence — token was refreshed even if write fails
794
+ }
795
+
796
+ return { refreshed: true, reason: "token refreshed successfully" };
688
797
  }