agent-sh 0.12.25 → 0.12.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,9 +5,20 @@
5
5
  *
6
6
  * Requires opencode authenticated locally (`opencode auth login`).
7
7
  */
8
- import { createOpencode, type OpencodeClient, type Event, type Part, type ToolPart } from "@opencode-ai/sdk";
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";
9
17
  import type { ExtensionContext } from "agent-sh/types";
18
+ import type { InteractiveSession } from "agent-sh/agent/types";
10
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";
11
22
 
12
23
  function parseUnifiedDiff(patch: string): DiffResult | null {
13
24
  if (!patch) return null;
@@ -49,7 +60,7 @@ function parseUnifiedDiff(patch: string): DiffResult | null {
49
60
  }
50
61
 
51
62
  export default function activate(ctx: ExtensionContext): void {
52
- const { bus, call } = ctx;
63
+ const { bus, call, compositor } = ctx;
53
64
 
54
65
  const cwd = (): string => {
55
66
  const v = call("cwd");
@@ -81,6 +92,7 @@ export default function activate(ctx: ExtensionContext): void {
81
92
  // prompt() and SSE deltas race; resolve the turn on session.idle.
82
93
  let pendingTurnEnd: (() => void) | null = null;
83
94
  let turnIdleSeen = false;
95
+ let turnError: string | null = null;
84
96
 
85
97
  const listeners: Array<{ event: string; fn: Function }> = [];
86
98
 
@@ -112,6 +124,9 @@ export default function activate(ctx: ExtensionContext): void {
112
124
 
113
125
  function handleToolPart(part: ToolPart): void {
114
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;
115
130
  const kind = toolKind(toolName);
116
131
 
117
132
  if (state.status !== "pending" && !announcedTools.has(callID)) {
@@ -177,6 +192,7 @@ export default function activate(ctx: ExtensionContext): void {
177
192
  turnText += text;
178
193
  }
179
194
 
195
+
180
196
  function handleEvent(event: Event): void {
181
197
  if (!sessionId) return;
182
198
  const evType = (event as any).type as string;
@@ -209,23 +225,86 @@ export default function activate(ctx: ExtensionContext): void {
209
225
  }
210
226
  case "session.error": {
211
227
  const err = props.error as { message?: string } | undefined;
212
- bus.emit("agent:error", { message: err?.message ?? "opencode session error" });
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
+ });
213
297
  break;
214
298
  }
215
299
  // Without a reply the gated tool hangs forever. The bridge has no
216
300
  // interactive approval UI, so auto-approve — mirrors claude-code-
217
301
  // bridge's permissionMode: "acceptEdits". Set permission.edit:
218
302
  // "allow" in opencode.json to skip the round-trip entirely.
219
- case "permission.asked":
220
- case "permission.updated": {
221
- const permissionID = props.id as string | undefined;
222
- if (!permissionID || !runtime || !sessionId) break;
223
- runtime.client
224
- .postSessionIdPermissionsPermissionId({
225
- path: { id: sessionId, permissionID },
226
- query: sessionDirectory ? { directory: sessionDirectory } : undefined,
227
- body: { response: "once" },
228
- })
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 })
229
308
  .catch(() => { /* approval is best-effort */ });
230
309
  break;
231
310
  }
@@ -235,7 +314,7 @@ export default function activate(ctx: ExtensionContext): void {
235
314
  async function consumeEvents(client: OpencodeClient, signal: AbortSignal): Promise<void> {
236
315
  while (!signal.aborted) {
237
316
  try {
238
- const result = await client.event.subscribe({ signal });
317
+ const result = await client.event.subscribe({}, { signal });
239
318
  for await (const ev of result.stream) {
240
319
  if (signal.aborted) return;
241
320
  handleEvent(ev as Event);
@@ -261,6 +340,7 @@ export default function activate(ctx: ExtensionContext): void {
261
340
  bus.emit("agent:processing-start", {});
262
341
  turnText = "";
263
342
  turnIdleSeen = false;
343
+ turnError = null;
264
344
  // Set the idle waiter BEFORE prompt() so a fast session.idle can't
265
345
  // race in before we're listening.
266
346
  const idlePromise = new Promise<void>((resolve) => {
@@ -272,11 +352,9 @@ export default function activate(ctx: ExtensionContext): void {
272
352
 
273
353
  try {
274
354
  const res = await runtime.client.session.prompt({
275
- path: { id: sessionId },
276
- query: sessionDirectory ? { directory: sessionDirectory } : undefined,
277
- body: {
278
- parts: [{ type: "text", text: finalPrompt }],
279
- },
355
+ sessionID: sessionId,
356
+ directory: sessionDirectory ?? undefined,
357
+ parts: [{ type: "text", text: finalPrompt }],
280
358
  });
281
359
  if (!turnIdleSeen) {
282
360
  await Promise.race([
@@ -284,23 +362,29 @@ export default function activate(ctx: ExtensionContext): void {
284
362
  new Promise<void>((r) => setTimeout(r, 60_000)),
285
363
  ]);
286
364
  }
287
- // Fallback if SSE never delivered text (network blip, missed
288
- // partKinds entry); the prompt response always carries the final.
289
- if (!turnText && res.data?.parts) {
290
- for (const p of res.data.parts) {
291
- if (p.type === "text" && p.text) turnText += p.text;
292
- }
293
- if (turnText) {
294
- bus.emitTransform("agent:response-chunk", {
295
- blocks: [{ type: "text" as const, text: turnText }],
296
- });
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
+ }
297
379
  }
380
+ bus.emitTransform("agent:response-done", { response: turnText });
298
381
  }
299
- bus.emitTransform("agent:response-done", { response: turnText });
300
382
  } catch (err) {
301
- bus.emit("agent:error", {
302
- message: err instanceof Error ? err.message : String(err),
303
- });
383
+ if (!turnError) {
384
+ bus.emit("agent:error", {
385
+ message: err instanceof Error ? err.message : String(err),
386
+ });
387
+ }
304
388
  } finally {
305
389
  pendingTurnEnd = null;
306
390
  bus.emit("agent:processing-done", {});
@@ -310,7 +394,7 @@ export default function activate(ctx: ExtensionContext): void {
310
394
  const onCancel = async () => {
311
395
  if (!runtime || !sessionId) return;
312
396
  try {
313
- await runtime.client.session.abort({ path: { id: sessionId } });
397
+ await runtime.client.session.abort({ sessionID: sessionId, directory: sessionDirectory ?? undefined });
314
398
  } catch { /* abort is best-effort */ }
315
399
  };
316
400
 
@@ -321,7 +405,7 @@ export default function activate(ctx: ExtensionContext): void {
321
405
  partKinds.clear();
322
406
  // /reset is the one moment we deliberately let the project switch.
323
407
  sessionDirectory = cwd();
324
- const res = await runtime.client.session.create({ query: { directory: sessionDirectory } });
408
+ const res = await runtime.client.session.create({ directory: sessionDirectory });
325
409
  sessionId = res.data?.id ?? null;
326
410
  };
327
411
 
@@ -352,13 +436,13 @@ export default function activate(ctx: ExtensionContext): void {
352
436
  void consumeEvents(runtime.client, streamAbort.signal);
353
437
 
354
438
  sessionDirectory = cwd();
355
- const res = await runtime.client.session.create({ query: { directory: sessionDirectory } });
439
+ const res = await runtime.client.session.create({ directory: sessionDirectory });
356
440
  sessionId = res.data?.id ?? null;
357
441
  if (!sessionId) throw new Error("session.create returned no id");
358
442
 
359
443
  wireListeners();
360
444
  booting = false;
361
- bus.emit("agent:info", { name: "opencode", version: "1.x" });
445
+ bus.emit("agent:info", { name: "opencode", version: "2.x" });
362
446
  } catch (err) {
363
447
  booting = false;
364
448
  bus.emit("ui:error", {
@@ -381,3 +465,137 @@ export default function activate(ctx: ExtensionContext): void {
381
465
  },
382
466
  });
383
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
+ }
@@ -104,8 +104,18 @@ export default function activate(ctx: ExtensionContext): void {
104
104
  surface: panelSurface,
105
105
  });
106
106
  }
107
- panel.setActive();
108
- session.submit(query);
107
+ if (query.startsWith("/")) {
108
+ // Sync commands (/model, /help) render via ui:info and leave us in
109
+ // input phase; ones that fan out to agent:submit flip the phase via
110
+ // the agent:processing-start listener below.
111
+ const spaceIdx = query.indexOf(" ");
112
+ const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
113
+ const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
114
+ bus.emit("command:execute", { name, args });
115
+ } else {
116
+ panel.setActive();
117
+ session.submit(query);
118
+ }
109
119
  });
110
120
 
111
121
  panel.handlers.advise("panel:show", (_next) => {
@@ -114,9 +124,9 @@ export default function activate(ctx: ExtensionContext): void {
114
124
  }
115
125
  });
116
126
 
117
- // Keep the session alive while the agent is still working, even after
118
- // dismiss so output keeps buffering and tools keep executing.
119
- panel.handlers.advise("panel:dismiss", (next) => {
127
+ // While the agent is still working, keep the session open so output and
128
+ // tool calls survive a hide. Once it's idle, close to release redirects.
129
+ panel.handlers.advise("panel:hide", (next) => {
120
130
  next();
121
131
  if (session && !panel.processing) {
122
132
  session.close();
@@ -124,6 +134,19 @@ export default function activate(ctx: ExtensionContext): void {
124
134
  }
125
135
  });
126
136
 
137
+ panel.handlers.advise("panel:reset", (next) => {
138
+ next();
139
+ if (session) {
140
+ session.close();
141
+ session = null;
142
+ }
143
+ });
144
+
145
+ // Picks up turns triggered indirectly (e.g. /skill:foo → agent:submit).
146
+ bus.on("agent:processing-start", () => {
147
+ if (panel.active && !panel.processing) panel.setActive();
148
+ });
149
+
127
150
  bus.on("agent:processing-done", () => {
128
151
  if (panel.active) panel.setDone();
129
152
  });