agent-sh 0.14.0 → 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.
package/README.md CHANGED
@@ -19,7 +19,7 @@ So I built agent-sh. Under the hood it's a normal shell on top of node-pty — y
19
19
  ~ $ > draft a commit message # agent reads your diff and shell history
20
20
  ```
21
21
 
22
- agent-sh is built to be agent-agnostic. You can [bring your own coding agent](#bring-your-own-agent) or use the built-in agent `ash` — a lightweight, extensible agent if you'd like to build extensions on top of it.
22
+ agent-sh is built to be agent-agnostic. The recommended path is the built-in agent `ash` — a lightweight agent designed so extensions can plug into the same tool surface. If you'd rather host an existing coding agent (pi, claude-code, opencode), you can [bring your own](#bring-your-own-agent) — with the trade-off that it manages its own separate tools.
23
23
 
24
24
  ## Quick Start
25
25
 
@@ -55,24 +55,9 @@ alias ash="agent-sh"
55
55
 
56
56
  Once installed, pick a backend below.
57
57
 
58
- ### Option A: Bring your own coding agent
58
+ ### Option A: Use the built-in agent (ash) — recommended
59
59
 
60
- If you already use a coding agent, host it inside agent-shsame terminal, same `>` entry point, same shell-context wiring. Three bridges ship in the box:
61
-
62
- - **pi** — [pi-mono](https://github.com/badlogic/pi-mono) coding agent
63
- - **claude-code** — official [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk)
64
- - **opencode** — [opencode](https://opencode.ai/) via `@opencode-ai/sdk`
65
-
66
- ```bash
67
- agent-sh install pi-bridge
68
- agent-sh --backend pi
69
- ```
70
-
71
- See [Bring your own agent](#bring-your-own-agent) below for full details and the other backends.
72
-
73
- ### Option B: Use the built-in agent (ash)
74
-
75
- `ash` is agent-sh's own lightweight agent. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, openai-compatible, deepseek) register on startup; ash activates the first one with a usable key.
60
+ `ash` is agent-sh's own lightweight agent, and the path most users should start with: it shares its tool surface with the rest of the system, so extensions you install (new tools, content transforms, slash commands, themes) compose with it directly. It works with any OpenAI-compatible API pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, openai-compatible, deepseek) register on startup; ash activates the first one with a usable key.
76
61
 
77
62
  **Quickest path** — store a key once via the auth subcommand:
78
63
 
@@ -121,6 +106,10 @@ For richer configuration (multiple providers, extensions), run `agent-sh init` t
121
106
 
122
107
  `ash` is designed to be extended. Extensions can add tools, content transforms (e.g. render LaTeX or Mermaid), themes, slash commands, or new input modes — see [Extensions](docs/extensions.md) for the full surface.
123
108
 
109
+ ### Option B: Bring your own coding agent
110
+
111
+ If you already use pi, claude-code, or opencode, agent-sh can host it as the backend instead — see [Bring your own agent](#bring-your-own-agent) just below for the full setup and the trade-offs.
112
+
124
113
  ## Bring your own agent
125
114
 
126
115
  The built-in agent (`ash`) is the default, but agent-sh can host a different coding agent as its backend — same terminal, same `>` entry point, same shell-context wiring. Three bridges ship in the box:
@@ -16,6 +16,7 @@ function attributionHeaders(config) {
16
16
  return {
17
17
  "HTTP-Referer": config.appUrl ?? "https://agent-sh.dev",
18
18
  "X-Title": config.appName ?? "agent-sh",
19
+ "X-OpenRouter-Categories": "cli-agent,programming-app",
19
20
  };
20
21
  }
21
22
  export class LlmClient {
@@ -57,8 +57,8 @@ export interface InteractiveSession<T> {
57
57
  render(width: number): string[];
58
58
  /** Handle raw input. Call done(result) to finish the session. */
59
59
  handleInput(data: string, done: (result: T) => void): void;
60
- /** Called when session starts. Receives invalidate() for async re-renders. */
61
- onMount?(invalidate: () => void): void;
60
+ /** done() lets the session resolve itself from outside handleInput. */
61
+ onMount?(invalidate: () => void, done: (result: T) => void): void;
62
62
  /** Called when session ends (cleanup). */
63
63
  onUnmount?(): void;
64
64
  }
package/dist/cli/args.js CHANGED
@@ -3,7 +3,9 @@ const HELP_TEXT = `agent-sh — a shell-first terminal where AI is one keystroke
3
3
 
4
4
  Usage: agent-sh [options]
5
5
  agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
6
- agent-sh install <spec> [--force] Install an extension (bundled name, file:, npm:, github:)
6
+ agent-sh install <spec> [--force] [--sync-deps]
7
+ Install an extension (bundled name, file:, npm:, github:)
8
+ --sync-deps rewrites a stale agent-sh pin to the host version
7
9
  agent-sh uninstall <name> Remove an installed extension
