agent-sh 0.12.23 → 0.12.25

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,10 +19,12 @@ 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
- I still use a proper coding harness for serious work — this doesn't replace that. But for the quick stuff in the terminal, I reach for agent-sh almost every day now. The built-in agent is lightweight and good enough for most of what I throw at it, and when it isn't, you can swap in [pi](examples/extensions/pi-bridge/) as the backend via a bridge extension.
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.
23
23
 
24
24
  ## Quick Start
25
25
 
26
+ ### Installation
27
+
26
28
  Install from npm:
27
29
 
28
30
  ```bash
@@ -41,7 +43,36 @@ npm run build # produces dist/
41
43
  npm link # exposes `agent-sh` globally
42
44
  ```
43
45
 
44
- Pick one of the zero-config paths below no settings file needed. agent-sh auto-activates a built-in provider when it sees a known key.
46
+ Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fish, nushell, etc.) are not yet wired up.
47
+
48
+ **Windows:** the interactive shell layer is bash/zsh-only. Run agent-sh inside **WSL** for the full experience. Native Windows (cmd.exe / PowerShell) is not supported as the host shell, though headless / library / ACP-bridge usage may work — file an issue if you hit a gap.
49
+
50
+ Tip — add a shell alias:
51
+
52
+ ```bash
53
+ alias ash="agent-sh"
54
+ ```
55
+
56
+ Once installed, pick a backend below.
57
+
58
+ ### Option A: Bring your own coding agent
59
+
60
+ If you already use a coding agent, host it inside agent-sh — same 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. agent-sh auto-activates a built-in provider when it sees a known key.
45
76
 
46
77
  **Hosted models via OpenRouter** (300+ models, one key):
47
78
 
