agent-sh 0.12.24 → 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 — `agent-sh install pi-bridge` followed by `agent-sh --backend pi`.
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 — `agent-sh install pi-bridge && agent-sh --backend pi` runs [pi](examples/extensions/pi-bridge/) as a drop-in backend.
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
 
@@ -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
@@ -236,49 +236,9 @@ async function main() {
236
236
  }
237
237
  process.exit(0);
238
238
  };
239
- // ── Extension context (must precede shell activation) ────────
240
- if (process.env.DEBUG) {
241
- console.error('[agent-sh] Setting up extensions...');
242
- }
243
239
  const extCtx = core.extensionContext({ quit: cleanup });
244
- // ── Shell frontend bootstrap (special-cased; see src/shell/index.ts) ──
245
- if (process.env.DEBUG) {
246
- console.error('[agent-sh] Creating Shell...');
247
- }
248
- await new Promise(resolve => setTimeout(resolve, 100));
249
- shell = activateShell(extCtx, {
250
- cols,
251
- rows,
252
- shellPath: config.shell || process.env.SHELL || "/bin/bash",
253
- cwd: process.cwd(),
254
- onShowAgentInfo: () => {
255
- if (agentInfo) {
256
- return { info: `${p.dim}${agentInfo.name}${agentInfo.model ? ` (${agentInfo.model})` : ""}${p.reset}` };
257
- }
258
- return { info: "" };
259
- },
260
- });
261
- if (process.env.DEBUG) {
262
- console.error('[agent-sh] Shell created');
263
- }
264
- // ── Input mode ───────────────────────────────────────────────
265
- bus.emit("input-mode:register", {
266
- id: "agent",
267
- trigger: ">",
268
- label: "agent",
269
- promptIcon: "❯",
270
- indicator: "●",
271
- onSubmit(query, b) {
272
- b.emit("agent:submit", { query });
273
- },
274
- returnToSelf: true,
275
- });
276
- // Load built-in extensions (individually disableable via settings.disabledBuiltins)
240
+ // Load before spawning the shell so PS1 lands below the banner.
277
241
  await loadBuiltinExtensions(extCtx, getSettings().disabledBuiltins);
278
- // Load user extensions (may register alternative agent backends)
279
- if (process.env.DEBUG) {
280
- console.error('[agent-sh] Loading extensions...');
281
- }
282
242
  const loadExtensionsTimeoutMs = 10000;
283
243
  let loadedExtensions = [];
284
244
  await Promise.race([
@@ -287,17 +247,9 @@ async function main() {
287
247
  ]).catch((err) => {
288
248
  console.error(`Warning: ${err.message}`);
289
249
  });
290
- if (process.env.DEBUG) {
291
- console.error('[agent-sh] Extensions loaded');
292
- }
293
- // Names ride along so backend extensions can build banner sections.
294
250
  core.bus.emit("core:extensions-loaded", { names: loadedExtensions });
295
- // ── Activate agent backend ────────────────────────────────────
296
- // Extensions had their chance to register via agent:register-backend.
297
- // If none did, the built-in AgentLoop gets wired to bus events.
298
251
  const { names: backendNames } = core.bus.emitPipe("config:get-backends", { names: [], active: null });
299
252
  if (backendNames.length === 0) {
300
- shell?.kill();
301
253
  console.error("\nagent-sh: no agent backend available.\n\n" +
302
254
  " Export OPENROUTER_API_KEY or OPENAI_API_KEY for zero-config launch, or\n" +
303
255
  " pass --api-key on the command line, or\n" +
@@ -306,7 +258,6 @@ async function main() {
306
258
  process.exit(1);
307
259
  }
308
260
  if (config.backend && !backendNames.includes(config.backend)) {
309
- shell?.kill();
310
261
  const bridge = suggestBridgeFor(config.backend);
311
262
  const hint = bridge
312
263
  ? ` Try: agent-sh install ${bridge}\n`
@@ -316,9 +267,6 @@ async function main() {
316
267
  hint);
317
268
  process.exit(1);
318
269
  }
319
- // No await: banner must out-race the shell's PS1 arriving via PTY.
320
- core.activateBackend(config.backend);
321
- // ── Startup banner ───────────────────────────────────────────
322
270
  const settings = getSettings();
323
271
  if (settings.startupBanner !== false) {
324
272
  const termW = process.stdout.columns || 80;
@@ -346,6 +294,32 @@ async function main() {
346
294
  "\n " + hint + "\n" +
347
295
  borderLine + "\n\n");
348
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);
349
323
  // ── Terminal lifecycle ────────────────────────────────────────
350
324
  process.on("SIGTERM", cleanup);
351
325
  process.on("SIGHUP", cleanup);
@@ -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.24",
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",