agent-sh 0.13.7 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +7 -18
  2. package/dist/agent/agent-loop.d.ts +13 -17
  3. package/dist/agent/agent-loop.js +118 -224
  4. package/dist/agent/conversation-state.d.ts +1 -1
  5. package/dist/agent/events.d.ts +218 -0
  6. package/dist/agent/events.js +1 -0
  7. package/dist/agent/host-types.d.ts +20 -0
  8. package/dist/agent/index.d.ts +5 -9
  9. package/dist/agent/index.js +269 -167
  10. package/dist/{utils → agent}/llm-client.js +1 -0
  11. package/dist/agent/llm-facade.d.ts +13 -0
  12. package/dist/{utils → agent}/llm-facade.js +1 -1
  13. package/dist/agent/nuclear-form.d.ts +1 -1
  14. package/dist/agent/providers/deepseek.js +2 -5
  15. package/dist/agent/providers/openai-compatible.js +2 -2
  16. package/dist/agent/providers/openai.js +2 -5
  17. package/dist/agent/providers/openrouter.js +5 -5
  18. package/dist/agent/subagent.d.ts +1 -1
  19. package/dist/agent/tool-protocol.d.ts +1 -1
  20. package/dist/agent/tool-registry.d.ts +1 -1
  21. package/dist/agent/types.d.ts +2 -2
  22. package/dist/cli/args.js +3 -1
  23. package/dist/cli/auth/cli.js +11 -6
  24. package/dist/cli/auth/discover.d.ts +5 -0
  25. package/dist/cli/auth/discover.js +25 -0
  26. package/dist/cli/auth/keys.d.ts +5 -2
  27. package/dist/cli/auth/keys.js +22 -2
  28. package/dist/cli/index.d.ts +16 -0
  29. package/dist/cli/index.js +12 -2
  30. package/dist/cli/install.d.ts +1 -0
  31. package/dist/cli/install.js +86 -2
  32. package/dist/cli/subcommands.js +4 -1
  33. package/dist/core/event-bus.d.ts +28 -371
  34. package/dist/core/extension-loader.js +6 -6
  35. package/dist/core/index.d.ts +10 -29
  36. package/dist/core/index.js +32 -84
  37. package/dist/extensions/index.d.ts +2 -1
  38. package/dist/extensions/index.js +1 -1
  39. package/dist/extensions/slash-commands/events.d.ts +18 -0
  40. package/dist/extensions/slash-commands/events.js +1 -0
  41. package/dist/extensions/slash-commands/index.d.ts +15 -0
  42. package/dist/extensions/{slash-commands.js → slash-commands/index.js} +4 -3
  43. package/dist/shell/events.d.ts +85 -0
  44. package/dist/shell/events.js +1 -0
  45. package/dist/shell/index.d.ts +1 -0
  46. package/dist/shell/index.js +6 -0
  47. package/dist/shell/tui-renderer.js +0 -1
  48. package/dist/utils/tool-interactive.js +4 -2
  49. package/examples/extensions/ash-acp-bridge/src/index.ts +2 -2
  50. package/examples/extensions/ashi/package.json +2 -2
  51. package/examples/extensions/ashi/src/cli.ts +4 -1
  52. package/examples/extensions/claude-code-bridge/index.ts +7 -2
  53. package/examples/extensions/claude-code-bridge/package.json +1 -1
  54. package/examples/extensions/ollama.ts +47 -42
  55. package/examples/extensions/opencode-bridge/README.md +4 -0
  56. package/examples/extensions/opencode-bridge/index.ts +208 -54
  57. package/examples/extensions/opencode-bridge/package.json +1 -1
  58. package/examples/extensions/pi-bridge/index.ts +3 -4
  59. package/examples/extensions/zai-coding-plan.ts +2 -6
  60. package/package.json +1 -1
  61. package/dist/extensions/slash-commands.d.ts +0 -2
  62. package/dist/utils/llm-facade.d.ts +0 -11
  63. /package/dist/{utils → agent}/llm-client.d.ts +0 -0
