opencodekit 0.15.10 → 0.15.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,11 @@
5
5
 
6
6
  import type { Plugin } from "@opencode-ai/plugin";
7
7
 
8
- const CLIENT_ID = "Iv1.b507a08c87ecfe98";
8
+ const CLIENT_ID = "Ov23li8tweQw6odWQebz";
9
+
10
+ // Add a small safety buffer when polling to avoid hitting the server
11
+ // slightly too early due to clock skew / timer drift.
12
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000; // 3 seconds
9
13
 
10
14
  const HEADERS = {
11
15
  "User-Agent": "GitHubCopilotChat/0.35.0",
@@ -40,11 +44,12 @@ function getUrls(domain: string) {
40
44
  return {
41
45
  DEVICE_CODE_URL: `https://${domain}/login/device/code`,
42
46
  ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
43
- COPILOT_API_KEY_URL: `https://api.github.com/copilot_internal/v2/token`,
44
47
  };
45
48
  }
46
49
 
47
- export const CopilotAuthPlugin: Plugin = async ({ client }) => {
50
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
51
+
52
+ export const CopilotAuthPlugin: Plugin = async ({ client: _client }) => {
48
53
  return {
49
54
  auth: {
50
55
  provider: "github-copilot",
@@ -52,6 +57,11 @@ export const CopilotAuthPlugin: Plugin = async ({ client }) => {
52
57
  const info = await getAuth();
53
58
  if (!info || info.type !== "oauth") return {};
54
59
 
60
+ const enterpriseUrl = info.enterpriseUrl;
61
+ const baseURL = enterpriseUrl
62
+ ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
63
+ : undefined;
64
+
55
65
  if (provider && provider.models) {
56
66
  for (const model of Object.values(provider.models)) {
57
67
  model.cost = {
@@ -62,71 +72,47 @@ export const CopilotAuthPlugin: Plugin = async ({ client }) => {
62
72
  write: 0,
63
73
  },
64
74
  };
75
+
76
+ // Sync with official: Handle Claude routing and SDK mapping
77
+ const base =
78
+ baseURL ?? model.api.url ?? "https://api.githubcopilot.com";
79
+ const isClaude = model.id.includes("claude");
80
+
81
+ let url = base;
82
+ if (isClaude) {
83
+ if (!url.endsWith("/v1")) {
84
+ url = url.endsWith("/") ? `${url}v1` : `${url}/v1`;
85
+ }
86
+ }
87
+
88
+ model.api.url = url;
89
+ model.api.npm = isClaude
90
+ ? "@ai-sdk/anthropic"
91
+ : "@ai-sdk/github-copilot";
65
92
  }
66
93
  }
67
94
 
68
- const enterpriseUrl = info.enterpriseUrl;
69
- const baseURL = enterpriseUrl
70
- ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
71
- : "https://api.githubcopilot.com";
72
-
73
95
  return {
74
- baseURL,
75
96
  apiKey: "",
76
97
  async fetch(input, init) {
77
98
  const info = await getAuth();
78
- if (info.type !== "oauth") return {};
79
- if (!info.access) {
80
- const domain = info.enterpriseUrl
81
- ? normalizeDomain(info.enterpriseUrl)
82
- : "github.com";
83
- const urls = getUrls(domain);
84
-
85
- const response = await fetch(urls.COPILOT_API_KEY_URL, {
86
- headers: {
87
- Accept: "application/json",
88
- Authorization: `Bearer ${info.refresh}`,
89
- ...HEADERS,
90
- },
91
- });
92
-
93
- if (!response.ok) {
94
- throw new Error(`Token refresh failed: ${response.status}`);
95
- }
96
-
97
- const tokenData = await response.json();
98
-
99
- const saveProviderID = info.enterpriseUrl
100
- ? "github-copilot-enterprise"
101
- : "github-copilot";
102
- await client.auth.set({
103
- path: {
104
- id: saveProviderID,
105
- },
106
- body: {
107
- type: "oauth",
108
- refresh: info.refresh,
109
- access: tokenData.token,
110
- expires: tokenData.expires_at * 1000 - 5 * 60 * 1000,
111
- ...(info.enterpriseUrl && {
112
- enterpriseUrl: info.enterpriseUrl,
113
- }),
114
- },
115
- });
116
- info.access = tokenData.token;
117
- }
99
+ if (info.type !== "oauth") return fetch(input, init);
118
100
 
119
101
  let isAgentCall = false;
120
102
  let isVisionRequest = false;
121
103
  try {
122
104
  const body =
123
- typeof init.body === "string"
105
+ typeof init?.body === "string"
124
106
  ? JSON.parse(init.body)
125
- : init.body;
126
- if (body?.messages) {
127
- isAgentCall = body.messages.some(
128
- (msg: any) =>
129
- msg.role && ["tool", "assistant"].includes(msg.role),
107
+ : init?.body;
108
+
109
+ const url = input.toString();
110
+
111
+ // Completions API
112
+ if (body?.messages && url.includes("completions")) {
113
+ // Keep local logic: detect if any message is assistant/tool
114
+ isAgentCall = body.messages.some((msg: any) =>
115
+ ["tool", "assistant"].includes(msg.role),
130
116
  );
131
117
  isVisionRequest = body.messages.some(
132
118
  (msg: any) =>
@@ -135,34 +121,58 @@ export const CopilotAuthPlugin: Plugin = async ({ client }) => {
135
121
  );
136
122
  }
137
123
 
124
+ // Responses API
138
125
  if (body?.input) {
139
- const lastInput = body.input[body.input.length - 1];
140
-
141
- const isAssistant = lastInput?.role === "assistant";
142
- const hasAgentType = lastInput?.type
143
- ? RESPONSES_API_ALTERNATE_INPUT_TYPES.includes(lastInput.type)
144
- : false;
145
- isAgentCall = isAssistant || hasAgentType;
146
-
147
- isVisionRequest =
148
- Array.isArray(lastInput?.content) &&
149
- lastInput.content.some(
150
- (part: any) => part.type === "input_image",
151
- );
126
+ isAgentCall = body.input.some(
127
+ (item: any) =>
128
+ item?.role === "assistant" ||
129
+ (item?.type &&
130
+ RESPONSES_API_ALTERNATE_INPUT_TYPES.includes(item.type)),
131
+ );
132
+
133
+ isVisionRequest = body.input.some(
134
+ (item: any) =>
135
+ Array.isArray(item?.content) &&
136
+ item.content.some(
137
+ (part: any) => part.type === "input_image",
138
+ ),
139
+ );
140
+ }
141
+
142
+ // Messages API (Anthropic style)
143
+ if (body?.messages && !url.includes("completions")) {
144
+ isAgentCall = body.messages.some((msg: any) =>
145
+ ["tool", "assistant"].includes(msg.role),
146
+ );
147
+ isVisionRequest = body.messages.some(
148
+ (item: any) =>
149
+ Array.isArray(item?.content) &&
150
+ item.content.some(
151
+ (part: any) =>
152
+ part?.type === "image" ||
153
+ (part?.type === "tool_result" &&
154
+ Array.isArray(part?.content) &&
155
+ part.content.some(
156
+ (nested: any) => nested?.type === "image",
157
+ )),
158
+ ),
159
+ );
152
160
  }
153
161
  } catch {}
154
162
 
155
- const headers = {
156
- ...init.headers,
163
+ const headers: Record<string, string> = {
164
+ "x-initiator": isAgentCall ? "agent" : "user",
165
+ ...(init?.headers as Record<string, string>),
157
166
  ...HEADERS,
158
- Authorization: `Bearer ${info.access}`,
167
+ Authorization: `Bearer ${info.refresh}`,
159
168
  "Openai-Intent": "conversation-edits",
160
- "X-Initiator": isAgentCall ? "agent" : "user",
161
169
  };
170
+
162
171
  if (isVisionRequest) {
163
172
  headers["Copilot-Vision-Request"] = "true";
164
173
  }
165
174
 
175
+ // Official only deletes lowercase "authorization"
166
176
  delete headers["x-api-key"];
167
177
  delete headers["authorization"];
168
178
 
@@ -286,7 +296,7 @@ export const CopilotAuthPlugin: Plugin = async ({ client }) => {
286
296
  } = {
287
297
  type: "success",
288
298
  refresh: data.access_token,
289
- access: "",
299
+ access: data.access_token,
290
300
  expires: 0,
291
301
  };
292
302
 
@@ -299,16 +309,33 @@ export const CopilotAuthPlugin: Plugin = async ({ client }) => {
299
309
  }
300
310
 
301
311
  if (data.error === "authorization_pending") {
302
- await new Promise((resolve) =>
303
- setTimeout(resolve, deviceData.interval * 1000),
312
+ await sleep(
313
+ deviceData.interval * 1000 +
314
+ OAUTH_POLLING_SAFETY_MARGIN_MS,
304
315
  );
305
316
  continue;
306
317
  }
307
318
 
319
+ if (data.error === "slow_down") {
320
+ // Based on the RFC spec, we must add 5 seconds to our current polling interval.
321
+ let newInterval = (deviceData.interval + 5) * 1000;
322
+
323
+ if (
324
+ data.interval &&
325
+ typeof data.interval === "number" &&
326
+ data.interval > 0
327
+ ) {
328
+ newInterval = data.interval * 1000;
329
+ }
330
+
331
+ await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS);
332
+ continue;
333
+ }
334
+
308
335
  if (data.error) return { type: "failed" };
309
336
 
310
- await new Promise((resolve) =>
311
- setTimeout(resolve, deviceData.interval * 1000),
337
+ await sleep(
338
+ deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS,
312
339
  );
313
340
  continue;
314
341
  }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Swarm Enforcer Plugin
3
+ *
4
+ * Beads is the single source of truth for the swarm board.
5
+ * This plugin nudges agents to:
6
+ * - Claim a Beads task before making code changes
7
+ * - Ensure `spec.md` exists for in-progress tasks
8
+ * - Close/sync in-progress work at session end
9
+ *
10
+ * This plugin is intentionally non-destructive: it never runs `bd update/close/sync`.
11
+ */
12
+
13
+ import fsPromises from "node:fs/promises";
14
+ import path from "node:path";
15
+ import type { Plugin } from "@opencode-ai/plugin";
16
+
17
+ type BeadsIssue = {
18
+ id: string;
19
+ title?: string;
20
+ status?: string;
21
+ };
22
+
23
+ const BEADS_DIR = ".beads";
24
+ const ISSUES_FILE = "issues.jsonl";
25
+
26
+ const CODE_EXTENSIONS = [
27
+ ".ts",
28
+ ".tsx",
29
+ ".js",
30
+ ".jsx",
31
+ ".mjs",
32
+ ".cjs",
33
+ ".py",
34
+ ".go",
35
+ ".rs",
36
+ ".java",
37
+ ".c",
38
+ ".cpp",
39
+ ".h",
40
+ ".hpp",
41
+ ];
42
+
43
+ const WORK_INTENT_PATTERNS = [
44
+ /\b(implement|fix|refactor|add|remove|delete|update|change|modify|create|build)\b/i,
45
+ /\b(edit|patch)\b/i,
46
+ ];
47
+
48
+ function looksLikeWorkIntent(text: string): boolean {
49
+ return WORK_INTENT_PATTERNS.some((p) => p.test(text));
50
+ }
51
+
52
+ function isCodeFile(filePath: string): boolean {
53
+ return CODE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
54
+ }
55
+
56
+ function isIgnoredPath(repoDir: string, filePath: string): boolean {
57
+ const absPath = path.isAbsolute(filePath)
58
+ ? filePath
59
+ : path.join(repoDir, filePath);
60
+ const rel = path.relative(repoDir, absPath);
61
+
62
+ // Outside repo: ignore
63
+ if (rel.startsWith("..")) return true;
64
+
65
+ const normalized = rel.replace(/\\/g, "/");
66
+ return (
67
+ normalized.startsWith("node_modules/") ||
68
+ normalized.startsWith("dist/") ||
69
+ normalized.startsWith(".beads/") ||
70
+ normalized.startsWith(".git/")
71
+ );
72
+ }
73
+
74
+ function summarizeIssues(issues: BeadsIssue[], limit = 5): string {
75
+ return issues
76
+ .slice(0, limit)
77
+ .map((i) => `${i.id}${i.title ? `: ${i.title}` : ""}`)
78
+ .join("\n");
79
+ }
80
+
81
+ async function readIssuesJsonl(repoDir: string): Promise<BeadsIssue[]> {
82
+ const issuesPath = path.join(repoDir, BEADS_DIR, ISSUES_FILE);
83
+
84
+ let content: string;
85
+ try {
86
+ content = await fsPromises.readFile(issuesPath, "utf-8");
87
+ } catch {
88
+ return [];
89
+ }
90
+
91
+ const issues: BeadsIssue[] = [];
92
+ const lines = content.split(/\r?\n/);
93
+ for (const line of lines) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) continue;
96
+ try {
97
+ const parsed = JSON.parse(trimmed);
98
+ if (parsed && typeof parsed.id === "string") {
99
+ issues.push({
100
+ id: parsed.id,
101
+ title: typeof parsed.title === "string" ? parsed.title : undefined,
102
+ status: typeof parsed.status === "string" ? parsed.status : undefined,
103
+ });
104
+ }
105
+ } catch {
106
+ // Ignore malformed JSONL lines
107
+ }
108
+ }
109
+
110
+ return issues;
111
+ }
112
+
113
+ async function specExists(repoDir: string, issueId: string): Promise<boolean> {
114
+ const specPath = path.join(
115
+ repoDir,
116
+ BEADS_DIR,
117
+ "artifacts",
118
+ issueId,
119
+ "spec.md",
120
+ );
121
+ try {
122
+ await fsPromises.access(specPath);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function buildNudge(params: {
130
+ inProgress: BeadsIssue[];
131
+ missingSpec: BeadsIssue[];
132
+ }): string {
133
+ const { inProgress, missingSpec } = params;
134
+
135
+ if (inProgress.length === 0) {
136
+ return `
137
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
138
+ ⚡ [SWARM PROTOCOL]
139
+
140
+ Beads is the swarm board. Before any code changes:
141
+
142
+ 1) Pick a task: \`bd ready\` (or \`bd list\`)
143
+ 2) Inspect: \`bd show <id>\`
144
+ 3) Claim: \`bd update <id> --status=in_progress\`
145
+
146
+ Then proceed with work and collect verification evidence.
147
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
148
+ `;
149
+ }
150
+
151
+ if (missingSpec.length > 0) {
152
+ return `
153
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
154
+ ⚡ [SWARM PROTOCOL]
155
+
156
+ In-progress Beads exist, but \`spec.md\` is missing for:
157
+
158
+ ${summarizeIssues(missingSpec)}
159
+
160
+ Create \`.beads/artifacts/<id>/spec.md\` before implementation.
161
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
162
+ `;
163
+ }
164
+
165
+ return "";
166
+ }
167
+
168
+ export const SwarmEnforcer: Plugin = async ({ client, directory }) => {
169
+ const repoDir = directory || process.cwd();
170
+ let lastStateAt = 0;
171
+ let cachedInProgress: BeadsIssue[] = [];
172
+ let cachedMissingSpec: BeadsIssue[] = [];
173
+
174
+ const refreshState = async () => {
175
+ const now = Date.now();
176
+ if (now - lastStateAt < 1500) return;
177
+ lastStateAt = now;
178
+
179
+ const issues = await readIssuesJsonl(repoDir);
180
+ const inProgress = issues.filter((i) => i.status === "in_progress");
181
+
182
+ const missingSpec: BeadsIssue[] = [];
183
+ for (const issue of inProgress.slice(0, 10)) {
184
+ if (!(await specExists(repoDir, issue.id))) {
185
+ missingSpec.push(issue);
186
+ }
187
+ }
188
+
189
+ cachedInProgress = inProgress;
190
+ cachedMissingSpec = missingSpec;
191
+ };
192
+
193
+ const showToast = async (
194
+ title: string,
195
+ message: string,
196
+ variant: "info" | "success" | "warning" | "error" = "info",
197
+ ) => {
198
+ try {
199
+ await client.tui.showToast({
200
+ body: {
201
+ title,
202
+ message,
203
+ variant,
204
+ duration: variant === "error" ? 8000 : 5000,
205
+ },
206
+ });
207
+ } catch {
208
+ // If toast is unavailable, fail silently
209
+ }
210
+ };
211
+
212
+ return {
213
+ // Nudge early when user expresses implementation intent
214
+ "chat.message": async (input, output) => {
215
+ const { sessionID, messageID } = input;
216
+ const { message, parts } = output;
217
+ if (message.role !== "user") return;
218
+
219
+ const fullText = parts
220
+ .filter((p) => p.type === "text" && !("synthetic" in p && p.synthetic))
221
+ .map((p) => ("text" in p ? p.text : ""))
222
+ .join(" ");
223
+
224
+ if (!looksLikeWorkIntent(fullText)) return;
225
+
226
+ await refreshState();
227
+
228
+ const nudge = buildNudge({
229
+ inProgress: cachedInProgress,
230
+ missingSpec: cachedMissingSpec,
231
+ });
232
+ if (!nudge) return;
233
+
234
+ parts.push({
235
+ id: `swarm-nudge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
236
+ sessionID,
237
+ messageID: messageID || "",
238
+ type: "text",
239
+ text: nudge,
240
+ synthetic: true,
241
+ } as import("@opencode-ai/sdk").Part);
242
+ },
243
+
244
+ // Warn if code gets edited while no task is claimed / spec missing
245
+ "file.edited": async ({ event }) => {
246
+ const filePath = event.properties?.file || event.properties?.path;
247
+ if (!filePath || typeof filePath !== "string") return;
248
+ if (isIgnoredPath(repoDir, filePath)) return;
249
+
250
+ const absPath = path.isAbsolute(filePath)
251
+ ? filePath
252
+ : path.join(repoDir, filePath);
253
+
254
+ if (!isCodeFile(absPath)) return;
255
+
256
+ await refreshState();
257
+
258
+ if (cachedInProgress.length === 0) {
259
+ await showToast(
260
+ "Swarm: No task claimed",
261
+ "Beads is the board. Claim a task before code edits (bd ready/show/update).",
262
+ "warning",
263
+ );
264
+ return;
265
+ }
266
+
267
+ if (cachedMissingSpec.length > 0) {
268
+ await showToast(
269
+ "Swarm: Missing spec.md",
270
+ `Create .beads/artifacts/<id>/spec.md for: ${cachedMissingSpec
271
+ .slice(0, 3)
272
+ .map((i) => i.id)
273
+ .join(", ")}`,
274
+ "warning",
275
+ );
276
+ }
277
+ },
278
+
279
+ // Session end reminder: close/sync if tasks still in progress
280
+ "session.idle": async () => {
281
+ await refreshState();
282
+ if (cachedInProgress.length === 0) return;
283
+
284
+ const list = cachedInProgress
285
+ .slice(0, 5)
286
+ .map((i) => i.id)
287
+ .join(", ");
288
+ await showToast(
289
+ "Swarm: Work still in progress",
290
+ `In-progress Beads: ${list}. Close with bd close + bd sync when done.`,
291
+ "info",
292
+ );
293
+ },
294
+ };
295
+ };
296
+
297
+ export default SwarmEnforcer;