agent-sh 0.14.0 → 0.14.2

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 (64) hide show
  1. package/README.md +7 -18
  2. package/dist/agent/agent-loop.d.ts +1 -1
  3. package/dist/agent/agent-loop.js +42 -31
  4. package/dist/agent/conversation-state.d.ts +3 -2
  5. package/dist/agent/conversation-state.js +20 -3
  6. package/dist/agent/events.d.ts +2 -0
  7. package/dist/agent/host-types.d.ts +3 -0
  8. package/dist/agent/index.js +2 -1
  9. package/dist/agent/llm-client.js +1 -0
  10. package/dist/agent/subagent.d.ts +1 -1
  11. package/dist/agent/subagent.js +5 -1
  12. package/dist/agent/tool-protocol.d.ts +2 -2
  13. package/dist/agent/tool-protocol.js +5 -4
  14. package/dist/agent/tools/glob.d.ts +1 -1
  15. package/dist/agent/tools/glob.js +4 -2
  16. package/dist/agent/tools/grep.d.ts +1 -1
  17. package/dist/agent/tools/grep.js +4 -2
  18. package/dist/agent/tools/ls.d.ts +1 -1
  19. package/dist/agent/tools/ls.js +4 -2
  20. package/dist/agent/tools/read-file.d.ts +1 -1
  21. package/dist/agent/tools/read-file.js +30 -2
  22. package/dist/agent/types.d.ts +13 -3
  23. package/dist/agent/types.js +6 -1
  24. package/dist/cli/args.js +3 -1
  25. package/dist/cli/index.js +0 -0
  26. package/dist/cli/install.d.ts +1 -0
  27. package/dist/cli/install.js +86 -2
  28. package/dist/cli/subcommands.js +4 -1
  29. package/dist/core/index.d.ts +1 -1
  30. package/dist/core/settings.d.ts +3 -0
  31. package/dist/core/settings.js +2 -2
  32. package/dist/shell/index.d.ts +6 -0
  33. package/dist/shell/index.js +10 -10
  34. package/dist/shell/shell.d.ts +4 -0
  35. package/dist/shell/shell.js +15 -29
  36. package/dist/shell/terminal.d.ts +33 -0
  37. package/dist/shell/terminal.js +62 -0
  38. package/dist/utils/tool-interactive.js +4 -2
  39. package/examples/extensions/ash-scheme/index.ts +2170 -0
  40. package/examples/extensions/ash-scheme/package.json +11 -0
  41. package/examples/extensions/ash-scheme-render.ts +58 -0
  42. package/examples/extensions/ashi/README.md +36 -26
  43. package/examples/extensions/ashi/package.json +9 -1
  44. package/examples/extensions/ashi/src/capture.ts +1 -0
  45. package/examples/extensions/ashi/src/cli.ts +25 -8
  46. package/examples/extensions/ashi/src/compaction.ts +25 -96
  47. package/examples/extensions/ashi/src/components.ts +64 -166
  48. package/examples/extensions/ashi/src/default-schema-renderers.ts +229 -0
  49. package/examples/extensions/ashi/src/display-config.ts +21 -22
  50. package/examples/extensions/ashi/src/frontend.ts +64 -65
  51. package/examples/extensions/ashi/src/hooks.ts +47 -63
  52. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  53. package/examples/extensions/ashi/src/schema.ts +407 -0
  54. package/examples/extensions/ashi/src/session-store.ts +55 -4
  55. package/examples/extensions/ashi/src/status-footer.ts +27 -6
  56. package/examples/extensions/ashi-compact-llm.ts +93 -0
  57. package/examples/extensions/claude-code-bridge/index.ts +9 -2
  58. package/examples/extensions/claude-code-bridge/package.json +1 -1
  59. package/examples/extensions/opencode-bridge/index.ts +208 -53
  60. package/examples/extensions/opencode-bridge/package.json +1 -1
  61. package/examples/extensions/opencode-provider.ts +252 -0
  62. package/examples/extensions/pi-bridge/index.ts +1 -0
  63. package/package.json +12 -1
  64. package/examples/extensions/ashi/src/default-renderers.ts +0 -171