@@ -13,6 +13,7 @@ import {
13
13
  type ToolPart,
14
14
  type QuestionRequest,
15
15
  type QuestionInfo,
16
+ type PermissionRequest,
16
17
  } from "@opencode-ai/sdk/v2";
17
18
  import type { ExtensionContext } from "agent-sh/types";
18
19
  import type { InteractiveSession } from "agent-sh/agent/types";
@@ -60,7 +61,8 @@ function parseUnifiedDiff(patch: string): DiffResult | null {
60
61
  }
61
62
 
62
63
  export default function activate(ctx: ExtensionContext): void {
63
- const { bus, call } = ctx; const { compositor } = ctx.shell;
64
+ const { bus, call } = ctx;
65
+ const compositor = ctx.shell?.compositor;
64
66
 
65
67
  const cwd = (): string => {
66
68
  const v = call("cwd");
@@ -94,6 +96,18 @@ export default function activate(ctx: ExtensionContext): void {
94
96
  let turnIdleSeen = false;
95
97
  let turnError: string | null = null;
96
98
 
99
+ let pickerOpen = false;
100
+ const eventQueue: Event[] = [];
101
+ const drainQueue = (): void => {
102
+ const events = eventQueue.splice(0);
103
+ for (const ev of events) handleEvent(ev);
104
+ };
105
+
106
+ // After Ctrl+C, opencode emits a tail of tool / error state for the
107
+ // aborted turn. Suppress until the next turn so it doesn't restart the
108
+ // spinner or replay dead tool entries.
109
+ let cancelledTurn = false;
110
+
97
111
  const listeners: Array<{ event: string; fn: Function }> = [];
98
112
 
99
113
  function toolKind(name: string): string {
@@ -194,12 +208,24 @@ export default function activate(ctx: ExtensionContext): void {
194
208
 
195
209
 
196
210
  function handleEvent(event: Event): void {
211
+ if (pickerOpen) { eventQueue.push(event); return; }
197
212
  if (!sessionId) return;
198
213
  const evType = (event as any).type as string;
199
214
  const props = (event as any).properties ?? {};
200
215
  const sid = props.sessionID;
201
216
  if (typeof sid === "string" && sid !== sessionId) return;
202
217
 
218
+ if (cancelledTurn) {
219
+ // Only let through what unblocks onSubmit. Clearing the flag earlier
220
+ // (e.g. on session.error) lets the trailing bash part.updated slip
221
+ // past and restart the spinner.
222
+ if (evType === "session.idle" || evType === "session.error") {
223
+ turnIdleSeen = true;
224
+ pendingTurnEnd?.();
225
+ }
226
+ return;
227
+ }
228
+
203
229
  switch (evType) {
204
230
  // message.part.delta is undocumented in the SDK's Event union but
205
231
  // the SSE consumer yields it. Drop chunks for unknown partIDs —
@@ -242,72 +268,141 @@ export default function activate(ctx: ExtensionContext): void {
242
268
  case "question.asked": {
243
269
  const req = props as QuestionRequest;
244
270
  if (!runtime) break;
271
+ if (!compositor) {
272
+ runtime.client.question
273
+ .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
274
+ .catch(() => { /* best-effort */ });
275
+ bus.emit("ui:error", {
276
+ message: `opencode-bridge: rejected interactive question (no shell host): ${req.questions.map((q) => q.question).join("; ")}`,
277
+ });
278
+ break;
279
+ }
280
+ pickerOpen = true;
245
281
  const ui = createToolUI(bus, compositor.surface("agent"));
246
282
  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) {
283
+ try {
284
+ if (!runtime) return;
285
+ // Record the question + answer as a synthetic tool entry so the
286
+ // timeline shows what was asked and what the user picked.
287
+ const callID = `question-${req.id}`;
288
+ const detail = req.questions.length === 1
289
+ ? req.questions[0]!.question
290
+ : req.questions.map((q, i) => `${q.header || `Q${i + 1}`}: ${q.question}`).join("; ");
291
+ bus.emit("agent:tool-started", {
292
+ title: "question",
293
+ toolCallId: callID,
294
+ kind: "execute",
295
+ displayDetail: detail,
296
+ });
297
+ if (result.cancelled) {
298
+ bus.emitTransform("agent:tool-completed", {
299
+ toolCallId: callID,
300
+ exitCode: 1,
301
+ rawOutput: "cancelled",
302
+ kind: "execute",
303
+ resultDisplay: { summary: "cancelled" },
304
+ });
305
+ runtime.client.question
306
+ .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
307
+ .catch(() => { /* best-effort */ });
308
+ return;
309
+ }
310
+ const summary = result.answers.length === 1
311
+ ? result.answers[0]!.join(", ")
312
+ : result.answers
313
+ .map((ans, i) => `${req.questions[i]!.header || `Q${i + 1}`}: ${ans.join(", ")}`)
314
+ .join("; ");
315
+ bus.emitTransform("agent:tool-completed", {
316
+ toolCallId: callID,
317
+ exitCode: 0,
318
+ rawOutput: summary,
319
+ kind: "execute",
320
+ resultDisplay: { summary },
321
+ });
322
+ try {
323
+ await runtime.client.question.reply({
324
+ requestID: req.id,
325
+ answers: result.answers,
326
+ directory: sessionDirectory ?? undefined,
327
+ });
328
+ } catch (err) {
329
+ bus.emit("agent:error", {
330
+ message: err instanceof Error ? err.message : String(err),
331
+ });
332
+ }
333
+ } finally {
334
+ pickerOpen = false;
335
+ if (result.cancelled) {
336
+ eventQueue.length = 0;
337
+ cancelledTurn = true;
338
+ pendingTurnEnd?.();
339
+ } else {
340
+ drainQueue();
341
+ }
342
+ }
343
+ });
344
+ break;
345
+ }
346
+ case "permission.asked": {
347
+ const req = props as PermissionRequest;
348
+ if (!runtime) break;
349
+ const detail = req.patterns.length > 0
350
+ ? `${req.permission}: ${req.patterns.join(", ")}`
351
+ : req.permission;
352
+ const finish = (reply: "once" | "always" | "reject", opts?: { note?: string; skipReply?: boolean }) => {
353
+ if (reply === "reject") {
354
+ const callID = `permission-${req.id}`;
355
+ const summary = opts?.note ? `denied (${opts.note})` : `denied: ${detail}`;
356
+ bus.emit("agent:tool-started", {
357
+ title: "permission",
358
+ toolCallId: callID,
359
+ kind: "execute",
360
+ displayDetail: detail,
361
+ });
261
362
  bus.emitTransform("agent:tool-completed", {
262
363
  toolCallId: callID,
263
364
  exitCode: 1,
264
- rawOutput: "cancelled",
365
+ rawOutput: summary,
265
366
  kind: "execute",
266
- resultDisplay: { summary: "cancelled" },
367
+ resultDisplay: { summary },
267
368
  });
268
- runtime.client.question
269
- .reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
270
- .catch(() => { /* best-effort */ });
271
- return;
272
369
  }
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 },
370
+ if (!runtime || opts?.skipReply) return;
371
+ runtime.client.permission
372
+ .reply({ requestID: req.id, reply, directory: sessionDirectory ?? undefined })
373
+ .catch((err) => {
374
+ bus.emit("agent:error", {
375
+ message: err instanceof Error ? err.message : String(err),
376
+ });
377
+ });
378
+ };
379
+ if (!compositor) {
380
+ finish("reject", { note: "no shell host" });
381
+ bus.emit("ui:error", {
382
+ message: `opencode-bridge: rejected permission (no shell host): ${detail}`,
284
383
  });
384
+ break;
385
+ }
386
+ pickerOpen = true;
387
+ const ui = createToolUI(bus, compositor.surface("agent"));
388
+ ui.custom(createPermissionSession(req, bus)).then((result: PermissionResult) => {
285
389
  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
- });
390
+ finish(result.reply, result.cancelled ? { skipReply: true } : undefined);
391
+ } finally {
392
+ pickerOpen = false;
393
+ if (result.cancelled) {
394
+ // Let onSubmit's finally emit the single agent:processing-done;
395
+ // a second one here races and stacks prompts.
396
+ eventQueue.length = 0;
397
+ cancelledTurn = true;
398
+ pendingTurnEnd?.();
399
+ } else {
400
+ drainQueue();
401
+ }
295
402
  }
296
403
  });
297
404
  break;
298
405
  }
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
406
  }
312
407
  }