8
10
  agent-sh list List installed extensions
9
11
  agent-sh auth login [provider] Store an API key for a built-in provider
@@ -1,5 +1,6 @@
1
1
  interface InstallOpts {
2
2
  force?: boolean;
3
+ syncDeps?: boolean;
3
4
  }
4
5
  export declare function listBundled(): string[];
5
6
  /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
@@ -75,6 +75,84 @@ function readPackageJson(target) {
75
75
  return null;
76
76
  return JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
77
77
  }
78
+ function hostAgentShVersion() {
79
+ try {
80
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"));
81
+ return typeof pkg.version === "string" ? pkg.version : null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ function satisfies(version, spec) {
88
+ if (spec === version || spec === "*" || spec === "latest")
89
+ return true;
90
+ const [vMaj, vMin, vPatch] = version.split(/[.-]/, 3).map(Number);
91
+ if ([vMaj, vMin, vPatch].some(Number.isNaN))
92
+ return true;
93
+ const m = spec.match(/^([\^~]?)(\d+)\.(\d+)\.(\d+)/);
94
+ if (!m)
95
+ return true;
96
+ const op = m[1];
97
+ const sMaj = Number(m[2]);
98
+ const sMin = Number(m[3]);
99
+ const sPatch = Number(m[4]);
100
+ if (op === "")
101
+ return vMaj === sMaj && vMin === sMin && vPatch === sPatch;
102
+ if (op === "~")
103
+ return vMaj === sMaj && vMin === sMin && vPatch >= sPatch;
104
+ // ^x.y.z: zero-major treats minor as the breaking boundary (npm rule).
105
+ if (sMaj > 0)
106
+ return vMaj === sMaj && (vMin > sMin || (vMin === sMin && vPatch >= sPatch));
107
+ if (sMin > 0)
108
+ return vMaj === 0 && vMin === sMin && vPatch >= sPatch;
109
+ return vMaj === 0 && vMin === 0 && vPatch === sPatch;
110
+ }
111
+ /** Warn when the extension's `agent-sh` pin can't admit the host version;
112
+ * only rewrite when --sync-deps is set. */
113
+ function syncAgentShVersion(target, syncDeps) {
114
+ const hostVersion = hostAgentShVersion();
115
+ if (!hostVersion)
116
+ return;
117
+ // Prerelease hosts aren't on npm; rewriting would leave npm install unable to resolve.
118
+ if (hostVersion.includes("-"))
119
+ return;
120
+ const pkgJson = path.join(target, "package.json");
121
+ if (!fs.existsSync(pkgJson))
122
+ return;
123
+ const raw = fs.readFileSync(pkgJson, "utf-8");
124
+ const pkg = JSON.parse(raw);
125
+ const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
126
+ const name = path.basename(target);
127
+ let changed = false;
128
+ let warned = false;
129
+ for (const section of sections) {
130
+ const deps = pkg[section];
131
+ if (!deps || typeof deps !== "object")
132
+ continue;
133
+ const d = deps;
134
+ const current = d["agent-sh"];
135
+ if (typeof current !== "string")
136
+ continue;
137
+ if (current.startsWith("file:"))
138
+ continue;
139
+ if (satisfies(hostVersion, current))
140
+ continue;
141
+ if (syncDeps) {
142
+ console.log(`agent-sh: rewriting ${name} agent-sh ${current} -> ${hostVersion}.`);
143
+ d["agent-sh"] = hostVersion;
144
+ changed = true;
145
+ }
146
+ else if (!warned) {
147
+ console.warn(`agent-sh: ${name} pins agent-sh ${current}, which doesn't admit host ${hostVersion}. ` +
148
+ `npm install will land an older agent-sh inside the extension and drift from the running host. ` +
149
+ `Re-run with --sync-deps to rewrite the pin to ${hostVersion}, or update the bridge's source pin.`);
150
+ warned = true;
151
+ }
152
+ }
153
+ if (changed)
154
+ fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
155
+ }
78
156
  /** Relative `file:` deps in bundled extensions (e.g. `"agent-sh": "file:../../.."`)
79
157
  * point at the wrong location after the source is copied into ~/.agent-sh/extensions/.
80
158
  * Resolve them against the original source dir so npm install in the target succeeds. */
@@ -165,7 +243,7 @@ function linkBins(target, pkg) {
165
243
  }
166
244
  export async function runInstall(spec, opts = {}) {
167
245
  if (!spec) {
168
- console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force]\n\n" +
246
+ console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force] [--sync-deps]\n\n" +
169
247
  "Bundled extensions:\n" +
170
248
  listBundled()
171
249
  .map((n) => ` ${n}`)
@@ -191,9 +269,15 @@ export async function runInstall(spec, opts = {}) {
191
269
  }
192
270
  let linkedBins = [];
193
271
  if (resolved.isDirectory) {
194
- fs.cpSync(resolved.sourcePath, target, { recursive: true });
272
+ fs.cpSync(resolved.sourcePath, target, {
273
+ recursive: true,
274
+ // Skip source node_modules: maybeNpmInstall short-circuits on
275
+ // existing node_modules, silently leaving the bridge's deps stale.
276
+ filter: (src) => path.basename(src) !== "node_modules",
277
+ });
195
278
  try {
196
279
  rewriteFileDeps(target, resolved.sourcePath);
280
+ syncAgentShVersion(target, opts.syncDeps ?? false);
197
281
  const pkg = readPackageJson(target);
198
282
  if (pkg) {
199
283
  maybeNpmInstall(target, pkg);
@@ -3,7 +3,10 @@ import { runInstall, runUninstall, runList } from "./install.js";
3
3
  import { runAuth } from "./auth/cli.js";
4
4
  const SUBCOMMANDS = {
5
5
  init: (args) => runInit({ force: args.includes("--force") }),
6
- install: (args) => runInstall(args[0] ?? "", { force: args.includes("--force") }),
6
+ install: (args) => runInstall(args[0] ?? "", {
7
+ force: args.includes("--force"),
8
+ syncDeps: args.includes("--sync-deps"),
9
+ }),
7
10
  uninstall: (args) => runUninstall(args[0] ?? ""),
8
11
  list: () => runList(),
9
12
  auth: (args) => runAuth(args),
@@ -14,7 +14,6 @@ export function createToolUI(bus, surface) {
14
14
  if (finished)
15
15
  return;
16
16
  finished = true;
17
- clearLines(surface, prevLineCount);
18
17
  bus.offPipe("input:intercept", interceptor);
19
18
  bus.emit("shell:stdout-hide", {});
20
19
  bus.emit("tool:interactive-end", {});
@@ -45,7 +44,10 @@ export function createToolUI(bus, surface) {
45
44
  bus.emit("tool:interactive-start", {});
46
45
  bus.emit("shell:stdout-show", {});
47
46
  bus.onPipe("input:intercept", interceptor);
48
- session.onMount?.(() => render());
47
+ // Drop to a fresh row in case the cursor was mid-line; uncounted
48
+ // so clearLines on dismiss stops at the gap, not above it.
49
+ surface.write("\n");
50
+ session.onMount?.(() => render(), done);
49
51
  render();
50
52
  });
51
53
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -70,7 +70,10 @@ async function main(): Promise<void> {
70
70
 
71
71
  if (sub === "install" || sub === "uninstall" || sub === "list") {
72
72
  const { runInstall, runUninstall, runList } = await import("agent-sh/cli/install");
73
- if (sub === "install") await runInstall(rest[0] ?? "", { force: rest.includes("--force") });
73
+ if (sub === "install") await runInstall(rest[0] ?? "", {
74
+ force: rest.includes("--force"),
75
+ syncDeps: rest.includes("--sync-deps"),
76
+ });
74
77
  else if (sub === "uninstall") await runUninstall(rest[0] ?? "");
75
78
  else runList();
76
79
  process.exit(0);
@@ -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
 
@@ -262,8 +264,11 @@ export default function activate(ctx: ExtensionContext): void {
262
264
  // Tool still running — nothing to do, TUI spinner already active
263
265
  break;
264
266
 
265
- case "result":
267
+ case "result": {
268
+ const sid = (message as any).session_id;
269
+ if (typeof sid === "string" && sid) sessionId = sid;
266
270
  break;
271
+ }
267
272
  }
268
273
  }
269
274
 
@@ -291,7 +296,7 @@ export default function activate(ctx: ExtensionContext): void {
291
296
  };
292
297
 
293
298
  const onCancel = () => { activeQuery?.interrupt(); };
294
- const onReset = () => { /* each query() is a new session */ };
299
+ const onReset = () => { sessionId = null; };
295
300
 
296
301
  bus.on("agent:submit", onSubmit);
297
302
  bus.on("agent:cancel-request", onCancel);
@@ -6,7 +6,7 @@
6
6
  "main": "index.ts",
7
7
  "dependencies": {
8
8
  "@anthropic-ai/claude-agent-sdk": "^0.2.0",
9
- "agent-sh": "^0.9.0",
9
+ "agent-sh": "^0.14.0",
10
10
  "zod": "^4.0.0"
11
11
  }
12
12
  }
@@ -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
@@ -471,6 +567,7 @@ export default function activate(ctx: ExtensionContext): void {
471
567
  // ── Interactive question picker ──────────────────────────────────
472
568
 
473
569
  type QuestionResult = { answers: string[][]; cancelled: boolean };
570
+ type PermissionResult = { reply: "once" | "always" | "reject"; cancelled?: boolean };
474
571
 
475
572
  function isKey(data: string, key: string): boolean {
476
573
  switch (key) {
@@ -601,3 +698,58 @@ function createQuestionSession(questions: QuestionInfo[]): InteractiveSession<Qu
601
698
  },
602
699
  };
603
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.14.0",
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",