agent-sh 0.12.24 → 0.12.26

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.
@@ -0,0 +1,601 @@
1
+ /**
2
+ * opencode bridge — runs opencode in-process as agent-sh's backend via
3
+ * @opencode-ai/sdk. The SDK boots an embedded HTTP server we talk to with
4
+ * a generated client; events stream over a single global SSE channel.
5
+ *
6
+ * Requires opencode authenticated locally (`opencode auth login`).
7
+ */
8
+ import {
9
+ createOpencode,
10
+ type OpencodeClient,
11
+ type Event,
12
+ type Part,
13
+ type ToolPart,
14
+ type QuestionRequest,
15
+ type QuestionInfo,
16
+ } from "@opencode-ai/sdk/v2";
17
+ import type { ExtensionContext } from "agent-sh/types";
18
+ import type { InteractiveSession } from "agent-sh/agent/types";
19
+ import { computeDiff, type DiffResult } from "agent-sh/utils/diff";
20
+ import { createToolUI } from "agent-sh/utils/tool-interactive";
21
+ import { palette as p } from "agent-sh/utils/palette";
22
+
23
+ function parseUnifiedDiff(patch: string): DiffResult | null {
24
+ if (!patch) return null;
25
+ const hunks: DiffResult["hunks"] = [];
26
+ let current: DiffResult["hunks"][number] | null = null;
27
+ let oldNo = 0;
28
+ let newNo = 0;
29
+ let added = 0;
30
+ let removed = 0;
31
+
32
+ for (const raw of patch.split("\n")) {
33
+ if (raw.startsWith("Index:") || raw.startsWith("===") || raw.startsWith("--- ") || raw.startsWith("+++ ")) continue;
34
+ const hunkHeader = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
35
+ if (hunkHeader) {
36
+ if (current) hunks.push(current);
37
+ current = { lines: [] };
38
+ oldNo = parseInt(hunkHeader[1]!, 10);
39
+ newNo = parseInt(hunkHeader[2]!, 10);
40
+ continue;
41
+ }
42
+ if (!current) continue;
43
+ if (raw.startsWith("+")) {
44
+ current.lines.push({ type: "added", oldNo: null, newNo, text: raw.slice(1) });
45
+ newNo++;
46
+ added++;
47
+ } else if (raw.startsWith("-")) {
48
+ current.lines.push({ type: "removed", oldNo, newNo: null, text: raw.slice(1) });
49
+ oldNo++;
50
+ removed++;
51
+ } else if (raw.startsWith(" ")) {
52
+ current.lines.push({ type: "context", oldNo, newNo, text: raw.slice(1) });
53
+ oldNo++;
54
+ newNo++;
55
+ }
56
+ }
57
+ if (current) hunks.push(current);
58
+ if (hunks.length === 0) return null;
59
+ return { hunks, added, removed, isIdentical: added + removed === 0, isNewFile: false };
60
+ }
61
+
62
+ export default function activate(ctx: ExtensionContext): void {
63
+ const { bus, call, compositor } = ctx;
64
+
65
+ const cwd = (): string => {
66
+ const v = call("cwd");
67
+ return typeof v === "string" && v ? v : process.cwd();
68
+ };
69
+
70
+ let runtime: { client: OpencodeClient; server: { url: string; close(): void } } | null = null;
71
+ let sessionId: string | null = null;
72
+ // opencode treats `directory` as the project ID and routes its SSE event
73
+ // stream per-project. If we let prompts use the user's PTY cwd freely,
74
+ // an in-shell `cd` switches opencode's project mid-session and our SSE
75
+ // (opened on the original project) goes silent — including for tool
76
+ // events. Pin everything to the directory captured at session.create;
77
+ // the agent still learns the user's real cwd via <shell_events> and
78
+ // can operate elsewhere through absolute paths or `cd && cmd` in Bash.
79
+ let sessionDirectory: string | null = null;
80
+ let serverAbort: AbortController | null = null;
81
+ let streamAbort: AbortController | null = null;
82
+ let booting = true;
83
+
84
+ const announcedTools = new Set<string>();
85
+ const completedTools = new Set<string>();
86
+ // message.part.delta only carries `field` ("text"), not the part's
87
+ // type. Cache type from message.part.updated to route deltas correctly
88
+ // (text → response, reasoning → thinking).
89
+ const partKinds = new Map<string, string>();
90
+ let turnText = "";
91
+
92
+ // prompt() and SSE deltas race; resolve the turn on session.idle.
93
+ let pendingTurnEnd: (() => void) | null = null;
94
+ let turnIdleSeen = false;
95
+ let turnError: string | null = null;
96
+
97
+ const listeners: Array<{ event: string; fn: Function }> = [];
98
+
99
+ function toolKind(name: string): string {
100
+ const n = name.toLowerCase();
101
+ if (n === "read") return "read";
102
+ if (n === "edit" || n === "patch") return "edit";
103
+ if (n === "write") return "write";
104
+ if (n === "glob" || n === "grep" || n === "list") return "search";
105
+ if (n === "bash" || n === "shell") return "execute";
106
+ return "execute";
107
+ }
108
+
109
+ function formatToolCall(name: string, input: Record<string, unknown>): string {
110
+ const str = (v: unknown) => typeof v === "string" ? v : "";
111
+ const n = name.toLowerCase();
112
+ if (n === "bash" || n === "shell") return `$ ${str(input.command)}`;
113
+ if (n === "read" || n === "edit" || n === "write") return str(input.filePath ?? input.file_path ?? input.path);
114
+ if (n === "grep" || n === "glob") return `${str(input.pattern)} ${str(input.path)}`.trim();
115
+ return name;
116
+ }
117
+
118
+ function toolLocations(input: Record<string, unknown>): { path: string; line?: number | null }[] | undefined {
119
+ const raw = input.filePath ?? input.file_path ?? input.path;
120
+ if (typeof raw !== "string") return undefined;
121
+ const line = (input.line_number ?? input.line ?? input.offset) as number | undefined;
122
+ return [{ path: raw, line: line ?? null }];
123
+ }
124
+
125
+ function handleToolPart(part: ToolPart): void {
126
+ const { callID, tool: toolName, state } = part;
127
+ // Question tool is presented via an interactive picker (see question.asked) —
128
+ // skip the timeline entry to avoid a duplicate "running" bar.
129
+ if (toolName === "question") return;
130
+ const kind = toolKind(toolName);
131
+
132
+ if (state.status !== "pending" && !announcedTools.has(callID)) {
133
+ announcedTools.add(callID);
134
+ bus.emit("agent:tool-started", {
135
+ title: toolName,
136
+ toolCallId: callID,
137
+ kind,
138
+ locations: toolLocations(state.input ?? {}),
139
+ rawInput: state.input,
140
+ displayDetail: formatToolCall(toolName, state.input ?? {}),
141
+ });
142
+ }
143
+
144
+ if ((state.status === "completed" || state.status === "error") && !completedTools.has(callID)) {
145
+ completedTools.add(callID);
146
+ const isError = state.status === "error";
147
+ const rawOutput = isError ? state.error : state.output;
148
+
149
+ let resultDisplay: { summary?: string; body?: { kind: "diff"; diff: DiffResult; filePath: string } } | undefined;
150
+ if (!isError && state.status === "completed") {
151
+ const filePath = state.input?.filePath as string | undefined;
152
+ let diff: DiffResult | null = null;
153
+ if (toolName === "edit") {
154
+ const patch = (state.metadata as any)?.filediff?.patch as string | undefined;
155
+ if (patch) diff = parseUnifiedDiff(patch);
156
+ } else if (toolName === "write") {
157
+ // Overwrites of existing files render as new-file diffs —
158
+ // opencode doesn't surface old content.
159
+ const content = state.input?.content as string | undefined;
160
+ if (typeof content === "string") diff = computeDiff(null, content);
161
+ }
162
+ if (diff && filePath && !diff.isIdentical) {
163
+ const summary = diff.isNewFile
164
+ ? `+${diff.added}`
165
+ : `+${diff.added} -${diff.removed}`;
166
+ resultDisplay = {
167
+ summary,
168
+ body: { kind: "diff", diff, filePath },
169
+ };
170
+ }
171
+ }
172
+
173
+ bus.emitTransform("agent:tool-completed", {
174
+ toolCallId: callID,
175
+ exitCode: isError ? 1 : 0,
176
+ rawOutput,
177
+ kind,
178
+ resultDisplay,
179
+ });
180
+ bus.emit("agent:tool-output", {
181
+ tool: toolName,
182
+ output: typeof rawOutput === "string" ? rawOutput : "",
183
+ exitCode: isError ? 1 : 0,
184
+ });
185
+ }
186
+ }
187
+
188
+ function emitTextDelta(text: string): void {
189
+ bus.emitTransform("agent:response-chunk", {
190
+ blocks: [{ type: "text" as const, text }],
191
+ });
192
+ turnText += text;
193
+ }
194
+
195
+
196
+ function handleEvent(event: Event): void {
197
+ if (!sessionId) return;
198
+ const evType = (event as any).type as string;
199
+ const props = (event as any).properties ?? {};
200
+ const sid = props.sessionID;
201
+ if (typeof sid === "string" && sid !== sessionId) return;
202
+
203
+ switch (evType) {
204
+ // message.part.delta is undocumented in the SDK's Event union but
205
+ // the SSE consumer yields it. Drop chunks for unknown partIDs —
206
+ // misrouting bleeds reasoning into the response or vice versa.
207
+ case "message.part.delta": {
208
+ if (typeof props.delta !== "string" || !props.delta) break;
209
+ const kind = partKinds.get(props.partID);
210
+ if (kind === "reasoning") bus.emit("agent:thinking-chunk", { text: props.delta });
211
+ else if (kind === "text") emitTextDelta(props.delta);
212
+ break;
213
+ }
214
+ case "message.part.updated": {
215
+ const part = props.part as Part | undefined;
216
+ if (!part) break;
217
+ partKinds.set(part.id, part.type);
218
+ if (part.type === "tool") handleToolPart(part);
219
+ break;
220
+ }
221
+ case "session.idle": {
222
+ turnIdleSeen = true;
223
+ pendingTurnEnd?.();
224
+ break;
225
+ }
226
+ case "session.error": {
227
+ const err = props.error as { message?: string } | undefined;
228
+ const message = err?.message ?? "opencode session error";
229
+ // session.prompt() does not always reject on session error;
230
+ // drive turn-end ourselves and abort to unstick a hanging prompt().
231
+ turnError = message;
232
+ bus.emit("agent:error", { message });
233
+ turnIdleSeen = true;
234
+ pendingTurnEnd?.();
235
+ if (runtime && sessionId) {
236
+ runtime.client.session
237
+ .abort({ sessionID: sessionId, directory: sessionDirectory ?? undefined })
238
+ .catch(() => { /* abort is best-effort */ });
239
+ }
240
+ break;
241
+ }
242
+ case "question.asked": {
243
+ const req = props as QuestionRequest;
244
+ if (!runtime) break;
245
+ const ui = createToolUI(bus, compositor.surface("agent"));
246
+ 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) {
261
+ bus.emitTransform("agent:tool-completed", {
262
+ toolCallId: callID,
263
+ exitCode: 1,
264
+ rawOutput: "cancelled",
265
+ kind: "execute",
266
+ resultDisplay: { summary: "cancelled" },
267
+ });
268
+ runtime.client.question
269
+ .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
270
+ .catch(() => { /* best-effort */ });
271
+ return;
272
+ }
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 },
284
+ });
285
+ 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
+ });
295
+ }
296
+ });
297
+ break;
298
+ }
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
+ }
312
+ }
313
+
314
+ async function consumeEvents(client: OpencodeClient, signal: AbortSignal): Promise<void> {
315
+ while (!signal.aborted) {
316
+ try {
317
+ const result = await client.event.subscribe({}, { signal });
318
+ for await (const ev of result.stream) {
319
+ if (signal.aborted) return;
320
+ handleEvent(ev as Event);
321
+ }
322
+ } catch {
323
+ if (signal.aborted) return;
324
+ await new Promise((r) => setTimeout(r, 1000));
325
+ }
326
+ }
327
+ }
328
+
329
+ const wireListeners = () => {
330
+ const onSubmit = async ({ query: userQuery }: { query: string }) => {
331
+ if (!runtime || !sessionId) {
332
+ bus.emit("agent:error", {
333
+ message: booting ? "opencode is still starting up..." : "opencode session not initialized",
334
+ });
335
+ bus.emit("agent:processing-done", {});
336
+ return;
337
+ }
338
+
339
+ bus.emit("agent:query", { query: userQuery });
340
+ bus.emit("agent:processing-start", {});
341
+ turnText = "";
342
+ turnIdleSeen = false;
343
+ turnError = null;
344
+ // Set the idle waiter BEFORE prompt() so a fast session.idle can't
345
+ // race in before we're listening.
346
+ const idlePromise = new Promise<void>((resolve) => {
347
+ pendingTurnEnd = () => { resolve(); pendingTurnEnd = null; };
348
+ });
349
+
350
+ const ctxText = String(call("query-context:build") ?? "").trim();
351
+ const finalPrompt = ctxText ? `${ctxText}\n\n${userQuery}` : userQuery;
352
+
353
+ try {
354
+ const res = await runtime.client.session.prompt({
355
+ sessionID: sessionId,
356
+ directory: sessionDirectory ?? undefined,
357
+ parts: [{ type: "text", text: finalPrompt }],
358
+ });
359
+ if (!turnIdleSeen) {
360
+ await Promise.race([
361
+ idlePromise,
362
+ new Promise<void>((r) => setTimeout(r, 60_000)),
363
+ ]);
364
+ }
365
+ if (turnError) {
366
+ bus.emitTransform("agent:response-done", { response: "" });
367
+ } else {
368
+ // Fallback if SSE never delivered text (network blip, missed
369
+ // partKinds entry); the prompt response always carries the final.
370
+ if (!turnText && res.data?.parts) {
371
+ for (const p of res.data.parts) {
372
+ if (p.type === "text" && p.text) turnText += p.text;
373
+ }
374
+ if (turnText) {
375
+ bus.emitTransform("agent:response-chunk", {
376
+ blocks: [{ type: "text" as const, text: turnText }],
377
+ });
378
+ }
379
+ }
380
+ bus.emitTransform("agent:response-done", { response: turnText });
381
+ }
382
+ } catch (err) {
383
+ if (!turnError) {
384
+ bus.emit("agent:error", {
385
+ message: err instanceof Error ? err.message : String(err),
386
+ });
387
+ }
388
+ } finally {
389
+ pendingTurnEnd = null;
390
+ bus.emit("agent:processing-done", {});
391
+ }
392
+ };
393
+
394
+ const onCancel = async () => {
395
+ if (!runtime || !sessionId) return;
396
+ try {
397
+ await runtime.client.session.abort({ sessionID: sessionId, directory: sessionDirectory ?? undefined });
398
+ } catch { /* abort is best-effort */ }
399
+ };
400
+
401
+ const onReset = async () => {
402
+ if (!runtime) return;
403
+ announcedTools.clear();
404
+ completedTools.clear();
405
+ partKinds.clear();
406
+ // /reset is the one moment we deliberately let the project switch.
407
+ sessionDirectory = cwd();
408
+ const res = await runtime.client.session.create({ directory: sessionDirectory });
409
+ sessionId = res.data?.id ?? null;
410
+ };
411
+
412
+ bus.on("agent:submit", onSubmit);
413
+ bus.on("agent:cancel-request", onCancel);
414
+ bus.on("agent:reset-session", onReset);
415
+ listeners.push(
416
+ { event: "agent:submit", fn: onSubmit },
417
+ { event: "agent:cancel-request", fn: onCancel },
418
+ { event: "agent:reset-session", fn: onReset },
419
+ );
420
+ };
421
+
422
+ const unwireListeners = () => {
423
+ for (const { event, fn } of listeners) bus.off(event as any, fn as any);
424
+ listeners.length = 0;
425
+ };
426
+
427
+ bus.emit("agent:register-backend", {
428
+ name: "opencode",
429
+ start: async () => {
430
+ try {
431
+ serverAbort = new AbortController();
432
+ runtime = await createOpencode({ signal: serverAbort.signal });
433
+
434
+ streamAbort = new AbortController();
435
+ // Subscribe before creating the session so we don't miss early events.
436
+ void consumeEvents(runtime.client, streamAbort.signal);
437
+
438
+ sessionDirectory = cwd();
439
+ const res = await runtime.client.session.create({ directory: sessionDirectory });
440
+ sessionId = res.data?.id ?? null;
441
+ if (!sessionId) throw new Error("session.create returned no id");
442
+
443
+ wireListeners();
444
+ booting = false;
445
+ bus.emit("agent:info", { name: "opencode", version: "2.x" });
446
+ } catch (err) {
447
+ booting = false;
448
+ bus.emit("ui:error", {
449
+ message: `opencode-bridge: failed to initialize — ${err instanceof Error ? err.message : String(err)}`,
450
+ });
451
+ }
452
+ },
453
+ kill: () => {
454
+ unwireListeners();
455
+ streamAbort?.abort();
456
+ serverAbort?.abort();
457
+ runtime?.server.close();
458
+ runtime = null;
459
+ sessionId = null;
460
+ sessionDirectory = null;
461
+ announcedTools.clear();
462
+ completedTools.clear();
463
+ partKinds.clear();
464
+ booting = true;
465
+ },
466
+ });
467
+ }
468
+
469
+ // ── Interactive question picker ──────────────────────────────────
470
+
471
+ type QuestionResult = { answers: string[][]; cancelled: boolean };
472
+
473
+ function isKey(data: string, key: string): boolean {
474
+ switch (key) {
475
+ case "up": return data === "\x1b[A" || data === "\x1bOA";
476
+ case "down": return data === "\x1b[B" || data === "\x1bOB";
477
+ case "left": return data === "\x1b[D" || data === "\x1bOD";
478
+ case "right": return data === "\x1b[C" || data === "\x1bOC";
479
+ case "enter": return data === "\r" || data === "\n";
480
+ case "escape": return data === "\x1b";
481
+ case "tab": return data === "\t";
482
+ default: return data === key;
483
+ }
484
+ }
485
+
486
+ function createQuestionSession(questions: QuestionInfo[]): InteractiveSession<QuestionResult> {
487
+ const isMulti = questions.length > 1;
488
+ let tab = 0;
489
+ let optionIdx = 0;
490
+ // Per-question selected option indices (set, to support `multiple`).
491
+ const selections: Set<number>[] = questions.map(() => new Set());
492
+
493
+ return {
494
+ render(width) {
495
+ const w = Math.min(80, width);
496
+ const lines: string[] = [];
497
+ const q = questions[tab]!;
498
+ const sel = selections[tab]!;
499
+
500
+ lines.push(`${p.muted}${"─".repeat(w)}${p.reset}`);
501
+
502
+ if (isMulti) {
503
+ const tabs = questions.map((qq, i) => {
504
+ const answered = selections[i]!.size > 0;
505
+ const active = i === tab;
506
+ const box = answered ? "■" : "□";
507
+ const label = ` ${box} ${qq.header || `Q${i + 1}`} `;
508
+ return active
509
+ ? `${p.accent}${p.bold}${label}${p.reset}`
510
+ : `${p.muted}${label}${p.reset}`;
511
+ });
512
+ lines.push(` ${tabs.join(" ")}`);
513
+ lines.push("");
514
+ }
515
+
516
+ lines.push(` ${q.question}`);
517
+ lines.push("");
518
+ for (let i = 0; i < q.options.length; i++) {
519
+ const opt = q.options[i]!;
520
+ const cursor = i === optionIdx ? p.accent : "";
521
+ const reset = i === optionIdx ? p.reset : "";
522
+ const arrow = i === optionIdx ? `${p.accent}>${p.reset} ` : " ";
523
+ const mark = q.multiple
524
+ ? (sel.has(i) ? "[x]" : "[ ]")
525
+ : (sel.has(i) ? "(o)" : "( )");
526
+ lines.push(`${arrow}${cursor}${mark} ${i + 1}. ${opt.label}${reset}`);
527
+ if (opt.description) {
528
+ lines.push(` ${p.muted}${opt.description}${p.reset}`);
529
+ }
530
+ }
531
+
532
+ lines.push("");
533
+ const navKeys = isMulti ? "Tab/←→ switch • " : "";
534
+ const actionKeys = q.multiple
535
+ ? "↑↓ navigate • Space toggle • Enter confirm • Esc cancel"
536
+ : "↑↓ navigate • Enter select • Esc cancel";
537
+ lines.push(` ${p.dim}${navKeys}${actionKeys}${p.reset}`);
538
+ lines.push(`${p.muted}${"─".repeat(w)}${p.reset}`);
539
+ return lines;
540
+ },
541
+
542
+ handleInput(data, done) {
543
+ const q = questions[tab]!;
544
+ const sel = selections[tab]!;
545
+
546
+ if (isKey(data, "escape")) {
547
+ done({ answers: [], cancelled: true });
548
+ return;
549
+ }
550
+
551
+ if (isMulti) {
552
+ if (isKey(data, "tab") || isKey(data, "right")) {
553
+ tab = (tab + 1) % questions.length;
554
+ optionIdx = 0;
555
+ return;
556
+ }
557
+ if (isKey(data, "left")) {
558
+ tab = (tab - 1 + questions.length) % questions.length;
559
+ optionIdx = 0;
560
+ return;
561
+ }
562
+ }
563
+
564
+ if (isKey(data, "up")) {
565
+ optionIdx = Math.max(0, optionIdx - 1);
566
+ return;
567
+ }
568
+ if (isKey(data, "down")) {
569
+ optionIdx = Math.min(q.options.length - 1, optionIdx + 1);
570
+ return;
571
+ }
572
+
573
+ if (q.multiple && data === " ") {
574
+ if (sel.has(optionIdx)) sel.delete(optionIdx); else sel.add(optionIdx);
575
+ return;
576
+ }
577
+
578
+ if (isKey(data, "enter")) {
579
+ if (!q.multiple) {
580
+ sel.clear();
581
+ sel.add(optionIdx);
582
+ }
583
+ if (sel.size === 0) return;
584
+
585
+ const allAnswered = selections.every((s) => s.size > 0);
586
+ if (!isMulti || allAnswered) {
587
+ const answers = questions.map((qq, i) =>
588
+ Array.from(selections[i]!).map((idx) => qq.options[idx]!.label),
589
+ );
590
+ done({ answers, cancelled: false });
591
+ return;
592
+ }
593
+ const next = selections.findIndex((s) => s.size === 0);
594
+ if (next !== -1) {
595
+ tab = next;
596
+ optionIdx = 0;
597
+ }
598
+ }
599
+ },
600
+ };
601
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "agent-sh-opencode-bridge",
3
+ "version": "0.1.0",
4
+ "description": "opencode agent backend for agent-sh",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "dependencies": {
8
+ "@opencode-ai/sdk": "^1.14.41",
9
+ "agent-sh": "^0.12.0"
10
+ }
11
+ }
@@ -4,10 +4,17 @@ Runs [pi](https://github.com/badlogic/pi-mono) (`@mariozechner/pi-coding-agent`)
4
4
 
5
5
  ## Install
6
6
 
7
+ ```bash
8
+ agent-sh install pi-bridge
9
+ ```
10
+
11
+ This copies the bundled extension into `~/.agent-sh/extensions/pi-bridge` and runs `npm install` for you. To overwrite an existing install, pass `--force`. To uninstall, run `agent-sh uninstall pi-bridge`.
12
+
13
+ Manual alternative (e.g. for a development checkout you want to symlink):
14
+
7
15
  ```bash
8
16
  cp -r examples/extensions/pi-bridge ~/.agent-sh/extensions/pi-bridge
9
- cd ~/.agent-sh/extensions/pi-bridge
10
- npm install
17
+ cd ~/.agent-sh/extensions/pi-bridge && npm install
11
18
  ```
12
19
 
13
20
  ## Configure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.24",
3
+ "version": "0.12.26",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",