313
408
 
@@ -339,6 +434,7 @@ export default function activate(ctx: ExtensionContext): void {
339
434
  bus.emit("agent:query", { query: userQuery });
340
435
  bus.emit("agent:processing-start", {});
341
436
  turnText = "";
437
+ cancelledTurn = false;
342
438
  turnIdleSeen = false;
343
439
  turnError = null;
344
440
  // Set the idle waiter BEFORE prompt() so a fast session.idle can't
@@ -429,7 +525,9 @@ export default function activate(ctx: ExtensionContext): void {
429
525
  start: async () => {
430
526
  try {
431
527
  serverAbort = new AbortController();
432
- runtime = await createOpencode({ signal: serverAbort.signal });
528
+ // port: 0 dodges collision with SDK default 4096 (override via OPENCODE_SDK_PORT).
529
+ const port = process.env.OPENCODE_SDK_PORT ? Number(process.env.OPENCODE_SDK_PORT) : 0;
530
+ runtime = await createOpencode({ signal: serverAbort.signal, port });
433
531
 
434
532
  streamAbort = new AbortController();
435
533
  // Subscribe before creating the session so we don't miss early events.
@@ -469,6 +567,7 @@ export default function activate(ctx: ExtensionContext): void {
469
567
  // ── Interactive question picker ──────────────────────────────────
470
568
 
471
569
  type QuestionResult = { answers: string[][]; cancelled: boolean };
570
+ type PermissionResult = { reply: "once" | "always" | "reject"; cancelled?: boolean };
472
571
 
473
572
  function isKey(data: string, key: string): boolean {
474
573
  switch (key) {
@@ -599,3 +698,58 @@ function createQuestionSession(questions: QuestionInfo[]): InteractiveSession<Qu
599
698
  },
600
699
  };
601
700
  }
701
+
702
+ function createPermissionSession(
703
+ req: PermissionRequest,
704
+ bus: { on: (e: "agent:cancel-request", fn: () => void) => void; off: (e: "agent:cancel-request", fn: () => void) => void },
705
+ ): InteractiveSession<PermissionResult> {
706
+ let cancelHandler: (() => void) | null = null;
707
+ // Cast widens onMount: the vendored agent-sh type still declares the 1-arg signature.
708
+ const onMount = ((_invalidate: () => void, done: (r: PermissionResult) => void): void => {
709
+ cancelHandler = () => done({ reply: "reject", cancelled: true });
710
+ bus.on("agent:cancel-request", cancelHandler);
711
+ }) as InteractiveSession<PermissionResult>["onMount"];
712
+ return {
713
+ onMount,
714
+ onUnmount() {
715
+ if (cancelHandler) bus.off("agent:cancel-request", cancelHandler);
716
+ cancelHandler = null;
717
+ },
718
+ render(_width) {
719
+ const lines: string[] = [];
720
+ lines.push(` ${p.warning}${p.bold}Permission required: ${req.permission}${p.reset}`);
721
+
722
+ const meta = req.metadata ?? {};
723
+ const cmd = typeof meta.command === "string" ? meta.command : null;
724
+ const file = typeof meta.file === "string"
725
+ ? meta.file
726
+ : typeof meta.path === "string" ? meta.path : null;
727
+ if (cmd) {
728
+ for (const line of cmd.split("\n").slice(0, 6)) {
729
+ lines.push(` ${p.dim}${line}${p.reset}`);
730
+ }
731
+ } else if (file) {
732
+ lines.push(` ${p.dim}${file}${p.reset}`);
733
+ }
734
+
735
+ if (req.patterns.length > 0 && !cmd && !file) {
736
+ for (const pat of req.patterns) {
737
+ lines.push(` ${p.dim}${pat}${p.reset}`);
738
+ }
739
+ }
740
+
741
+ lines.push(` ${p.dim}[y] allow once [a] allow always [n] reject${p.reset}`);
742
+ return lines;
743
+ },
744
+
745
+ handleInput(data, done) {
746
+ if (isKey(data, "escape") || data === "n" || data === "N") {
747
+ done({ reply: "reject" });
748
+ } else if (data === "y" || data === "Y" || isKey(data, "enter")) {
749
+ done({ reply: "once" });
750
+ } else if (data === "a" || data === "A") {
751
+ done({ reply: "always" });
752
+ }
753
+ },
754
+ };
755
+ }
@@ -6,6 +6,6 @@
6
6
  "main": "index.ts",
7
7
  "dependencies": {
8
8
  "@opencode-ai/sdk": "^1.14.41",
9
- "agent-sh": "^0.12.0"
9
+ "agent-sh": "^0.14.0"
10
10
  }
11
11
  }
@@ -272,14 +272,13 @@ export default function activate(ctx: ExtensionContext): void {
272
272
  }
273
273
  });
274
274
 
275
- const model = session.model;
275
+ booting = false;
276
+ const m = session.model;
276
277
  bus.emit("agent:info", {
277
278
  name: "pi",
278
279
  version: "0.66",
279
- model: model ? `${model.provider}/${model.id}` : undefined,
280
+ model: m ? `${m.provider}/${m.id}` : undefined,
280
281
  });
281
-
282
- booting = false;
283
282
  } catch (err) {
284
283
  booting = false;
285
284
  bus.emit("ui:error", {
@@ -25,14 +25,10 @@ function buildReasoningParams(level: string, _model?: string): Record<string, un
25
25
 
26
26
  export default function activate(ctx: AgentContext): void {
27
27
  const { key } = resolveApiKey(ID);
28
- const apiKey = key ?? process.env.ZAI_API_KEY ?? process.env.ZHIPU_API_KEY;
29
- if (!apiKey) return;
30
-
31
28
  ctx.agent.providers.configure(ID, { reasoningParams: buildReasoningParams });
32
-
33
- ctx.bus.emit("provider:register", {
29
+ ctx.agent.providers.register({
34
30
  id: ID,
35
- apiKey: apiKey,
31
+ apiKey: key ?? process.env.ZAI_API_KEY ?? process.env.ZHIPU_API_KEY,
36
32
  baseURL: BASE_URL,
37
33
  defaultModel: DEFAULT_MODELS[0].id,
38
34
  models: DEFAULT_MODELS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.13.7",
3
+ "version": "0.14.1",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core/index.js",
@@ -1,2 +0,0 @@
1
- import type { ExtensionContext } from "../shell/host-types.js";
2
- export default function activate(ctx: ExtensionContext): void;
@@ -1,11 +0,0 @@
1
- /**
2
- * ctx.llm facade — delegates to an `llm:invoke` handler registered by the
3
- * active backend. No handler → `available` is false and calls reject.
4
- */
5
- import type { LlmInterface } from "../agent/host-types.js";
6
- interface HandlerGate {
7
- list: () => string[];
8
- call: (name: string, ...args: unknown[]) => unknown;
9
- }
10
- export declare function createLlmFacade(handlers: HandlerGate): LlmInterface;
11
- export {};
File without changes