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 +61 -9
- package/dist/agent/system-prompt.js +2 -2
- package/dist/core.d.ts +1 -1
- package/dist/core.js +2 -2
- package/dist/event-bus.d.ts +2 -0
- package/dist/extensions/agent-backend.js +1 -2
- package/dist/extensions/shell-context.d.ts +2 -1
- package/dist/extensions/shell-context.js +5 -4
- package/dist/index.js +70 -62
- package/dist/install.d.ts +10 -0
- package/dist/install.js +205 -0
- package/dist/types.d.ts +2 -0
- package/examples/extensions/claude-code-bridge/README.md +14 -14
- package/examples/extensions/claude-code-bridge/index.ts +19 -25
- package/examples/extensions/opencode-bridge/README.md +59 -0
- package/examples/extensions/opencode-bridge/index.ts +383 -0
- package/examples/extensions/opencode-bridge/package.json +11 -0
- package/examples/extensions/pi-bridge/README.md +9 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
alias ash="agent-sh"
|
|
84
|
-
```
|
|
113
|
+
## Bring your own agent
|
|
85
114
|
|
|
86
|
-
|
|
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
|
-
**
|
|
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/)
|
|
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>\` (
|
|
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
|
-
|
|
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
|
},
|
package/dist/event-bus.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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-
|
|
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
|
|
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
|
|
54
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
//
|
|
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
|
|
284
|
-
"
|
|
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 =
|
|
296
|
-
?
|
|
297
|
-
: backendNames
|
|
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 {};
|
package/dist/install.js
ADDED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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:
|
|
87
|
+
prompt: finalPrompt,
|
|
94
88
|
options: {
|
|
95
|
-
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
|
@@ -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
|