@@ -77,15 +108,36 @@ Once running, switch models at any time with `/model <name>` (tab-completes; sel
77
108
 
78
109
  For richer configuration (multiple providers, extensions), run `agent-sh init` to scaffold `~/.agent-sh/settings.json` with copy-pasteable examples. See the [Usage Guide](docs/usage.md) for the full list of supported providers.
79
110
 
80
- Tip add a shell alias:
111
+ `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.
81
112
 
82
- ```bash
83
- alias ash="agent-sh"
84
- ```
113
+ ## Bring your own agent
85
114
 
86
- Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fish, nushell, etc.) are not yet wired up.
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:
87
116
 
88
- **Windows:** the interactive shell layer is bash/zsh-only. Run agent-sh inside **WSL** for the full experience. Native Windows (cmd.exe / PowerShell) is not supported as the host shell, though headless / library / ACP-bridge usage may work — file an issue if you hit a gap.
117
+ - **[pi-bridge](examples/extensions/pi-bridge/)** runs [pi](https://github.com/badlogic/pi-mono) (`@mariozechner/pi-coding-agent`) in-process. Pi brings its own models, tools, and `~/.pi/agent/settings.json`.
118
+
119
+ ```bash
120
+ agent-sh install pi-bridge
121
+ agent-sh --backend pi
122
+ ```
123
+
124
+ - **[claude-code-bridge](examples/extensions/claude-code-bridge/)** — runs claude-code (the official [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk)) in-process. Uses claude-code's own `Read`/`Edit`/`Write`/`Bash`/`Glob`/`Grep` tools.
125
+
126
+ ```bash
127
+ agent-sh install claude-code-bridge
128
+ agent-sh --backend claude-code
129
+ ```
130
+
131
+ - **[opencode-bridge](examples/extensions/opencode-bridge/)** — runs [opencode](https://opencode.ai/) in-process via `@opencode-ai/sdk`. Uses opencode's tools, models, and `opencode auth login` credentials.
132
+
133
+ ```bash
134
+ agent-sh install opencode-bridge
135
+ agent-sh --backend opencode
136
+ ```
137
+
138
+ All three bridges receive agent-sh's per-query shell context (`<shell_events>`) and follow the PTY-tracked cwd, so the hosted agent sees what you ran and where you are. Switching at runtime with `/backend <name>` persists the choice across sessions automatically; the `--backend` flag above is per-session only.
139
+
140
+ **Caveat:** pi, claude-code, and opencode each manage their own tool surfaces, so agent-sh extensions that register tools (or skills, instructions, etc.) for the built-in `ash` agent generally won't be visible to a hosted backend. Frontend extensions (themes, content transforms, slash commands, the TUI renderer) keep working — only the agent-side capabilities differ. Use the bridges when you want that agent's toolset; stay on `ash` when you want agent-sh's extension ecosystem.
89
141
 
90
142
  ## Key Features
91
143
 
@@ -95,7 +147,7 @@ Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fis
95
147
 
96
148
  **Context that just works.** Every query includes your cwd, recent commands, and their output. Run a failing test, type `> fix this`, and agent-sh knows exactly what happened. Context management works like shell history — continuous, persistent across restarts, no sessions to manage. See [Context Management](docs/context-management.md).
97
149
 
98
- **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — [pi](examples/extensions/pi-bridge/) runs as a drop-in backend extension.
150
+ **Any LLM, any backend.** agent-sh works with any OpenAI-compatible API out of the box. Define multiple providers in settings and switch models at runtime with `/model <name>`. Or swap in a completely different agent — bundled bridges run [pi](examples/extensions/pi-bridge/), [claude-code](examples/extensions/claude-code-bridge/), or [opencode](examples/extensions/opencode-bridge/) as a drop-in backend (see [Bring your own agent](#bring-your-own-agent)).
99
151
 
100
152
  **Extensible by design.** The entire system is built on a typed event bus. Extensions can add custom input modes, content transforms (render LaTeX as images, Mermaid as diagrams), themes, slash commands, or replace the agent backend entirely. The built-in TUI renderer is itself just an extension.
101
153
 
@@ -108,9 +108,9 @@ Extensions may register additional tools — follow their instructions.
108
108
  - Always check command exit codes for errors
109
109
 
110
110
  # Context Envelopes
111
- - \`<query_context>\` (e.g. \`<shell_events>\`): the user's situation when they sent this turn — ground "fix this" / "what just happened" requests with it.
111
+ - \`<query_context>\` (contains \`<cwd>\` always, and \`<shell_events>\` when there were user shell commands since the last turn): the user's situation when they sent this turn — \`<cwd>\` anchors where they are right now, \`<shell_events>\` grounds "fix this" / "what just happened" requests. Trust the most recent \`<cwd>\` over any cwd referenced in earlier history.
112
112
  - \`<dynamic_context>\`: current system state — in-flight work, mode markers, warnings.
113
- Either may be absent on any turn.
113
+ \`<dynamic_context>\` may be absent on any turn.
114
114
 
115
115
  # Preference Learning
116
116
 
package/dist/core.d.ts CHANGED
@@ -37,7 +37,7 @@ export interface AgentShellCore {
37
37
  /** Unique id for this agent process; used for shell-marker tagging and lineage tracking. */
38
38
  instanceId: string;
39
39
  /** Activate the agent backend (call after extensions load). */
40
- activateBackend(): Promise<void>;
40
+ activateBackend(override?: string): Promise<void>;
41
41
  /** Convenience: emit agent:submit and await the response. */
42
42
  query(text: string): Promise<string>;
43
43
  /** Convenience: emit agent:cancel-request. */
package/dist/core.js CHANGED
@@ -102,10 +102,10 @@ export function createCore(config) {
102
102
  bus,
103
103
  handlers,
104
104
  instanceId,
105
- async activateBackend() {
105
+ async activateBackend(override) {
106
106
  if (backends.size === 0)
107
107
  return;
108
- const preferred = settings.defaultBackend;
108
+ const preferred = override ?? settings.defaultBackend;
109
109
  const name = preferred && backends.has(preferred) ? preferred : backends.keys().next().value;
110
110
  await activateByName(name);
111
111
  },
@@ -367,6 +367,8 @@ export interface ShellEvents {
367
367
  label: string;
368
368
  items: string[];
369
369
  }>;
370
+ /** Name of the backend being launched. Extensions should gate per-backend sections on this rather than settings.defaultBackend. */
371
+ activeBackend?: string;
370
372
  };
371
373
  "autocomplete:request": {
372
374
  buffer: string;
@@ -293,8 +293,7 @@ export default function agentBackend(ctx) {
293
293
  bus.emit("config:changed", {});
294
294
  });
295
295
  bus.onPipe("banner:collect", (e) => {
296
- const settings = getSettings();
297
- if (settings.defaultBackend && settings.defaultBackend !== "ash")
296
+ if (e.activeBackend && e.activeBackend !== "ash")
298
297
  return e;
299
298
  if (loadedExtensionNames.length > 0) {
300
299
  e.sections.push({ label: "Extensions", items: [...loadedExtensionNames] });
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Tracks PTY commands and cwd, spills long outputs, contributes the
3
- * `<shell_events>` per-query envelope. Frontends without a PTY skip this
3
+ * per-query `<cwd>` (always) and `<shell_events>` (when there are fresh
4
+ * user-shell exchanges) signals. Frontends without a PTY skip this
4
5
  * built-in and the agent runs cwd-aware via core's process.cwd() default.
5
6
  */
6
7
  import type { ExtensionContext } from "../types.js";
@@ -43,15 +43,16 @@ export default function activate(ctx) {
43
43
  bus.on("shell:agent-exec-done", () => { agentShellActive = false; });
44
44
  // Override core's process.cwd() default with the PTY-tracked value.
45
45
  ctx.advise("cwd", () => currentCwd);
46
- ctx.registerContextProducer("shell-events", () => {
46
+ ctx.registerContextProducer("shell-context", () => {
47
+ const cwdTag = `<cwd>${currentCwd}</cwd>`;
47
48
  const fresh = exchanges.filter((ex) => ex.id > lastSeq && ex.source !== "agent");
48
49
  if (fresh.length === 0)
49
- return null;
50
+ return cwdTag;
50
51
  lastSeq = exchanges[exchanges.length - 1].id;
51
52
  const text = fresh.map(formatExchangeTruncated).filter(Boolean).join("\n");
52
53
  if (!text)
53
- return null;
54
- return `<shell_events>\n${text}\n</shell_events>`;
54
+ return cwdTag;
55
+ return `${cwdTag}\n<shell_events>\n${text}\n</shell_events>`;
55
56
  }, { mode: "per-query" });
56
57
  ctx.define("shell:context-recent", (n = 25) => {
57
58
  const recent = exchanges.slice(-n);
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { loadBuiltinExtensions } from "./extensions/index.js";
8
8
  import { loadExtensions } from "./extension-loader.js";
9
9
  import { getSettings } from "./settings.js";
10
10
  import { runInit } from "./init.js";
11
+ import { runInstall, runUninstall, runList, suggestBridgeFor } from "./install.js";
11
12
  import { PACKAGE_VERSION } from "./utils/package-version.js";
12
13
  /**
13
14
  * Capture the user's full shell environment.
@@ -78,7 +79,8 @@ function parseArgs(argv) {
78
79
  let model;
79
80
  let extensions;
80
81
  let provider;
81
- const shell = process.env.SHELL || "/bin/bash";
82
+ let backend;
83
+ let shell = process.env.SHELL || "/bin/bash";
82
84
  let apiKey = process.env.OPENAI_API_KEY;
83
85
  let baseURL = process.env.OPENAI_BASE_URL;
84
86
  for (let i = 0; i < argv.length; i++) {
@@ -95,8 +97,11 @@ function parseArgs(argv) {
95
97
  else if (arg === "--provider" && argv[i + 1]) {
96
98
  provider = argv[++i];
97
99
  }
100
+ else if (arg === "--backend" && argv[i + 1]) {
101
+ backend = argv[++i];
102
+ }
98
103
  else if (arg === "--shell" && argv[i + 1]) {
99
- return { shell: argv[++i], model, extensions, apiKey, baseURL, provider };
104
+ shell = argv[++i];
100
105
  }
101
106
  else if ((arg === "--extensions" || arg === "-e") && argv[i + 1]) {
102
107
  const exts = argv[++i].split(",").map(s => s.trim());
@@ -110,7 +115,10 @@ function parseArgs(argv) {
110
115
  console.log(`agent-sh — a shell-first terminal where AI is one keystroke away
111
116
 
112
117
  Usage: agent-sh [options]
113
- agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
118
+ agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
119
+ agent-sh install <spec> [--force] Install an extension (bundled name, file:, npm:, github:)
120
+ agent-sh uninstall <name> Remove an installed extension
121
+ agent-sh list List installed extensions
114
122
 
115
123
  Provider Profiles:
116
124
  --provider <name> Use a provider from ~/.agent-sh/settings.json
@@ -121,6 +129,7 @@ Direct LLM API:
121
129
  --base-url <url> Base URL for API (or set OPENAI_BASE_URL)
122
130
 
123
131
  General Options:
132
+ --backend <name> Agent backend to launch (e.g. ash, pi); overrides settings.defaultBackend for this session
124
133
  --shell <path> Shell to use (default: $SHELL or /bin/bash)
125
134
  -e, --extensions Extensions to load (comma-separated, repeatable)
126
135
  -h, --help Show this help
@@ -149,7 +158,7 @@ Inside the shell:
149
158
  process.exit(0);
150
159
  }
151
160
  }
152
- return { shell, model, extensions, apiKey, baseURL, provider };
161
+ return { shell, model, extensions, apiKey, baseURL, provider, backend };
153
162
  }
