@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,234 @@
1
+ import {
2
+ estimateTextTokens,
3
+ type AgentActivity,
4
+ type AgentStepEvent,
5
+ type AgentTurnStart,
6
+ type AgentStepRequest,
7
+ type LLMProvider,
8
+ type ModelDescriptor,
9
+ type ProviderEvent,
10
+ type ProviderRequest,
11
+ type TokenUsage,
12
+ } from '@moxxy/sdk';
13
+ import { AgentSessionPool, Inbox, JsonRpcLineClient, oneOffPrompt, type AgentSessionLease } from '@moxxy/agent-connection-kit';
14
+ import { AcpAgentSession, CLIENT_CAPABILITIES, type AcpConnection, type StartAcpAgent } from './acp-session.js';
15
+ import { OPENCODE_AGENT, type AcpAgentSpec } from './agents.js';
16
+ import { spawnOpenCodeAcp } from './executable.js';
17
+ import { readOpenCodeMcpServers, readOpenCodeRules } from './login.js';
18
+ import type { ReadAgentMcpServers, ReadAgentRules } from './rules.js';
19
+ import { ACP_PROTOCOL_VERSION, type AcpPromptResponse, type AcpSessionResponse } from './protocol.js';
20
+ import { toTokenUsage } from './translate.js';
21
+
22
+ const NON_TEXT_BLOCK_TOKENS = 256;
23
+ const MAX_LIVE_SESSIONS = 8;
24
+ /** The agent picks its own window per model; this only sizes Moxxy's view of the conversation. */
25
+ const CONTEXT_WINDOW = 200_000;
26
+
27
+ const agentCapabilities = {
28
+ contextWindow: CONTEXT_WINDOW,
29
+ supportsTools: true,
30
+ supportsStreaming: true,
31
+ supportsImages: true,
32
+ supportsDocuments: false,
33
+ supportsAudio: false,
34
+ supportsReasoning: true,
35
+ } as const;
36
+
37
+ export function acpModel(id: string): ModelDescriptor {
38
+ return { id, ...agentCapabilities };
39
+ }
40
+
41
+ export interface AcpAgentProviderConfig {
42
+ readonly agent?: AcpAgentSpec;
43
+ /** The person's agent CLI; resolved from PATH when left out. */
44
+ readonly executable?: string;
45
+ /** Persisted provider-item model (`plugins.provider.items.opencode.model`). */
46
+ readonly model?: string;
47
+ readonly defaultModel?: string;
48
+ /** Runner workspace, supplied on activation. */
49
+ readonly cwd?: string;
50
+ /** The models the person's agent offers (`opencode models`), read when the connection is switched on. */
51
+ readonly models?: ReadonlyArray<string>;
52
+ /** How the agent is started; spawns the person's `opencode acp` by default. */
53
+ readonly startAgent?: StartAcpAgent;
54
+ /** How the person's agent runs; reads `opencode debug agent` by default. */
55
+ readonly readRules?: ReadAgentRules;
56
+ /** The person's own MCP servers; reads `opencode debug config` by default. */
57
+ readonly readMcpServers?: ReadAgentMcpServers;
58
+ /** The live agent sessions; shared across the process by default. */
59
+ readonly sessions?: AgentSessionPool<AcpAgentSession>;
60
+ }
61
+
62
+ let sharedSessions: AgentSessionPool<AcpAgentSession> | undefined;
63
+
64
+ /** The live ACP agent sessions of this process, one per Moxxy conversation. */
65
+ export function acpSessions(): AgentSessionPool<AcpAgentSession> {
66
+ sharedSessions ??= new AgentSessionPool<AcpAgentSession>({ limit: MAX_LIVE_SESSIONS });
67
+ return sharedSessions;
68
+ }
69
+
70
+ /**
71
+ * An agent connection over the Agent Client Protocol: the agent (OpenCode)
72
+ * does each turn itself, unmodified (its instructions, config, tools, skills,
73
+ * permission rules and MCP servers). Moxxy adds only its own MCP server to
74
+ * the session, and nothing to the prompt.
75
+ */
76
+ export class AcpAgentProvider implements LLMProvider {
77
+ readonly name: string;
78
+ readonly agentName: string;
79
+ readonly models: ModelDescriptor[];
80
+ /** No plan hand-off and no goal of its own: Moxxy's plan and goal modes steer it. */
81
+ readonly agentOwns = { plan: false, goal: false } as const;
82
+ private readonly agent: AcpAgentSpec;
83
+ private readonly defaultModel: string;
84
+ private readonly start: StartAcpAgent;
85
+ private readonly readRules: ReadAgentRules;
86
+ private readonly readMcpServers: ReadAgentMcpServers;
87
+
88
+ constructor(private readonly config: AcpAgentProviderConfig = {}) {
89
+ this.agent = config.agent ?? OPENCODE_AGENT;
90
+ this.name = this.agent.providerId;
91
+ this.agentName = this.agent.label;
92
+ this.defaultModel = config.model ?? config.defaultModel ?? this.agent.defaultModel;
93
+ const ids = config.models && config.models.length > 0 ? config.models : [this.defaultModel];
94
+ this.models = [...new Set([this.defaultModel, ...ids])].map(acpModel);
95
+ this.start = config.startAgent ?? spawnOpenCodeAcp(config.executable ?? 'opencode');
96
+ this.readRules = config.readRules ?? readOpenCodeRules(config.executable ?? 'opencode');
97
+ this.readMcpServers = config.readMcpServers ?? readOpenCodeMcpServers(config.executable ?? 'opencode');
98
+ }
99
+
100
+ /** Any model the agent names can be picked; it gets the same description as the rest. */
101
+ async prepareModel(model: string): Promise<void> {
102
+ if (model && !this.models.some((descriptor) => descriptor.id === model)) this.models.push(acpModel(model));
103
+ }
104
+
105
+ async *agentStep(req: AgentStepRequest): AsyncIterable<AgentStepEvent> {
106
+ let lease: AgentSessionLease<AcpAgentSession>;
107
+ try {
108
+ lease = await this.lease(req);
109
+ } catch (err) {
110
+ if (!req.signal.aborted) yield { type: 'error', message: messageOf(err), retryable: false };
111
+ return;
112
+ }
113
+ try {
114
+ yield* lease.session.turn(req);
115
+ } finally {
116
+ if (lease.session.isAlive) lease.release();
117
+ else await lease.discard();
118
+ }
119
+ }
120
+
121
+ /**
122
+ * A subagent OpenCode ran in the background finished while no Moxxy turn
123
+ * runs, and OpenCode answered it by itself: Moxxy opens a turn to show that.
124
+ */
125
+ onAgentTurn(listener: (start: AgentTurnStart) => void): () => void {
126
+ return this.sessions().onAgentTurn(listener);
127
+ }
128
+
129
+ /** What OpenCode's background subagents do, recorded at once. */
130
+ onAgentActivity(listener: (activity: AgentActivity) => void): () => void {
131
+ return this.sessions().onActivity(listener);
132
+ }
133
+
134
+ /** Stop one subagent OpenCode runs in the background in the conversation. */
135
+ async stopBackgroundWork(sessionId: string, workId: string): Promise<boolean> {
136
+ const live = this.sessions().running(sessionId);
137
+ return live ? live.stopWork(workId) : false;
138
+ }
139
+
140
+ private sessions(): AgentSessionPool<AcpAgentSession> {
141
+ return this.config.sessions ?? acpSessions();
142
+ }
143
+
144
+ /** One-off text (summaries, skill drafts): a session of its own in plan mode, without Moxxy's tools. */
145
+ async *stream(req: ProviderRequest): AsyncIterable<ProviderEvent> {
146
+ const model = req.model || this.defaultModel;
147
+ yield { type: 'message_start', model };
148
+ const cwd = this.config.cwd ?? process.cwd();
149
+ let usage: TokenUsage | undefined;
150
+ let connection: AcpConnection | undefined;
151
+ try {
152
+ connection = await this.start({ cwd });
153
+ const client = new JsonRpcLineClient({ readable: connection.readable, writable: connection.writable, jsonrpc: true });
154
+ const deltas = new Inbox<string | null>();
155
+ client.onNotification((method, params) => {
156
+ const update = (params as { update?: { sessionUpdate?: unknown; content?: { type?: unknown; text?: unknown } } } | undefined)?.update;
157
+ if (method === 'session/update' && update?.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text' && typeof update.content.text === 'string') {
158
+ deltas.push(update.content.text);
159
+ }
160
+ });
161
+ await client.request('initialize', { protocolVersion: ACP_PROTOCOL_VERSION, clientCapabilities: CLIENT_CAPABILITIES });
162
+ const session = await client.request<AcpSessionResponse>('session/new', { cwd, mcpServers: [] });
163
+ const sessionId = session.sessionId;
164
+ if (!sessionId) throw new Error(`${this.agent.label} started no session.`);
165
+ for (const option of session.configOptions ?? []) {
166
+ const wanted = option.category === 'model' ? model : option.category === 'mode' ? 'plan' : undefined;
167
+ if (wanted && option.currentValue !== wanted && (option.options ?? []).some((o) => o.value === wanted)) {
168
+ await client.request('session/set_config_option', { sessionId, configId: option.id, value: wanted });
169
+ }
170
+ }
171
+ const { system, prompt } = oneOffPrompt(req);
172
+ const text = [system, prompt].filter(Boolean).join('\n\n');
173
+ const stop = () => client.notify('session/cancel', { sessionId });
174
+ req.signal?.addEventListener('abort', stop, { once: true });
175
+ const answered = client.request<AcpPromptResponse>('session/prompt', { sessionId, prompt: [{ type: 'text', text }] }).finally(() => deltas.push(null));
176
+ try {
177
+ for (let delta = await deltas.next(); delta !== null; delta = await deltas.next()) yield { type: 'text_delta', delta };
178
+ const result = await answered;
179
+ if (result.usage) usage = toTokenUsage(result.usage);
180
+ } finally {
181
+ req.signal?.removeEventListener('abort', stop);
182
+ }
183
+ } catch (err) {
184
+ if (!req.signal?.aborted) yield { type: 'error', message: messageOf(err), retryable: false };
185
+ return;
186
+ } finally {
187
+ connection?.close();
188
+ }
189
+ yield { type: 'message_end', stopReason: 'end_turn', ...(usage ? { usage } : {}) };
190
+ }
191
+
192
+ async countTokens(req: Pick<ProviderRequest, 'model' | 'messages' | 'system' | 'tools'>): Promise<number> {
193
+ let text = req.system ?? '';
194
+ let nonTextBlocks = 0;
195
+ for (const message of req.messages) {
196
+ for (const block of message.content) {
197
+ if (block.type === 'text') text += block.text;
198
+ else nonTextBlocks += 1;
199
+ }
200
+ }
201
+ for (const tool of req.tools ?? []) text += tool.name + tool.description;
202
+ return estimateTextTokens(text) + nonTextBlocks * NON_TEXT_BLOCK_TOKENS;
203
+ }
204
+
205
+ /** The conversation's running agent, or a new one (loading its session) when there is none that fits. */
206
+ private async lease(req: AgentStepRequest): Promise<AgentSessionLease<AcpAgentSession>> {
207
+ const settings = {
208
+ agent: this.agent,
209
+ start: this.start,
210
+ readRules: this.readRules,
211
+ readMcpServers: this.readMcpServers,
212
+ cwd: req.cwd,
213
+ tools: req.allTools ?? req.tools,
214
+ ...(req.skills ? { skills: req.skills } : {}),
215
+ ...(req.resumeSessionId ? { resume: req.resumeSessionId } : {}),
216
+ onAgentTurn: (name: string, workId?: string) => this.sessions().agentTurn({ sessionId: req.sessionId, name, ...(workId ? { workId } : {}) }),
217
+ onActivity: (events: ReadonlyArray<AgentStepEvent>) => this.sessions().activity({ sessionId: req.sessionId, events }),
218
+ };
219
+ const sessions = this.sessions();
220
+ const create = () => AcpAgentSession.start(settings);
221
+ const lease = await sessions.acquire(req.sessionId, { signal: req.signal, create });
222
+ if (lease.session.isAlive && lease.session.fits(settings)) return lease;
223
+ await lease.discard();
224
+ return sessions.acquire(req.sessionId, { signal: req.signal, create });
225
+ }
226
+ }
227
+
228
+ export function createAcpAgentClient(config: AcpAgentProviderConfig = {}): LLMProvider {
229
+ return new AcpAgentProvider(config);
230
+ }
231
+
232
+ function messageOf(err: unknown): string {
233
+ return err instanceof Error ? err.message : String(err);
234
+ }
package/src/agents.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { MOXXY_MCP_SERVER, OPENCODE_DEFAULT_MODEL, OPENCODE_PROVIDER_ID } from './constants.js';
2
+
3
+ /** What differs between agents that speak ACP. OpenCode is the only one so far. */
4
+ export interface AcpAgentSpec {
5
+ readonly providerId: string;
6
+ /** The agent's name in messages ("OpenCode stopped."). */
7
+ readonly label: string;
8
+ readonly defaultModel: string;
9
+ /** The name Moxxy's MCP server has in the agent's session. */
10
+ readonly mcpServerName: string;
11
+ /** How the agent names a tool of Moxxy's MCP server: those calls are recorded by Moxxy when it runs them. */
12
+ readonly moxxyToolPrefix: string;
13
+ /** Moxxy tools the agent has its own of, so they are not lent over MCP; the person's Moxxy skills come as a `load_skill` of their own. */
14
+ readonly ownTools: ReadonlySet<string>;
15
+ /** What the person runs to sign the agent in to a model provider. */
16
+ readonly loginCommand: string;
17
+ }
18
+
19
+ /**
20
+ * OpenCode (`opencode acp`). It has its own file, shell, web-fetch, todo,
21
+ * skill and sub-agent tools. It has no question tool over ACP, no plan hand-off
22
+ * and no goal, so Moxxy lends `ask_user`, `plan_complete` and the goal tools,
23
+ * and `recall`, which searches Moxxy's record of the whole conversation.
24
+ */
25
+ export const OPENCODE_AGENT: AcpAgentSpec = {
26
+ providerId: OPENCODE_PROVIDER_ID,
27
+ label: 'OpenCode',
28
+ defaultModel: OPENCODE_DEFAULT_MODEL,
29
+ mcpServerName: MOXXY_MCP_SERVER,
30
+ moxxyToolPrefix: `${MOXXY_MCP_SERVER}_`,
31
+ ownTools: new Set([
32
+ 'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob', 'Sleep', 'Wait', 'StopJob',
33
+ 'web_fetch', 'session_recall',
34
+ 'load_skill', 'reload_skills', 'synthesize_skill', 'load_tool',
35
+ ]),
36
+ loginCommand: 'opencode auth login',
37
+ };
@@ -0,0 +1,36 @@
1
+ /** Provider name in the registry / model picker / provider config. */
2
+ export const OPENCODE_PROVIDER_ID = 'opencode';
3
+
4
+ /** Human-readable upstream service, shown in login and setup surfaces. */
5
+ export const OPENCODE_SERVICE_NAME = 'OpenCode';
6
+
7
+ /** Optional executable override for login/status surfaces that do not carry provider config. */
8
+ export const OPENCODE_EXECUTABLE_ENV = 'OPENCODE_EXECUTABLE';
9
+
10
+ /** What a fresh OpenCode runs; the person's own default wins when they set one. */
11
+ export const OPENCODE_DEFAULT_MODEL = 'opencode/big-pickle';
12
+
13
+ /**
14
+ * What the picker offers before OpenCode is switched on and lists the person's own. Only the free model OpenCode
15
+ * keeps: its promotional free models come and go (space-bunny-free was one), and the daily compat check fails
16
+ * when one listed here is no longer offered.
17
+ */
18
+ export const OPENCODE_FREE_MODELS: ReadonlyArray<string> = [OPENCODE_DEFAULT_MODEL];
19
+
20
+ /** The name Moxxy's MCP server has in the agent's session; OpenCode calls its tools `moxxy_<tool>`. */
21
+ export const MOXXY_MCP_SERVER = 'moxxy';
22
+
23
+ export const OPENCODE_ACP_ARGS = ['acp'] as const;
24
+ /** Where `opencode acp` serves its HTTP API: Moxxy reads OpenCode's background subagents there, which ACP does not report. */
25
+ export const OPENCODE_API_HOST = '127.0.0.1';
26
+ export const OPENCODE_API_USERNAME = 'opencode';
27
+ export const OPENCODE_SERVER_USERNAME_ENV = 'OPENCODE_SERVER_USERNAME';
28
+ export const OPENCODE_SERVER_PASSWORD_ENV = 'OPENCODE_SERVER_PASSWORD';
29
+ /** OpenCode 1.19's switch for subagents that work on after its turn (`task` with `background: true`). */
30
+ export const OPENCODE_BACKGROUND_SUBAGENTS_ENV = 'OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS';
31
+ export const OPENCODE_MODELS_ARGS = ['models'] as const;
32
+ export const OPENCODE_DEBUG_AGENT_ARGS = ['debug', 'agent'] as const;
33
+ export const OPENCODE_DEBUG_CONFIG_ARGS = ['debug', 'config'] as const;
34
+ export const OPENCODE_AUTH_LIST_ARGS = ['auth', 'list'] as const;
35
+ export const OPENCODE_AUTH_LOGIN_ARGS = ['auth', 'login'] as const;
36
+ export const OPENCODE_AUTH_LOGOUT_ARGS = ['auth', 'logout'] as const;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Every session update the Agent Client Protocol defines — its `SessionUpdate`
3
+ * kinds, listed in fixtures/ — checked against what Moxxy does with it: shown
4
+ * in the chat, or left alone for the reason given. A kind the protocol adds
5
+ * becomes a decision here instead of something lost without a trace. A tool
6
+ * shows whatever it is called: one Moxxy has no formatting for is a plain call.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { describe, expect, it } from 'vitest';
10
+ import type { AcpSessionUpdate } from './protocol.js';
11
+ import { AcpTurnTranslator } from './translate.js';
12
+
13
+ const kinds = JSON.parse(readFileSync(new URL('./fixtures/acp-session-updates.json', import.meta.url), 'utf8')) as { readonly updates: ReadonlyArray<string> };
14
+
15
+ /** The kinds that show, each with an update of that kind. */
16
+ const SHOWN: Record<string, AcpSessionUpdate> = {
17
+ agent_message_chunk: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Done.' } },
18
+ agent_thought_chunk: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Looking.' } },
19
+ tool_call: { sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'read', kind: 'read', status: 'in_progress', rawInput: { filePath: '/repo/a.txt' } },
20
+ tool_call_update: { sessionUpdate: 'tool_call_update', toolCallId: 'c2', title: 'read', kind: 'read', status: 'completed', rawInput: { filePath: '/repo/a.txt' }, rawOutput: { output: 'hi' } },
21
+ plan: { sessionUpdate: 'plan', entries: [{ content: 'Read the code', priority: 'high', status: 'pending' }] },
22
+ };
23
+
24
+ /** The kinds Moxxy leaves out of the chat, and why. */
25
+ const LEFT_ALONE: Record<string, string> = {
26
+ user_message_chunk: "the person's own message, which Moxxy already shows (sent again when a session is loaded)",
27
+ available_commands_update: "the session reads the agent's commands and skills from it, so a Moxxy skill of the same name is left to the agent's",
28
+ config_option_update: 'the agent reporting its model, mode and effort, which Moxxy sets itself',
29
+ current_mode_update: 'the agent reporting its mode, which Moxxy sets itself',
30
+ session_info_update: "the session's title and details, for the agent's own list",
31
+ usage_update: "how full the agent's context is; the session reads the window size from it, not the tokens of the turn",
32
+ notice: "a note for the agent's own screen",
33
+ compaction_update: "the agent shortening its own context; Moxxy's record of the conversation stays whole",
34
+ compaction_summary_chunk: "the agent shortening its own context; Moxxy's record of the conversation stays whole",
35
+ };
36
+
37
+ describe('every session update ACP defines', () => {
38
+ it('has a decision here, so a kind the protocol adds needs one', () => {
39
+ expect([...Object.keys(SHOWN), ...Object.keys(LEFT_ALONE)].sort()).toEqual([...kinds.updates]);
40
+ });
41
+
42
+ it.each(Object.keys(SHOWN))('%s shows in the chat', (kind) => {
43
+ const update = SHOWN[kind];
44
+
45
+ expect(update?.sessionUpdate).toBe(kind);
46
+ expect(update ? new AcpTurnTranslator('moxxy_', '/repo').update(update).length : 0).toBeGreaterThan(0);
47
+ });
48
+
49
+ it.each(Object.keys(LEFT_ALONE))('%s leaves the chat as it is', (kind) => {
50
+ expect(new AcpTurnTranslator('moxxy_', '/repo').update({ sessionUpdate: kind })).toEqual([]);
51
+ });
52
+ });
53
+
54
+ describe('a tool Moxxy has no formatting for', () => {
55
+ it('shows as a call by its name, with its result', () => {
56
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
57
+
58
+ expect(t.update({ sessionUpdate: 'tool_call', toolCallId: 'm1', title: 'lsp_hover', kind: 'other', status: 'in_progress', rawInput: { line: 3 } })).toEqual([
59
+ expect.objectContaining({ type: 'tool_call', callId: 'm1', name: 'lsp_hover' }),
60
+ ]);
61
+ expect(t.update({ sessionUpdate: 'tool_call_update', toolCallId: 'm1', status: 'completed', rawOutput: { output: 'a number' } })).toEqual([
62
+ expect.objectContaining({ type: 'tool_result', callId: 'm1', ok: true }),
63
+ ]);
64
+ });
65
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { opencodeAcpLaunch, opencodeServesApiBesideAcp, opencodeTarget } from './executable.js';
3
+
4
+ describe('starting the person OpenCode without a shell', () => {
5
+ it('runs what PATH finds as it is on macOS and Linux', () => {
6
+ expect(opencodeTarget('opencode', { platform: 'darwin', find: () => '/opt/homebrew/bin/opencode' })).toEqual({
7
+ kind: 'direct',
8
+ command: '/opt/homebrew/bin/opencode',
9
+ });
10
+ });
11
+
12
+ it('runs opencode.exe as it is on Windows', () => {
13
+ const exe = 'C:\\Users\\me\\scoop\\shims\\opencode.exe';
14
+ expect(opencodeTarget('opencode', { platform: 'win32', find: () => exe })).toEqual({ kind: 'direct', command: exe });
15
+ });
16
+
17
+ it('starts the opencode.exe behind the npm .cmd shim on Windows', () => {
18
+ const shim = 'C:\\Users\\me\\AppData\\Roaming\\npm\\opencode.cmd';
19
+ const exe = 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe';
20
+ expect(opencodeTarget('opencode', { platform: 'win32', find: () => shim, exists: (file) => file === exe })).toEqual({
21
+ kind: 'direct',
22
+ command: exe,
23
+ });
24
+ });
25
+
26
+ it('starts the opencode.exe behind the npm shim of OpenCode 2, which npm installs as @opencode/cli', () => {
27
+ const shim = 'C:\\Users\\me\\AppData\\Roaming\\npm\\opencode.cmd';
28
+ const exe = 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@opencode\\cli\\bin\\opencode.exe';
29
+ expect(opencodeTarget('opencode', { platform: 'win32', find: () => shim, exists: (file) => file === exe })).toEqual({
30
+ kind: 'direct',
31
+ command: exe,
32
+ });
33
+ });
34
+
35
+ it('says what to do when the shim leads nowhere', () => {
36
+ expect(() => opencodeTarget('opencode', { platform: 'win32', find: () => 'C:\\npm\\opencode.cmd', exists: () => false })).toThrow(
37
+ /reinstall OpenCode/,
38
+ );
39
+ });
40
+
41
+ it('says how to install OpenCode when it is not found', () => {
42
+ expect(() => opencodeTarget('opencode', { find: () => null })).toThrow(/OPENCODE_EXECUTABLE/);
43
+ });
44
+ });
45
+
46
+ describe('the OpenCode Moxxy starts for a conversation', () => {
47
+ it('serves its HTTP API on a port Moxxy picked, behind a password only Moxxy knows, with background subagents on', () => {
48
+ const launch = opencodeAcpLaunch({ server: { port: 4321, password: 'secret' }, inherited: {} });
49
+
50
+ expect(launch.args).toEqual(['acp', '--port', '4321', '--hostname', '127.0.0.1']);
51
+ expect(launch.env).toEqual({
52
+ OPENCODE_SERVER_USERNAME: 'opencode',
53
+ OPENCODE_SERVER_PASSWORD: 'secret',
54
+ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: 'true',
55
+ });
56
+ expect(launch.api).toEqual({ url: 'http://127.0.0.1:4321', authorization: `Basic ${Buffer.from('opencode:secret').toString('base64')}` });
57
+ });
58
+
59
+ it('keeps background subagents off when the person turned them off', () => {
60
+ const launch = opencodeAcpLaunch({ server: { port: 4321, password: 'secret' }, inherited: { OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: 'false' } });
61
+
62
+ expect(launch.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS).toBe('false');
63
+ });
64
+
65
+ it('adds its own settings to those of the restart that asks before changes', () => {
66
+ const launch = opencodeAcpLaunch({ server: { port: 1, password: 'p' }, inherited: {}, env: { OPENCODE_CONFIG_CONTENT: '{}' } });
67
+
68
+ expect(launch.env).toMatchObject({ OPENCODE_CONFIG_CONTENT: '{}', OPENCODE_SERVER_PASSWORD: 'p' });
69
+ });
70
+
71
+ it('starts OpenCode 2 with `acp` alone: it runs a private server and takes no port, so Moxxy has no API to read', () => {
72
+ const launch = opencodeAcpLaunch({ inherited: {} });
73
+
74
+ expect(launch.args).toEqual(['acp']);
75
+ expect(launch.env).toEqual({ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: 'true' });
76
+ expect(launch.api).toBeUndefined();
77
+ });
78
+ });
79
+
80
+ describe('which OpenCode serves its HTTP API beside ACP', () => {
81
+ it('is OpenCode 1, which takes --port and --hostname for it', () => {
82
+ expect(opencodeServesApiBesideAcp('1.19.4')).toBe(true);
83
+ });
84
+
85
+ it('is not OpenCode 2, nor one whose version could not be read, since `acp` alone starts on both', () => {
86
+ expect(opencodeServesApiBesideAcp('2.0.22')).toBe(false);
87
+ expect(opencodeServesApiBesideAcp(null)).toBe(false);
88
+ });
89
+ });
@@ -0,0 +1,143 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:net';
3
+ import { spawnExecutableTarget, type ExecutableTarget } from '@moxxy/sdk/server';
4
+ import {
5
+ OPENCODE_CLI,
6
+ agentCliTargetAt,
7
+ compareVersions,
8
+ findAgentCli,
9
+ readCliVersion,
10
+ stopAgentProcess,
11
+ type AgentCliTargetOptions,
12
+ } from '@moxxy/agent-connection-kit';
13
+ import {
14
+ OPENCODE_ACP_ARGS,
15
+ OPENCODE_API_HOST,
16
+ OPENCODE_API_USERNAME,
17
+ OPENCODE_BACKGROUND_SUBAGENTS_ENV,
18
+ OPENCODE_EXECUTABLE_ENV,
19
+ OPENCODE_SERVER_PASSWORD_ENV,
20
+ OPENCODE_SERVER_USERNAME_ENV,
21
+ } from './constants.js';
22
+ import type { AcpConnection, StartAcpAgent } from './acp-session.js';
23
+ import type { OpenCodeApi } from './opencode-api.js';
24
+
25
+ export interface OpenCodeTargetOptions extends AgentCliTargetOptions {
26
+ readonly find?: (command: string) => string | null;
27
+ }
28
+
29
+ /**
30
+ * How to start the person's OpenCode without a shell. A Windows npm install
31
+ * puts an `opencode.cmd` shim on PATH, which only a shell can run; the
32
+ * `opencode.exe` it points at is started directly instead. The other
33
+ * installers (scoop, choco, the install script) put `opencode.exe` on PATH.
34
+ */
35
+ export function opencodeTarget(command: string, opts: OpenCodeTargetOptions = {}): ExecutableTarget {
36
+ const found = (opts.find ?? ((name) => findAgentCli(OPENCODE_CLI, { command: name })))(command);
37
+ if (!found) throw new Error(`OpenCode not found: ${command}. Install OpenCode or set ${OPENCODE_EXECUTABLE_ENV}.`);
38
+ return agentCliTargetAt(OPENCODE_CLI, found, opts);
39
+ }
40
+
41
+ export interface OpenCodeAcpLaunch {
42
+ readonly args: ReadonlyArray<string>;
43
+ readonly env: Readonly<Record<string, string>>;
44
+ /** OpenCode's HTTP API, when this OpenCode serves it beside ACP. */
45
+ readonly api?: OpenCodeApi;
46
+ }
47
+
48
+ /**
49
+ * Whether `opencode acp` serves OpenCode's HTTP API beside ACP. OpenCode 1
50
+ * does, on `--port` / `--hostname`; OpenCode 2 runs a private server with no
51
+ * port and refuses those flags. An unreadable version is started as OpenCode
52
+ * 2 is, since `acp` alone starts on both.
53
+ */
54
+ export function opencodeServesApiBesideAcp(version: string | null): boolean {
55
+ return version !== null && compareVersions(version, '2.0.0-0') < 0;
56
+ }
57
+
58
+ /**
59
+ * How `opencode acp` is started. Given a `server`, OpenCode serves its HTTP
60
+ * API on that port behind that password, so Moxxy can read the subagents
61
+ * OpenCode runs in the background, which ACP does not report, and nothing
62
+ * else on the computer can use the API. Background subagents are switched on
63
+ * unless the person switched them off.
64
+ */
65
+ export function opencodeAcpLaunch(opts: {
66
+ readonly server?: { readonly port: number; readonly password: string };
67
+ /** The environment OpenCode inherits (`process.env`). */
68
+ readonly inherited: Readonly<Record<string, string | undefined>>;
69
+ /** Settings of this start (the restart that asks before changes). */
70
+ readonly env?: Readonly<Record<string, string>>;
71
+ }): OpenCodeAcpLaunch {
72
+ const env = {
73
+ ...opts.env,
74
+ [OPENCODE_BACKGROUND_SUBAGENTS_ENV]: opts.inherited[OPENCODE_BACKGROUND_SUBAGENTS_ENV] ?? 'true',
75
+ };
76
+ const { server } = opts;
77
+ if (!server) return { args: [...OPENCODE_ACP_ARGS], env };
78
+ return {
79
+ args: [...OPENCODE_ACP_ARGS, '--port', String(server.port), '--hostname', OPENCODE_API_HOST],
80
+ env: {
81
+ ...env,
82
+ [OPENCODE_SERVER_USERNAME_ENV]: OPENCODE_API_USERNAME,
83
+ [OPENCODE_SERVER_PASSWORD_ENV]: server.password,
84
+ },
85
+ api: {
86
+ url: `http://${OPENCODE_API_HOST}:${server.port}`,
87
+ authorization: `Basic ${Buffer.from(`${OPENCODE_API_USERNAME}:${server.password}`).toString('base64')}`,
88
+ },
89
+ };
90
+ }
91
+
92
+ /** A port free on the loopback right now, for OpenCode to serve its API on. */
93
+ function freePort(): Promise<number> {
94
+ return new Promise((resolve, reject) => {
95
+ const server = createServer();
96
+ server.once('error', reject);
97
+ server.listen(0, OPENCODE_API_HOST, () => {
98
+ const address = server.address();
99
+ server.close(() => (typeof address === 'object' && address ? resolve(address.port) : reject(new Error('No free port for OpenCode.'))));
100
+ });
101
+ });
102
+ }
103
+
104
+ /** Starts `opencode acp` in the conversation folder, speaking ACP on stdio. */
105
+ export function spawnOpenCodeAcp(command: string): StartAcpAgent {
106
+ return async ({ cwd, env }) => {
107
+ const target = opencodeTarget(command);
108
+ const server = opencodeServesApiBesideAcp(await readCliVersion(target))
109
+ ? { port: await freePort(), password: randomBytes(24).toString('base64url') }
110
+ : undefined;
111
+ const launch = opencodeAcpLaunch({ inherited: process.env, ...(server ? { server } : {}), ...(env ? { env } : {}) });
112
+ return new Promise<AcpConnection>((resolve, reject) => {
113
+ const child = spawnExecutableTarget(target, launch.args, {
114
+ cwd,
115
+ env: launch.env,
116
+ stdio: ['pipe', 'pipe', 'pipe'],
117
+ windowsHide: true,
118
+ });
119
+ const { stdin, stdout, stderr } = child;
120
+ if (!stdin || !stdout || !stderr) {
121
+ stopAgentProcess(child);
122
+ reject(new Error('OpenCode started without its stdio.'));
123
+ return;
124
+ }
125
+ child.once('error', reject);
126
+ child.once('spawn', () => {
127
+ child.off('error', reject);
128
+ // A failed write after OpenCode exits is reported by the closed stdout.
129
+ stdin.on('error', () => undefined);
130
+ resolve({
131
+ readable: stdout,
132
+ writable: stdin,
133
+ stderr,
134
+ ...(launch.api ? { api: launch.api } : {}),
135
+ close: () => {
136
+ stdin.end();
137
+ stopAgentProcess(child);
138
+ },
139
+ });
140
+ });
141
+ });
142
+ };
143
+ }