@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,892 @@
1
+ import { createRequire } from 'node:module';
2
+ import type { Readable, Writable } from 'node:stream';
3
+ import { isContextWindowSize, waitFor, type AgentApprovalDecision, type AgentBackgroundWork, type AgentStepEvent, type AgentStepRequest, type AgentToolCall, type AgentToolOutcome, type Skill, type ToolDef } from '@moxxy/sdk';
4
+ import {
5
+ Inbox,
6
+ JsonRpcError,
7
+ JsonRpcLineClient,
8
+ agentModeFor,
9
+ earlierConversationNote,
10
+ idleToolsHandler,
11
+ moxxyToolsHandler,
12
+ resumeAgentSession,
13
+ sameToolNames,
14
+ skillsForAgent,
15
+ startMoxxyMcpServer,
16
+ untilStopped,
17
+ type AgentMode,
18
+ type LiveSession,
19
+ type MoxxyMcpServer,
20
+ type NewSessionReason,
21
+ withoutOwnMcpServers,
22
+ } from '@moxxy/agent-connection-kit';
23
+ import type { AcpAgentSpec } from './agents.js';
24
+ import {
25
+ ACP_PROTOCOL_VERSION,
26
+ type AcpConfigOption,
27
+ type AcpContentBlock,
28
+ type AcpInitializeResponse,
29
+ type AcpPermissionOutcome,
30
+ type AcpPermissionRequest,
31
+ type AcpPromptResponse,
32
+ type AcpSessionList,
33
+ type AcpSessionResponse,
34
+ type AcpSessionUpdate,
35
+ } from './protocol.js';
36
+ import { AcpTurnTranslator, toAcpPrompt, toTokenUsage, type AcpShownCall } from './translate.js';
37
+ import { abortOpenCodeSession, watchOpenCodeEvents, type OpenCodeApi, type OpenCodeEvent, type OpenCodeEventWatch } from './opencode-api.js';
38
+ import { OpenCodeHelpers } from './opencode-helpers.js';
39
+ import { askingConfig, asksOutsideProject, runsWithoutAsking, type OpenCodeRule, type ReadAgentMcpServers, type ReadAgentRules } from './rules.js';
40
+
41
+ /** A running ACP agent (`opencode acp`): its stdout, stdin and stderr. */
42
+ export interface AcpConnection {
43
+ readonly readable: Readable;
44
+ readonly writable: Writable;
45
+ readonly stderr?: Readable;
46
+ /** OpenCode's HTTP API, where it reports its subagents; without it Moxxy sees no background work. */
47
+ readonly api?: OpenCodeApi;
48
+ close(): void;
49
+ }
50
+
51
+ export type StartAcpAgent = (opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> }) => Promise<AcpConnection>;
52
+
53
+ export interface AcpSessionSettings {
54
+ readonly agent: AcpAgentSpec;
55
+ readonly start: StartAcpAgent;
56
+ readonly cwd: string;
57
+ /** The conversation's agent session from an earlier process, to load. */
58
+ readonly resume?: string;
59
+ /** How the person's agent runs; without it Moxxy leaves the agent's own rules and the conversation's auto-approve alone. */
60
+ readonly readRules?: ReadAgentRules;
61
+ /** The agent's own MCP servers: Moxxy's server of the same name is left to the agent's. */
62
+ readonly readMcpServers?: ReadAgentMcpServers;
63
+ /** Every Moxxy tool of the conversation: the agent reads the list as the session opens, and again on a notice. */
64
+ readonly tools?: ReadonlyArray<ToolDef>;
65
+ /** The conversation's Moxxy skills: the agent reads those meant for it through one more Moxxy tool. */
66
+ readonly skills?: ReadonlyArray<Skill>;
67
+ /** The agent started a turn by itself while no Moxxy turn runs (a background subagent finished); `name` says why, `workId` which work. */
68
+ readonly onAgentTurn?: (name: string, workId?: string) => void;
69
+ /** What the agent's background work did, for the conversation to record at once: its list, a subagent's steps and end. */
70
+ readonly onActivity?: (events: ReadonlyArray<AgentStepEvent>) => void;
71
+ }
72
+
73
+ type RequestItem = {
74
+ readonly kind: 'request';
75
+ readonly method: string;
76
+ readonly params: unknown;
77
+ readonly resolve: (result: unknown) => void;
78
+ readonly reject: (error: Error) => void;
79
+ };
80
+
81
+ type Item =
82
+ | { readonly kind: 'note'; readonly params: unknown }
83
+ | RequestItem
84
+ | { readonly kind: 'moxxy-tool'; readonly call: AgentToolCall; readonly resolve: (outcome: AgentToolOutcome) => void }
85
+ | { readonly kind: 'done'; readonly result: AcpPromptResponse }
86
+ | { readonly kind: 'failed'; readonly error: unknown }
87
+ | { readonly kind: 'stop' }
88
+ /** What OpenCode's HTTP API says for the turn: steps of a subagent it waits for, a reached account limit. */
89
+ | { readonly kind: 'api'; readonly events: ReadonlyArray<AgentStepEvent> }
90
+ /** The conversation's session went idle and the agent has been quiet since: a turn it started by itself is over. */
91
+ | { readonly kind: 'idle' }
92
+ | { readonly kind: 'ended' };
93
+
94
+ const MAX_STDERR = 2048;
95
+ const MAX_DECLINE_FOLLOW_UPS = 3;
96
+ const STOPPED: AgentApprovalDecision = { allow: false, reason: 'The person stopped the turn.' };
97
+ const CANCELLED: AcpPermissionOutcome = { outcome: { outcome: 'cancelled' } };
98
+ const PLAN_MODE = 'plan';
99
+ const BUILD_MODE = 'build';
100
+ const SIGN_IN_EXPIRED = /token refresh failed|\b401\b|unauthori[sz]ed/i;
101
+ const CONFIG_CONTENT_ENV = 'OPENCODE_CONFIG_CONTENT';
102
+ /** ACP's error for a resource, here a session, that does not exist. */
103
+ const RESOURCE_NOT_FOUND = -32002;
104
+ /** How many pages of the agent's sessions Moxxy reads before it stops looking. */
105
+ const MAX_SESSION_PAGES = 50;
106
+ /** How long the first turn waits for the agent's list of commands; OpenCode sends it as the session opens. */
107
+ const COMMANDS_WAIT_MS = 2_000;
108
+ /** How long the agent stays quiet on ACP after OpenCode says its session is idle before a turn it started by itself counts as over (ACP lags its HTTP API). */
109
+ const SETTLE_MS = 300;
110
+ /** Steps kept per subagent whose start the conversation has not seen yet. */
111
+ const MAX_HELD_STEPS = 64;
112
+ /** ACP updates that show the agent at work: one outside any turn means it started one by itself. */
113
+ const WORK_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan']);
114
+ const CLIENT_VERSION = (createRequire(import.meta.url)('../package.json') as { version: string }).version;
115
+
116
+ export const CLIENT_CAPABILITIES = { fs: { readTextFile: false, writeTextFile: false }, terminal: false } as const;
117
+
118
+ /** Moxxy tools the agent lacks, lent over MCP for this turn. */
119
+ export function toolsLentTo(agent: AcpAgentSpec, tools: ReadonlyArray<ToolDef>): ToolDef[] {
120
+ return tools.filter((tool) => !agent.ownTools.has(tool.name) && !tool.name.startsWith('memory_'));
121
+ }
122
+
123
+ /**
124
+ * One running ACP agent with one session for one Moxxy conversation. Every
125
+ * message the person sends is the next prompt of the same session, so the
126
+ * agent keeps its context between turns. The agent's notifications, its
127
+ * permission asks and Moxxy tool calls over MCP are queued together and
128
+ * handled in order, so the log shows a call before the decision about it.
129
+ */
130
+ export class AcpAgentSession implements LiveSession {
131
+ private readonly inbox = new Inbox<Item>();
132
+ private client: JsonRpcLineClient;
133
+ private connection: AcpConnection;
134
+ /** A replacement agent is loading the session: what it replays is history. */
135
+ private loading = false;
136
+ private alive = true;
137
+ private closed = false;
138
+ private inTurn = false;
139
+ private sessionId = '';
140
+ private options: ReadonlyArray<AcpConfigOption> = [];
141
+ /** The agent mode the person's setup starts in, restored after plan mode. */
142
+ private ownMode = BUILD_MODE;
143
+ /** Why the person declined, for the next message when the turn ended first. */
144
+ private pendingNotes: string[] = [];
145
+ /** The Moxxy tools the agent is shown: every one of the conversation, the same in every mode. */
146
+ private listed: ToolDef[];
147
+ /** The Moxxy skills the agent is offered. */
148
+ private offered: Skill[];
149
+ /** Names of the agent's own commands and skills, as it last announced them. */
150
+ private ownCommands: ReadonlyArray<string> = [];
151
+ private commandsHeard = false;
152
+ private commandsAwaited = false;
153
+ private readonly commandWakers = new Set<() => void>();
154
+ /** The session is new while the conversation may already have run: its first prompt carries the conversation so far. */
155
+ private newSession: NewSessionReason | undefined;
156
+ private stderr = '';
157
+ /** No turn has shown the conversation yet how the agent runs. */
158
+ private fresh = true;
159
+ /** The agent's own rules, once read; `null` when it cannot say. */
160
+ private ownRules: ReadonlyArray<OpenCodeRule> | null | undefined;
161
+ private ownMcpServers: ReadonlyArray<string> | undefined;
162
+ /** This agent was started to ask before what its own rules allow. */
163
+ private asking = false;
164
+ /** Whether Moxxy answers the agent's asks for the person in this turn. */
165
+ private autoApprove = false;
166
+ /** Auto-approve is on because Moxxy turned it on, not because the person chose it. */
167
+ private adopted = false;
168
+ /** The agent can list its sessions, which tells a gone session from a failed load. */
169
+ private canList = false;
170
+ /** A replacement agent could not load the session: the next turn starts the agent again. */
171
+ private broken = false;
172
+ /** OpenCode's events, read while its HTTP API is up. */
173
+ private watch: OpenCodeEventWatch | undefined;
174
+ /** The subagents OpenCode runs, read from its events. */
175
+ private readonly helpers = new OpenCodeHelpers(() => this.sessionId);
176
+ /** Subagents whose start the conversation has seen; the steps of others wait for it. */
177
+ private readonly shownHelpers = new Set<string>();
178
+ private readonly heldSteps = new Map<string, AgentStepEvent[]>();
179
+ /** The background work last reported, to report it again only when it changes. */
180
+ private shownWork: ReadonlyArray<AgentBackgroundWork> = [];
181
+ /** OpenCode started a turn by itself and the conversation was asked for one to show it. */
182
+ private agentTurnPending = false;
183
+ /** A turn reads the one OpenCode started by itself. */
184
+ private readingOwnTurn = false;
185
+ /** OpenCode's session works now, as its events last said. */
186
+ private agentBusy = false;
187
+ private settleTimer: ReturnType<typeof setTimeout> | undefined;
188
+ /** This turn showed the account limit reached. */
189
+ private limitShown = false;
190
+
191
+ static async start(settings: AcpSessionSettings): Promise<AcpAgentSession> {
192
+ const mcp = await startMoxxyMcpServer({ name: settings.agent.mcpServerName });
193
+ let session: AcpAgentSession | undefined;
194
+ try {
195
+ session = new AcpAgentSession(settings, mcp, await settings.start({ cwd: settings.cwd }));
196
+ await session.open();
197
+ return session;
198
+ } catch (err) {
199
+ if (session) await session.close();
200
+ else await mcp.close();
201
+ throw err;
202
+ }
203
+ }
204
+
205
+ private constructor(
206
+ readonly settings: AcpSessionSettings,
207
+ private readonly mcp: MoxxyMcpServer,
208
+ connection: AcpConnection,
209
+ ) {
210
+ this.connection = connection;
211
+ this.listed = toolsLentTo(settings.agent, settings.tools ?? []);
212
+ this.offered = this.skillsFor(settings.skills);
213
+ this.mcp.setHandler(idleToolsHandler(this.listed, this.offered));
214
+ this.client = this.listen(connection);
215
+ }
216
+
217
+ /** The client for a running agent, its notifications and asks queued for the turn. */
218
+ private listen(connection: AcpConnection): JsonRpcLineClient {
219
+ const client = new JsonRpcLineClient({ readable: connection.readable, writable: connection.writable, jsonrpc: true });
220
+ connection.stderr?.on('data', (data: Buffer | string) => {
221
+ this.stderr = (this.stderr + String(data)).slice(-MAX_STDERR);
222
+ });
223
+ // Only the agent in use ending ends the conversation's agent; a replaced one is expected to.
224
+ const ended = () => {
225
+ if (connection === this.connection) this.ended();
226
+ };
227
+ connection.readable.once('end', ended);
228
+ connection.readable.once('close', ended);
229
+ // Outside a turn nothing is shown: that drops the history `session/load` replays.
230
+ // The agent at work outside one started a turn by itself, which the conversation is asked to show.
231
+ client.onNotification((method, params) => {
232
+ if (method !== 'session/update') return;
233
+ const commands = announcedCommands(params);
234
+ if (commands) {
235
+ this.ownCommands = commands;
236
+ this.commandsHeard = true;
237
+ this.commandWakers.forEach((wake) => wake());
238
+ return;
239
+ }
240
+ if (this.loading) return;
241
+ if (!this.inTurn && !this.agentTurnPending && this.startsOwnTurn(params)) this.askForTurn('A background task finished');
242
+ if (!this.inTurn && !this.agentTurnPending) return;
243
+ this.inbox.push({ kind: 'note', params });
244
+ if (this.settleTimer) this.settleSoon();
245
+ });
246
+ client.onRequest((method, params) =>
247
+ this.inTurn || this.agentTurnPending ? new Promise((resolve, reject) => this.inbox.push({ kind: 'request', method, params, resolve, reject })) : refusal(method),
248
+ );
249
+ return client;
250
+ }
251
+
252
+ get isAlive(): boolean {
253
+ return this.alive && !this.broken;
254
+ }
255
+
256
+ /**
257
+ * The agent lists its commands just after its session opens, maybe after the
258
+ * turn began: the first turn waits for that list, an agent that never sends
259
+ * one only briefly.
260
+ */
261
+ private async hearCommands(signal: AbortSignal): Promise<void> {
262
+ if (this.commandsHeard || this.commandsAwaited) return;
263
+ this.commandsAwaited = true;
264
+ await waitFor(() => this.commandsHeard || undefined, {
265
+ wakeOn: [
266
+ (wake) => {
267
+ this.commandWakers.add(wake);
268
+ return () => this.commandWakers.delete(wake);
269
+ },
270
+ ],
271
+ timeoutMs: COMMANDS_WAIT_MS,
272
+ signal,
273
+ });
274
+ }
275
+
276
+ /** A Moxxy skill named like one of the agent's own commands or skills is left to the agent's. */
277
+ private skillsFor(skills: ReadonlyArray<Skill> | undefined): Skill[] {
278
+ return skillsForAgent(skills ?? [], this.listed.map((tool) => tool.name), this.ownCommands);
279
+ }
280
+
281
+ /** Whether this agent was started where the turn runs (a running session cannot move). */
282
+ fits(settings: Pick<AcpSessionSettings, 'cwd'>): boolean {
283
+ return settings.cwd === this.settings.cwd;
284
+ }
285
+
286
+ async *turn(req: AgentStepRequest): AsyncGenerator<AgentStepEvent> {
287
+ // A turn opened to show the one OpenCode started by itself: OpenCode is sent nothing.
288
+ if (req.continuesAgentTurn) {
289
+ if (this.agentTurnPending) yield* this.ownTurn(req);
290
+ return;
291
+ }
292
+ const mode = agentModeFor(req.mode);
293
+ this.inTurn = true;
294
+ // A turn OpenCode started by itself and not shown yet is read by this one: OpenCode answers this prompt after it.
295
+ this.agentTurnPending = false;
296
+ this.clearSettle();
297
+ this.limitShown = false;
298
+ const onAbort = () => {
299
+ this.inbox.push({ kind: 'stop' });
300
+ this.client.notify('session/cancel', { sessionId: this.sessionId });
301
+ };
302
+ req.signal.addEventListener('abort', onAbort, { once: true });
303
+ const all = withoutOwnMcpServers(toolsLentTo(this.settings.agent, req.allTools ?? req.tools), await this.mcpServers());
304
+ if (!sameToolNames(all, this.listed)) this.listed = all;
305
+ try {
306
+ yield { type: 'session', sessionId: this.sessionId };
307
+ this.autoApprove = req.autoApprove;
308
+ // Auto-approve OpenCode's own rules turned on, not the person: the
309
+ // person still answers what those rules ask (work outside the project).
310
+ // Once the person turned it off, auto-approve on again is their choice.
311
+ if (req.autoApproveFromAgent !== undefined) this.adopted = req.autoApprove && req.autoApproveFromAgent;
312
+ else if (!req.autoApprove) this.adopted = false;
313
+ // A new conversation shows how the person's agent really runs (one
314
+ // state); a conversation going on, or one that recorded a switch, keeps
315
+ // the auto-approve the person chose. In goal mode Moxxy answers for the person because of the goal,
316
+ // not because they chose auto-approve, so that waits.
317
+ if (this.fresh && mode !== 'goal') {
318
+ this.fresh = false;
319
+ const rules = await this.rules();
320
+ const ownAuto = rules !== undefined && runsWithoutAsking(rules);
321
+ if (this.settings.resume) {
322
+ // Told nothing of who turned it on, while the agent's own rules would have, it asks as if the person did not.
323
+ if (ownAuto && this.autoApprove && req.autoApproveFromAgent === undefined) this.adopted = true;
324
+ } else if (ownAuto && !this.autoApprove && !req.autoApproveRecorded) {
325
+ this.autoApprove = true;
326
+ this.adopted = true;
327
+ yield { type: 'auto_approve', enabled: true };
328
+ }
329
+ }
330
+ if (!this.autoApprove && mode !== 'goal') await this.askFromNowOn();
331
+ // OpenCode reads the Moxxy tools when the session opens and keeps that
332
+ // list: when the conversation gained or lost a tool or a skill, it reads them again first.
333
+ await this.hearCommands(req.signal);
334
+ this.offered = this.skillsFor(req.skills);
335
+ this.mcp.setHandler(
336
+ moxxyToolsHandler(
337
+ this.listed,
338
+ { runTool: (call) => new Promise((resolve) => this.inbox.push({ kind: 'moxxy-tool', call, resolve })) },
339
+ this.offered,
340
+ ),
341
+ );
342
+ await this.mcp.announceTools({ signal: req.signal });
343
+ await this.configure(req, mode);
344
+ // A new session of the agent's own gets the whole conversation; one it goes on in, the turns it was away for.
345
+ const note = this.newSession ? earlierConversationNote(this.newSession, req.earlierConversation?.()) : req.missedTurns?.();
346
+ this.newSession = undefined;
347
+ if (note) this.pendingNotes.unshift(note);
348
+ const notes = this.pendingNotes.map((text): AcpContentBlock => ({ type: 'text', text }));
349
+ this.pendingNotes = [];
350
+ let input: AcpContentBlock[] = [...notes, ...toAcpPrompt(req.prompt)];
351
+ // The agent's answer to an ask carries no reason, and it ends its turn on
352
+ // a refusal: the person's reason is its next message, in the same turn.
353
+ // An agent that keeps asking for what was refused gets the reason with
354
+ // the person's next message instead.
355
+ for (let followUps = 0; ; followUps += 1) {
356
+ if (req.signal.aborted) return;
357
+ const declines: string[] = [];
358
+ const finished = yield* this.prompt(input, req, declines);
359
+ if (!finished || declines.length === 0 || followUps === MAX_DECLINE_FOLLOW_UPS) {
360
+ this.pendingNotes.push(...declines);
361
+ return;
362
+ }
363
+ input = declines.map((text): AcpContentBlock => ({ type: 'text', text }));
364
+ }
365
+ } catch (err) {
366
+ if (!req.signal.aborted) yield { type: 'error', message: this.alive ? this.failureMessage(err) : this.endedMessage(), retryable: false };
367
+ } finally {
368
+ req.signal.removeEventListener('abort', onAbort);
369
+ this.mcp.setHandler(idleToolsHandler(this.listed, this.offered));
370
+ this.inTurn = false;
371
+ this.dismissQueued();
372
+ }
373
+ }
374
+
375
+ /** Stop one subagent OpenCode runs in the background (`AgentBackgroundWork.id`); `false` when it runs no such work. */
376
+ async stopWork(workId: string): Promise<boolean> {
377
+ const api = this.connection.api;
378
+ const session = api ? this.helpers.stop(workId) : undefined;
379
+ if (!api || !session) return false;
380
+ const stopped = await abortOpenCodeSession(api, this.settings.cwd, session);
381
+ if (!stopped) this.helpers.unstop(workId);
382
+ return stopped;
383
+ }
384
+
385
+ async close(): Promise<void> {
386
+ if (this.closed) return;
387
+ this.closed = true;
388
+ this.alive = false;
389
+ this.unwatch();
390
+ this.client.close();
391
+ this.connection.close();
392
+ this.dismissQueued();
393
+ await this.mcp.close();
394
+ }
395
+
396
+ /** The agent's own MCP servers that are on, read once. */
397
+ private async mcpServers(): Promise<ReadonlyArray<string>> {
398
+ const { readMcpServers, cwd } = this.settings;
399
+ if (this.ownMcpServers === undefined) {
400
+ const servers = readMcpServers ? ((await readMcpServers({ cwd }).catch(() => undefined)) ?? []) : [];
401
+ this.ownMcpServers = servers.filter((name) => name !== this.settings.agent.mcpServerName);
402
+ }
403
+ return this.ownMcpServers;
404
+ }
405
+
406
+ /** The rules of the agent the person runs, read once. */
407
+ private async rules(): Promise<ReadonlyArray<OpenCodeRule> | undefined> {
408
+ const { readRules, cwd } = this.settings;
409
+ if (this.ownRules === undefined) this.ownRules = readRules ? ((await readRules({ cwd, agent: this.ownMode })) ?? null) : null;
410
+ return this.ownRules ?? undefined;
411
+ }
412
+
413
+ /**
414
+ * With auto-approve off the agent asks before what its own rules allow:
415
+ * it is started again with those rules turned into asks (for this process
416
+ * only, nothing written to its config) and loads the same session. Once
417
+ * started so it stays, and Moxxy answers its asks while auto-approve is on.
418
+ */
419
+ private async askFromNowOn(): Promise<void> {
420
+ if (this.asking) return;
421
+ const rules = await this.rules();
422
+ if (!rules || !runsWithoutAsking(rules)) return;
423
+ const env = { [CONFIG_CONTENT_ENV]: askingConfig(this.ownMode, rules, process.env[CONFIG_CONTENT_ENV]) };
424
+ const replaced = { connection: this.connection, client: this.client };
425
+ const next = await this.settings.start({ cwd: this.settings.cwd, env });
426
+ // The subagents of the OpenCode replaced end with it.
427
+ this.unwatch();
428
+ this.connection = next;
429
+ this.client = this.listen(next);
430
+ replaced.client.close();
431
+ replaced.connection.close();
432
+ this.asking = true;
433
+ this.loading = true;
434
+ try {
435
+ await this.open(this.sessionId);
436
+ } catch (err) {
437
+ this.broken = true;
438
+ throw err;
439
+ } finally {
440
+ this.loading = false;
441
+ }
442
+ }
443
+
444
+ private async open(resume = this.settings.resume): Promise<void> {
445
+ const init = await this.client.request<AcpInitializeResponse>('initialize', {
446
+ protocolVersion: ACP_PROTOCOL_VERSION,
447
+ clientCapabilities: CLIENT_CAPABILITIES,
448
+ clientInfo: { name: 'moxxy', title: 'Moxxy', version: CLIENT_VERSION },
449
+ });
450
+ this.canList = init.agentCapabilities?.sessionCapabilities?.list !== undefined;
451
+ // OpenCode serves its HTTP API once it answered `initialize`.
452
+ this.watchEvents();
453
+ const { cwd } = this.settings;
454
+ const mcpServers = [
455
+ {
456
+ type: 'http',
457
+ name: this.settings.agent.mcpServerName,
458
+ url: this.mcp.url,
459
+ headers: [{ name: 'Authorization', value: this.mcp.authorization }],
460
+ },
461
+ ];
462
+ let session: AcpSessionResponse | undefined;
463
+ if (resume) {
464
+ const resumed = await resumeAgentSession({
465
+ label: this.settings.agent.label,
466
+ load: () => this.client.request<AcpSessionResponse>('session/load', { sessionId: resume, cwd, mcpServers }),
467
+ gone: (error) => this.sessionGone(error, resume),
468
+ });
469
+ if (resumed.kind === 'loaded') {
470
+ session = resumed.session;
471
+ this.sessionId = resume;
472
+ } else {
473
+ this.newSession = 'lost';
474
+ }
475
+ } else {
476
+ this.newSession = 'new';
477
+ }
478
+ if (!session) {
479
+ session = await this.client.request<AcpSessionResponse>('session/new', { cwd, mcpServers });
480
+ if (!session.sessionId) throw new Error(`${this.settings.agent.label} started no session.`);
481
+ this.sessionId = session.sessionId;
482
+ }
483
+ this.options = session.configOptions ?? [];
484
+ const mode = this.option('mode');
485
+ if (mode && mode.currentValue !== PLAN_MODE) this.ownMode = mode.currentValue;
486
+ }
487
+
488
+ /**
489
+ * Whether a session the agent failed to load no longer exists. OpenCode 1.18
490
+ * answers a missing session as it answers a failure ("OpenCode service
491
+ * failure"), so its session list decides; an agent that can say neither
492
+ * keeps the session as existing, and nothing replaces it.
493
+ */
494
+ private async sessionGone(error: unknown, sessionId: string): Promise<boolean> {
495
+ if (error instanceof JsonRpcError && error.code === RESOURCE_NOT_FOUND) return true;
496
+ if (!this.canList) return false;
497
+ try {
498
+ let cursor: string | undefined;
499
+ for (let page = 0; page < MAX_SESSION_PAGES; page += 1) {
500
+ const listed = await this.client.request<AcpSessionList>('session/list', { cwd: this.settings.cwd, ...(cursor ? { cursor } : {}) });
501
+ if (listed.sessions.some((session) => session.sessionId === sessionId)) return false;
502
+ if (!listed.nextCursor) return true;
503
+ cursor = listed.nextCursor;
504
+ }
505
+ return false;
506
+ } catch {
507
+ return false;
508
+ }
509
+ }
510
+
511
+ /** How full OpenCode's context is (`used`) and the window it runs the turn's model with (`size`), from its `usage_update`. */
512
+ private contextOf(update: AcpSessionUpdate, requested: string): AgentStepEvent[] {
513
+ if (update.sessionUpdate !== 'usage_update') return [];
514
+ const { used, size } = update as { used?: unknown; size?: unknown };
515
+ const model = requested || this.option('model')?.currentValue;
516
+ return [
517
+ ...(typeof used === 'number' && Number.isSafeInteger(used) && used >= 0 ? [{ type: 'context_used' as const, tokens: used }] : []),
518
+ ...(typeof model === 'string' && model !== '' && isContextWindowSize(size) ? [{ type: 'context_window' as const, model, tokens: size }] : []),
519
+ ];
520
+ }
521
+
522
+ /** Model, effort and mode for this turn, changed on the session only where they differ. */
523
+ private async configure(req: AgentStepRequest, mode: AgentMode): Promise<void> {
524
+ const model = this.option('model');
525
+ if (req.model && model && model.currentValue !== req.model) {
526
+ await this.set(model.id, req.model).catch((err: unknown) => {
527
+ throw new Error(`${this.settings.agent.label} could not switch to the model ${req.model}: ${messageOf(err)}`);
528
+ });
529
+ }
530
+ const effort = typeof req.reasoning === 'object' ? req.reasoning.effort : undefined;
531
+ const level = this.option('thought_level');
532
+ if (effort && level && level.currentValue !== effort && offers(level, effort)) await this.set(level.id, effort);
533
+ const agentMode = this.option('mode');
534
+ const wanted = mode === 'plan' ? PLAN_MODE : this.ownMode;
535
+ if (agentMode && agentMode.currentValue !== wanted && offers(agentMode, wanted)) await this.set(agentMode.id, wanted);
536
+ }
537
+
538
+ private option(category: string): AcpConfigOption | undefined {
539
+ return this.options.find((option) => option.category === category) ?? this.options.find((option) => option.id === category);
540
+ }
541
+
542
+ private async set(configId: string, value: string): Promise<void> {
543
+ const response = await this.client.request<AcpSessionResponse>('session/set_config_option', { sessionId: this.sessionId, configId, value });
544
+ this.options = response.configOptions ?? this.options.map((option) => (option.id === configId ? { ...option, currentValue: value } : option));
545
+ }
546
+
547
+ /** One prompt, read until the agent answers it. False when the turn is over without it (stopped, failed). */
548
+ private async *prompt(input: ReadonlyArray<AcpContentBlock>, req: AgentStepRequest, declines: string[]): AsyncGenerator<AgentStepEvent, boolean> {
549
+ const translator = new AcpTurnTranslator(this.settings.agent.moxxyToolPrefix, this.settings.cwd);
550
+ this.client.request<AcpPromptResponse>('session/prompt', { sessionId: this.sessionId, prompt: input }).then(
551
+ (result) => this.inbox.push({ kind: 'done', result }),
552
+ (error: unknown) => this.inbox.push({ kind: 'failed', error }),
553
+ );
554
+ for (;;) {
555
+ const item = await this.inbox.next();
556
+ switch (item.kind) {
557
+ case 'ended':
558
+ yield* translator.finish();
559
+ yield { type: 'error', message: this.endedMessage(), retryable: false };
560
+ return false;
561
+ case 'stop':
562
+ // The agent answers the prompt as cancelled.
563
+ continue;
564
+ case 'idle':
565
+ continue;
566
+ case 'moxxy-tool':
567
+ item.resolve(await req.host.runTool(item.call));
568
+ continue;
569
+ case 'request':
570
+ yield* this.answer(item, req, translator, declines);
571
+ continue;
572
+ case 'api':
573
+ yield* item.events;
574
+ continue;
575
+ case 'note':
576
+ yield* this.shown(item.params, translator, req);
577
+ continue;
578
+ case 'done':
579
+ yield* translator.finish();
580
+ if (item.result.usage) yield { type: 'usage', usage: toTokenUsage(item.result.usage) };
581
+ return item.result.stopReason !== 'cancelled' && !req.signal.aborted;
582
+ case 'failed':
583
+ yield* translator.finish();
584
+ if (!this.alive) {
585
+ yield { type: 'error', message: this.endedMessage(), retryable: false };
586
+ return false;
587
+ }
588
+ throw item.error;
589
+ }
590
+ }
591
+ }
592
+
593
+ /** What the agent asked of Moxxy, answered with the turn's host. */
594
+ private async *answer(item: RequestItem, req: AgentStepRequest, translator: AcpTurnTranslator, declines: string[]): AsyncGenerator<AgentStepEvent> {
595
+ if (item.method !== 'session/request_permission') {
596
+ item.reject(new JsonRpcError(-32601, `Moxxy does not handle ${item.method}.`));
597
+ return;
598
+ }
599
+ try {
600
+ const ask = (item.params ?? {}) as AcpPermissionRequest;
601
+ const callId = ask.toolCall.toolCallId;
602
+ const { call, events } = translator.callFor(ask.toolCall);
603
+ yield* this.withHeldSteps(events);
604
+ // With auto-approve on Moxxy answers for the person, as OpenCode's own auto mode does; standing rules still apply.
605
+ // Work outside the project OpenCode asks about even on its defaults: while
606
+ // Moxxy turned auto-approve on, not the person, the person still answers.
607
+ const askPerson = this.adopted && asksOutsideProject(ask.toolCall.rawInput);
608
+ const answering = askPerson ? { askPerson: true } : this.autoApprove ? { policyOnly: true } : {};
609
+ const decision = await untilStopped(
610
+ req.host.approve({ callId, name: call.name, input: call.input, ...answering }),
611
+ req.signal,
612
+ STOPPED,
613
+ );
614
+ if (req.signal.aborted) {
615
+ item.resolve(CANCELLED);
616
+ return;
617
+ }
618
+ item.resolve(permissionOutcome(decision, ask.options));
619
+ if (!decision.allow) declines.push(declineNote(call, decision.reason));
620
+ } catch (err) {
621
+ item.reject(err instanceof Error ? err : new Error(String(err)));
622
+ }
623
+ }
624
+
625
+ /**
626
+ * A turn OpenCode started by itself: a background subagent finished and
627
+ * OpenCode prompted its session with the result. It is read until OpenCode's
628
+ * session is idle again; its asks and Moxxy tool calls are answered as in any turn.
629
+ */
630
+ private async *ownTurn(req: AgentStepRequest): AsyncGenerator<AgentStepEvent> {
631
+ this.inTurn = true;
632
+ this.readingOwnTurn = true;
633
+ this.limitShown = false;
634
+ const onAbort = () => {
635
+ this.inbox.push({ kind: 'stop' });
636
+ this.client.notify('session/cancel', { sessionId: this.sessionId });
637
+ };
638
+ req.signal.addEventListener('abort', onAbort, { once: true });
639
+ this.mcp.setHandler(
640
+ moxxyToolsHandler(
641
+ this.listed,
642
+ { runTool: (call) => new Promise((resolve) => this.inbox.push({ kind: 'moxxy-tool', call, resolve })) },
643
+ this.offered,
644
+ ),
645
+ );
646
+ const translator = new AcpTurnTranslator(this.settings.agent.moxxyToolPrefix, this.settings.cwd);
647
+ // Already over (OpenCode's ACP lags its events), it is read to the end of what came.
648
+ if (!this.agentBusy) this.settleSoon();
649
+ try {
650
+ yield { type: 'session', sessionId: this.sessionId };
651
+ for (;;) {
652
+ const item = await this.inbox.next();
653
+ switch (item.kind) {
654
+ case 'ended':
655
+ yield* translator.finish();
656
+ yield { type: 'error', message: this.endedMessage(), retryable: false };
657
+ return;
658
+ case 'idle':
659
+ yield* translator.finish();
660
+ return;
661
+ case 'moxxy-tool':
662
+ item.resolve(await req.host.runTool(item.call));
663
+ continue;
664
+ case 'request': {
665
+ const declines: string[] = [];
666
+ yield* this.answer(item, req, translator, declines);
667
+ this.pendingNotes.push(...declines);
668
+ continue;
669
+ }
670
+ case 'api':
671
+ yield* item.events;
672
+ continue;
673
+ case 'note':
674
+ yield* this.shown(item.params, translator, req);
675
+ continue;
676
+ default:
677
+ continue;
678
+ }
679
+ }
680
+ } finally {
681
+ req.signal.removeEventListener('abort', onAbort);
682
+ this.mcp.setHandler(idleToolsHandler(this.listed, this.offered));
683
+ this.agentTurnPending = false;
684
+ this.readingOwnTurn = false;
685
+ this.inTurn = false;
686
+ this.clearSettle();
687
+ this.dismissQueued();
688
+ }
689
+ }
690
+
691
+ /** One ACP update of the conversation's session as agent events. */
692
+ private *shown(params: unknown, translator: AcpTurnTranslator, req: AgentStepRequest): Generator<AgentStepEvent> {
693
+ const { sessionId, update } = (params ?? {}) as { sessionId?: unknown; update?: AcpSessionUpdate };
694
+ if (sessionId !== this.sessionId || !update) return;
695
+ yield* this.contextOf(update, req.model);
696
+ yield* this.withHeldSteps(translator.update(update));
697
+ }
698
+
699
+ /** The events, each subagent's start followed by the steps it took before the conversation saw it start. */
700
+ private *withHeldSteps(events: Iterable<AgentStepEvent>): Generator<AgentStepEvent> {
701
+ for (const event of events) {
702
+ yield event;
703
+ if (event.type !== 'subagent_started') continue;
704
+ this.shownHelpers.add(event.id);
705
+ const held = this.heldSteps.get(event.id) ?? [];
706
+ this.heldSteps.delete(event.id);
707
+ if (this.helpers.inBackground(event.id)) this.report(held);
708
+ else yield* held;
709
+ }
710
+ }
711
+
712
+ /** Start reading the events of the OpenCode in use, when it serves its HTTP API. */
713
+ private watchEvents(): void {
714
+ this.watch?.close();
715
+ const api: OpenCodeApi | undefined = this.connection.api;
716
+ if (!api) return;
717
+ this.watch = watchOpenCodeEvents(api, this.settings.cwd, (event) => this.onEvent(event), {
718
+ // Without events the end of a turn OpenCode started by itself would never come.
719
+ onDisconnected: () => {
720
+ if (this.agentTurnPending || this.readingOwnTurn) this.settleSoon();
721
+ },
722
+ });
723
+ }
724
+
725
+ /** Stop reading OpenCode's events: its subagents end with it. */
726
+ private unwatch(): void {
727
+ this.watch?.close();
728
+ this.watch = undefined;
729
+ this.clearSettle();
730
+ this.helpers.clear();
731
+ this.shownHelpers.clear();
732
+ this.heldSteps.clear();
733
+ this.agentBusy = false;
734
+ this.reportWork([]);
735
+ }
736
+
737
+ private onEvent(event: OpenCodeEvent): void {
738
+ if (this.closed) return;
739
+ const news = this.helpers.take(event);
740
+ for (const step of news.steps) this.deliver(step);
741
+ if (news.workChanged) this.reportWork();
742
+ if (news.status) {
743
+ this.agentBusy = news.status === 'busy';
744
+ if (this.agentBusy) this.clearSettle();
745
+ else if (this.agentTurnPending || this.readingOwnTurn) this.settleSoon();
746
+ }
747
+ // OpenCode would wait for the reset, hours maybe, retrying: the turn ends on the limit, as Claude's does.
748
+ if (news.limitReached && this.inTurn && !this.limitShown) {
749
+ this.limitShown = true;
750
+ this.inbox.push({ kind: 'api', events: [{ type: 'account_limit', limit: { state: 'reached' } }] });
751
+ this.client.notify('session/cancel', { sessionId: this.sessionId });
752
+ }
753
+ if (news.finished && !this.inTurn) {
754
+ const { label, failed, workId } = news.finished;
755
+ this.askForTurn(`${label} ${failed ? 'failed' : 'finished'} in the background`, workId);
756
+ }
757
+ }
758
+
759
+ /**
760
+ * A subagent's step: in the turn that waits for it, or recorded at once for
761
+ * one working in the background. A step of a subagent whose start the
762
+ * conversation has not seen waits for it.
763
+ */
764
+ private deliver(step: AgentStepEvent): void {
765
+ const id = 'id' in step && typeof step.id === 'string' ? step.id : undefined;
766
+ if (id === undefined) return;
767
+ if (!this.shownHelpers.has(id)) {
768
+ const held = this.heldSteps.get(id) ?? [];
769
+ if (held.length < MAX_HELD_STEPS) held.push(step);
770
+ this.heldSteps.set(id, held);
771
+ return;
772
+ }
773
+ if (this.inTurn && !this.helpers.inBackground(id)) this.inbox.push({ kind: 'api', events: [step] });
774
+ else this.report([step]);
775
+ }
776
+
777
+ /** Whether an ACP update outside any turn shows the agent at work in the conversation's session. */
778
+ private startsOwnTurn(params: unknown): boolean {
779
+ const { sessionId, update } = (params ?? {}) as { sessionId?: unknown; update?: { sessionUpdate?: unknown } };
780
+ return this.watch !== undefined && sessionId === this.sessionId && typeof update?.sessionUpdate === 'string' && WORK_UPDATES.has(update.sessionUpdate);
781
+ }
782
+
783
+ private askForTurn(name: string, workId?: string): void {
784
+ if (this.closed || this.agentTurnPending) return;
785
+ this.agentTurnPending = true;
786
+ // Already idle by the time ACP shows its work, the turn is over once the agent is quiet.
787
+ if (!this.agentBusy) this.settleSoon();
788
+ this.settings.onAgentTurn?.(name, workId);
789
+ }
790
+
791
+ /** The turn OpenCode started by itself is over once its session is idle and ACP has been quiet a moment. */
792
+ private settleSoon(): void {
793
+ this.clearSettle();
794
+ this.settleTimer = setTimeout(() => {
795
+ this.settleTimer = undefined;
796
+ this.inbox.push({ kind: 'idle' });
797
+ }, SETTLE_MS);
798
+ }
799
+
800
+ private clearSettle(): void {
801
+ if (this.settleTimer) clearTimeout(this.settleTimer);
802
+ this.settleTimer = undefined;
803
+ }
804
+
805
+ /** What OpenCode runs in the background now; reported when it changes. */
806
+ private reportWork(work: ReadonlyArray<AgentBackgroundWork> = this.helpers.running()): void {
807
+ if (sameWork(work, this.shownWork)) return;
808
+ this.shownWork = work;
809
+ this.report([{ type: 'background_work', work }]);
810
+ }
811
+
812
+ private report(events: ReadonlyArray<AgentStepEvent>): void {
813
+ if (events.length > 0) this.settings.onActivity?.(events);
814
+ }
815
+
816
+ private failureMessage(err: unknown): string {
817
+ const message = messageOf(err);
818
+ const label = this.settings.agent.label;
819
+ if (SIGN_IN_EXPIRED.test(message)) {
820
+ return `${label} is signed out of the model's provider (${message}). Run \`${this.settings.agent.loginCommand}\` and try again.`;
821
+ }
822
+ return err instanceof JsonRpcError ? `${label}: ${message}` : message;
823
+ }
824
+
825
+ private ended(): void {
826
+ if (!this.alive) return;
827
+ this.alive = false;
828
+ this.inbox.push({ kind: 'ended' });
829
+ }
830
+
831
+ private endedMessage(): string {
832
+ const stderr = this.stderr.trim();
833
+ const stopped = `${this.settings.agent.label} stopped.`;
834
+ return stderr ? `${stopped}\n${stderr}` : stopped;
835
+ }
836
+
837
+ /** Answer whatever is still queued once no turn can: the agent must never wait on Moxxy forever. */
838
+ private dismissQueued(): void {
839
+ for (const item of this.inbox.drain()) {
840
+ if (item.kind === 'ended') this.inbox.push(item);
841
+ else if (item.kind === 'api') this.report(item.events.filter((event) => event.type !== 'account_limit'));
842
+ else if (item.kind === 'moxxy-tool') item.resolve({ ok: false, message: 'No Moxxy turn is running, so the tool cannot run now.' });
843
+ else if (item.kind === 'request') {
844
+ try {
845
+ item.resolve(refusal(item.method));
846
+ } catch (err) {
847
+ item.reject(err instanceof Error ? err : new Error(String(err)));
848
+ }
849
+ }
850
+ }
851
+ }
852
+ }
853
+
854
+ /** What Moxxy tells the agent when nobody is in the conversation to decide. */
855
+ function refusal(method: string): unknown {
856
+ if (method === 'session/request_permission') return CANCELLED;
857
+ throw new JsonRpcError(-32601, `Moxxy does not handle ${method}.`);
858
+ }
859
+
860
+ function sameWork(a: ReadonlyArray<AgentBackgroundWork>, b: ReadonlyArray<AgentBackgroundWork>): boolean {
861
+ return a.length === b.length && a.every((work, i) => work.id === b[i]?.id && work.label === b[i]?.label);
862
+ }
863
+
864
+ function permissionOutcome(decision: AgentApprovalDecision, options: AcpPermissionRequest['options']): AcpPermissionOutcome {
865
+ const find = (...kinds: ReadonlyArray<string>) => kinds.flatMap((kind) => options.filter((option) => option.kind === kind))[0];
866
+ const chosen = !decision.allow ? find('reject_once', 'reject_always') : decision.always ? find('allow_always', 'allow_once') : find('allow_once', 'allow_always');
867
+ return chosen ? { outcome: { outcome: 'selected', optionId: chosen.optionId } } : CANCELLED;
868
+ }
869
+
870
+ function offers(option: AcpConfigOption, value: string): boolean {
871
+ return option.options === undefined || option.options.some((candidate) => candidate.value === value);
872
+ }
873
+
874
+ function declineNote(call: AcpShownCall, reason: string): string {
875
+ const input = (call.input ?? {}) as { command?: unknown; file_path?: unknown };
876
+ const what =
877
+ typeof input.command === 'string' ? `${call.name} \`${input.command}\``
878
+ : typeof input.file_path === 'string' ? `${call.name} (${input.file_path})`
879
+ : call.name;
880
+ return `The person declined ${what}. Their reason: ${reason}`;
881
+ }
882
+
883
+ function messageOf(err: unknown): string {
884
+ return err instanceof Error ? err.message : String(err);
885
+ }
886
+
887
+ /** The names in an `available_commands_update`, the agent's commands and skills; `undefined` for any other update. */
888
+ function announcedCommands(params: unknown): string[] | undefined {
889
+ const update = (params as { update?: { sessionUpdate?: unknown; availableCommands?: unknown } } | null)?.update;
890
+ if (!update || update.sessionUpdate !== 'available_commands_update' || !Array.isArray(update.availableCommands)) return undefined;
891
+ return update.availableCommands.flatMap((command: { name?: unknown }) => (typeof command.name === 'string' ? [command.name] : []));
892
+ }