agent-sh 0.12.24 → 0.12.26

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.
@@ -11,130 +11,171 @@ export class TuiInputView {
11
11
  cursorTermCol = 1;
12
12
  autocompleteLines = 0;
13
13
  surface;
14
+ frameBuf = null;
14
15
  constructor(surface) {
15
16
  this.surface = surface ?? new StdoutSurface();
16
17
  }
18
+ // Frame buffering: coalesces all emit() calls until endFrame() into one
19
+ // surface.write, bracketed by cursor hide/show so intermediate redraw
20
+ // states never flicker through.
21
+ beginFrame() {
22
+ if (this.frameBuf === null)
23
+ this.frameBuf = "\x1b[?25l";
24
+ }
25
+ endFrame() {
26
+ if (this.frameBuf === null)
27
+ return;
28
+ const out = this.frameBuf + "\x1b[?25h";
29
+ this.frameBuf = null;
30
+ this.surface.write(out);
31
+ }
32
+ emit(s) {
33
+ if (this.frameBuf !== null)
34
+ this.frameBuf += s;
35
+ else
36
+ this.surface.write(s);
37
+ }
38
+ autoFrame(fn) {
39
+ const owned = this.frameBuf === null;
40
+ if (owned)
41
+ this.beginFrame();
42
+ try {
43
+ return fn();
44
+ }
45
+ finally {
46
+ if (owned)
47
+ this.endFrame();
48
+ }
49
+ }
17
50
  resetCursor() {
18
51
  this.cursorRowsBelow = 0;
19
52
  this.cursorTermCol = 1;
20
53
  }
21
54
  enableModeKeys() {
22
55
  // Kitty progressive enhancement + bracket paste (Shift+Enter → \x1b[13;2u).
23
- this.surface.write("\x1b[>1u\x1b[?2004h");
56
+ this.emit("\x1b[>1u\x1b[?2004h");
24
57
  }
25
58
  disableModeKeys() {
26
- this.surface.write("\x1b[<u\x1b[?2004l");
59
+ this.emit("\x1b[<u\x1b[?2004l");
27
60
  }
28
61
  clearPromptArea() {
29
- if (this.cursorRowsBelow > 0) {
30
- this.surface.write(`\x1b[${this.cursorRowsBelow}A`);
31
- }
32
- this.surface.write("\r\x1b[J");
33
- this.cursorRowsBelow = 0;
62
+ this.autoFrame(() => {
63
+ if (this.cursorRowsBelow > 0) {
64
+ this.emit(`\x1b[${this.cursorRowsBelow}A`);
65
+ }
66
+ this.emit("\r\x1b[J");
67
+ this.cursorRowsBelow = 0;
68
+ });
34
69
  }
35
70
  drawPrompt(vm) {
36
- const termW = this.surface.columns;
37
- if (this.cursorRowsBelow > 0) {
38
- this.surface.write(`\x1b[${this.cursorRowsBelow}A`);
39
- }
40
- this.surface.write("\r\x1b[J");
41
- const infoPrefix = vm.agentInfo.info
42
- ? `${vm.agentInfo.info} ${p.success}${vm.indicator}${p.reset} `
43
- : `${p.success}${vm.indicator}${p.reset} `;
44
- const promptPrefix = infoPrefix + p.warning + p.bold + vm.promptIcon + " " + p.reset;
45
- const promptVisLen = visibleLen(infoPrefix) + visibleLen(vm.promptIcon) + 1;
46
- const display = vm.showBuffer ? vm.displayText : "";
47
- const dCursor = vm.showBuffer ? vm.displayCursor : 0;
48
- if (!vm.showBuffer) {
49
- this.surface.write(promptPrefix);
50
- const N = promptVisLen;
51
- this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
52
- this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
53
- }
54
- else if (!display.includes("\n")) {
55
- // DECSC/DECRC bracket the after-cursor text so the cursor lands mid-line.
56
- const before = display.slice(0, dCursor);
57
- const after = display.slice(dCursor);
58
- this.surface.write(promptPrefix + p.accent + before + p.reset +
59
- "\x1b7" +
60
- p.accent + after + p.reset +
61
- "\x1b8");
62
- const cursorVisCol = promptVisLen + visibleLen(before);
63
- this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
64
- this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
65
- }
66
- else {
67
- const lines = display.split("\n");
68
- const indent = " ".repeat(promptVisLen);
69
- let charsRemaining = dCursor;
70
- let cursorLine = 0;
71
- for (let li = 0; li < lines.length; li++) {
72
- if (charsRemaining <= lines[li].length) {
73
- cursorLine = li;
74
- break;
75
- }
76
- charsRemaining -= lines[li].length + 1;
77
- cursorLine = li + 1;
71
+ this.autoFrame(() => {
72
+ const termW = this.surface.columns;
73
+ if (this.cursorRowsBelow > 0) {
74
+ this.emit(`\x1b[${this.cursorRowsBelow}A`);
78
75
  }
79
- let output = "";
80
- let cursorRowFromTop = 0;
81
- let rowsSoFar = 0;
82
- for (let li = 0; li < lines.length; li++) {
83
- const prefix = li === 0 ? promptPrefix : indent;
84
- const lineText = lines[li];
85
- const lineVisLen = promptVisLen + visibleLen(lineText);
86
- const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
87
- if (li === cursorLine) {
88
- const before = lineText.slice(0, charsRemaining);
89
- const after = lineText.slice(charsRemaining);
90
- output += prefix + p.accent + before + p.reset;
91
- output += "\x1b7";
92
- output += p.accent + after + p.reset;
93
- const beforeVisCol = promptVisLen + visibleLen(before);
94
- cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
95
- this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
76
+ this.emit("\r\x1b[J");
77
+ const infoPrefix = vm.agentInfo.info
78
+ ? `${vm.agentInfo.info} ${p.success}${vm.indicator}${p.reset} `
79
+ : `${p.success}${vm.indicator}${p.reset} `;
80
+ const promptPrefix = infoPrefix + p.warning + p.bold + vm.promptIcon + " " + p.reset;
81
+ const promptVisLen = visibleLen(infoPrefix) + visibleLen(vm.promptIcon) + 1;
82
+ const display = vm.showBuffer ? vm.displayText : "";
83
+ const dCursor = vm.showBuffer ? vm.displayCursor : 0;
84
+ if (!vm.showBuffer) {
85
+ this.emit(promptPrefix);
86
+ const N = promptVisLen;
87
+ this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
88
+ this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
89
+ }
90
+ else if (!display.includes("\n")) {
91
+ // DECSC/DECRC bracket the after-cursor text so the cursor lands mid-line.
92
+ const before = display.slice(0, dCursor);
93
+ const after = display.slice(dCursor);
94
+ this.emit(promptPrefix + p.accent + before + p.reset +
95
+ "\x1b7" +
96
+ p.accent + after + p.reset +
97
+ "\x1b8");
98
+ const cursorVisCol = promptVisLen + visibleLen(before);
99
+ this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
100
+ this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
101
+ }
102
+ else {
103
+ const lines = display.split("\n");
104
+ const indent = " ".repeat(promptVisLen);
105
+ let charsRemaining = dCursor;
106
+ let cursorLine = 0;
107
+ for (let li = 0; li < lines.length; li++) {
108
+ if (charsRemaining <= lines[li].length) {
109
+ cursorLine = li;
110
+ break;
111
+ }
112
+ charsRemaining -= lines[li].length + 1;
113
+ cursorLine = li + 1;
96
114
  }
97
- else {
98
- output += prefix + p.accent + lineText + p.reset;
115
+ let output = "";
116
+ let cursorRowFromTop = 0;
117
+ let rowsSoFar = 0;
118
+ for (let li = 0; li < lines.length; li++) {
119
+ const prefix = li === 0 ? promptPrefix : indent;
120
+ const lineText = lines[li];
121
+ const lineVisLen = promptVisLen + visibleLen(lineText);
122
+ const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
123
+ if (li === cursorLine) {
124
+ const before = lineText.slice(0, charsRemaining);
125
+ const after = lineText.slice(charsRemaining);
126
+ output += prefix + p.accent + before + p.reset;
127
+ output += "\x1b7";
128
+ output += p.accent + after + p.reset;
129
+ const beforeVisCol = promptVisLen + visibleLen(before);
130
+ cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
131
+ this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
132
+ }
133
+ else {
134
+ output += prefix + p.accent + lineText + p.reset;
135
+ }
136
+ if (li < lines.length - 1)
137
+ output += "\n";
138
+ rowsSoFar += lineTermRows;
99
139
  }
100
- if (li < lines.length - 1)
101
- output += "\n";
102
- rowsSoFar += lineTermRows;
140
+ this.emit(output + "\x1b8");
141
+ this.cursorRowsBelow = cursorRowFromTop;
103
142
  }
104
- this.surface.write(output + "\x1b8");
105
- this.cursorRowsBelow = cursorRowFromTop;
106
- }
143
+ });
107
144
  }
108
145
  drawAutocomplete(vm) {
109
146
  if (vm.items.length === 0)
110
147
  return;
111
- const lines = [];
112
- for (let i = 0; i < vm.items.length; i++) {
113
- const item = vm.items[i];
114
- const selected = i === vm.selected;
115
- if (selected) {
116
- lines.push(` \x1b[7m ${p.accent}${item.name.padEnd(12)}${p.reset}\x1b[7m ${item.description} ${p.reset}`);
148
+ this.autoFrame(() => {
149
+ const lines = [];
150
+ for (let i = 0; i < vm.items.length; i++) {
151
+ const item = vm.items[i];
152
+ const selected = i === vm.selected;
153
+ if (selected) {
154
+ lines.push(` \x1b[7m ${p.accent}${item.name.padEnd(12)}${p.reset}\x1b[7m ${item.description} ${p.reset}`);
155
+ }
156
+ else {
157
+ lines.push(` ${p.muted}${item.name.padEnd(12)} ${item.description}${p.reset}`);
158
+ }
117
159
  }
118
- else {
119
- lines.push(` ${p.muted}${item.name.padEnd(12)} ${item.description}${p.reset}`);
160
+ this.emit("\n" + lines.join("\n"));
161
+ this.autocompleteLines = lines.length;
162
+ if (this.autocompleteLines > 0) {
163
+ this.emit(`\x1b[${this.autocompleteLines}A`);
120
164
  }
121
- }
122
- this.surface.write("\n" + lines.join("\n"));
123
- this.autocompleteLines = lines.length;
124
- if (this.autocompleteLines > 0) {
125
- this.surface.write(`\x1b[${this.autocompleteLines}A`);
126
- }
127
- // Absolute column set — preceding \n may have scrolled, invalidating DECSC.
128
- this.surface.write(`\x1b[${this.cursorTermCol}G`);
165
+ // Absolute column set — preceding \n may have scrolled, invalidating DECSC.
166
+ this.emit(`\x1b[${this.cursorTermCol}G`);
167
+ });
129
168
  }
130
169
  clearAutocomplete() {
131
170
  if (this.autocompleteLines <= 0)
132
171
  return;
133
- // CSI B (cursor down, bounded) so we don't scroll on the last row.
134
- for (let i = 0; i < this.autocompleteLines; i++) {
135
- this.surface.write("\x1b[B\x1b[2K");
136
- }
137
- this.surface.write(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
138
- this.autocompleteLines = 0;
172
+ this.autoFrame(() => {
173
+ // CSI B (cursor down, bounded) so we don't scroll on the last row.
174
+ for (let i = 0; i < this.autocompleteLines; i++) {
175
+ this.emit("\x1b[B\x1b[2K");
176
+ }
177
+ this.emit(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
178
+ this.autocompleteLines = 0;
179
+ });
139
180
  }
140
181
  }
@@ -1,6 +1,4 @@
1
1
  import type { EventBus } from "../event-bus.js";
2
- /** Check if @xterm/headless is installed without loading it. */
3
- export declare function isXtermAvailable(): boolean;
4
2
  export interface TerminalBufferConfig {
5
3
  /** Terminal width in columns. Default: process.stdout.columns || 80. */
6
4
  cols?: number;
@@ -31,15 +29,14 @@ export declare class TerminalBuffer {
31
29
  /** Flush pending drip-feed data (set by createWired). */
32
30
  _flushPending: (() => void) | null;
33
31
  private constructor();
32
+ static create(config?: TerminalBufferConfig): TerminalBuffer;
34
33
  /**
35
- * Create a new TerminalBuffer. Returns null if xterm is not installed.
34
+ * Create a TerminalBuffer wired to a bus's `shell:pty-data` event.
35
+ * Drip-feeds writes asynchronously: synchronous `term.write()` in the
36
+ * pty-data handler changes PTY read coalescing enough to introduce
37
+ * visual artifacts.
36
38
  */
37
- static create(config?: TerminalBufferConfig): TerminalBuffer | null;
38
- /**
39
- * Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
40
- * Returns null if xterm is not installed.
41
- */
42
- static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer | null;
39
+ static createWired(bus: EventBus, config?: TerminalBufferConfig): TerminalBuffer;
43
40
  /** Flush any pending drip-feed data into the virtual terminal. */
44
41
  flush(): void;
45
42
  /** Write raw data into the virtual terminal. */
@@ -9,38 +9,19 @@
9
9
  * - floating-panel.ts: composited overlay rendering + screen restore
10
10
  * - terminal-buffer extension: agent tools (terminal_read, terminal_keys)
11
11
  * - Any extension needing a virtual terminal snapshot
12
- *
13
- * The xterm dependency is loaded lazily on first use. If @xterm/headless
14
- * is not installed, create() returns null.
15
- *
16
- * Install (optional):
17
- * npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
18
12
  */
13
+ // xterm is loaded lazily on first TerminalBuffer.create(). Subcommands
14
+ // (init/install/list) and non-shell frontends (web bridges) import this
15
+ // file transitively but never instantiate a buffer; they shouldn't pay
16
+ // the xterm parse cost at startup.
19
17
  import { createRequire } from "module";
20
- // ── Lazy xterm loader ───────────────────────────────────────────
21
18
  const require = createRequire(import.meta.url);
22
- let loadAttempted = false;
23
- let available = false;
24
- let TerminalCtor;
25
- let SerializeAddonCtor;
26
- function ensureXterm() {
27
- if (loadAttempted)
28
- return available;
29
- loadAttempted = true;
30
- try {
31
- TerminalCtor = require("@xterm/headless").Terminal;
32
- SerializeAddonCtor = require("@xterm/addon-serialize").SerializeAddon;
33
- available = true;
34
- }
35
- catch {
36
- available = false;
37
- }
38
- return available;
39
- }
40
- /** Check if @xterm/headless is installed without loading it. */
41
- export function isXtermAvailable() {
42
- return ensureXterm();
43
- }
19
+ // Node's require cache memoizes the first hit; subsequent calls are
20
+ // just a hashmap lookup, so this stays lazy without our own caching.
21
+ const loadXterm = () => ({
22
+ Terminal: require("@xterm/headless").Terminal,
23
+ SerializeAddon: require("@xterm/addon-serialize").SerializeAddon,
24
+ });
44
25
  /**
45
26
  * Format a screen snapshot as an XML context block for agent injection.
46
27
  * Trims, caps to `maxLines` (from the bottom), and wraps in `<terminal_buffer>`.
@@ -71,47 +52,35 @@ export class TerminalBuffer {
71
52
  this.term = term;
72
53
  this.serializeAddon = serialize;
73
54
  }
74
- /**
75
- * Create a new TerminalBuffer. Returns null if xterm is not installed.
76
- */
77
55
  static create(config) {
78
- if (!ensureXterm())
79
- return null;
56
+ const { Terminal, SerializeAddon } = loadXterm();
80
57
  const cols = config?.cols ?? (process.stdout.columns || 80);
81
58
  const rows = config?.rows ?? (process.stdout.rows || 24);
82
59
  const scrollback = config?.scrollback ?? 200;
83
- const term = new TerminalCtor({ cols, rows, allowProposedApi: true, scrollback });
84
- const serialize = new SerializeAddonCtor();
60
+ const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback });
61
+ const serialize = new SerializeAddon();
85
62
  term.loadAddon(serialize);
86
63
  return new TerminalBuffer(term, serialize);
87
64
  }
88
65
  /**
89
- * Create a TerminalBuffer and wire it to a bus's shell:pty-data event.
90
- * Returns null if xterm is not installed.
66
+ * Create a TerminalBuffer wired to a bus's `shell:pty-data` event.
67
+ * Drip-feeds writes asynchronously: synchronous `term.write()` in the
68
+ * pty-data handler changes PTY read coalescing enough to introduce
69
+ * visual artifacts.
91
70
  */
92
71
  static createWired(bus, config) {
93
72
  const tb = TerminalBuffer.create(config);
94
- if (!tb)
95
- return null;
96
- // Buffer PTY data and drip-feed to xterm in the background.
97
- // Synchronous term.write() in the pty-data handler introduces enough
98
- // latency to change PTY read coalescing, causing visual artifacts.
99
73
  let pending = "";
100
- bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
101
- setInterval(() => {
102
- if (pending) {
103
- const d = pending;
104
- pending = "";
105
- tb.write(d);
106
- }
107
- }, 50);
108
- tb._flushPending = () => {
74
+ const drain = () => {
109
75
  if (pending) {
110
76
  const d = pending;
111
77
  pending = "";
112
78
  tb.write(d);
113
79
  }
114
80
  };
81
+ bus.on("shell:pty-data", ({ raw }) => { pending += raw; });
82
+ setInterval(drain, 50);
83
+ tb._flushPending = drain;
115
84
  process.stdout.on("resize", () => {
116
85
  tb.resize(process.stdout.columns || 80, process.stdout.rows || 24);
117
86
  });
@@ -171,7 +140,6 @@ export class TerminalBuffer {
171
140
  const line = buf.getLine(y);
172
141
  lines.push(line ? line.translateToString(true) : "");
173
142
  }
174
- // Trim trailing empty lines
175
143
  while (lines.length > 0 && lines[lines.length - 1] === "") {
176
144
  lines.pop();
177
145
  }
@@ -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.