agent-sh 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -18
- package/dist/agent/llm-client.js +1 -0
- package/dist/agent/types.d.ts +2 -2
- package/dist/cli/args.js +3 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +86 -2
- package/dist/cli/subcommands.js +4 -1
- package/dist/utils/tool-interactive.js +4 -2
- package/examples/extensions/ashi/package.json +1 -1
- package/examples/extensions/ashi/src/cli.ts +4 -1
- package/examples/extensions/claude-code-bridge/index.ts +7 -2
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/opencode-bridge/index.ts +205 -53
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ So I built agent-sh. Under the hood it's a normal shell on top of node-pty — y
|
|
|
19
19
|
~ $ > draft a commit message # agent reads your diff and shell history
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
agent-sh is built to be agent-agnostic.
|
|
22
|
+
agent-sh is built to be agent-agnostic. The recommended path is the built-in agent `ash` — a lightweight agent designed so extensions can plug into the same tool surface. If you'd rather host an existing coding agent (pi, claude-code, opencode), you can [bring your own](#bring-your-own-agent) — with the trade-off that it manages its own separate tools.
|
|
23
23
|
|
|
24
24
|
## Quick Start
|
|
25
25
|
|
|
@@ -55,24 +55,9 @@ alias ash="agent-sh"
|
|
|
55
55
|
|
|
56
56
|
Once installed, pick a backend below.
|
|
57
57
|
|
|
58
|
-
### Option A:
|
|
58
|
+
### Option A: Use the built-in agent (ash) — recommended
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
- **pi** — [pi-mono](https://github.com/badlogic/pi-mono) coding agent
|
|
63
|
-
- **claude-code** — official [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk)
|
|
64
|
-
- **opencode** — [opencode](https://opencode.ai/) via `@opencode-ai/sdk`
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
agent-sh install pi-bridge
|
|
68
|
-
agent-sh --backend pi
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
See [Bring your own agent](#bring-your-own-agent) below for full details and the other backends.
|
|
72
|
-
|
|
73
|
-
### Option B: Use the built-in agent (ash)
|
|
74
|
-
|
|
75
|
-
`ash` is agent-sh's own lightweight agent. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, openai-compatible, deepseek) register on startup; ash activates the first one with a usable key.
|
|
60
|
+
`ash` is agent-sh's own lightweight agent, and the path most users should start with: it shares its tool surface with the rest of the system, so extensions you install (new tools, content transforms, slash commands, themes) compose with it directly. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, openai-compatible, deepseek) register on startup; ash activates the first one with a usable key.
|
|
76
61
|
|
|
77
62
|
**Quickest path** — store a key once via the auth subcommand:
|
|
78
63
|
|
|
@@ -121,6 +106,10 @@ For richer configuration (multiple providers, extensions), run `agent-sh init` t
|
|
|
121
106
|
|
|
122
107
|
`ash` is designed to be extended. Extensions can add tools, content transforms (e.g. render LaTeX or Mermaid), themes, slash commands, or new input modes — see [Extensions](docs/extensions.md) for the full surface.
|
|
123
108
|
|
|
109
|
+
### Option B: Bring your own coding agent
|
|
110
|
+
|
|
111
|
+
If you already use pi, claude-code, or opencode, agent-sh can host it as the backend instead — see [Bring your own agent](#bring-your-own-agent) just below for the full setup and the trade-offs.
|
|
112
|
+
|
|
124
113
|
## Bring your own agent
|
|
125
114
|
|
|
126
115
|
The built-in agent (`ash`) is the default, but agent-sh can host a different coding agent as its backend — same terminal, same `>` entry point, same shell-context wiring. Three bridges ship in the box:
|
package/dist/agent/llm-client.js
CHANGED
package/dist/agent/types.d.ts
CHANGED
|
@@ -57,8 +57,8 @@ export interface InteractiveSession<T> {
|
|
|
57
57
|
render(width: number): string[];
|
|
58
58
|
/** Handle raw input. Call done(result) to finish the session. */
|
|
59
59
|
handleInput(data: string, done: (result: T) => void): void;
|
|
60
|
-
/**
|
|
61
|
-
onMount?(invalidate: () => void): void;
|
|
60
|
+
/** done() lets the session resolve itself from outside handleInput. */
|
|
61
|
+
onMount?(invalidate: () => void, done: (result: T) => void): void;
|
|
62
62
|
/** Called when session ends (cleanup). */
|
|
63
63
|
onUnmount?(): void;
|
|
64
64
|
}
|
package/dist/cli/args.js
CHANGED
|
@@ -3,7 +3,9 @@ const HELP_TEXT = `agent-sh — a shell-first terminal where AI is one keystroke
|
|
|
3
3
|
|
|
4
4
|
Usage: agent-sh [options]
|
|
5
5
|
agent-sh init [--force] Scaffold ~/.agent-sh/ (settings, examples, AGENTS.md)
|
|
6
|
-
agent-sh install <spec> [--force]
|
|
6
|
+
agent-sh install <spec> [--force] [--sync-deps]
|
|
7
|
+
Install an extension (bundled name, file:, npm:, github:)
|
|
8
|
+
--sync-deps rewrites a stale agent-sh pin to the host version
|
|
7
9
|
agent-sh uninstall <name> Remove an installed extension
|
|
8
10
|
agent-sh list List installed extensions
|
|
9
11
|
agent-sh auth login [provider] Store an API key for a built-in provider
|
package/dist/cli/install.d.ts
CHANGED
package/dist/cli/install.js
CHANGED
|
@@ -75,6 +75,84 @@ function readPackageJson(target) {
|
|
|
75
75
|
return null;
|
|
76
76
|
return JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
|
|
77
77
|
}
|
|
78
|
+
function hostAgentShVersion() {
|
|
79
|
+
try {
|
|
80
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"));
|
|
81
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function satisfies(version, spec) {
|
|
88
|
+
if (spec === version || spec === "*" || spec === "latest")
|
|
89
|
+
return true;
|
|
90
|
+
const [vMaj, vMin, vPatch] = version.split(/[.-]/, 3).map(Number);
|
|
91
|
+
if ([vMaj, vMin, vPatch].some(Number.isNaN))
|
|
92
|
+
return true;
|
|
93
|
+
const m = spec.match(/^([\^~]?)(\d+)\.(\d+)\.(\d+)/);
|
|
94
|
+
if (!m)
|
|
95
|
+
return true;
|
|
96
|
+
const op = m[1];
|
|
97
|
+
const sMaj = Number(m[2]);
|
|
98
|
+
const sMin = Number(m[3]);
|
|
99
|
+
const sPatch = Number(m[4]);
|
|
100
|
+
if (op === "")
|
|
101
|
+
return vMaj === sMaj && vMin === sMin && vPatch === sPatch;
|
|
102
|
+
if (op === "~")
|
|
103
|
+
return vMaj === sMaj && vMin === sMin && vPatch >= sPatch;
|
|
104
|
+
// ^x.y.z: zero-major treats minor as the breaking boundary (npm rule).
|
|
105
|
+
if (sMaj > 0)
|
|
106
|
+
return vMaj === sMaj && (vMin > sMin || (vMin === sMin && vPatch >= sPatch));
|
|
107
|
+
if (sMin > 0)
|
|
108
|
+
return vMaj === 0 && vMin === sMin && vPatch >= sPatch;
|
|
109
|
+
return vMaj === 0 && vMin === 0 && vPatch === sPatch;
|
|
110
|
+
}
|
|
111
|
+
/** Warn when the extension's `agent-sh` pin can't admit the host version;
|
|
112
|
+
* only rewrite when --sync-deps is set. */
|
|
113
|
+
function syncAgentShVersion(target, syncDeps) {
|
|
114
|
+
const hostVersion = hostAgentShVersion();
|
|
115
|
+
if (!hostVersion)
|
|
116
|
+
return;
|
|
117
|
+
// Prerelease hosts aren't on npm; rewriting would leave npm install unable to resolve.
|
|
118
|
+
if (hostVersion.includes("-"))
|
|
119
|
+
return;
|
|
120
|
+
const pkgJson = path.join(target, "package.json");
|
|
121
|
+
if (!fs.existsSync(pkgJson))
|
|
122
|
+
return;
|
|
123
|
+
const raw = fs.readFileSync(pkgJson, "utf-8");
|
|
124
|
+
const pkg = JSON.parse(raw);
|
|
125
|
+
const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
|
126
|
+
const name = path.basename(target);
|
|
127
|
+
let changed = false;
|
|
128
|
+
let warned = false;
|
|
129
|
+
for (const section of sections) {
|
|
130
|
+
const deps = pkg[section];
|
|
131
|
+
if (!deps || typeof deps !== "object")
|
|
132
|
+
continue;
|
|
133
|
+
const d = deps;
|
|
134
|
+
const current = d["agent-sh"];
|
|
135
|
+
if (typeof current !== "string")
|
|
136
|
+
continue;
|
|
137
|
+
if (current.startsWith("file:"))
|
|
138
|
+
continue;
|
|
139
|
+
if (satisfies(hostVersion, current))
|
|
140
|
+
continue;
|
|
141
|
+
if (syncDeps) {
|
|
142
|
+
console.log(`agent-sh: rewriting ${name} agent-sh ${current} -> ${hostVersion}.`);
|
|
143
|
+
d["agent-sh"] = hostVersion;
|
|
144
|
+
changed = true;
|
|
145
|
+
}
|
|
146
|
+
else if (!warned) {
|
|
147
|
+
console.warn(`agent-sh: ${name} pins agent-sh ${current}, which doesn't admit host ${hostVersion}. ` +
|
|
148
|
+
`npm install will land an older agent-sh inside the extension and drift from the running host. ` +
|
|
149
|
+
`Re-run with --sync-deps to rewrite the pin to ${hostVersion}, or update the bridge's source pin.`);
|
|
150
|
+
warned = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (changed)
|
|
154
|
+
fs.writeFileSync(pkgJson, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
155
|
+
}
|
|
78
156
|
/** Relative `file:` deps in bundled extensions (e.g. `"agent-sh": "file:../../.."`)
|
|
79
157
|
* point at the wrong location after the source is copied into ~/.agent-sh/extensions/.
|
|
80
158
|
* Resolve them against the original source dir so npm install in the target succeeds. */
|
|
@@ -165,7 +243,7 @@ function linkBins(target, pkg) {
|
|
|
165
243
|
}
|
|
166
244
|
export async function runInstall(spec, opts = {}) {
|
|
167
245
|
if (!spec) {
|
|
168
|
-
console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force]\n\n" +
|
|
246
|
+
console.error("Usage: agent-sh install <name|file:|npm:|github:> [--force] [--sync-deps]\n\n" +
|
|
169
247
|
"Bundled extensions:\n" +
|
|
170
248
|
listBundled()
|
|
171
249
|
.map((n) => ` ${n}`)
|
|
@@ -191,9 +269,15 @@ export async function runInstall(spec, opts = {}) {
|
|
|
191
269
|
}
|
|
192
270
|
let linkedBins = [];
|
|
193
271
|
if (resolved.isDirectory) {
|
|
194
|
-
fs.cpSync(resolved.sourcePath, target, {
|
|
272
|
+
fs.cpSync(resolved.sourcePath, target, {
|
|
273
|
+
recursive: true,
|
|
274
|
+
// Skip source node_modules: maybeNpmInstall short-circuits on
|
|
275
|
+
// existing node_modules, silently leaving the bridge's deps stale.
|
|
276
|
+
filter: (src) => path.basename(src) !== "node_modules",
|
|
277
|
+
});
|
|
195
278
|
try {
|
|
196
279
|
rewriteFileDeps(target, resolved.sourcePath);
|
|
280
|
+
syncAgentShVersion(target, opts.syncDeps ?? false);
|
|
197
281
|
const pkg = readPackageJson(target);
|
|
198
282
|
if (pkg) {
|
|
199
283
|
maybeNpmInstall(target, pkg);
|
package/dist/cli/subcommands.js
CHANGED
|
@@ -3,7 +3,10 @@ import { runInstall, runUninstall, runList } from "./install.js";
|
|
|
3
3
|
import { runAuth } from "./auth/cli.js";
|
|
4
4
|
const SUBCOMMANDS = {
|
|
5
5
|
init: (args) => runInit({ force: args.includes("--force") }),
|
|
6
|
-
install: (args) => runInstall(args[0] ?? "", {
|
|
6
|
+
install: (args) => runInstall(args[0] ?? "", {
|
|
7
|
+
force: args.includes("--force"),
|
|
8
|
+
syncDeps: args.includes("--sync-deps"),
|
|
9
|
+
}),
|
|
7
10
|
uninstall: (args) => runUninstall(args[0] ?? ""),
|
|
8
11
|
list: () => runList(),
|
|
9
12
|
auth: (args) => runAuth(args),
|
|
@@ -14,7 +14,6 @@ export function createToolUI(bus, surface) {
|
|
|
14
14
|
if (finished)
|
|
15
15
|
return;
|
|
16
16
|
finished = true;
|
|
17
|
-
clearLines(surface, prevLineCount);
|
|
18
17
|
bus.offPipe("input:intercept", interceptor);
|
|
19
18
|
bus.emit("shell:stdout-hide", {});
|
|
20
19
|
bus.emit("tool:interactive-end", {});
|
|
@@ -45,7 +44,10 @@ export function createToolUI(bus, surface) {
|
|
|
45
44
|
bus.emit("tool:interactive-start", {});
|
|
46
45
|
bus.emit("shell:stdout-show", {});
|
|
47
46
|
bus.onPipe("input:intercept", interceptor);
|
|
48
|
-
|
|
47
|
+
// Drop to a fresh row in case the cursor was mid-line; uncounted
|
|
48
|
+
// so clearLines on dismiss stops at the gap, not above it.
|
|
49
|
+
surface.write("\n");
|
|
50
|
+
session.onMount?.(() => render(), done);
|
|
49
51
|
render();
|
|
50
52
|
});
|
|
51
53
|
},
|
|
@@ -70,7 +70,10 @@ async function main(): Promise<void> {
|
|
|
70
70
|
|
|
71
71
|
if (sub === "install" || sub === "uninstall" || sub === "list") {
|
|
72
72
|
const { runInstall, runUninstall, runList } = await import("agent-sh/cli/install");
|
|
73
|
-
if (sub === "install") await runInstall(rest[0] ?? "", {
|
|
73
|
+
if (sub === "install") await runInstall(rest[0] ?? "", {
|
|
74
|
+
force: rest.includes("--force"),
|
|
75
|
+
syncDeps: rest.includes("--sync-deps"),
|
|
76
|
+
});
|
|
74
77
|
else if (sub === "uninstall") await runUninstall(rest[0] ?? "");
|
|
75
78
|
else runList();
|
|
76
79
|
process.exit(0);
|
|
@@ -21,6 +21,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
let activeQuery: Query | null = null;
|
|
24
|
+
let sessionId: string | null = null;
|
|
24
25
|
const listeners: Array<{ event: string; fn: Function }> = [];
|
|
25
26
|
|
|
26
27
|
// ── Tool display helpers ────────────────────────────────────────
|
|
@@ -97,6 +98,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
97
98
|
allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
|
|
98
99
|
permissionMode: "acceptEdits",
|
|
99
100
|
includePartialMessages: true,
|
|
101
|
+
...(sessionId ? { resume: sessionId } : {}),
|
|
100
102
|
},
|
|
101
103
|
});
|
|
102
104
|
|
|
@@ -262,8 +264,11 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
262
264
|
// Tool still running — nothing to do, TUI spinner already active
|
|
263
265
|
break;
|
|
264
266
|
|
|
265
|
-
case "result":
|
|
267
|
+
case "result": {
|
|
268
|
+
const sid = (message as any).session_id;
|
|
269
|
+
if (typeof sid === "string" && sid) sessionId = sid;
|
|
266
270
|
break;
|
|
271
|
+
}
|
|
267
272
|
}
|
|
268
273
|
}
|
|
269
274
|
|
|
@@ -291,7 +296,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
291
296
|
};
|
|
292
297
|
|
|
293
298
|
const onCancel = () => { activeQuery?.interrupt(); };
|
|
294
|
-
const onReset = () => {
|
|
299
|
+
const onReset = () => { sessionId = null; };
|
|
295
300
|
|
|
296
301
|
bus.on("agent:submit", onSubmit);
|
|
297
302
|
bus.on("agent:cancel-request", onCancel);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ToolPart,
|
|
14
14
|
type QuestionRequest,
|
|
15
15
|
type QuestionInfo,
|
|
16
|
+
type PermissionRequest,
|
|
16
17
|
} from "@opencode-ai/sdk/v2";
|
|
17
18
|
import type { ExtensionContext } from "agent-sh/types";
|
|
18
19
|
import type { InteractiveSession } from "agent-sh/agent/types";
|
|
@@ -60,7 +61,8 @@ function parseUnifiedDiff(patch: string): DiffResult | null {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export default function activate(ctx: ExtensionContext): void {
|
|
63
|
-
const { bus, call } = ctx;
|
|
64
|
+
const { bus, call } = ctx;
|
|
65
|
+
const compositor = ctx.shell?.compositor;
|
|
64
66
|
|
|
65
67
|
const cwd = (): string => {
|
|
66
68
|
const v = call("cwd");
|
|
@@ -94,6 +96,18 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
94
96
|
let turnIdleSeen = false;
|
|
95
97
|
let turnError: string | null = null;
|
|
96
98
|
|
|
99
|
+
let pickerOpen = false;
|
|
100
|
+
const eventQueue: Event[] = [];
|
|
101
|
+
const drainQueue = (): void => {
|
|
102
|
+
const events = eventQueue.splice(0);
|
|
103
|
+
for (const ev of events) handleEvent(ev);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// After Ctrl+C, opencode emits a tail of tool / error state for the
|
|
107
|
+
// aborted turn. Suppress until the next turn so it doesn't restart the
|
|
108
|
+
// spinner or replay dead tool entries.
|
|
109
|
+
let cancelledTurn = false;
|
|
110
|
+
|
|
97
111
|
const listeners: Array<{ event: string; fn: Function }> = [];
|
|
98
112
|
|
|
99
113
|
function toolKind(name: string): string {
|
|
@@ -194,12 +208,24 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
194
208
|
|
|
195
209
|
|
|
196
210
|
function handleEvent(event: Event): void {
|
|
211
|
+
if (pickerOpen) { eventQueue.push(event); return; }
|
|
197
212
|
if (!sessionId) return;
|
|
198
213
|
const evType = (event as any).type as string;
|
|
199
214
|
const props = (event as any).properties ?? {};
|
|
200
215
|
const sid = props.sessionID;
|
|
201
216
|
if (typeof sid === "string" && sid !== sessionId) return;
|
|
202
217
|
|
|
218
|
+
if (cancelledTurn) {
|
|
219
|
+
// Only let through what unblocks onSubmit. Clearing the flag earlier
|
|
220
|
+
// (e.g. on session.error) lets the trailing bash part.updated slip
|
|
221
|
+
// past and restart the spinner.
|
|
222
|
+
if (evType === "session.idle" || evType === "session.error") {
|
|
223
|
+
turnIdleSeen = true;
|
|
224
|
+
pendingTurnEnd?.();
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
203
229
|
switch (evType) {
|
|
204
230
|
// message.part.delta is undocumented in the SDK's Event union but
|
|
205
231
|
// the SSE consumer yields it. Drop chunks for unknown partIDs —
|
|
@@ -242,72 +268,141 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
242
268
|
case "question.asked": {
|
|
243
269
|
const req = props as QuestionRequest;
|
|
244
270
|
if (!runtime) break;
|
|
271
|
+
if (!compositor) {
|
|
272
|
+
runtime.client.question
|
|
273
|
+
.reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
|
|
274
|
+
.catch(() => { /* best-effort */ });
|
|
275
|
+
bus.emit("ui:error", {
|
|
276
|
+
message: `opencode-bridge: rejected interactive question (no shell host): ${req.questions.map((q) => q.question).join("; ")}`,
|
|
277
|
+
});
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
pickerOpen = true;
|
|
245
281
|
const ui = createToolUI(bus, compositor.surface("agent"));
|
|
246
282
|
ui.custom(createQuestionSession(req.questions)).then(async (result: QuestionResult) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
283
|
+
try {
|
|
284
|
+
if (!runtime) return;
|
|
285
|
+
// Record the question + answer as a synthetic tool entry so the
|
|
286
|
+
// timeline shows what was asked and what the user picked.
|
|
287
|
+
const callID = `question-${req.id}`;
|
|
288
|
+
const detail = req.questions.length === 1
|
|
289
|
+
? req.questions[0]!.question
|
|
290
|
+
: req.questions.map((q, i) => `${q.header || `Q${i + 1}`}: ${q.question}`).join("; ");
|
|
291
|
+
bus.emit("agent:tool-started", {
|
|
292
|
+
title: "question",
|
|
293
|
+
toolCallId: callID,
|
|
294
|
+
kind: "execute",
|
|
295
|
+
displayDetail: detail,
|
|
296
|
+
});
|
|
297
|
+
if (result.cancelled) {
|
|
298
|
+
bus.emitTransform("agent:tool-completed", {
|
|
299
|
+
toolCallId: callID,
|
|
300
|
+
exitCode: 1,
|
|
301
|
+
rawOutput: "cancelled",
|
|
302
|
+
kind: "execute",
|
|
303
|
+
resultDisplay: { summary: "cancelled" },
|
|
304
|
+
});
|
|
305
|
+
runtime.client.question
|
|
306
|
+
.reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
|
|
307
|
+
.catch(() => { /* best-effort */ });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const summary = result.answers.length === 1
|
|
311
|
+
? result.answers[0]!.join(", ")
|
|
312
|
+
: result.answers
|
|
313
|
+
.map((ans, i) => `${req.questions[i]!.header || `Q${i + 1}`}: ${ans.join(", ")}`)
|
|
314
|
+
.join("; ");
|
|
315
|
+
bus.emitTransform("agent:tool-completed", {
|
|
316
|
+
toolCallId: callID,
|
|
317
|
+
exitCode: 0,
|
|
318
|
+
rawOutput: summary,
|
|
319
|
+
kind: "execute",
|
|
320
|
+
resultDisplay: { summary },
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
await runtime.client.question.reply({
|
|
324
|
+
requestID: req.id,
|
|
325
|
+
answers: result.answers,
|
|
326
|
+
directory: sessionDirectory ?? undefined,
|
|
327
|
+
});
|
|
328
|
+
} catch (err) {
|
|
329
|
+
bus.emit("agent:error", {
|
|
330
|
+
message: err instanceof Error ? err.message : String(err),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
pickerOpen = false;
|
|
335
|
+
if (result.cancelled) {
|
|
336
|
+
eventQueue.length = 0;
|
|
337
|
+
cancelledTurn = true;
|
|
338
|
+
pendingTurnEnd?.();
|
|
339
|
+
} else {
|
|
340
|
+
drainQueue();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case "permission.asked": {
|
|
347
|
+
const req = props as PermissionRequest;
|
|
348
|
+
if (!runtime) break;
|
|
349
|
+
const detail = req.patterns.length > 0
|
|
350
|
+
? `${req.permission}: ${req.patterns.join(", ")}`
|
|
351
|
+
: req.permission;
|
|
352
|
+
const finish = (reply: "once" | "always" | "reject", opts?: { note?: string; skipReply?: boolean }) => {
|
|
353
|
+
if (reply === "reject") {
|
|
354
|
+
const callID = `permission-${req.id}`;
|
|
355
|
+
const summary = opts?.note ? `denied (${opts.note})` : `denied: ${detail}`;
|
|
356
|
+
bus.emit("agent:tool-started", {
|
|
357
|
+
title: "permission",
|
|
358
|
+
toolCallId: callID,
|
|
359
|
+
kind: "execute",
|
|
360
|
+
displayDetail: detail,
|
|
361
|
+
});
|
|
261
362
|
bus.emitTransform("agent:tool-completed", {
|
|
262
363
|
toolCallId: callID,
|
|
263
364
|
exitCode: 1,
|
|
264
|
-
rawOutput:
|
|
365
|
+
rawOutput: summary,
|
|
265
366
|
kind: "execute",
|
|
266
|
-
resultDisplay: { summary
|
|
367
|
+
resultDisplay: { summary },
|
|
267
368
|
});
|
|
268
|
-
runtime.client.question
|
|
269
|
-
.reject({ requestID: req.id, directory: sessionDirectory ?? undefined })
|
|
270
|
-
.catch(() => { /* best-effort */ });
|
|
271
|
-
return;
|
|
272
369
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
370
|
+
if (!runtime || opts?.skipReply) return;
|
|
371
|
+
runtime.client.permission
|
|
372
|
+
.reply({ requestID: req.id, reply, directory: sessionDirectory ?? undefined })
|
|
373
|
+
.catch((err) => {
|
|
374
|
+
bus.emit("agent:error", {
|
|
375
|
+
message: err instanceof Error ? err.message : String(err),
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
if (!compositor) {
|
|
380
|
+
finish("reject", { note: "no shell host" });
|
|
381
|
+
bus.emit("ui:error", {
|
|
382
|
+
message: `opencode-bridge: rejected permission (no shell host): ${detail}`,
|
|
284
383
|
});
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
pickerOpen = true;
|
|
387
|
+
const ui = createToolUI(bus, compositor.surface("agent"));
|
|
388
|
+
ui.custom(createPermissionSession(req, bus)).then((result: PermissionResult) => {
|
|
285
389
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
390
|
+
finish(result.reply, result.cancelled ? { skipReply: true } : undefined);
|
|
391
|
+
} finally {
|
|
392
|
+
pickerOpen = false;
|
|
393
|
+
if (result.cancelled) {
|
|
394
|
+
// Let onSubmit's finally emit the single agent:processing-done;
|
|
395
|
+
// a second one here races and stacks prompts.
|
|
396
|
+
eventQueue.length = 0;
|
|
397
|
+
cancelledTurn = true;
|
|
398
|
+
pendingTurnEnd?.();
|
|
399
|
+
} else {
|
|
400
|
+
drainQueue();
|
|
401
|
+
}
|
|
295
402
|
}
|
|
296
403
|
});
|
|
297
404
|
break;
|
|
298
405
|
}
|
|
299
|
-
// Without a reply the gated tool hangs forever. The bridge has no
|
|
300
|
-
// interactive approval UI, so auto-approve — mirrors claude-code-
|
|
301
|
-
// bridge's permissionMode: "acceptEdits". Set permission.edit:
|
|
302
|
-
// "allow" in opencode.json to skip the round-trip entirely.
|
|
303
|
-
case "permission.asked": {
|
|
304
|
-
const requestID = props.id as string | undefined;
|
|
305
|
-
if (!requestID || !runtime) break;
|
|
306
|
-
runtime.client.permission
|
|
307
|
-
.reply({ requestID, reply: "once", directory: sessionDirectory ?? undefined })
|
|
308
|
-
.catch(() => { /* approval is best-effort */ });
|
|
309
|
-
break;
|
|
310
|
-
}
|
|
311
406
|
}
|
|
312
407
|
}
|
|
313
408
|
|
|
@@ -339,6 +434,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
339
434
|
bus.emit("agent:query", { query: userQuery });
|
|
340
435
|
bus.emit("agent:processing-start", {});
|
|
341
436
|
turnText = "";
|
|
437
|
+
cancelledTurn = false;
|
|
342
438
|
turnIdleSeen = false;
|
|
343
439
|
turnError = null;
|
|
344
440
|
// Set the idle waiter BEFORE prompt() so a fast session.idle can't
|
|
@@ -471,6 +567,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
471
567
|
// ── Interactive question picker ──────────────────────────────────
|
|
472
568
|
|
|
473
569
|
type QuestionResult = { answers: string[][]; cancelled: boolean };
|
|
570
|
+
type PermissionResult = { reply: "once" | "always" | "reject"; cancelled?: boolean };
|
|
474
571
|
|
|
475
572
|
function isKey(data: string, key: string): boolean {
|
|
476
573
|
switch (key) {
|
|
@@ -601,3 +698,58 @@ function createQuestionSession(questions: QuestionInfo[]): InteractiveSession<Qu
|
|
|
601
698
|
},
|
|
602
699
|
};
|
|
603
700
|
}
|
|
701
|
+
|
|
702
|
+
function createPermissionSession(
|
|
703
|
+
req: PermissionRequest,
|
|
704
|
+
bus: { on: (e: "agent:cancel-request", fn: () => void) => void; off: (e: "agent:cancel-request", fn: () => void) => void },
|
|
705
|
+
): InteractiveSession<PermissionResult> {
|
|
706
|
+
let cancelHandler: (() => void) | null = null;
|
|
707
|
+
// Cast widens onMount: the vendored agent-sh type still declares the 1-arg signature.
|
|
708
|
+
const onMount = ((_invalidate: () => void, done: (r: PermissionResult) => void): void => {
|
|
709
|
+
cancelHandler = () => done({ reply: "reject", cancelled: true });
|
|
710
|
+
bus.on("agent:cancel-request", cancelHandler);
|
|
711
|
+
}) as InteractiveSession<PermissionResult>["onMount"];
|
|
712
|
+
return {
|
|
713
|
+
onMount,
|
|
714
|
+
onUnmount() {
|
|
715
|
+
if (cancelHandler) bus.off("agent:cancel-request", cancelHandler);
|
|
716
|
+
cancelHandler = null;
|
|
717
|
+
},
|
|
718
|
+
render(_width) {
|
|
719
|
+
const lines: string[] = [];
|
|
720
|
+
lines.push(` ${p.warning}${p.bold}Permission required: ${req.permission}${p.reset}`);
|
|
721
|
+
|
|
722
|
+
const meta = req.metadata ?? {};
|
|
723
|
+
const cmd = typeof meta.command === "string" ? meta.command : null;
|
|
724
|
+
const file = typeof meta.file === "string"
|
|
725
|
+
? meta.file
|
|
726
|
+
: typeof meta.path === "string" ? meta.path : null;
|
|
727
|
+
if (cmd) {
|
|
728
|
+
for (const line of cmd.split("\n").slice(0, 6)) {
|
|
729
|
+
lines.push(` ${p.dim}${line}${p.reset}`);
|
|
730
|
+
}
|
|
731
|
+
} else if (file) {
|
|
732
|
+
lines.push(` ${p.dim}${file}${p.reset}`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (req.patterns.length > 0 && !cmd && !file) {
|
|
736
|
+
for (const pat of req.patterns) {
|
|
737
|
+
lines.push(` ${p.dim}${pat}${p.reset}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
lines.push(` ${p.dim}[y] allow once [a] allow always [n] reject${p.reset}`);
|
|
742
|
+
return lines;
|
|
743
|
+
},
|
|
744
|
+
|
|
745
|
+
handleInput(data, done) {
|
|
746
|
+
if (isKey(data, "escape") || data === "n" || data === "N") {
|
|
747
|
+
done({ reply: "reject" });
|
|
748
|
+
} else if (data === "y" || data === "Y" || isKey(data, "enter")) {
|
|
749
|
+
done({ reply: "once" });
|
|
750
|
+
} else if (data === "a" || data === "A") {
|
|
751
|
+
done({ reply: "always" });
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
}
|