154
163
  async function main() {
155
164
  // Subcommands — handled before the shell-launch path.
@@ -158,6 +167,18 @@ async function main() {
158
167
  runInit({ force: rawArgs.includes("--force") });
159
168
  return;
160
169
  }
170
+ if (rawArgs[0] === "install") {
171
+ await runInstall(rawArgs[1] ?? "", { force: rawArgs.includes("--force") });
172
+ return;
173
+ }
174
+ if (rawArgs[0] === "uninstall") {
175
+ await runUninstall(rawArgs[1] ?? "");
176
+ return;
177
+ }
178
+ if (rawArgs[0] === "list") {
179
+ runList();
180
+ return;
181
+ }
161
182
  if (process.env.AGENT_SH) {
162
183
  console.error("agent-sh: already running inside an agent-sh session (nested sessions are not supported).");
163
184
  process.exit(1);
@@ -215,49 +236,9 @@ async function main() {
215
236
  }
216
237
  process.exit(0);
217
238
  };
218
- // ── Extension context (must precede shell activation) ────────
219
- if (process.env.DEBUG) {
220
- console.error('[agent-sh] Setting up extensions...');
221
- }
222
239
  const extCtx = core.extensionContext({ quit: cleanup });
223
- // ── Shell frontend bootstrap (special-cased; see src/shell/index.ts) ──
224
- if (process.env.DEBUG) {
225
- console.error('[agent-sh] Creating Shell...');
226
- }
227
- await new Promise(resolve => setTimeout(resolve, 100));
228
- shell = activateShell(extCtx, {
229
- cols,
230
- rows,
231
- shellPath: config.shell || process.env.SHELL || "/bin/bash",
232
- cwd: process.cwd(),
233
- onShowAgentInfo: () => {
234
- if (agentInfo) {
235
- return { info: `${p.dim}${agentInfo.name}${agentInfo.model ? ` (${agentInfo.model})` : ""}${p.reset}` };
236
- }
237
- return { info: "" };
238
- },
239
- });
240
- if (process.env.DEBUG) {
241
- console.error('[agent-sh] Shell created');
242
- }
243
- // ── Input mode ───────────────────────────────────────────────
244
- bus.emit("input-mode:register", {
245
- id: "agent",
246
- trigger: ">",
247
- label: "agent",
248
- promptIcon: "❯",
249
- indicator: "●",
250
- onSubmit(query, b) {
251
- b.emit("agent:submit", { query });
252
- },
253
- returnToSelf: true,
254
- });
255
- // Load built-in extensions (individually disableable via settings.disabledBuiltins)
240
+ // Load before spawning the shell so PS1 lands below the banner.
256
241
  await loadBuiltinExtensions(extCtx, getSettings().disabledBuiltins);
257
- // Load user extensions (may register alternative agent backends)
258
- if (process.env.DEBUG) {
259
- console.error('[agent-sh] Loading extensions...');
260
- }
261
242
  const loadExtensionsTimeoutMs = 10000;
262
243
  let loadedExtensions = [];
263
244
  await Promise.race([
@@ -266,38 +247,39 @@ async function main() {
266
247
  ]).catch((err) => {
267
248
  console.error(`Warning: ${err.message}`);
268
249
  });
269
- if (process.env.DEBUG) {
270
- console.error('[agent-sh] Extensions loaded');
271
- }
272
- // Names ride along so backend extensions can build banner sections.
273
250
  core.bus.emit("core:extensions-loaded", { names: loadedExtensions });
274
- // ── Activate agent backend ────────────────────────────────────
275
- // Extensions had their chance to register via agent:register-backend.
276
- // If none did, the built-in AgentLoop gets wired to bus events.
277
251
  const { names: backendNames } = core.bus.emitPipe("config:get-backends", { names: [], active: null });
278
252
  if (backendNames.length === 0) {
279
- shell?.kill();
280
253
  console.error("\nagent-sh: no agent backend available.\n\n" +
281
254
  " Export OPENROUTER_API_KEY or OPENAI_API_KEY for zero-config launch, or\n" +
282
255
  " pass --api-key on the command line, or\n" +
283
- " run `agent-sh init` for a settings.json template.\n" +
284
- " Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
256
+ " run `agent-sh init` for a settings.json template, or\n" +
257
+ " run `agent-sh install <bridge>` (e.g. pi-bridge, claude-code-bridge) to use a non-ash backend.\n");
258
+ process.exit(1);
259
+ }
260
+ if (config.backend && !backendNames.includes(config.backend)) {
261
+ const bridge = suggestBridgeFor(config.backend);
262
+ const hint = bridge
263
+ ? ` Try: agent-sh install ${bridge}\n`
264
+ : ` Run \`agent-sh install\` to see bundled bridge extensions.\n`;
265
+ console.error(`\nagent-sh: backend "${config.backend}" is not available.\n\n` +
266
+ ` Available backends: ${backendNames.join(", ")}\n` +
267
+ hint);
285
268
  process.exit(1);
286
269
  }
287
- // No await: banner must out-race the shell's PS1 arriving via PTY.
288
- core.activateBackend();
289
- // ── Startup banner ───────────────────────────────────────────
290
270
  const settings = getSettings();
291
271
  if (settings.startupBanner !== false) {
292
272
  const termW = process.stdout.columns || 80;
293
273
  const bannerW = Math.min(termW, 60);
294
274
  const productName = `${p.accent}${p.bold}agent-sh${p.reset}`;
295
- const backendName = settings.defaultBackend && backendNames.includes(settings.defaultBackend)
296
- ? settings.defaultBackend
297
- : backendNames[0];
275
+ const backendName = config.backend && backendNames.includes(config.backend)
276
+ ? config.backend
277
+ : settings.defaultBackend && backendNames.includes(settings.defaultBackend)
278
+ ? settings.defaultBackend
279
+ : backendNames[0];
298
280
  let sections = "";
299
281
  sections += `\n\n ${p.muted}Backend:${p.reset} ${p.dim}${backendName}${p.reset}`;
300
- const extSections = bus.emitPipe("banner:collect", { sections: [] }).sections;
282
+ const extSections = bus.emitPipe("banner:collect", { sections: [], activeBackend: backendName }).sections;
301
283
  for (const sec of extSections) {
302
284
  sections += `\n\n ${p.muted}${sec.label}:${p.reset}`;
303
285
  for (const item of sec.items) {
@@ -312,6 +294,32 @@ async function main() {
312
294
  "\n " + hint + "\n" +
313
295
  borderLine + "\n\n");
314
296
  }
297
+ // 100ms sidesteps macOS SIGTTOU during fg-pgrp handoff.
298
+ await new Promise(resolve => setTimeout(resolve, 100));
299
+ shell = activateShell(extCtx, {
300
+ cols,
301
+ rows,
302
+ shellPath: config.shell || process.env.SHELL || "/bin/bash",
303
+ cwd: process.cwd(),
304
+ onShowAgentInfo: () => {
305
+ if (agentInfo) {
306
+ return { info: `${p.dim}${agentInfo.name}${agentInfo.model ? ` (${agentInfo.model})` : ""}${p.reset}` };
307
+ }
308
+ return { info: "" };
309
+ },
310
+ });
311
+ bus.emit("input-mode:register", {
312
+ id: "agent",
313
+ trigger: ">",
314
+ label: "agent",
315
+ promptIcon: "❯",
316
+ indicator: "●",
317
+ onSubmit(query, b) {
318
+ b.emit("agent:submit", { query });
319
+ },
320
+ returnToSelf: true,
321
+ });
322
+ core.activateBackend(config.backend);
315
323
  // ── Terminal lifecycle ────────────────────────────────────────
316
324
  process.on("SIGTERM", cleanup);
317
325
  process.on("SIGHUP", cleanup);
@@ -0,0 +1,10 @@
1
+ interface InstallOpts {
2
+ force?: boolean;
3
+ }
4
+ export declare function listBundled(): string[];
5
+ /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
6
+ export declare function suggestBridgeFor(backend: string): string | null;
7
+ export declare function runInstall(spec: string, opts?: InstallOpts): Promise<void>;
8
+ export declare function runUninstall(name: string): Promise<void>;
9
+ export declare function runList(): void;
10
+ export {};
@@ -0,0 +1,205 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { CONFIG_DIR, getSettings } from "./settings.js";
6
+ // Kept in sync with extension-loader.ts SCRIPT_EXTS.
7
+ const SCRIPT_EXTS = [".js", ".mjs", ".ts", ".tsx", ".mts"];
8
+ function hasIndexFile(dir) {
9
+ return SCRIPT_EXTS.some((ext) => fs.existsSync(path.join(dir, `index${ext}`)));
10
+ }
11
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../");
12
+ const BUNDLED_DIR = path.join(PACKAGE_ROOT, "examples/extensions");
13
+ const EXT_DIR = path.join(CONFIG_DIR, "extensions");
14
+ export function listBundled() {
15
+ if (!fs.existsSync(BUNDLED_DIR))
16
+ return [];
17
+ return fs.readdirSync(BUNDLED_DIR).map((n) => n.replace(/\.(ts|js|mjs)$/, ""));
18
+ }
19
+ /** Heuristic: a backend named "pi" is typically provided by an extension called "pi-bridge". */
20
+ export function suggestBridgeFor(backend) {
21
+ const candidate = `${backend}-bridge`;
22
+ return listBundled().includes(candidate) ? candidate : null;
23
+ }
24
+ const bundledResolver = {
25
+ resolve: async (spec) => {
26
+ const candidates = [
27
+ { p: path.join(BUNDLED_DIR, spec), name: spec },
28
+ { p: path.join(BUNDLED_DIR, `${spec}.ts`), name: `${spec}.ts` },
29
+ { p: path.join(BUNDLED_DIR, `${spec}.js`), name: `${spec}.js` },
30
+ ];
31
+ for (const c of candidates) {
32
+ if (fs.existsSync(c.p)) {
33
+ const isDirectory = fs.statSync(c.p).isDirectory();
34
+ return { sourcePath: c.p, name: c.name, isDirectory };
35
+ }
36
+ }
37
+ const available = listBundled();
38
+ throw new Error(`No bundled extension named "${spec}".\n\n` +
39
+ `Available:\n${available.map((n) => ` ${n}`).join("\n")}`);
40
+ },
41
+ };
42
+ const npmResolver = {
43
+ canHandle: (spec) => spec.startsWith("npm:"),
44
+ resolve: async () => {
45
+ throw new Error("npm: source is not yet implemented");
46
+ },
47
+ };
48
+ const githubResolver = {
49
+ canHandle: (spec) => spec.startsWith("github:") || spec.startsWith("https://github.com/"),
50
+ resolve: async () => {
51
+ throw new Error("github: source is not yet implemented");
52
+ },
53
+ };
54
+ const fileResolver = {
55
+ canHandle: (spec) => spec.startsWith("file:") || spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../"),
56
+ resolve: async (spec) => {
57
+ const raw = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
58
+ const abs = path.resolve(raw);
59
+ if (!fs.existsSync(abs))
60
+ throw new Error(`Path does not exist: ${abs}`);
61
+ const isDirectory = fs.statSync(abs).isDirectory();
62
+ return { sourcePath: abs, name: path.basename(abs), isDirectory };
63
+ },
64
+ };
65
+ const PREFIX_RESOLVERS = [npmResolver, githubResolver, fileResolver];
66
+ function pickResolver(spec) {
67
+ for (const r of PREFIX_RESOLVERS)
68
+ if (r.canHandle?.(spec))
69
+ return r;
70
+ return bundledResolver;
71
+ }
72
+ function maybeNpmInstall(target) {
73
+ const pkgJson = path.join(target, "package.json");
74
+ if (!fs.existsSync(pkgJson))
75
+ return;
76
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
77
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) };
78
+ if (Object.keys(deps).length === 0)
79
+ return;
80
+ if (fs.existsSync(path.join(target, "node_modules")))
81
+ return;
82
+ console.log(`Running npm install in ${target}...`);
83
+ const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], {
84
+ cwd: target,
85
+ stdio: "inherit",
86
+ });
87
+ if (result.status !== 0) {
88
+ throw new Error(`npm install failed in ${target}; run it manually.`);
89
+ }
90
+ }
91
+ export async function runInstall(spec, opts = {}) {
92
+ if (!spec) {
93
+ console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force]\n\n" +
94
+ "Bundled extensions:\n" +
95
+ listBundled()
96
+ .map((n) => ` ${n}`)
97
+ .join("\n"));
98
+ process.exit(1);
99
+ }
100
+ fs.mkdirSync(EXT_DIR, { recursive: true });
101
+ let resolved;
102
+ try {
103
+ resolved = await pickResolver(spec).resolve(spec);
104
+ }
105
+ catch (err) {
106
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
107
+ process.exit(1);
108
+ }
109
+ const target = path.join(EXT_DIR, resolved.name);
110
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) {
111
+ if (!opts.force) {
112
+ console.error(`agent-sh: ${target} already exists (pass --force to overwrite)`);
113
+ process.exit(1);
114
+ }
115
+ fs.rmSync(target, { recursive: true, force: true });
116
+ }
117
+ if (resolved.isDirectory) {
118
+ fs.cpSync(resolved.sourcePath, target, { recursive: true });
119
+ try {
120
+ maybeNpmInstall(target);
121
+ }
122
+ catch (err) {
123
+ console.error(`agent-sh: ${err instanceof Error ? err.message : String(err)}`);
124
+ process.exit(1);
125
+ }
126
+ }
127
+ else {
128
+ fs.copyFileSync(resolved.sourcePath, target);
129
+ }
130
+ console.log(`Installed: ${resolved.name} -> ${target}`);
131
+ }
132
+ export async function runUninstall(name) {
133
+ if (!name) {
134
+ console.error("Usage: agent-sh uninstall <name>");
135
+ process.exit(1);
136
+ }
137
+ const target = path.join(EXT_DIR, name);
138
+ // Refuse path-traversal: target must sit directly under EXT_DIR.
139
+ const resolvedTarget = path.resolve(target);
140
+ const resolvedExtDir = path.resolve(EXT_DIR);
141
+ if (!resolvedTarget.startsWith(resolvedExtDir + path.sep)) {
142
+ console.error(`agent-sh: refusing to uninstall outside ${EXT_DIR}`);
143
+ process.exit(1);
144
+ }
145
+ if (!fs.lstatSync(target, { throwIfNoEntry: false })) {
146
+ console.error(`agent-sh: not installed: ${name}`);
147
+ process.exit(1);
148
+ }
149
+ fs.rmSync(target, { recursive: true, force: true });
150
+ console.log(`Uninstalled: ${name}`);
151
+ }
152
+ function listFromExtDir(disabled) {
153
+ if (!fs.existsSync(EXT_DIR))
154
+ return [];
155
+ const dirents = fs.readdirSync(EXT_DIR, { withFileTypes: true });
156
+ const out = [];
157
+ for (const d of dirents) {
158
+ if (d.name.startsWith("."))
159
+ continue;
160
+ const nameForDisable = d.name.replace(/\.[^.]+$/, "");
161
+ if (disabled.has(nameForDisable))
162
+ continue;
163
+ const full = path.join(EXT_DIR, d.name);
164
+ let isDir = d.isDirectory();
165
+ if (d.isSymbolicLink()) {
166
+ try {
167
+ isDir = fs.statSync(full).isDirectory();
168
+ }
169
+ catch {
170
+ continue;
171
+ }
172
+ }
173
+ if (isDir) {
174
+ if (!hasIndexFile(full))
175
+ continue;
176
+ }
177
+ else if (!SCRIPT_EXTS.some((ext) => d.name.endsWith(ext))) {
178
+ continue;
179
+ }
180
+ const detail = d.isSymbolicLink() ? `-> ${fs.readlinkSync(full)}` : undefined;
181
+ out.push({ name: d.name, source: "extensions dir", detail });
182
+ }
183
+ return out;
184
+ }
185
+ function listFromSettings(disabled) {
186
+ const specs = getSettings().extensions ?? [];
187
+ return specs
188
+ .filter((s) => !disabled.has(s.replace(/\.[^.]+$/, "")))
189
+ .map((s) => ({ name: s, source: "settings.json" }));
190
+ }
191
+ export function runList() {
192
+ const disabled = new Set(getSettings().disabledExtensions ?? []);
193
+ const items = [...listFromExtDir(disabled), ...listFromSettings(disabled)];
194
+ if (items.length === 0) {
195
+ console.log("No extensions installed.");
196
+ return;
197
+ }
198
+ const nameWidth = Math.max(...items.map((i) => i.name.length));
199
+ console.log("Installed extensions:");
200
+ for (const item of items) {
201
+ const padded = item.name.padEnd(nameWidth);
202
+ const detail = item.detail ? ` ${item.detail}` : "";
203
+ console.log(` ${padded} (${item.source})${detail}`);
204
+ }
205
+ }
package/dist/types.d.ts CHANGED
@@ -100,6 +100,8 @@ export interface AgentShellConfig {
100
100
  baseURL?: string;
101
101
  /** Named provider to use from settings.json. */
102
102
  provider?: string;
103
+ /** Override settings.defaultBackend for this session only (does not persist). */
104
+ backend?: string;
103
105
  /** Conversation history backend. Defaults to the on-disk HistoryFile. */
104
106
  history?: HistoryAdapter;
105
107
  }
