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.
- package/README.md +7 -18
- package/dist/agent/agent-loop.d.ts +1 -1
- package/dist/agent/agent-loop.js +42 -31
- package/dist/agent/conversation-state.d.ts +3 -2
- package/dist/agent/conversation-state.js +20 -3
- package/dist/agent/events.d.ts +2 -0
- package/dist/agent/host-types.d.ts +3 -0
- package/dist/agent/index.js +2 -1
- package/dist/agent/llm-client.js +1 -0
- package/dist/agent/subagent.d.ts +1 -1
- package/dist/agent/subagent.js +5 -1
- package/dist/agent/tool-protocol.d.ts +2 -2
- package/dist/agent/tool-protocol.js +5 -4
- package/dist/agent/tools/glob.d.ts +1 -1
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.d.ts +1 -1
- package/dist/agent/tools/grep.js +4 -2
- package/dist/agent/tools/ls.d.ts +1 -1
- package/dist/agent/tools/ls.js +4 -2
- package/dist/agent/tools/read-file.d.ts +1 -1
- package/dist/agent/tools/read-file.js +30 -2
- package/dist/agent/types.d.ts +13 -3
- package/dist/agent/types.js +6 -1
- package/dist/cli/args.js +3 -1
- package/dist/cli/index.js +0 -0
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +86 -2
- package/dist/cli/subcommands.js +4 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/settings.d.ts +3 -0
- package/dist/core/settings.js +2 -2
- package/dist/shell/index.d.ts +6 -0
- package/dist/shell/index.js +10 -10
- package/dist/shell/shell.d.ts +4 -0
- package/dist/shell/shell.js +15 -29
- package/dist/shell/terminal.d.ts +33 -0
- package/dist/shell/terminal.js +62 -0
- package/dist/utils/tool-interactive.js +4 -2
- package/examples/extensions/ash-scheme/index.ts +2170 -0
- package/examples/extensions/ash-scheme/package.json +11 -0
- package/examples/extensions/ash-scheme-render.ts +58 -0
- package/examples/extensions/ashi/README.md +36 -26
- package/examples/extensions/ashi/package.json +9 -1
- package/examples/extensions/ashi/src/capture.ts +1 -0
- package/examples/extensions/ashi/src/cli.ts +25 -8
- package/examples/extensions/ashi/src/compaction.ts +25 -96
- package/examples/extensions/ashi/src/components.ts +64 -166
- package/examples/extensions/ashi/src/default-schema-renderers.ts +229 -0
- package/examples/extensions/ashi/src/display-config.ts +21 -22
- package/examples/extensions/ashi/src/frontend.ts +64 -65
- package/examples/extensions/ashi/src/hooks.ts +47 -63
- package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
- package/examples/extensions/ashi/src/schema.ts +407 -0
- package/examples/extensions/ashi/src/session-store.ts +55 -4
- package/examples/extensions/ashi/src/status-footer.ts +27 -6
- package/examples/extensions/ashi-compact-llm.ts +93 -0
- package/examples/extensions/claude-code-bridge/index.ts +9 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/index.ts +208 -53
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/examples/extensions/opencode-provider.ts +252 -0
- package/examples/extensions/pi-bridge/index.ts +1 -0
- package/package.json +12 -1
- 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 = () => {
|
|
301
|
+
const onReset = () => { sessionId = null; };
|
|
295
302
|
|
|
296
303
|
bus.on("agent:submit", onSubmit);
|
|
297
304
|
bus.on("agent:cancel-request", onCancel);
|
|
@@ -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;
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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:
|
|
368
|
+
rawOutput: summary,
|
|
265
369
|
kind: "execute",
|
|
266
|
-
resultDisplay: { summary
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|