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.
- package/README.md +61 -9
- package/dist/agent/system-prompt.js +2 -2
- package/dist/extensions/shell-context.d.ts +2 -1
- package/dist/extensions/shell-context.js +5 -4
- package/dist/index.js +30 -54
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +13 -8
- package/dist/shell/input-handler.js +75 -27
- package/dist/shell/tui-input-view.d.ts +5 -0
- package/dist/shell/tui-input-view.js +137 -96
- package/dist/utils/terminal-buffer.d.ts +6 -9
- package/dist/utils/terminal-buffer.js +21 -53
- package/examples/extensions/claude-code-bridge/README.md +14 -14
- package/examples/extensions/claude-code-bridge/index.ts +19 -25
- package/examples/extensions/opencode-bridge/README.md +59 -0
- package/examples/extensions/opencode-bridge/index.ts +601 -0
- package/examples/extensions/opencode-bridge/package.json +11 -0
- package/examples/extensions/pi-bridge/README.md +9 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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
|