@@ -5,12 +5,16 @@ Runs Claude Code as an agent-sh backend using the official [@anthropic-ai/claude
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- # Copy or symlink into your extensions directory
9
- cp -r examples/extensions/claude-code-bridge ~/.agent-sh/extensions/claude-code-bridge
8
+ agent-sh install claude-code-bridge
9
+ ```
10
+
11
+ This copies the bundled extension into `~/.agent-sh/extensions/claude-code-bridge` and runs `npm install` for you. To overwrite an existing install, pass `--force`. To uninstall, run `agent-sh uninstall claude-code-bridge`.
10
12
 
11
- # Install dependencies
12
- cd ~/.agent-sh/extensions/claude-code-bridge
13
- npm install
13
+ Manual alternative (e.g. for a development checkout you want to symlink):
14
+
15
+ ```bash
16
+ cp -r examples/extensions/claude-code-bridge ~/.agent-sh/extensions/claude-code-bridge
17
+ cd ~/.agent-sh/extensions/claude-code-bridge && npm install
14
18
  ```
15
19
 
16
20
  ## Configure
@@ -34,16 +38,12 @@ Or switch at runtime:
34
38
  - `ANTHROPIC_API_KEY` must be set in your environment
35
39
  - Claude Code manages its own model selection — no model configuration needed in agent-sh
36
40
 
37
- ## What this bridge is
38
-
39
- A pure protocol translator between the Claude Agent SDK's event stream and agent-sh's bus events. Claude Code uses its own built-in tools exactly as the SDK ships them (`Read`, `Edit`, `Write`, `Bash`, `Glob`, `Grep`). The bridge adds no tools of its own.
41
+ ## What works under claude-code
40
42
 
41
- ## What this bridge intentionally does NOT bundle
43
+ agent-sh's per-query context producers (e.g. `<shell_events>` from `shell-context`) are inlined into the prompt before each query, so claude-code sees the user's recent shell activity even though the SDK doesn't subscribe to agent-sh's shell bus directly.
42
44
 
43
- Three PTY-access tools are left out on purpose:
45
+ The SDK's working directory follows agent-sh's PTY-tracked cwd, so when the user `cd`s in the terminal, claude-code's tools (Bash, Read, etc.) operate in the new directory.
44
46
 
45
- - `terminal_read` observe the user's live terminal screen
46
- - `terminal_keys` — send keystrokes to the user's PTY
47
- - `user_shell` — run commands in the user's live shell with lasting `cd`/`export`/`source` effects
47
+ ## What this bridge is
48
48
 
49
- These are opt-in capabilities that belong in their own extensions. If you want any of them with Claude Code, write a companion extension that uses the SDK's `tool()` + `createSdkMcpServer()` to expose them as MCP tools, and extend the bridge (or fork it) to attach that MCP server to the SDK's `query()` options.
49
+ A pure protocol translator between the Claude Agent SDK's event stream and agent-sh's bus events. Claude Code uses its own built-in tools exactly as the SDK ships them (`Read`, `Edit`, `Write`, `Bash`, `Glob`, `Grep`). The bridge adds no tools of its own.
@@ -1,25 +1,8 @@
1
1
  /**
2
2
  * Claude Code bridge — runs Claude Code Agent SDK in-process as agent-sh's backend.
3
3
  *
4
- * Uses the official @anthropic-ai/claude-agent-sdk to spawn a Claude Code
5
- * session. Claude Code handles its own model selection, tool execution, and
6
- * permissions — the bridge is a pure protocol translator between the SDK's
7
- * event stream and agent-sh's bus events.
8
- *
9
- * PTY-access tools (`terminal_read`, `terminal_keys`, `user_shell`) are
10
- * intentionally NOT bundled here. If you want Claude Code to observe or
11
- * drive the user's live terminal, load a companion extension that
12
- * registers those tools as MCP tools the SDK can consume.
13
- *
14
- * Setup (from repo root):
15
- * npm run build && npm link # register local agent-sh globally
16
- * cd examples/extensions/claude-code-bridge
17
- * npm install && npm link agent-sh # link local dev copy
18
- *
19
- * Usage:
20
- * agent-sh -e examples/extensions/claude-code-bridge
21
- *
22
- * Requires: Claude Code CLI installed and authenticated (claude login).
4
+ * Pure protocol translator between the SDK's event stream and agent-sh's bus.
5
+ * Requires Claude Code CLI installed and authenticated (claude login).
23
6
  */
24
7
  import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
25
8
  import { readFile } from "node:fs/promises";
@@ -29,7 +12,13 @@ import { computeDiff, type DiffResult } from "agent-sh/utils/diff";
29
12
 
30
13
  // ── Extension entry point ─────────────────────────────────────────
31
14
  export default function activate(ctx: ExtensionContext): void {
32
- const { bus } = ctx;
15
+ const { bus, call } = ctx;
16
+
17
+ // PTY-tracked cwd from shell-context; falls back when no PTY frontend.
18
+ const cwd = (): string => {
19
+ const v = call("cwd");
20
+ return typeof v === "string" && v ? v : process.cwd();
21
+ };
33
22
 
34
23
  let activeQuery: Query | null = null;
35
24
  const listeners: Array<{ event: string; fn: Function }> = [];
@@ -88,11 +77,16 @@ export default function activate(ctx: ExtensionContext): void {
88
77
  /** Pre-edit file snapshots for diff display (Edit/Write tools). */
89
78
  const fileSnapshots = new Map<string, string | null>();
90
79
 
80
+ // Splice per-query context (e.g. <shell_events>) into the prompt — the
81
+ // SDK has no other channel for it. Mirrors pi-bridge.
82
+ const ctxText = String(call("query-context:build") ?? "").trim();
83
+ const finalPrompt = ctxText ? `${ctxText}\n\n${userQuery}` : userQuery;
84
+
91
85
  try {
92
86
  activeQuery = query({
93
- prompt: userQuery,
87
+ prompt: finalPrompt,
94
88
  options: {
95
- cwd: process.cwd(),
89
+ cwd: cwd(),
96
90
  systemPrompt: {
97
91
  type: "preset",
98
92
  preset: "claude_code",
@@ -155,7 +149,7 @@ export default function activate(ctx: ExtensionContext): void {
155
149
 
156
150
  // Snapshot file content before Edit/Write modifies it
157
151
  if ((meta.name === "Edit" || meta.name === "Write") && typeof (input as any).file_path === "string") {
158
- const absPath = resolve(process.cwd(), (input as any).file_path);
152
+ const absPath = resolve(cwd(), (input as any).file_path);
159
153
  readFile(absPath, "utf-8")
160
154
  .then(content => fileSnapshots.set(meta.id, content))
161
155
  .catch(() => fileSnapshots.set(meta.id, null)); // file doesn't exist yet
@@ -191,7 +185,7 @@ export default function activate(ctx: ExtensionContext): void {
191
185
 
192
186
  // Snapshot file content before Edit/Write modifies it
193
187
  if ((b.name === "Edit" || b.name === "Write") && typeof (input as any).file_path === "string") {
194
- const absPath = resolve(process.cwd(), (input as any).file_path);
188
+ const absPath = resolve(cwd(), (input as any).file_path);
195
189
  readFile(absPath, "utf-8")
196
190
  .then(content => fileSnapshots.set(b.id, content))
197
191
  .catch(() => fileSnapshots.set(b.id, null));
@@ -226,7 +220,7 @@ export default function activate(ctx: ExtensionContext): void {
226
220
  fileSnapshots.delete(toolUseId);
227
221
  const filePath = (pending.input as any)?.file_path as string | undefined;
228
222
  if (filePath) {
229
- const absPath = resolve(process.cwd(), filePath);
223
+ const absPath = resolve(cwd(), filePath);
230
224
  try {
231
225
  const newContent = await readFile(absPath, "utf-8");
232
226
  const diff = computeDiff(oldContent, newContent);
@@ -0,0 +1,59 @@
1
+ # opencode-bridge
2
+
3
+ Runs [opencode](https://opencode.ai/) as an agent-sh backend using the official [@opencode-ai/sdk](https://www.npmjs.com/package/@opencode-ai/sdk). opencode brings its own configuration, models, tools, and authentication — agent-sh just provides the terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ agent-sh install opencode-bridge
9
+ ```
10
+
11
+ This copies the bundled extension into `~/.agent-sh/extensions/opencode-bridge` and runs `npm install` for you. To overwrite an existing install, pass `--force`. To uninstall, run `agent-sh uninstall opencode-bridge`.
12
+
13
+ Manual alternative (e.g. for a development checkout you want to symlink):
14
+
15
+ ```bash
16
+ cp -r examples/extensions/opencode-bridge ~/.agent-sh/extensions/opencode-bridge
17
+ cd ~/.agent-sh/extensions/opencode-bridge && npm install
18
+ ```
19
+
20
+ ## Configure
21
+
22
+ Set as default backend in `~/.agent-sh/settings.json`:
23
+
24
+ ```json
25
+ {
26
+ "defaultBackend": "opencode"
27
+ }
28
+ ```
29
+
30
+ Or switch at runtime:
31
+
32
+ ```
33
+ > /backend opencode
34
+ ```
35
+
36
+ opencode reads its own config from `~/.local/share/opencode/` (auth credentials) and `opencode.json` / `opencode.jsonc` in your project. Configure providers and authentication by running `opencode auth login` directly — agent-sh does not override opencode's configuration.
37
+
38
+ ## Requirements
39
+
40
+ - opencode authenticated locally — run `opencode auth login` once before using this bridge.
41
+ - Provider env vars (e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) as required by opencode for the model you've selected.
42
+
43
+ ## What works under opencode
44
+
45
+ agent-sh's per-query context producers (e.g. `<shell_events>` from `shell-context`) are inlined into opencode's prompt before each query, so opencode sees the user's recent shell activity even though the SDK doesn't subscribe to agent-sh's shell bus directly. The current cwd is part of that context, so opencode knows where the user is even when its tools are anchored elsewhere.
46
+
47
+ ## cwd handling
48
+
49
+ opencode treats the `directory` query param as a project ID and routes its event stream per-project — switching project mid-session silences the SSE channel we already subscribed to (no tool events, no streaming text). Because of that, the bridge **pins the session to the directory agent-sh launched from** and does not propagate later in-shell `cd`s to opencode. opencode's tools (`Bash`, `Read`, `Edit`, etc.) operate from that pinned directory; the agent learns the user's real cwd from `<shell_events>` and can still reach other locations through absolute paths or `cd && cmd` in `Bash`.
50
+
51
+ To re-anchor the agent to your current cwd, run `/reset` — it tears down the conversation and creates a fresh session in your present directory.
52
+
53
+ ## Permission prompts
54
+
55
+ opencode supports a `permission.edit = "ask"` config in `opencode.json` that gates write/edit tools behind an approval. The bridge has no UI primitive for showing that prompt, so it **auto-approves each request once** — without this, write/edit tool calls hang forever waiting for a reply that never comes. This matches claude-code-bridge's `permissionMode: "acceptEdits"` behavior. If you want to actually gate edits, set `permission.edit` to `"allow"` (skip the prompt entirely) or run opencode standalone for the interactive flow.
56
+
57
+ ## What this bridge is
58
+
59
+ A pure protocol translator between opencode's SSE event stream and agent-sh's bus events. opencode runs as an in-process HTTP server (booted by `createOpencode()`); the bridge consumes its global event stream, filters by the active session's ID, and translates `message.part.updated` events (text/reasoning deltas, `ToolPart.state` transitions) into agent-sh tool/response events. opencode's built-in tools (bash, edit, read, write, grep, glob, etc.) are used exactly as opencode ships them. The bridge adds no tools of its own.
@@ -0,0 +1,383 @@
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 { createOpencode, type OpencodeClient, type Event, type Part, type ToolPart } from "@opencode-ai/sdk";
9
+ import type { ExtensionContext } from "agent-sh/types";
10
+ import { computeDiff, type DiffResult } from "agent-sh/utils/diff";
11
+
12
+ function parseUnifiedDiff(patch: string): DiffResult | null {
13
+ if (!patch) return null;
14
+ const hunks: DiffResult["hunks"] = [];
15
+ let current: DiffResult["hunks"][number] | null = null;
16
+ let oldNo = 0;
17
+ let newNo = 0;
18
+ let added = 0;
19
+ let removed = 0;
20
+
21
+ for (const raw of patch.split("\n")) {
22
+ if (raw.startsWith("Index:") || raw.startsWith("===") || raw.startsWith("--- ") || raw.startsWith("+++ ")) continue;
23
+ const hunkHeader = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
24
+ if (hunkHeader) {
25
+ if (current) hunks.push(current);
26
+ current = { lines: [] };
27
+ oldNo = parseInt(hunkHeader[1]!, 10);
28
+ newNo = parseInt(hunkHeader[2]!, 10);
29
+ continue;
30
+ }
31
+ if (!current) continue;
32
+ if (raw.startsWith("+")) {
33
+ current.lines.push({ type: "added", oldNo: null, newNo, text: raw.slice(1) });
34
+ newNo++;
35
+ added++;
36
+ } else if (raw.startsWith("-")) {
37
+ current.lines.push({ type: "removed", oldNo, newNo: null, text: raw.slice(1) });
38
+ oldNo++;
39
+ removed++;
40
+ } else if (raw.startsWith(" ")) {
41
+ current.lines.push({ type: "context", oldNo, newNo, text: raw.slice(1) });
42
+ oldNo++;
43
+ newNo++;
44
+ }
45
+ }
46
+ if (current) hunks.push(current);
47
+ if (hunks.length === 0) return null;
48
+ return { hunks, added, removed, isIdentical: added + removed === 0, isNewFile: false };
49
+ }
50
+
51
+ export default function activate(ctx: ExtensionContext): void {
52
+ const { bus, call } = ctx;
53
+
54
+ const cwd = (): string => {
55
+ const v = call("cwd");
56
+ return typeof v === "string" && v ? v : process.cwd();
57
+ };
58
+
59
+ let runtime: { client: OpencodeClient; server: { url: string; close(): void } } | null = null;
60
+ let sessionId: string | null = null;
61
+ // opencode treats `directory` as the project ID and routes its SSE event
62
+ // stream per-project. If we let prompts use the user's PTY cwd freely,
63
+ // an in-shell `cd` switches opencode's project mid-session and our SSE
64
+ // (opened on the original project) goes silent — including for tool
65
+ // events. Pin everything to the directory captured at session.create;
66
+ // the agent still learns the user's real cwd via <shell_events> and
67
+ // can operate elsewhere through absolute paths or `cd && cmd` in Bash.
68
+ let sessionDirectory: string | null = null;
69
+ let serverAbort: AbortController | null = null;
70
+ let streamAbort: AbortController | null = null;
71
+ let booting = true;
72
+
73
+ const announcedTools = new Set<string>();
74
+ const completedTools = new Set<string>();
75
+ // message.part.delta only carries `field` ("text"), not the part's
76
+ // type. Cache type from message.part.updated to route deltas correctly
77
+ // (text → response, reasoning → thinking).
78
+ const partKinds = new Map<string, string>();
79
+ let turnText = "";
80
+
81
+ // prompt() and SSE deltas race; resolve the turn on session.idle.
82
+ let pendingTurnEnd: (() => void) | null = null;
83
+ let turnIdleSeen = false;
84
+
85
+ const listeners: Array<{ event: string; fn: Function }> = [];
86
+
87
+ function toolKind(name: string): string {
88
+ const n = name.toLowerCase();
89
+ if (n === "read") return "read";
90
+ if (n === "edit" || n === "patch") return "edit";
91
+ if (n === "write") return "write";
92
+ if (n === "glob" || n === "grep" || n === "list") return "search";
93
+ if (n === "bash" || n === "shell") return "execute";
94
+ return "execute";
95
+ }
96
+
97
+ function formatToolCall(name: string, input: Record<string, unknown>): string {
98
+ const str = (v: unknown) => typeof v === "string" ? v : "";
99
+ const n = name.toLowerCase();
100
+ if (n === "bash" || n === "shell") return `$ ${str(input.command)}`;
101
+ if (n === "read" || n === "edit" || n === "write") return str(input.filePath ?? input.file_path ?? input.path);
102
+ if (n === "grep" || n === "glob") return `${str(input.pattern)} ${str(input.path)}`.trim();
103
+ return name;
104
+ }
105
+
106
+ function toolLocations(input: Record<string, unknown>): { path: string; line?: number | null }[] | undefined {
107
+ const raw = input.filePath ?? input.file_path ?? input.path;
108
+ if (typeof raw !== "string") return undefined;
109
+ const line = (input.line_number ?? input.line ?? input.offset) as number | undefined;
110
+ return [{ path: raw, line: line ?? null }];
111
+ }
112
+
113
+ function handleToolPart(part: ToolPart): void {
114
+ const { callID, tool: toolName, state } = part;
115
+ const kind = toolKind(toolName);
116
+
117
+ if (state.status !== "pending" && !announcedTools.has(callID)) {
118
+ announcedTools.add(callID);
119
+ bus.emit("agent:tool-started", {
120
+ title: toolName,
121
+ toolCallId: callID,
122
+ kind,
123
+ locations: toolLocations(state.input ?? {}),
124
+ rawInput: state.input,
125
+ displayDetail: formatToolCall(toolName, state.input ?? {}),
126
+ });
127
+ }
128
+
129
+ if ((state.status === "completed" || state.status === "error") && !completedTools.has(callID)) {
130
+ completedTools.add(callID);
131
+ const isError = state.status === "error";
132
+ const rawOutput = isError ? state.error : state.output;
133
+
134
+ let resultDisplay: { summary?: string; body?: { kind: "diff"; diff: DiffResult; filePath: string } } | undefined;
135
+ if (!isError && state.status === "completed") {
136
+ const filePath = state.input?.filePath as string | undefined;
137
+ let diff: DiffResult | null = null;
138
+ if (toolName === "edit") {
139
+ const patch = (state.metadata as any)?.filediff?.patch as string | undefined;
140
+ if (patch) diff = parseUnifiedDiff(patch);
141
+ } else if (toolName === "write") {
142
+ // Overwrites of existing files render as new-file diffs —
143
+ // opencode doesn't surface old content.
144
+ const content = state.input?.content as string | undefined;
145
+ if (typeof content === "string") diff = computeDiff(null, content);
146
+ }
147
+ if (diff && filePath && !diff.isIdentical) {
148
+ const summary = diff.isNewFile
149
+ ? `+${diff.added}`
150
+ : `+${diff.added} -${diff.removed}`;
151
+ resultDisplay = {
152
+ summary,
153
+ body: { kind: "diff", diff, filePath },
154
+ };
155
+ }
156
+ }
157
+
158
+ bus.emitTransform("agent:tool-completed", {
159
+ toolCallId: callID,
160
+ exitCode: isError ? 1 : 0,
161
+ rawOutput,
162
+ kind,
163
+ resultDisplay,
164
+ });
165
+ bus.emit("agent:tool-output", {
166
+ tool: toolName,
167
+ output: typeof rawOutput === "string" ? rawOutput : "",
168
+ exitCode: isError ? 1 : 0,
169
+ });
170
+ }
171
+ }
172
+
173
+ function emitTextDelta(text: string): void {
174
+ bus.emitTransform("agent:response-chunk", {
175
+ blocks: [{ type: "text" as const, text }],
176
+ });
177
+ turnText += text;
178
+ }
179
+
180
+ function handleEvent(event: Event): void {
181
+ if (!sessionId) return;
182
+ const evType = (event as any).type as string;
183
+ const props = (event as any).properties ?? {};
184
+ const sid = props.sessionID;
185
+ if (typeof sid === "string" && sid !== sessionId) return;
186
+
187
+ switch (evType) {
188
+ // message.part.delta is undocumented in the SDK's Event union but
189
+ // the SSE consumer yields it. Drop chunks for unknown partIDs —
190
+ // misrouting bleeds reasoning into the response or vice versa.
191
+ case "message.part.delta": {
192
+ if (typeof props.delta !== "string" || !props.delta) break;
193
+ const kind = partKinds.get(props.partID);
194
+ if (kind === "reasoning") bus.emit("agent:thinking-chunk", { text: props.delta });
195
+ else if (kind === "text") emitTextDelta(props.delta);
196
+ break;
197
+ }
198
+ case "message.part.updated": {
199
+ const part = props.part as Part | undefined;
200
+ if (!part) break;
201
+ partKinds.set(part.id, part.type);
202
+ if (part.type === "tool") handleToolPart(part);
203
+ break;
204
+ }
205
+ case "session.idle": {
206
+ turnIdleSeen = true;
207
+ pendingTurnEnd?.();
208
+ break;
209
+ }
210
+ case "session.error": {
211
+ const err = props.error as { message?: string } | undefined;
212
+ bus.emit("agent:error", { message: err?.message ?? "opencode session error" });
213
+ break;
214
+ }
215
+ // Without a reply the gated tool hangs forever. The bridge has no
216
+ // interactive approval UI, so auto-approve — mirrors claude-code-
217
+ // bridge's permissionMode: "acceptEdits". Set permission.edit:
218
+ // "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
+ })
229
+ .catch(() => { /* approval is best-effort */ });
230
+ break;
231
+ }
232
+ }
233
+ }
234
+
235
+ async function consumeEvents(client: OpencodeClient, signal: AbortSignal): Promise<void> {
236
+ while (!signal.aborted) {
237
+ try {
238
+ const result = await client.event.subscribe({ signal });
239
+ for await (const ev of result.stream) {
240
+ if (signal.aborted) return;
241
+ handleEvent(ev as Event);
242
+ }
243
+ } catch {
244
+ if (signal.aborted) return;
245
+ await new Promise((r) => setTimeout(r, 1000));
246
+ }
247
+ }
248
+ }
249
+
250
+ const wireListeners = () => {
251
+ const onSubmit = async ({ query: userQuery }: { query: string }) => {
252
+ if (!runtime || !sessionId) {
253
+ bus.emit("agent:error", {
254
+ message: booting ? "opencode is still starting up..." : "opencode session not initialized",
255
+ });
256
+ bus.emit("agent:processing-done", {});
257
+ return;
258
+ }
259
+
260
+ bus.emit("agent:query", { query: userQuery });
261
+ bus.emit("agent:processing-start", {});
262
+ turnText = "";
263
+ turnIdleSeen = false;
264
+ // Set the idle waiter BEFORE prompt() so a fast session.idle can't
265
+ // race in before we're listening.
266
+ const idlePromise = new Promise<void>((resolve) => {
267
+ pendingTurnEnd = () => { resolve(); pendingTurnEnd = null; };
268
+ });
269
+
270
+ const ctxText = String(call("query-context:build") ?? "").trim();
271
+ const finalPrompt = ctxText ? `${ctxText}\n\n${userQuery}` : userQuery;
272
+
273
+ try {
274
+ 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
+ },
280
+ });
281
+ if (!turnIdleSeen) {
282
+ await Promise.race([
283
+ idlePromise,
284
+ new Promise<void>((r) => setTimeout(r, 60_000)),
285
+ ]);
286
+ }
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
+ });
297
+ }
298
+ }
299
+ bus.emitTransform("agent:response-done", { response: turnText });
300
+ } catch (err) {
301
+ bus.emit("agent:error", {
302
+ message: err instanceof Error ? err.message : String(err),
303
+ });
304
+ } finally {
305
+ pendingTurnEnd = null;
306
+ bus.emit("agent:processing-done", {});
307
+ }
308
+ };
309
+
310
+ const onCancel = async () => {
311
+ if (!runtime || !sessionId) return;
312
+ try {
313
+ await runtime.client.session.abort({ path: { id: sessionId } });
314
+ } catch { /* abort is best-effort */ }
315
+ };
316
+
317
+ const onReset = async () => {
318
+ if (!runtime) return;
319
+ announcedTools.clear();
320
+ completedTools.clear();
321
+ partKinds.clear();
322
+ // /reset is the one moment we deliberately let the project switch.
323
+ sessionDirectory = cwd();
324
+ const res = await runtime.client.session.create({ query: { directory: sessionDirectory } });
325
+ sessionId = res.data?.id ?? null;
326
+ };
327
+
328
+ bus.on("agent:submit", onSubmit);
329
+ bus.on("agent:cancel-request", onCancel);
330
+ bus.on("agent:reset-session", onReset);
331
+ listeners.push(
332
+ { event: "agent:submit", fn: onSubmit },
333
+ { event: "agent:cancel-request", fn: onCancel },
334
+ { event: "agent:reset-session", fn: onReset },
335
+ );
336
+ };
337
+
338
+ const unwireListeners = () => {
339
+ for (const { event, fn } of listeners) bus.off(event as any, fn as any);
340
+ listeners.length = 0;
341
+ };
342
+
343
+ bus.emit("agent:register-backend", {
344
+ name: "opencode",
345
+ start: async () => {
346
+ try {
347
+ serverAbort = new AbortController();
348
+ runtime = await createOpencode({ signal: serverAbort.signal });
349
+
350
+ streamAbort = new AbortController();
351
+ // Subscribe before creating the session so we don't miss early events.
352
+ void consumeEvents(runtime.client, streamAbort.signal);
353
+
354
+ sessionDirectory = cwd();
355
+ const res = await runtime.client.session.create({ query: { directory: sessionDirectory } });
356
+ sessionId = res.data?.id ?? null;
357
+ if (!sessionId) throw new Error("session.create returned no id");
358
+
359
+ wireListeners();
360
+ booting = false;
361
+ bus.emit("agent:info", { name: "opencode", version: "1.x" });
362
+ } catch (err) {
363
+ booting = false;
364
+ bus.emit("ui:error", {
365
+ message: `opencode-bridge: failed to initialize — ${err instanceof Error ? err.message : String(err)}`,
366
+ });
367
+ }
368
+ },
369
+ kill: () => {
370
+ unwireListeners();
371
+ streamAbort?.abort();
372
+ serverAbort?.abort();
373
+ runtime?.server.close();
374
+ runtime = null;
375
+ sessionId = null;
376
+ sessionDirectory = null;
377
+ announcedTools.clear();
378
+ completedTools.clear();
379
+ partKinds.clear();
380
+ booting = true;
381
+ },
382
+ });
383
+ }
@@ -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.23",
3
+ "version": "0.12.25",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",