@@ -21,6 +21,7 @@ export default function activate(ctx: ExtensionContext): void {
21
21
  };
22
22
 
23
23
  let activeQuery: Query | null = null;
24
+ let sessionId: string | null = null;
24
25
  const listeners: Array<{ event: string; fn: Function }> = [];
25
26
 
26
27
  // ── Tool display helpers ────────────────────────────────────────
@@ -97,6 +98,7 @@ export default function activate(ctx: ExtensionContext): void {
97
98
  allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
98
99
  permissionMode: "acceptEdits",
99
100
  includePartialMessages: true,
101
+ ...(sessionId ? { resume: sessionId } : {}),
100
102
  },
101
103
  });
102
104
 
@@ -138,6 +140,7 @@ export default function activate(ctx: ExtensionContext): void {
138
140
  const kind = toolKind(meta.name);
139
141
  bus.emit("agent:tool-started", {
140
142
  title: meta.name,
143
+ name: meta.name,
141
144
  toolCallId: meta.id,
142
145
  kind,
143
146
  icon: toolIcon(meta.name),
@@ -174,6 +177,7 @@ export default function activate(ctx: ExtensionContext): void {
174
177
  const kind = toolKind(b.name);
175
178
  bus.emit("agent:tool-started", {
176
179
  title: b.name,
180
+ name: b.name,
177
181
  toolCallId: b.id,
178
182
  kind,
179
183
  icon: toolIcon(b.name),
@@ -262,8 +266,11 @@ export default function activate(ctx: ExtensionContext): void {
262
266
  // Tool still running — nothing to do, TUI spinner already active
263
267
  break;
264
268
 
265
- case "result":
269
+ case "result": {
270
+ const sid = (message as any).session_id;
271
+ if (typeof sid === "string" && sid) sessionId = sid;
266
272
  break;
273
+ }
267
274
  }
268
275
  }
269
276
 
@@ -291,7 +298,7 @@ export default function activate(ctx: ExtensionContext): void {
291
298
  };
292
299
 
293
300
  const onCancel = () => { activeQuery?.interrupt(); };
294
- const onReset = () => { /* each query() is a new session */ };
301
+ const onReset = () => { sessionId = null; };
295
302
 
296
303
  bus.on("agent:submit", onSubmit);
297
304
  bus.on("agent:cancel-request", onCancel);
@@ -6,7 +6,7 @@
6
6
  "main": "index.ts",
7
7
  "dependencies": {
8
8
  "@anthropic-ai/claude-agent-sdk": "^0.2.0",
9
- "agent-sh": "^0.9.0",
9
+ "agent-sh": "^0.14.0",
10
10
  "zod": "^4.0.0"
11
11
  }
12
12
  }
@@ -13,6 +13,7 @@ import {
13
13
  type ToolPart,
14
14
  type QuestionRequest,
15
15
  type QuestionInfo,
16
+ type PermissionRequest,
16
17
  } from "@opencode-ai/sdk/v2";
17
18
  import type { ExtensionContext } from "agent-sh/types";
18
19
  import type { InteractiveSession } from "agent-sh/agent/types";
@@ -60,7 +61,8 @@ function parseUnifiedDiff(patch: string): DiffResult | null {
60
61
  }
61
62
 
62
63
  export default function activate(ctx: ExtensionContext): void {
63
- const { bus, call } = ctx; const { compositor } = ctx.shell;
64
+ const { bus, call } = ctx;
65
+ const compositor = ctx.shell?.compositor;
64
66
 
65
67
  const cwd = (): string => {
66
68
  const v = call("cwd");
@@ -94,6 +96,18 @@ export default function activate(ctx: ExtensionContext): void {
94
96
  let turnIdleSeen = false;
95
97
  let turnError: string | null = null;
96
98
 
99
+ let pickerOpen = false;
100
+ const eventQueue: Event[] = [];
101
+ const drainQueue = (): void => {
102
+ const events = eventQueue.splice(0);
103
+ for (const ev of events) handleEvent(ev);
104
+ };
105
+
106
+ // After Ctrl+C, opencode emits a tail of tool / error state for the
107
+ // aborted turn. Suppress until the next turn so it doesn't restart the
108
+ // spinner or replay dead tool entries.
109
+ let cancelledTurn = false;
110
+
97
111
  const listeners: Array<{ event: string; fn: Function }> = [];
98
112
 
99
113
  function toolKind(name: string): string {
@@ -133,6 +147,7 @@ export default function activate(ctx: ExtensionContext): void {
133
147
  announcedTools.add(callID);
134
148
  bus.emit("agent:tool-started", {
135
149
  title: toolName,
150
+ name: toolName,
136
151
  toolCallId: callID,
137
152
  kind,
138
153
  locations: toolLocations(state.input ?? {}),
@@ -194,12 +209,24 @@ export default function activate(ctx: ExtensionContext): void {
194
209
 
195
210
 
196
211
  function handleEvent(event: Event): void {
212
+ if (pickerOpen) { eventQueue.push(event); return; }
197
213
  if (!sessionId) return;
198
214
  const evType = (event as any).type as string;
199
215
  const props = (event as any).properties ?? {};
200
216
  const sid = props.sessionID;
201
217
  if (typeof sid === "string" && sid !== sessionId) return;
202
218
 
219
+ if (cancelledTurn) {
220
+ // Only let through what unblocks onSubmit. Clearing the flag earlier
221
+ // (e.g. on session.error) lets the trailing bash part.updated slip
222
+ // past and restart the spinner.
223
+ if (evType === "session.idle" || evType === "session.error") {
224
+ turnIdleSeen = true;
225
+ pendingTurnEnd?.();
226
+ }
227
+ return;
228
+ }
229
+
203
230
  switch (evType) {
204
231
  // message.part.delta is undocumented in the SDK's Event union but
205
232
  // the SSE consumer yields it. Drop chunks for unknown partIDs —
@@ -242,72 +269,143 @@ export default function activate(ctx: ExtensionContext): void {
242
269
  case "question.asked": {
243
270
  const req = props as QuestionRequest;
244
271
  if (!runtime) break;
272
+ if (!compositor) {
273
+ runtime.client.question
274
+ .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
275
+ .catch(() => { /* best-effort */ });
276
+ bus.emit("ui:error", {
277
+ message: `opencode-bridge: rejected interactive question (no shell host): ${req.questions.map((q) => q.question).join("; ")}`,
278
+ });
279
+ break;
280
+ }
281
+ pickerOpen = true;
245
282
  const ui = createToolUI(bus, compositor.surface("agent"));
246
283
  ui.custom(createQuestionSession(req.questions)).then(async (result: QuestionResult) => {
247
- if (!runtime) return;
248
- // Record the question + answer as a synthetic tool entry so the
249
- // timeline shows what was asked and what the user picked.
250
- const callID = `question-${req.id}`;
251
- const detail = req.questions.length === 1
252
- ? req.questions[0]!.question
253
- : req.questions.map((q, i) => `${q.header || `Q${i + 1}`}: ${q.question}`).join("; ");
254
- bus.emit("agent:tool-started", {
255
- title: "question",
256
- toolCallId: callID,
257
- kind: "execute",
258
- displayDetail: detail,
259
- });
260
- if (result.cancelled) {
284
+ try {
285
+ if (!runtime) return;
286
+ // Record the question + answer as a synthetic tool entry so the
287
+ // timeline shows what was asked and what the user picked.
288
+ const callID = `question-${req.id}`;
289
+ const detail = req.questions.length === 1
290
+ ? req.questions[0]!.question
291
+ : req.questions.map((q, i) => `${q.header || `Q${i + 1}`}: ${q.question}`).join("; ");
292
+ bus.emit("agent:tool-started", {
293
+ title: "question",
294
+ name: "question",
295
+ toolCallId: callID,
296
+ kind: "execute",
297
+ displayDetail: detail,
298
+ });
299
+ if (result.cancelled) {
300
+ bus.emitTransform("agent:tool-completed", {
301
+ toolCallId: callID,
302
+ exitCode: 1,
303
+ rawOutput: "cancelled",
304
+ kind: "execute",
305
+ resultDisplay: { summary: "cancelled" },
306
+ });
307
+ runtime.client.question
308
+ .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
309
+ .catch(() => { /* best-effort */ });
310
+ return;
311
+ }
312
+ const summary = result.answers.length === 1
313
+ ? result.answers[0]!.join(", ")
314
+ : result.answers
315
+ .map((ans, i) => `${req.questions[i]!.header || `Q${i + 1}`}: ${ans.join(", ")}`)
316
+ .join("; ");
317
+ bus.emitTransform("agent:tool-completed", {
318
+ toolCallId: callID,
319
+ exitCode: 0,
320
+ rawOutput: summary,
321
+ kind: "execute",
322
+ resultDisplay: { summary },
323
+ });
324
+ try {
325
+ await runtime.client.question.reply({
326
+ requestID: req.id,
327
+ answers: result.answers,
328
+ directory: sessionDirectory ?? undefined,
329
+ });
330
+ } catch (err) {
331
+ bus.emit("agent:error", {
332
+ message: err instanceof Error ? err.message : String(err),
333
+ });
334
+ }
335
+ } finally {
336
+ pickerOpen = false;
337
+ if (result.cancelled) {
338
+ eventQueue.length = 0;
339
+ cancelledTurn = true;
340
+ pendingTurnEnd?.();
341
+ } else {
342
+ drainQueue();
343
+ }
344
+ }
345
+ });
346
+ break;
347
+ }
348
+ case "permission.asked": {
349
+ const req = props as PermissionRequest;
350
+ if (!runtime) break;
351
+ const detail = req.patterns.length > 0
352
+ ? `${req.permission}: ${req.patterns.join(", ")}`
353
+ : req.permission;
354
+ const finish = (reply: "once" | "always" | "reject", opts?: { note?: string; skipReply?: boolean }) => {
355
+ if (reply === "reject") {
356
+ const callID = `permission-${req.id}`;
357
+ const summary = opts?.note ? `denied (${opts.note})` : `denied: ${detail}`;
358
+ bus.emit("agent:tool-started", {
359
+ title: "permission",
360
+ name: "permission",
361
+ toolCallId: callID,
362
+ kind: "execute",
363
+ displayDetail: detail,
364
+ });
261
365
  bus.emitTransform("agent:tool-completed", {
262
366
  toolCallId: callID,
263
367
  exitCode: 1,
264
- rawOutput: "cancelled",
368
+ rawOutput: summary,
265
369
  kind: "execute",
266
- resultDisplay: { summary: "cancelled" },
370
+ resultDisplay: { summary },
267
371
  });
268
- runtime.client.question
269
- .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
270
- .catch(() => { /* best-effort */ });
271
- return;
272
372
  }
273
- const summary = result.answers.length === 1
274
- ? result.answers[0]!.join(", ")
275
- : result.answers
276
- .map((ans, i) => `${req.questions[i]!.header || `Q${i + 1}`}: ${ans.join(", ")}`)
277
- .join("; ");
278
- bus.emitTransform("agent:tool-completed", {
279
- toolCallId: callID,
280
- exitCode: 0,
281
- rawOutput: summary,
282
- kind: "execute",
283
- resultDisplay: { summary },
373
+ if (!runtime || opts?.skipReply) return;
374
+ runtime.client.permission
375
+ .reply({ requestID: req.id, reply, directory: sessionDirectory ?? undefined })
376
+ .catch((err) => {
377
+ bus.emit("agent:error", {
378
+ message: err instanceof Error ? err.message : String(err),
379
+ });
380
+ });
381
+ };
382
+ if (!compositor) {
383
+ finish("reject", { note: "no shell host" });
384
+ bus.emit("ui:error", {
385
+ message: `opencode-bridge: rejected permission (no shell host): ${detail}`,
284
386
  });
387
+ break;
388
+ }
389
+ pickerOpen = true;
390
+ const ui = createToolUI(bus, compositor.surface("agent"));
391
+ ui.custom(createPermissionSession(req, bus)).then((result: PermissionResult) => {
285
392
  try {
286
- await runtime.client.question.reply({
287
- requestID: req.id,
288
- answers: result.answers,
289
- directory: sessionDirectory ?? undefined,
290
- });
291
- } catch (err) {
292
- bus.emit("agent:error", {
293
- message: err instanceof Error ? err.message : String(err),
294
- });
393
+ finish(result.reply, result.cancelled ? { skipReply: true } : undefined);
394
+ } finally {
395
+ pickerOpen = false;
396
+ if (result.cancelled) {
397
+ // Let onSubmit's finally emit the single agent:processing-done;
398
+ // a second one here races and stacks prompts.
399
+ eventQueue.length = 0;
400
+ cancelledTurn = true;
401
+ pendingTurnEnd?.();
402
+ } else {
403
+ drainQueue();
404
+ }
295
405
  }
296
406
  });
297
407
  break;
298
408
  }
299
- // Without a reply the gated tool hangs forever. The bridge has no
300
- // interactive approval UI, so auto-approve — mirrors claude-code-
301
- // bridge's permissionMode: "acceptEdits". Set permission.edit:
302
- // "allow" in opencode.json to skip the round-trip entirely.
303
- case "permission.asked": {
304
- const requestID = props.id as string | undefined;
305
- if (!requestID || !runtime) break;
306
- runtime.client.permission
307
- .reply({ requestID, reply: "once", directory: sessionDirectory ?? undefined })
308
- .catch(() => { /* approval is best-effort */ });
309
- break;
310
- }
311
409
  }
312
410
  }
313
411
 
@@ -339,6 +437,7 @@ export default function activate(ctx: ExtensionContext): void {
339
437
  bus.emit("agent:query", { query: userQuery });
340
438
  bus.emit("agent:processing-start", {});
341
439
  turnText = "";
440
+ cancelledTurn = false;
342
441
  turnIdleSeen = false;
343
442
  turnError = null;
344
443
  // Set the idle waiter BEFORE prompt() so a fast session.idle can't
@@ -471,6 +570,7 @@ export default function activate(ctx: ExtensionContext): void {
471
570
  // ── Interactive question picker ──────────────────────────────────
472
571
 
473
572
  type QuestionResult = { answers: string[][]; cancelled: boolean };
573
+ type PermissionResult = { reply: "once" | "always" | "reject"; cancelled?: boolean };
474
574
 
475
575
  function isKey(data: string, key: string): boolean {
476
576
  switch (key) {
@@ -601,3 +701,58 @@ function createQuestionSession(questions: QuestionInfo[]): InteractiveSession<Qu
601
701
  },
602
702
  };
603
703
  }
704
+
705
+ function createPermissionSession(
706
+ req: PermissionRequest,
707
+ bus: { on: (e: "agent:cancel-request", fn: () => void) => void; off: (e: "agent:cancel-request", fn: () => void) => void },
708
+ ): InteractiveSession<PermissionResult> {
709
+ let cancelHandler: (() => void) | null = null;
710
+ // Cast widens onMount: the vendored agent-sh type still declares the 1-arg signature.
711
+ const onMount = ((_invalidate: () => void, done: (r: PermissionResult) => void): void => {
712
+ cancelHandler = () => done({ reply: "reject", cancelled: true });
713
+ bus.on("agent:cancel-request", cancelHandler);
714
+ }) as InteractiveSession<PermissionResult>["onMount"];
715
+ return {
716
+ onMount,
717
+ onUnmount() {
718
+ if (cancelHandler) bus.off("agent:cancel-request", cancelHandler);
719
+ cancelHandler = null;
720
+ },
721
+ render(_width) {
722
+ const lines: string[] = [];
723
+ lines.push(` ${p.warning}${p.bold}Permission required: ${req.permission}${p.reset}`);
724
+
725
+ const meta = req.metadata ?? {};
726
+ const cmd = typeof meta.command === "string" ? meta.command : null;
727
+ const file = typeof meta.file === "string"
728
+ ? meta.file
729
+ : typeof meta.path === "string" ? meta.path : null;
730
+ if (cmd) {
731
+ for (const line of cmd.split("\n").slice(0, 6)) {
732
+ lines.push(` ${p.dim}${line}${p.reset}`);
733
+ }
734
+ } else if (file) {
735
+ lines.push(` ${p.dim}${file}${p.reset}`);
736
+ }
737
+
738
+ if (req.patterns.length > 0 && !cmd && !file) {
739
+ for (const pat of req.patterns) {
740
+ lines.push(` ${p.dim}${pat}${p.reset}`);
741
+ }
742
+ }
743
+
744
+ lines.push(` ${p.dim}[y] allow once [a] allow always [n] reject${p.reset}`);
745
+ return lines;
746
+ },
747
+
748
+ handleInput(data, done) {
749
+ if (isKey(data, "escape") || data === "n" || data === "N") {
750
+ done({ reply: "reject" });
751
+ } else if (data === "y" || data === "Y" || isKey(data, "enter")) {
752
+ done({ reply: "once" });
753
+ } else if (data === "a" || data === "A") {
754
+ done({ reply: "always" });
755
+ }
756
+ },
757
+ };
758
+ }
@@ -6,6 +6,6 @@
6
6
  "main": "index.ts",
7
7
  "dependencies": {
8
8
  "@opencode-ai/sdk": "^1.14.41",
9
- "agent-sh": "^0.12.0"
9
+ "agent-sh": "^0.14.0"
10
10
  }
11
11
  }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * opencode-provider — OpenCode Zen & Go LLM providers for agent-sh
3
+ *
4
+ * Registers two providers with runtime model discovery:
5
+ * - opencode — Zen tier (https://opencode.ai/zen/v1)
6
+ * - opencode-go — Go tier (https://opencode.ai/zen/go/v1)
7
+ *
8
+ * Both are OpenAI-compatible endpoints. OpenCode's backend proxies
9
+ * all models (OpenAI, Anthropic, Google, etc.) through them — no
10
+ * per-model transport routing needed at the agent-sh level.
11
+ *
12
+ * ## Setup
13
+ * export OPENCODE_***REDACTED***
14
+ * agent-sh -e ./examples/extensions/opencode-provider.ts
15
+ *
16
+ * # Or add to settings.json:
17
+ * { "extensions": ["./examples/extensions/opencode-provider.ts"] }
18
+ *
19
+ * # Or store via auth:
20
+ * agent-sh auth login opencode
21
+ *
22
+ * ## Model discovery
23
+ * On startup the extension:
24
+ * 1. Fetches official /models endpoints (authoritative model list)
25
+ * 2. Merges metadata from models.dev (context windows, reasoning flags)
26
+ * 3. Falls back to models.dev membership → conservative defaults
27
+ *
28
+ * Usage:
29
+ * /model — tab-completes all discovered models
30
+ * /provider — switch between opencode / opencode-go
31
+ */
32
+
33
+ import type { AgentContext } from "agent-sh/types";
34
+ import { resolveApiKey } from "agent-sh/auth";
35
+
36
+ // ── Constants ──────────────────────────────────────────────────────
37
+
38
+ const ZEN_BASE_URL = "https://opencode.ai/zen/v1";
39
+ const GO_BASE_URL = "https://opencode.ai/zen/go/v1";
40
+ const ZEN_MODELS_ENDPOINT = `${ZEN_BASE_URL}/models`;
41
+ const GO_MODELS_ENDPOINT = `${GO_BASE_URL}/models`;
42
+ const MODELS_DEV_ENDPOINT = "https://models.dev/api.json";
43
+
44
+ /** Conservative defaults when models.dev metadata is unavailable. */
45
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
46
+ const DEFAULT_MAX_TOKENS = 16_384;
47
+
48
+ // ── Fallback model lists (curated; kept in sync with OpenCode docs) ──
49
+
50
+ // Single fallback model per tier — the live /models catalog replaces these on startup.
51
+ const ZEN_FALLBACK_MODELS = ["claude-sonnet-4-6"];
52
+
53
+ const GO_FALLBACK_MODELS = ["gpt-5.2"];
54
+
55
+ // ── Types ───────────────────────────────────────────────────────────
56
+
57
+ interface ModelsDevLimit {
58
+ context?: number;
59
+ output?: number;
60
+ }
61
+
62
+ interface ModelsDevModelEntry {
63
+ id?: string;
64
+ name?: string;
65
+ reasoning?: boolean;
66
+ limit?: ModelsDevLimit;
67
+ modalities?: { input?: readonly string[] };
68
+ }
69
+
70
+ interface ModelsDevProviderEntry {
71
+ models?: Record<string, ModelsDevModelEntry>;
72
+ }
73
+
74
+ type ModelsDevResponse = Record<string, ModelsDevProviderEntry>;
75
+
76
+ interface OpenCodeModelListEntry {
77
+ id?: string;
78
+ }
79
+
80
+ interface OpenCodeModelListResponse {
81
+ data?: OpenCodeModelListEntry[];
82
+ }
83
+
84
+ interface ModelDef {
85
+ id: string;
86
+ reasoning: boolean;
87
+ contextWindow: number;
88
+ maxTokens: number;
89
+ modalities: ("text" | "image")[];
90
+ }
91
+
92
+ // ── Helpers ─────────────────────────────────────────────────────────
93
+
94
+ async function fetchJson<T>(url: string): Promise<T> {
95
+ const res = await fetch(url, {
96
+ headers: { Accept: "application/json" },
97
+ signal: AbortSignal.timeout(15_000),
98
+ });
99
+ if (!res.ok) {
100
+ throw new Error(`HTTP ${res.status} from ${url}`);
101
+ }
102
+ return res.json() as T;
103
+ }
104
+
105
+ function normalizePosNum(v: unknown): number | undefined {
106
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
107
+ }
108
+
109
+ // ── models.dev ──────────────────────────────────────────────────────
110
+
111
+ async function fetchModelsDev(): Promise<ModelsDevResponse | undefined> {
112
+ try {
113
+ const payload = await fetchJson<ModelsDevResponse>(MODELS_DEV_ENDPOINT);
114
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
115
+ return payload;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ function getModelsDevModel(
122
+ provider: ModelsDevProviderEntry | undefined,
123
+ modelId: string,
124
+ ): ModelsDevModelEntry | undefined {
125
+ const direct = provider?.models?.[modelId];
126
+ if (direct) return direct;
127
+ if (!provider?.models) return undefined;
128
+ for (const m of Object.values(provider.models)) {
129
+ if (m.id === modelId) return m;
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ // ── Official /models ────────────────────────────────────────────────
135
+
136
+ async function fetchOfficialModelIds(url: string): Promise<string[]> {
137
+ try {
138
+ const payload = await fetchJson<OpenCodeModelListResponse>(url);
139
+ if (!Array.isArray(payload.data)) throw new Error("Unexpected format");
140
+ const ids = new Set<string>();
141
+ for (const entry of payload.data) {
142
+ if (typeof entry.id === "string" && entry.id.trim()) {
143
+ ids.add(entry.id.trim());
144
+ }
145
+ }
146
+ return Array.from(ids);
147
+ } catch {
148
+ return [];
149
+ }
150
+ }
151
+
152
+ // ── Merge ───────────────────────────────────────────────────────────
153
+
154
+ function resolveModel(
155
+ modelId: string,
156
+ metadata: ModelsDevModelEntry | undefined,
157
+ ): ModelDef {
158
+ const rawModalities = metadata?.modalities?.input;
159
+ const modalities: ("text" | "image")[] = Array.isArray(rawModalities)
160
+ ? rawModalities.filter((v): v is "text" | "image" => v === "text" || v === "image")
161
+ : ["text"];
162
+ return {
163
+ id: modelId,
164
+ reasoning: metadata?.reasoning ?? false,
165
+ contextWindow: normalizePosNum(metadata?.limit?.context) ?? DEFAULT_CONTEXT_WINDOW,
166
+ maxTokens: normalizePosNum(metadata?.limit?.output) ?? DEFAULT_MAX_TOKENS,
167
+ modalities,
168
+ };
169
+ }
170
+
171
+ // ── Reasoning params (OpenAI-compatible) ────────────────────────────
172
+
173
+ function buildReasoningParams(level: string): Record<string, unknown> {
174
+ if (level === "off") return {};
175
+ return { reasoning_effort: level === "xhigh" ? "high" : level };
176
+ }
177
+
178
+ // ── Activation ──────────────────────────────────────────────────────
179
+
180
+ export default function activate(ctx: AgentContext): void {
181
+ const apiKey =
182
+ process.env.OPENCODE_API_KEY ??
183
+ resolveApiKey("opencode").key ?? undefined;
184
+
185
+ // ── Phase 1: register both providers synchronously with fallback models ──
186
+
187
+ ctx.agent.providers.configure("opencode", { reasoningParams: buildReasoningParams });
188
+ ctx.agent.providers.register({
189
+ id: "opencode",
190
+ apiKey,
191
+ baseURL: ZEN_BASE_URL,
192
+ defaultModel: ZEN_FALLBACK_MODELS[0],
193
+ models: ZEN_FALLBACK_MODELS,
194
+ supportsReasoningEffort: true,
195
+ });
196
+
197
+ ctx.agent.providers.configure("opencode-go", { reasoningParams: buildReasoningParams });
198
+ ctx.agent.providers.register({
199
+ id: "opencode-go",
200
+ apiKey,
201
+ baseURL: GO_BASE_URL,
202
+ defaultModel: GO_FALLBACK_MODELS[0],
203
+ models: GO_FALLBACK_MODELS,
204
+ supportsReasoningEffort: true,
205
+ });
206
+
207
+ if (!apiKey) return;
208
+
209
+ // ── Phase 2: fetch live catalogs and re-register with enriched metadata ──
210
+
211
+ fetchModelsDev()
212
+ .then(async (modelsDev) => {
213
+ const zenProvider = modelsDev?.opencode;
214
+ const goProvider = modelsDev?.["opencode-go"];
215
+
216
+ const [zenIds, goIds] = await Promise.all([
217
+ fetchOfficialModelIds(ZEN_MODELS_ENDPOINT),
218
+ fetchOfficialModelIds(GO_MODELS_ENDPOINT),
219
+ ]);
220
+
221
+ const resolveModels = (
222
+ ids: string[],
223
+ provider: ModelsDevProviderEntry | undefined,
224
+ fallback: string[],
225
+ ): ModelDef[] => {
226
+ const source = ids.length > 0 ? ids : fallback;
227
+ return source.map((id) => resolveModel(id, getModelsDevModel(provider, id)));
228
+ };
229
+
230
+ const zenModels = resolveModels(zenIds, zenProvider, ZEN_FALLBACK_MODELS);
231
+ const goModels = resolveModels(goIds, goProvider, GO_FALLBACK_MODELS);
232
+
233
+ ctx.agent.providers.register({
234
+ id: "opencode",
235
+ apiKey,
236
+ baseURL: ZEN_BASE_URL,
237
+ defaultModel: zenModels[0]?.id ?? ZEN_FALLBACK_MODELS[0],
238
+ models: zenModels,
239
+ supportsReasoningEffort: true,
240
+ });
241
+
242
+ ctx.agent.providers.register({
243
+ id: "opencode-go",
244
+ apiKey,
245
+ baseURL: GO_BASE_URL,
246
+ defaultModel: goModels[0]?.id ?? GO_FALLBACK_MODELS[0],
247
+ models: goModels,
248
+ supportsReasoningEffort: true,
249
+ });
250
+ })
251
+ .catch(() => {});
252
+ }
@@ -206,6 +206,7 @@ export default function activate(ctx: ExtensionContext): void {
206
206
  }
207
207
  bus.emit("agent:tool-started", {
208
208
  title: ev.toolName,
209
+ name: ev.toolName,
209
210
  toolCallId: ev.toolCallId,
210
211
  kind: kindForTool(ev.toolName),
211
212
  rawInput: ev.args,