@moxxy/plugin-provider-acp 0.0.0-stage → 0.46.0

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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/dist/acp-session.d.ts +201 -0
  3. package/dist/acp-session.d.ts.map +1 -0
  4. package/dist/acp-session.js +812 -0
  5. package/dist/acp-session.js.map +1 -0
  6. package/dist/agent-provider.d.ts +71 -0
  7. package/dist/agent-provider.d.ts.map +1 -0
  8. package/dist/agent-provider.js +203 -0
  9. package/dist/agent-provider.js.map +1 -0
  10. package/dist/agents.d.ts +23 -0
  11. package/dist/agents.d.ts.map +1 -0
  12. package/dist/agents.js +21 -0
  13. package/dist/agents.js.map +1 -0
  14. package/dist/constants.d.ts +31 -0
  15. package/dist/constants.d.ts.map +1 -0
  16. package/dist/constants.js +31 -0
  17. package/dist/constants.js.map +1 -0
  18. package/dist/executable.d.ts +47 -0
  19. package/dist/executable.d.ts.map +1 -0
  20. package/dist/executable.js +106 -0
  21. package/dist/executable.js.map +1 -0
  22. package/dist/index.d.ts +10 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +69 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/login.d.ts +48 -0
  27. package/dist/login.d.ts.map +1 -0
  28. package/dist/login.js +180 -0
  29. package/dist/login.js.map +1 -0
  30. package/dist/opencode-api.d.ts +32 -0
  31. package/dist/opencode-api.d.ts.map +1 -0
  32. package/dist/opencode-api.js +86 -0
  33. package/dist/opencode-api.js.map +1 -0
  34. package/dist/opencode-helpers.d.ts +49 -0
  35. package/dist/opencode-helpers.d.ts.map +1 -0
  36. package/dist/opencode-helpers.js +175 -0
  37. package/dist/opencode-helpers.js.map +1 -0
  38. package/dist/protocol.d.ts +143 -0
  39. package/dist/protocol.d.ts.map +1 -0
  40. package/dist/protocol.js +7 -0
  41. package/dist/protocol.js.map +1 -0
  42. package/dist/rules.d.ts +47 -0
  43. package/dist/rules.d.ts.map +1 -0
  44. package/dist/rules.js +84 -0
  45. package/dist/rules.js.map +1 -0
  46. package/dist/translate.d.ts +50 -0
  47. package/dist/translate.d.ts.map +1 -0
  48. package/dist/translate.js +290 -0
  49. package/dist/translate.js.map +1 -0
  50. package/package.json +81 -3
  51. package/src/acp-session.ts +892 -0
  52. package/src/agent-cli.test.ts +76 -0
  53. package/src/agent-provider.test.ts +1137 -0
  54. package/src/agent-provider.ts +234 -0
  55. package/src/agents.ts +37 -0
  56. package/src/constants.ts +36 -0
  57. package/src/event-coverage.test.ts +65 -0
  58. package/src/executable.test.ts +89 -0
  59. package/src/executable.ts +143 -0
  60. package/src/fake-opencode.testkit.ts +755 -0
  61. package/src/fixtures/acp-session-updates.json +19 -0
  62. package/src/index.test.ts +69 -0
  63. package/src/index.ts +83 -0
  64. package/src/login.test.ts +155 -0
  65. package/src/login.ts +222 -0
  66. package/src/opencode-api.ts +105 -0
  67. package/src/opencode-helpers.test.ts +76 -0
  68. package/src/opencode-helpers.ts +195 -0
  69. package/src/protocol.ts +104 -0
  70. package/src/rules.test.ts +107 -0
  71. package/src/rules.ts +102 -0
  72. package/src/translate.test.ts +253 -0
  73. package/src/translate.ts +318 -0
  74. package/README.md +0 -3
@@ -0,0 +1,755 @@
1
+ /**
2
+ * A stand-in for `opencode acp`, the external boundary of the OpenCode agent
3
+ * connection. It speaks JSON-RPC 2.0 lines over the same two streams, shaped
4
+ * after recorded 1.18.35 sessions: `session/new` answers with the session's
5
+ * settings, `session/prompt` streams `session/update` notifications and then
6
+ * answers with the stop reason and usage, permission asks are requests to
7
+ * Moxxy, `session/load` replays the saved history before it answers, and
8
+ * Moxxy's tools are called over Moxxy's MCP server with the real MCP client.
9
+ * What OpenCode does in a turn is a scene the test writes.
10
+ */
11
+ import { randomBytes } from 'node:crypto';
12
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
13
+ import { PassThrough } from 'node:stream';
14
+ import { createInterface } from 'node:readline';
15
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
16
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
17
+ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
18
+ import type { AcpConnection } from './acp-session.js';
19
+ import type { AcpContentBlock, AcpPermissionOutcome, AcpToolKind, AcpUsage } from './protocol.js';
20
+ import type { OpenCodeRule } from './rules.js';
21
+
22
+ export interface FakeModel {
23
+ readonly id: string;
24
+ readonly efforts?: ReadonlyArray<string>;
25
+ }
26
+
27
+ export interface FakeOpenCodeDefaults {
28
+ readonly model?: string;
29
+ readonly mode?: string;
30
+ readonly models?: ReadonlyArray<FakeModel>;
31
+ /**
32
+ * The rules `opencode debug agent <name>` reports, per agent; `null` when
33
+ * OpenCode cannot say. By default the person set OpenCode to ask before
34
+ * edits and commands.
35
+ */
36
+ readonly rules?: Readonly<Record<string, ReadonlyArray<OpenCodeRule>>> | null;
37
+ /** Names of the person's own OpenCode skills, announced with its commands as a session opens. */
38
+ readonly ownSkills?: ReadonlyArray<string>;
39
+ /** The person's own MCP servers that are on, as `opencode debug config` reports them. */
40
+ readonly mcpServers?: ReadonlyArray<string>;
41
+ /**
42
+ * Serve OpenCode's HTTP API beside ACP, as `opencode acp --port` does with
43
+ * background subagents on: its event stream and session abort, behind a password.
44
+ */
45
+ readonly api?: boolean;
46
+ }
47
+
48
+ /** OpenCode 1.18's answer to `session/load` that fails, whether or not the session exists. */
49
+ const SERVICE_FAILURE = { code: -32603, message: 'Internal error: OpenCode service failure', data: { service: 'session' } };
50
+ /** A stand-in for the model's time to answer, between OpenCode's HTTP events and what it says next over ACP. */
51
+ const MODEL_LATENCY_MS = 30;
52
+ /** How many sessions one `session/list` page holds here, so Moxxy has to page. */
53
+ const LIST_PAGE = 2;
54
+
55
+ /** OpenCode 1.18's own rules for the build agent, abridged: everything allowed. */
56
+ export const OPENCODE_DEFAULT_RULES: ReadonlyArray<OpenCodeRule> = [
57
+ { permission: '*', pattern: '*', action: 'allow' },
58
+ { permission: 'doom_loop', pattern: '*', action: 'ask' },
59
+ { permission: 'external_directory', pattern: '*', action: 'ask' },
60
+ ];
61
+
62
+ const ASKING_RULES: ReadonlyArray<OpenCodeRule> = [
63
+ ...OPENCODE_DEFAULT_RULES,
64
+ { permission: 'edit', pattern: '*', action: 'ask' },
65
+ { permission: 'bash', pattern: '*', action: 'ask' },
66
+ ];
67
+
68
+ export interface FakeSession {
69
+ readonly id: string;
70
+ readonly cwd: string;
71
+ model: string;
72
+ mode: string;
73
+ effort: string | undefined;
74
+ mcp: { readonly url: string; readonly headers: Record<string, string> } | undefined;
75
+ /**
76
+ * The Moxxy tools as OpenCode read them: once when the session opens, again
77
+ * only when Moxxy says the list changed. The model can call nothing else.
78
+ */
79
+ moxxyTools: ReadonlyArray<string>;
80
+ /** The read of the Moxxy tools in flight, if any. */
81
+ reading?: Promise<void>;
82
+ /** What a later `session/load` replays: the updates this session sent. */
83
+ readonly history: Array<Record<string, unknown>>;
84
+ }
85
+
86
+ export interface ToolOptions {
87
+ /** The title OpenCode shows once the input is known (the command, the file). */
88
+ readonly title?: string;
89
+ /** Ask Moxxy first, as OpenCode does for an `ask` rule. */
90
+ readonly ask?: boolean;
91
+ /**
92
+ * A path outside the project the call works in. OpenCode's own rules ask
93
+ * about it first (`external_directory`), even on its defaults.
94
+ */
95
+ readonly outside?: string;
96
+ readonly fails?: string;
97
+ }
98
+
99
+ export interface FakeTurn {
100
+ readonly session: FakeSession;
101
+ readonly server: FakeOpenCodeServer;
102
+ readonly prompt: ReadonlyArray<AcpContentBlock>;
103
+ /** The text of the prompt. */
104
+ readonly text: string;
105
+ /** Aborted when Moxxy cancels the prompt. */
106
+ readonly signal: AbortSignal;
107
+ say(text: string): void;
108
+ think(text: string): void;
109
+ /**
110
+ * One of OpenCode's own tools. With `ask` OpenCode asks Moxxy first and,
111
+ * when refused, reports the call failed; the scene should then end the turn,
112
+ * as OpenCode does.
113
+ */
114
+ tool(name: string, kind: AcpToolKind, input: Record<string, unknown>, output: string, opts?: ToolOptions): Promise<{ readonly allowed: boolean; readonly answer?: AcpPermissionOutcome }>;
115
+ callMoxxyTool(name: string, args: Record<string, unknown>): Promise<{ content: unknown; isError?: boolean }>;
116
+ usage(usage: AcpUsage): void;
117
+ /**
118
+ * OpenCode's `task` tool with `background: true` (1.19): the subagent works
119
+ * on in a session of its own after the turn moves on, reported only on the
120
+ * HTTP API; once it ends OpenCode prompts the conversation's session itself
121
+ * with its result. Resolves with the subagent's session id once started.
122
+ */
123
+ background(task: FakeBackgroundTask): Promise<string>;
124
+ /** OpenCode's `task` tool as it runs by default: the turn waits for the subagent, whose steps only the HTTP API reports. */
125
+ subagent(task: FakeSubagentTask): Promise<void>;
126
+ }
127
+
128
+ export interface FakeSubagentTask {
129
+ readonly description: string;
130
+ readonly prompt: string;
131
+ readonly agent?: string;
132
+ /** What the subagent does; returns its answer. */
133
+ readonly work: (child: FakeChild) => Promise<string>;
134
+ }
135
+
136
+ export interface FakeBackgroundTask extends FakeSubagentTask {
137
+ /** What OpenCode says in the turn it starts itself once the subagent finished. */
138
+ readonly then?: (turn: FakeOwnTurn) => Promise<void> | void;
139
+ }
140
+
141
+ /** A subagent's session, as the HTTP API reports it. */
142
+ export interface FakeChild {
143
+ readonly id: string;
144
+ /** Aborted when its session is aborted (`POST /session/{id}/abort`, or the conversation's session cancelled). */
145
+ readonly signal: AbortSignal;
146
+ tool(name: string, input: Record<string, unknown>, output: string): void;
147
+ say(text: string): void;
148
+ }
149
+
150
+ /** A turn OpenCode starts by itself: its ACP updates arrive outside any `session/prompt`. */
151
+ export interface FakeOwnTurn {
152
+ say(text: string): void;
153
+ think(text: string): void;
154
+ }
155
+
156
+ export type OpenCodeScene = (turn: FakeTurn) => Promise<void | { readonly error: { readonly code: number; readonly message: string } }>;
157
+
158
+ const DEFAULT_MODELS: ReadonlyArray<FakeModel> = [
159
+ { id: 'opencode/big-pickle' },
160
+ { id: 'opencode/space-bunny-free', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'default'] },
161
+ { id: 'openai/gpt-6-luna', efforts: ['none', 'low', 'medium', 'high', 'xhigh'] },
162
+ ];
163
+
164
+ /** The person's OpenCode install: every `opencode acp` Moxxy starts, and the sessions it saved. */
165
+ export class FakeOpenCode {
166
+ readonly servers: FakeOpenCodeServer[] = [];
167
+ readonly saved = new Map<string, FakeSession>();
168
+ sessionCount = 0;
169
+ promptCount = 0;
170
+ /** How many of the next `session/load` calls fail, as an OpenCode briefly unable to read its sessions. */
171
+ loadFailures = 0;
172
+
173
+ constructor(
174
+ readonly scene: OpenCodeScene,
175
+ readonly defaults: FakeOpenCodeDefaults = {},
176
+ ) {}
177
+
178
+ get models(): ReadonlyArray<FakeModel> {
179
+ return this.defaults.models ?? DEFAULT_MODELS;
180
+ }
181
+
182
+ /** What Moxxy calls to start `opencode acp`. */
183
+ readonly start = async (opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> }): Promise<AcpConnection> => {
184
+ const server = new FakeOpenCodeServer(this, opts.cwd, opts.env ?? {});
185
+ this.servers.push(server);
186
+ return this.defaults.api ? server.serve() : server.connection;
187
+ };
188
+
189
+ /** What Moxxy reads to learn how OpenCode runs. */
190
+ readonly readRules = async (opts: { readonly cwd: string; readonly agent: string }): Promise<ReadonlyArray<OpenCodeRule> | undefined> => {
191
+ this.rulesRead.push(opts.agent);
192
+ const { rules } = this.defaults;
193
+ if (rules === null) return undefined;
194
+ return rules ? rules[opts.agent] : ASKING_RULES;
195
+ };
196
+
197
+ readonly rulesRead: string[] = [];
198
+
199
+ readonly readMcpServers = async (): Promise<ReadonlyArray<string> | undefined> => this.defaults.mcpServers ?? [];
200
+
201
+ get last(): FakeOpenCodeServer {
202
+ const server = this.servers.at(-1);
203
+ if (!server) throw new Error('OpenCode was never started');
204
+ return server;
205
+ }
206
+ }
207
+
208
+ export class FakeOpenCodeServer {
209
+ readonly requests: Array<{ readonly method: string; readonly params: Record<string, unknown> }> = [];
210
+ readonly sessions = new Map<string, FakeSession>();
211
+ readonly connection: AcpConnection;
212
+ /** How Moxxy answered each permission ask, in order. */
213
+ readonly permissionAnswers: AcpPermissionOutcome[] = [];
214
+ closed = false;
215
+ private readonly toMoxxy = new PassThrough();
216
+ private readonly fromMoxxy = new PassThrough();
217
+ private readonly pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
218
+ private nextRequestId = 0;
219
+ private active: { readonly sessionId: string; readonly stop: AbortController } | undefined;
220
+ private readonly mcpClients: Client[] = [];
221
+ private readonly moxxyClients = new Map<string, Client>();
222
+ /** OpenCode's HTTP API, when it serves one. */
223
+ private http: Server | undefined;
224
+ private readonly password = randomBytes(8).toString('hex');
225
+ private readonly streams = new Set<ServerResponse>();
226
+ /** Subagent sessions still working, by id: aborting one stops it. */
227
+ private readonly children = new Map<string, { readonly parent: string; readonly stop: AbortController }>();
228
+ private childCount = 0;
229
+ /** Every HTTP request Moxxy made, as `METHOD /path`, and how many were refused for a wrong password. */
230
+ readonly httpRequests: string[] = [];
231
+ refusedHttp = 0;
232
+ /** What the subagents' work settled on: how each one ended. */
233
+ readonly childEnds = new Map<string, 'completed' | 'aborted'>();
234
+
235
+ constructor(
236
+ private readonly opencode: FakeOpenCode,
237
+ readonly cwd: string,
238
+ readonly env: Readonly<Record<string, string>>,
239
+ ) {
240
+ createInterface({ input: this.fromMoxxy }).on('line', (line) => void this.receive(line));
241
+ this.connection = {
242
+ readable: this.toMoxxy,
243
+ writable: this.fromMoxxy,
244
+ close: () => {
245
+ if (this.closed) return;
246
+ this.closed = true;
247
+ this.active?.stop.abort();
248
+ for (const child of this.children.values()) child.stop.abort();
249
+ this.toMoxxy.end();
250
+ for (const client of this.mcpClients) void client.close();
251
+ for (const stream of this.streams) stream.end();
252
+ this.streams.clear();
253
+ this.http?.close();
254
+ },
255
+ };
256
+ }
257
+
258
+ /** Serve the HTTP API on a free port; the connection carries where and how to reach it. */
259
+ async serve(): Promise<AcpConnection> {
260
+ const server = createServer((req, res) => this.serveHttp(req, res));
261
+ this.http = server;
262
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
263
+ const address = server.address();
264
+ const port = typeof address === 'object' && address ? address.port : 0;
265
+ const authorization = `Basic ${Buffer.from(`opencode:${this.password}`).toString('base64')}`;
266
+ return { ...this.connection, api: { url: `http://127.0.0.1:${port}`, authorization } };
267
+ }
268
+
269
+ /** How many event streams are open now. */
270
+ get eventStreams(): number {
271
+ return this.streams.size;
272
+ }
273
+
274
+ /** One event on every open stream, shaped as OpenCode 1.19 sends it. */
275
+ emit(type: string, properties: Record<string, unknown>): void {
276
+ for (const stream of this.streams) if (!stream.writableEnded) stream.write(`data: ${JSON.stringify({ type, properties })}\n\n`);
277
+ }
278
+
279
+ private serveHttp(req: IncomingMessage, res: ServerResponse): void {
280
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
281
+ if (req.headers.authorization !== `Basic ${Buffer.from(`opencode:${this.password}`).toString('base64')}`) {
282
+ this.refusedHttp += 1;
283
+ res.writeHead(401).end();
284
+ return;
285
+ }
286
+ this.httpRequests.push(`${req.method} ${url.pathname}`);
287
+ if (req.method === 'GET' && url.pathname === '/event') {
288
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
289
+ res.write(`data: ${JSON.stringify({ type: 'server.connected', properties: {} })}\n\n`);
290
+ this.streams.add(res);
291
+ res.on('close', () => this.streams.delete(res));
292
+ return;
293
+ }
294
+ const abort = /^\/session\/([^/]+)\/abort$/.exec(url.pathname);
295
+ if (req.method === 'POST' && abort) {
296
+ const child = this.children.get(decodeURIComponent(abort[1] ?? ''));
297
+ child?.stop.abort();
298
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end('true');
299
+ return;
300
+ }
301
+ res.writeHead(404).end();
302
+ }
303
+
304
+ /** OpenCode 1.19 cancels the background subagents of a session it is told to cancel. */
305
+ private cancelChildren(sessionId: string): void {
306
+ for (const child of this.children.values()) if (child.parent === sessionId) child.stop.abort();
307
+ }
308
+
309
+ /** Every request or notification Moxxy sent with this method, in order. */
310
+ sent(method: string): Array<Record<string, unknown>> {
311
+ return this.requests.filter((r) => r.method === method).map((r) => r.params);
312
+ }
313
+
314
+ get session(): FakeSession {
315
+ const session = [...this.sessions.values()].at(-1);
316
+ if (!session) throw new Error('No session was started');
317
+ return session;
318
+ }
319
+
320
+ /** OpenCode crashed: its stdout closes. */
321
+ crash(): void {
322
+ this.toMoxxy.end();
323
+ }
324
+
325
+ /** OpenCode 1.18 lists its commands, its skills among them, right after it answers for a session. */
326
+ private announceCommands(session: FakeSession): void {
327
+ const names = ['init', 'review', ...(this.opencode.defaults.ownSkills ?? [])];
328
+ setImmediate(() =>
329
+ this.update(session, { sessionUpdate: 'available_commands_update', availableCommands: names.map((name) => ({ name, description: `The ${name} command` })) }, false),
330
+ );
331
+ }
332
+
333
+ update(session: FakeSession, update: Record<string, unknown>, record = true): void {
334
+ if (record) session.history.push(update);
335
+ this.write({ method: 'session/update', params: { sessionId: session.id, update } });
336
+ }
337
+
338
+ request(method: string, params: Record<string, unknown>): Promise<unknown> {
339
+ const id = this.nextRequestId++;
340
+ return new Promise((resolve, reject) => {
341
+ this.pending.set(id, { resolve, reject });
342
+ this.write({ id, method, params });
343
+ });
344
+ }
345
+
346
+ private write(message: Record<string, unknown>): void {
347
+ if (!this.toMoxxy.writableEnded) this.toMoxxy.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
348
+ }
349
+
350
+ private async receive(line: string): Promise<void> {
351
+ const message = JSON.parse(line) as {
352
+ jsonrpc?: string;
353
+ id?: number;
354
+ method?: string;
355
+ params?: Record<string, unknown>;
356
+ result?: unknown;
357
+ error?: { message: string };
358
+ };
359
+ if (message.jsonrpc !== '2.0') throw new Error('ACP messages carry "jsonrpc": "2.0"');
360
+ if (message.method === undefined) {
361
+ const waiting = message.id === undefined ? undefined : this.pending.get(message.id);
362
+ if (!waiting || message.id === undefined) return;
363
+ this.pending.delete(message.id);
364
+ if (message.error) waiting.reject(new Error(message.error.message));
365
+ else waiting.resolve(message.result);
366
+ return;
367
+ }
368
+ const params = message.params ?? {};
369
+ this.requests.push({ method: message.method, params });
370
+ if (message.id === undefined) {
371
+ if (message.method === 'session/cancel') {
372
+ if (this.active && this.active.sessionId === params.sessionId) this.active.stop.abort();
373
+ this.cancelChildren(String(params.sessionId));
374
+ }
375
+ return;
376
+ }
377
+ try {
378
+ this.write({ id: message.id, result: await this.handle(message.method, params) });
379
+ } catch (err) {
380
+ const rpc = err as { code?: number; message?: string };
381
+ this.write({ id: message.id, error: { code: typeof rpc.code === 'number' ? rpc.code : -32603, message: rpc.message ?? String(err) } });
382
+ }
383
+ }
384
+
385
+ private async handle(method: string, params: Record<string, unknown>): Promise<unknown> {
386
+ switch (method) {
387
+ case 'initialize':
388
+ return {
389
+ protocolVersion: 1,
390
+ agentCapabilities: {
391
+ loadSession: true,
392
+ mcpCapabilities: { http: true, sse: true },
393
+ promptCapabilities: { image: true, embeddedContext: true },
394
+ sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
395
+ },
396
+ authMethods: [{ id: 'opencode-login', name: 'Login with opencode', description: 'Run `opencode auth login` in the terminal' }],
397
+ agentInfo: { name: 'OpenCode', version: '1.18.35' },
398
+ };
399
+ case 'session/new': {
400
+ const { defaults } = this.opencode;
401
+ const session: FakeSession = {
402
+ id: `ses_${++this.opencode.sessionCount}`,
403
+ cwd: String(params.cwd),
404
+ model: defaults.model ?? 'opencode/big-pickle',
405
+ mode: defaults.mode ?? 'build',
406
+ effort: undefined,
407
+ mcp: mcpOf(params.mcpServers),
408
+ moxxyTools: [],
409
+ history: [],
410
+ };
411
+ await this.connectMoxxy(session);
412
+ this.sessions.set(session.id, session);
413
+ this.opencode.saved.set(session.id, session);
414
+ this.announceCommands(session);
415
+ return { sessionId: session.id, configOptions: this.configOptions(session) };
416
+ }
417
+ case 'session/load': {
418
+ const saved = this.opencode.saved.get(String(params.sessionId));
419
+ if (this.opencode.loadFailures > 0 || !saved) {
420
+ this.opencode.loadFailures = Math.max(0, this.opencode.loadFailures - 1);
421
+ throw Object.assign(new Error(SERVICE_FAILURE.message), SERVICE_FAILURE);
422
+ }
423
+ const session: FakeSession = { ...saved, mcp: mcpOf(params.mcpServers), moxxyTools: [], history: saved.history };
424
+ await this.connectMoxxy(session);
425
+ this.sessions.set(session.id, session);
426
+ // OpenCode replays the whole conversation before it answers.
427
+ for (const update of saved.history) this.update(session, update, false);
428
+ this.announceCommands(session);
429
+ return { configOptions: this.configOptions(session) };
430
+ }
431
+ case 'session/list': {
432
+ const all = [...this.opencode.saved.values()].filter((session) => session.cwd === params.cwd);
433
+ const from = Number(params.cursor ?? 0);
434
+ const page = all.slice(from, from + LIST_PAGE).map((session) => ({ sessionId: session.id, cwd: session.cwd, title: 'New session', updatedAt: '2026-10-11T12:00:00.000Z' }));
435
+ return { sessions: page, ...(from + LIST_PAGE < all.length ? { nextCursor: String(from + LIST_PAGE) } : {}) };
436
+ }
437
+ case 'session/set_config_option': {
438
+ const session = this.sessionOf(params);
439
+ const value = String(params.value);
440
+ const option = this.configOptions(session).find((o) => o.id === params.configId);
441
+ if (!option) throw Object.assign(new Error(`Unknown config option: ${String(params.configId)}`), { code: -32602 });
442
+ if (!option.options.some((o) => o.value === value)) throw Object.assign(new Error(`Invalid value for ${option.id}: ${value}`), { code: -32602 });
443
+ if (option.id === 'model') {
444
+ session.model = value;
445
+ session.effort = this.efforts(session) ? 'low' : undefined;
446
+ } else if (option.id === 'mode') session.mode = value;
447
+ else if (option.id === 'effort') session.effort = value;
448
+ return { configOptions: this.configOptions(session) };
449
+ }
450
+ case 'session/prompt':
451
+ return this.prompt(this.sessionOf(params), (params.prompt ?? []) as AcpContentBlock[]);
452
+ default:
453
+ throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
454
+ }
455
+ }
456
+
457
+ private efforts(session: FakeSession): ReadonlyArray<string> | undefined {
458
+ return this.opencode.models.find((m) => m.id === session.model)?.efforts;
459
+ }
460
+
461
+ private configOptions(session: FakeSession): Array<{ id: string; category: string; type: 'select'; currentValue: string; options: Array<{ value: string; name: string }> }> {
462
+ const efforts = this.efforts(session);
463
+ return [
464
+ { id: 'model', category: 'model', type: 'select', currentValue: session.model, options: this.opencode.models.map((m) => ({ value: m.id, name: m.id })) },
465
+ ...(efforts && session.effort
466
+ ? [{ id: 'effort', category: 'thought_level', type: 'select' as const, currentValue: session.effort, options: efforts.map((e) => ({ value: e, name: e })) }]
467
+ : []),
468
+ { id: 'mode', category: 'mode', type: 'select', currentValue: session.mode, options: [{ value: 'build', name: 'build' }, { value: 'plan', name: 'plan' }] },
469
+ ];
470
+ }
471
+
472
+ private sessionOf(params: Record<string, unknown>): FakeSession {
473
+ const session = this.sessions.get(String(params.sessionId));
474
+ if (!session) throw Object.assign(new Error(`Session not found: ${String(params.sessionId)}`), { code: -32602 });
475
+ return session;
476
+ }
477
+
478
+ private async prompt(session: FakeSession, prompt: ReadonlyArray<AcpContentBlock>): Promise<unknown> {
479
+ if (this.active) throw new Error('a prompt is already running');
480
+ this.opencode.promptCount += 1;
481
+ const stop = new AbortController();
482
+ this.active = { sessionId: session.id, stop };
483
+ const text = prompt.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n');
484
+ for (const block of prompt) if (block.type === 'text') session.history.push({ sessionUpdate: 'user_message_chunk', content: block });
485
+ let usage: AcpUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
486
+ const stopped = new Promise<'stopped'>((resolve) => stop.signal.addEventListener('abort', () => resolve('stopped'), { once: true }));
487
+ // OpenCode settles its tools long after a prompt arrives (it saves the
488
+ // message and builds the system prompt first); a re-read in flight lands.
489
+ await session.reading;
490
+ try {
491
+ const outcome = await Promise.race([this.opencode.scene(this.turn(session, prompt, text, stop.signal, (u) => (usage = u))), stopped]);
492
+ if (outcome && outcome !== 'stopped' && 'error' in outcome) throw Object.assign(new Error(outcome.error.message), { code: outcome.error.code });
493
+ } finally {
494
+ this.active = undefined;
495
+ }
496
+ if (stop.signal.aborted) return { stopReason: 'cancelled', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, _meta: {} };
497
+ return { stopReason: 'end_turn', usage, _meta: {} };
498
+ }
499
+
500
+ private turn(session: FakeSession, prompt: ReadonlyArray<AcpContentBlock>, text: string, signal: AbortSignal, setUsage: (usage: AcpUsage) => void): FakeTurn {
501
+ let messages = 0;
502
+ let calls = 0;
503
+ return {
504
+ session,
505
+ server: this,
506
+ prompt,
507
+ text,
508
+ signal,
509
+ say: (said) => {
510
+ const messageId = `msg_${++messages}`;
511
+ for (const chunk of said.match(/.{1,8}/gs) ?? []) this.update(session, { sessionUpdate: 'agent_message_chunk', messageId, content: { type: 'text', text: chunk } });
512
+ },
513
+ think: (thought) => {
514
+ const messageId = `thought_${++messages}`;
515
+ for (const chunk of thought.match(/.{1,8}/gs) ?? []) this.update(session, { sessionUpdate: 'agent_thought_chunk', messageId, content: { type: 'text', text: chunk } });
516
+ },
517
+ tool: async (name, kind, input, output, opts = {}) => {
518
+ const toolCallId = `call_${session.id}_${++calls}`;
519
+ const title = opts.title ?? name;
520
+ // OpenCode 1.18 announces a command with its folder only; the command follows.
521
+ const announced = kind === 'execute' ? { cwd: session.cwd } : {};
522
+ this.update(session, { sessionUpdate: 'tool_call', toolCallId, title: name, kind, status: 'pending', locations: [], rawInput: announced });
523
+ let answer: AcpPermissionOutcome | undefined;
524
+ const ruled = this.ruleFor(session, name, input);
525
+ const asks = opts.ask === true || ruled === 'ask';
526
+ // OpenCode 1.18 asks about a change while the call is pending, and about anything else once it runs.
527
+ const running = () => this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'in_progress', kind, title, locations: [], rawInput: input });
528
+ const changeAsk = asks && kind === 'edit';
529
+ if (!changeAsk) running();
530
+ if (opts.outside !== undefined && !(await this.askOutside(session, toolCallId, kind, input, opts.outside))) {
531
+ const rejected = 'The user rejected permission to use this specific tool call.';
532
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'failed', kind, title, rawInput: input, content: [{ type: 'content', content: { type: 'text', text: rejected } }], rawOutput: { error: rejected } });
533
+ return { allowed: false };
534
+ }
535
+ if (ruled === 'deny') {
536
+ const denied = 'The user has specified a rule which prevents you from using this specific tool call.';
537
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'failed', kind, title, rawInput: input, content: [{ type: 'content', content: { type: 'text', text: denied } }], rawOutput: { error: denied } });
538
+ return { allowed: false };
539
+ }
540
+ if (asks) {
541
+ answer = (await this.request('session/request_permission', {
542
+ sessionId: session.id,
543
+ // OpenCode 1.18 asks about a change with the file and its diff, not the tool's own input.
544
+ toolCall: { toolCallId, title, kind, status: 'pending', locations: [], rawInput: kind === 'edit' ? changeAsked(input) : input },
545
+ options: [
546
+ { optionId: 'once', kind: 'allow_once', name: 'Allow once' },
547
+ { optionId: 'always', kind: 'allow_always', name: 'Always allow' },
548
+ { optionId: 'reject', kind: 'reject_once', name: 'Reject' },
549
+ ],
550
+ })) as AcpPermissionOutcome;
551
+ this.permissionAnswers.push(answer);
552
+ const chosen = answer.outcome.outcome === 'selected' ? answer.outcome.optionId : 'reject';
553
+ if (chosen === 'reject') {
554
+ const rejected = 'The user rejected permission to use this specific tool call.';
555
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'failed', kind, title, rawInput: input, content: [{ type: 'content', content: { type: 'text', text: rejected } }], rawOutput: { error: rejected } });
556
+ return { allowed: false, answer };
557
+ }
558
+ if (changeAsk) running();
559
+ }
560
+ if (opts.fails) {
561
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'failed', kind, title, rawInput: input, content: [{ type: 'content', content: { type: 'text', text: opts.fails } }], rawOutput: { error: opts.fails } });
562
+ return { allowed: true, ...(answer ? { answer } : {}) };
563
+ }
564
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'completed', title, content: [{ type: 'content', content: { type: 'text', text: output } }], rawOutput: { output, metadata: { truncated: false } } });
565
+ return { allowed: true, ...(answer ? { answer } : {}) };
566
+ },
567
+ callMoxxyTool: async (name, args) => {
568
+ const toolCallId = `call_${session.id}_${++calls}`;
569
+ this.update(session, { sessionUpdate: 'tool_call', toolCallId, title: `moxxy_${name}`, kind: 'other', status: 'pending', locations: [], rawInput: {} });
570
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'in_progress', kind: 'other', title: `moxxy_${name}`, locations: [], rawInput: args });
571
+ const client = this.moxxyClients.get(session.id);
572
+ if (!client || !session.moxxyTools.includes(name)) throw new Error(`OpenCode has no tool moxxy_${name}; it read ${JSON.stringify(session.moxxyTools)}`);
573
+ const result = await client.callTool({ name, arguments: args });
574
+ const text = (result.content as Array<{ type: string; text?: string }>).flatMap((c) => (c.type === 'text' && c.text ? [c.text] : [])).join('\n');
575
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: result.isError ? 'failed' : 'completed', content: [{ type: 'content', content: { type: 'text', text } }] });
576
+ return { content: result.content, ...(result.isError ? { isError: true } : {}) };
577
+ },
578
+ usage: (usage) => setUsage(usage),
579
+ background: async (task) => {
580
+ const toolCallId = `call_${session.id}_${++calls}`;
581
+ const child = this.startTask(session, toolCallId, task, true);
582
+ const output = `<task id="${child.id}" state="running">\n<summary>Background task started</summary>\n<task_result>\nThe task is working in the background. You will be notified automatically when it finishes.\n</task_result>\n</task>`;
583
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'completed', title: task.description, content: [{ type: 'content', content: { type: 'text', text: output } }], rawOutput: { output, metadata: { sessionId: child.id, background: true } } });
584
+ void child.run().then((answer) => this.finishBackground(session, child.id, task, answer));
585
+ return child.id;
586
+ },
587
+ subagent: async (task) => {
588
+ const toolCallId = `call_${session.id}_${++calls}`;
589
+ const child = this.startTask(session, toolCallId, task, false);
590
+ const answer = await child.run();
591
+ // OpenCode ends the call after the subagent's last events went out.
592
+ await new Promise((resolve) => setTimeout(resolve, MODEL_LATENCY_MS));
593
+ const output = `<task id="${child.id}" state="completed">\n<task_result>\n${answer ?? ''}\n</task_result>\n</task>`;
594
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'completed', title: task.description, content: [{ type: 'content', content: { type: 'text', text: output } }], rawOutput: { output, metadata: { sessionId: child.id } } });
595
+ },
596
+ };
597
+ }
598
+
599
+ /**
600
+ * A `task` call as OpenCode 1.19 makes it: ACP announces the call, the HTTP
601
+ * API reports the subagent's session and, on the conversation's tool part,
602
+ * which session that is. `run` does the subagent's work, its steps on the
603
+ * HTTP API only; it resolves with its answer, or `undefined` when aborted.
604
+ */
605
+ private startTask(session: FakeSession, toolCallId: string, task: FakeSubagentTask, background: boolean): { readonly id: string; run(): Promise<string | undefined> } {
606
+ const id = `ses_child_${++this.childCount}`;
607
+ const input = { ...(background ? { background: true } : {}), description: task.description, prompt: task.prompt, subagent_type: task.agent ?? 'general' };
608
+ this.update(session, { sessionUpdate: 'tool_call', toolCallId, title: 'task', kind: 'think', status: 'pending', locations: [], rawInput: {} });
609
+ this.emit('session.created', { sessionID: id, info: { id, parentID: session.id, title: `${task.description} (@${input.subagent_type} subagent)` } });
610
+ this.emit('message.part.updated', {
611
+ sessionID: session.id,
612
+ part: { type: 'tool', tool: 'task', callID: toolCallId, sessionID: session.id, id: `prt_${toolCallId}`, state: { status: 'running', title: task.description, input, metadata: { parentSessionId: session.id, sessionId: id, ...(background ? { background: true } : {}) } } },
613
+ });
614
+ this.update(session, { sessionUpdate: 'tool_call_update', toolCallId, status: 'in_progress', kind: 'think', title: task.description, locations: [], rawInput: input });
615
+ const stop = new AbortController();
616
+ this.children.set(id, { parent: session.id, stop });
617
+ let parts = 0;
618
+ const part = (body: Record<string, unknown>) => this.emit('message.part.updated', { sessionID: id, part: { sessionID: id, id: `prt_${id}_${++parts}`, ...body } });
619
+ const child: FakeChild = {
620
+ id,
621
+ signal: stop.signal,
622
+ tool: (name, toolInput, output) => {
623
+ const callID = `call_${id}_${parts + 1}`;
624
+ const partId = `prt_${id}_${++parts}`;
625
+ const emit = (state: Record<string, unknown>) => this.emit('message.part.updated', { sessionID: id, part: { type: 'tool', tool: name, callID, sessionID: id, id: partId, state } });
626
+ emit({ status: 'pending', input: {}, raw: '' });
627
+ emit({ status: 'running', input: toolInput, time: { start: 1 } });
628
+ emit({ status: 'completed', input: toolInput, output, metadata: {}, title: name, time: { start: 1, end: 2 } });
629
+ },
630
+ say: (text) => part({ type: 'text', text, time: { start: 1, end: 2 } }),
631
+ };
632
+ const run = async (): Promise<string | undefined> => {
633
+ // The subagent's own session opens with the task as its first message.
634
+ part({ type: 'text', text: task.prompt });
635
+ this.emit('session.status', { sessionID: id, status: { type: 'busy' } });
636
+ const aborted = new Promise<undefined>((resolve) => stop.signal.addEventListener('abort', () => resolve(undefined), { once: true }));
637
+ const answer = await Promise.race([task.work(child), aborted]);
638
+ this.children.delete(id);
639
+ if (stop.signal.aborted || answer === undefined) {
640
+ this.childEnds.set(id, 'aborted');
641
+ this.emit('session.error', { sessionID: id, error: { name: 'MessageAbortedError', data: { message: 'Aborted' } } });
642
+ } else {
643
+ this.childEnds.set(id, 'completed');
644
+ child.say(answer);
645
+ }
646
+ this.emit('session.status', { sessionID: id, status: { type: 'idle' } });
647
+ this.emit('session.idle', { sessionID: id });
648
+ return stop.signal.aborted ? undefined : answer;
649
+ };
650
+ return { id, run };
651
+ }
652
+
653
+ /**
654
+ * A background subagent ended. Aborted, OpenCode says nothing more; finished,
655
+ * it prompts the conversation's session itself with the result (a synthetic
656
+ * message) and answers it, outside any prompt of Moxxy's.
657
+ */
658
+ private async finishBackground(session: FakeSession, childId: string, task: FakeBackgroundTask, answer: string | undefined): Promise<void> {
659
+ if (answer === undefined || this.closed) return;
660
+ this.emit('message.part.updated', {
661
+ sessionID: session.id,
662
+ part: { type: 'text', synthetic: true, sessionID: session.id, id: `prt_${childId}_done`, text: `<task id="${childId}" state="completed">\n<summary>Background task completed: ${task.description}</summary>\n<task_result>\n${answer}\n</task_result>\n</task>` },
663
+ });
664
+ this.emit('session.status', { sessionID: session.id, status: { type: 'busy' } });
665
+ // The model takes a while to answer; its first words reach ACP well after these events.
666
+ await new Promise((resolve) => setTimeout(resolve, MODEL_LATENCY_MS));
667
+ let messages = 0;
668
+ const own: FakeOwnTurn = {
669
+ say: (said) => {
670
+ const messageId = `own_msg_${childId}_${++messages}`;
671
+ for (const chunk of said.match(/.{1,8}/gs) ?? []) this.update(session, { sessionUpdate: 'agent_message_chunk', messageId, content: { type: 'text', text: chunk } });
672
+ },
673
+ think: (thought) => {
674
+ const messageId = `own_thought_${childId}_${++messages}`;
675
+ for (const chunk of thought.match(/.{1,8}/gs) ?? []) this.update(session, { sessionUpdate: 'agent_thought_chunk', messageId, content: { type: 'text', text: chunk } });
676
+ },
677
+ };
678
+ await (task.then ?? ((turn: FakeOwnTurn) => turn.say(`Background task finished: ${answer}`)))(own);
679
+ this.emit('session.status', { sessionID: session.id, status: { type: 'idle' } });
680
+ this.emit('session.idle', { sessionID: session.id });
681
+ }
682
+
683
+ /**
684
+ * What the rules Moxxy handed this OpenCode (`OPENCODE_CONFIG_CONTENT`, the
685
+ * agent's own permissions, last matching pattern wins) say about a call.
686
+ */
687
+ /** OpenCode 1.18's `external_directory` ask, as its ACP agent sends it: a command names its folders, a file its parent. */
688
+ private async askOutside(session: FakeSession, toolCallId: string, kind: AcpToolKind, input: Record<string, unknown>, outside: string): Promise<boolean> {
689
+ const parentDir = kind === 'execute' ? outside : outside.slice(0, outside.lastIndexOf('/'));
690
+ const rawInput =
691
+ kind === 'execute'
692
+ ? { command: input.command, directories: [outside], patterns: [`${outside}/*`] }
693
+ : { filepath: outside, parentDir };
694
+ const answer = (await this.request('session/request_permission', {
695
+ sessionId: session.id,
696
+ toolCall: { toolCallId, title: kind === 'execute' ? input.command : parentDir, kind: 'other', status: 'pending', locations: [{ path: outside }], rawInput },
697
+ options: [
698
+ { optionId: 'once', kind: 'allow_once', name: 'Allow once' },
699
+ { optionId: 'always', kind: 'allow_always', name: 'Always allow' },
700
+ { optionId: 'reject', kind: 'reject_once', name: 'Reject' },
701
+ ],
702
+ })) as AcpPermissionOutcome;
703
+ this.permissionAnswers.push(answer);
704
+ return answer.outcome.outcome === 'selected' && answer.outcome.optionId !== 'reject';
705
+ }
706
+
707
+ private ruleFor(session: FakeSession, tool: string, input: Record<string, unknown>): string | undefined {
708
+ const content = this.env.OPENCODE_CONFIG_CONTENT;
709
+ if (!content) return undefined;
710
+ const permissions = (JSON.parse(content) as { agent?: Record<string, { permission?: Record<string, unknown> }> }).agent?.[session.mode]?.permission;
711
+ if (!permissions) return undefined;
712
+ const key = tool === 'write' || tool === 'edit' || tool === 'patch' ? 'edit' : tool;
713
+ const rule = permissions[key];
714
+ if (typeof rule === 'string' || rule === undefined) return rule;
715
+ const subject = String(input.command ?? input.filePath ?? '');
716
+ return Object.entries(rule as Record<string, string>).findLast(([pattern]) => wildcard(pattern).test(subject))?.[1];
717
+ }
718
+
719
+ /**
720
+ * Connect to the MCP server Moxxy listed on the session and read its tools,
721
+ * as OpenCode does while it opens the session; re-read them on a notice.
722
+ */
723
+ private async connectMoxxy(session: FakeSession): Promise<void> {
724
+ if (!session.mcp) return;
725
+ const client = new Client({ name: 'opencode', version: '1.18.35' });
726
+ await client.connect(new StreamableHTTPClientTransport(new URL(session.mcp.url), { requestInit: { headers: session.mcp.headers } }));
727
+ this.mcpClients.push(client);
728
+ this.moxxyClients.set(session.id, client);
729
+ const read = () =>
730
+ (session.reading = client.listTools().then(({ tools }) => {
731
+ session.moxxyTools = tools.map((tool) => tool.name);
732
+ }));
733
+ client.setNotificationHandler(ToolListChangedNotificationSchema, read);
734
+ await read();
735
+ }
736
+ }
737
+
738
+ function changeAsked(input: Record<string, unknown>): Record<string, unknown> {
739
+ const file = String(input.filePath);
740
+ const after = String(input.content ?? input.newString ?? '');
741
+ return { filepath: file, diff: `Index: ${file}\n--- ${file}\n+++ ${file}\n@@ -0,0 +1 @@\n+${after}\n` };
742
+ }
743
+
744
+ function wildcard(pattern: string): RegExp {
745
+ return new RegExp(`^${pattern.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
746
+ }
747
+
748
+ function mcpOf(servers: unknown): FakeSession['mcp'] {
749
+ if (!Array.isArray(servers)) return undefined;
750
+ const moxxy = (servers as Array<{ type?: string; name?: string; url?: string; headers?: Array<{ name: string; value: string }> }>).find(
751
+ (server) => server.name === 'moxxy',
752
+ );
753
+ if (!moxxy || moxxy.type !== 'http' || typeof moxxy.url !== 'string') return undefined;
754
+ return { url: moxxy.url, headers: Object.fromEntries((moxxy.headers ?? []).map((h) => [h.name, h.value])) };
755
+ }