@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,253 @@
1
+ /**
2
+ * OpenCode's file changes as Moxxy diffs. The updates are shaped as OpenCode
3
+ * 1.18 sends them over ACP: a finished call carries the tool's own output and
4
+ * metadata in `rawOutput`, and for an edit only the replaced text in `content`.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import type { AcpSessionUpdate } from './protocol.js';
8
+ import { AcpTurnTranslator } from './translate.js';
9
+
10
+ const started = (id: string, tool: string, rawInput: Record<string, unknown>): AcpSessionUpdate => ({
11
+ sessionUpdate: 'tool_call',
12
+ toolCallId: id,
13
+ title: tool,
14
+ kind: 'edit',
15
+ status: 'in_progress',
16
+ rawInput,
17
+ });
18
+ const finished = (id: string, output: string, metadata: Record<string, unknown>, content: unknown[] = []): AcpSessionUpdate => ({
19
+ sessionUpdate: 'tool_call_update',
20
+ toolCallId: id,
21
+ status: 'completed',
22
+ content: [{ type: 'content', content: { type: 'text', text: output } }, ...content],
23
+ rawOutput: { output, metadata },
24
+ });
25
+
26
+ describe("OpenCode's file changes", () => {
27
+ it('show an edit as a diff, from the unified diff in its metadata', () => {
28
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
29
+ t.update(started('e1', 'edit', { filePath: '/repo/src/db.ts', oldString: 'const port = 5432;', newString: 'const port = 5433;' }));
30
+ const diff =
31
+ 'Index: /repo/src/db.ts\n===================================================================\n--- /repo/src/db.ts\n+++ /repo/src/db.ts\n' +
32
+ '@@ -1,2 +1,2 @@\n const host = "x";\n-const port = 5432;\n+const port = 5433;\n';
33
+
34
+ const [result] = t.update(
35
+ finished('e1', 'Edit applied successfully.', { diagnostics: {}, diff, filediff: { file: '/repo/src/db.ts', additions: 1, deletions: 1 }, truncated: false }, [
36
+ { type: 'diff', path: '/repo/src/db.ts', oldText: 'const port = 5432;', newText: 'const port = 5433;' },
37
+ ]),
38
+ );
39
+
40
+ expect(result).toMatchObject({
41
+ type: 'tool_result',
42
+ callId: 'e1',
43
+ ok: true,
44
+ output: { display: { kind: 'file-diff', path: 'src/db.ts', mode: 'update', added: 1, removed: 1 } },
45
+ });
46
+ const output = (result as { output: { forModel: string; display: { hunks: Array<{ lines: Array<{ newNo?: number }> }> } } }).output;
47
+ expect(output.forModel).toContain('Edit applied successfully.');
48
+ expect(output.display.hunks[0]?.lines[2]?.newNo).toBe(2);
49
+ });
50
+
51
+ it('show a new file as all added, from the content it was given', () => {
52
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
53
+ t.update(started('w1', 'write', { filePath: '/repo/notes.md', content: '# Notes\nport 5433\n' }));
54
+
55
+ const [result] = t.update(finished('w1', 'Wrote file successfully.', { diagnostics: {}, filepath: '/repo/notes.md', exists: false, truncated: false }));
56
+
57
+ expect(result).toMatchObject({ output: { display: { kind: 'file-diff', path: 'notes.md', mode: 'create', added: 2, removed: 0 } } });
58
+ });
59
+
60
+ it('keep the text when a write replaced a file whose old content OpenCode does not send', () => {
61
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
62
+ t.update(started('w2', 'write', { filePath: '/repo/notes.md', content: 'new\n' }));
63
+
64
+ expect(t.update(finished('w2', 'Wrote file successfully.', { filepath: '/repo/notes.md', exists: true }))).toEqual([
65
+ { type: 'tool_result', callId: 'w2', ok: true, output: 'Wrote file successfully.' },
66
+ ]);
67
+ });
68
+ });
69
+
70
+ describe("OpenCode's images", () => {
71
+ it('keep the image a finished call sends, such as an image file it read, with its text', () => {
72
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
73
+ t.update({ sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'read', kind: 'read', status: 'in_progress', rawInput: { filePath: '/repo/shot.png' } });
74
+
75
+ const [result] = t.update({
76
+ sessionUpdate: 'tool_call_update',
77
+ toolCallId: 'r1',
78
+ status: 'completed',
79
+ content: [
80
+ { type: 'content', content: { type: 'text', text: 'Image read successfully' } },
81
+ { type: 'content', content: { type: 'image', mimeType: 'image/png', data: 'iVBOR' } },
82
+ ],
83
+ rawOutput: { output: 'Image read successfully', metadata: {} },
84
+ });
85
+
86
+ expect(result).toEqual({
87
+ type: 'tool_result',
88
+ callId: 'r1',
89
+ ok: true,
90
+ output: { forModel: 'Image read successfully', mediaType: 'image/png', base64: 'iVBOR' },
91
+ });
92
+ });
93
+
94
+ it('keep every different image a finished call sends, in order', () => {
95
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
96
+ t.update({ sessionUpdate: 'tool_call', toolCallId: 'r2', title: 'screens', kind: 'other', status: 'in_progress', rawInput: {} });
97
+ const image = (data: string) => ({ type: 'content', content: { type: 'image', mimeType: 'image/png', data } }) as const;
98
+
99
+ const [result] = t.update({
100
+ sessionUpdate: 'tool_call_update',
101
+ toolCallId: 'r2',
102
+ status: 'completed',
103
+ content: [{ type: 'content', content: { type: 'text', text: 'Two screens' } }, image('ONE'), image('ONE'), image('TWO')],
104
+ rawOutput: { output: 'Two screens', metadata: {} },
105
+ });
106
+
107
+ expect(result).toMatchObject({ output: { base64: 'ONE', moreImages: [{ mediaType: 'image/png', base64: 'TWO' }] } });
108
+ });
109
+ });
110
+
111
+ describe("OpenCode's todo list", () => {
112
+ it('shows the list OpenCode wrote as the result of its todowrite call', () => {
113
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
114
+ t.update({ sessionUpdate: 'tool_call', toolCallId: 'td1', title: 'todowrite', kind: 'other', status: 'pending', rawInput: {} });
115
+ const todos = [
116
+ { content: 'Create a.txt containing a', status: 'completed', priority: 'high' },
117
+ { content: 'Create b.txt containing b', status: 'in_progress', priority: 'high' },
118
+ ];
119
+ const shown = t.update({ sessionUpdate: 'tool_call_update', toolCallId: 'td1', status: 'in_progress', kind: 'other', title: 'todowrite', rawInput: { todos } });
120
+
121
+ const [result] = t.update({
122
+ sessionUpdate: 'tool_call_update',
123
+ toolCallId: 'td1',
124
+ status: 'completed',
125
+ title: '1 todos',
126
+ content: [{ type: 'content', content: { type: 'text', text: JSON.stringify(todos, null, 2) } }],
127
+ rawOutput: { output: JSON.stringify(todos, null, 2), metadata: {} },
128
+ });
129
+
130
+ expect(shown).toEqual([{ type: 'tool_call', callId: 'td1', name: 'TodoWrite', input: { todos } }]);
131
+ expect(result).toMatchObject({
132
+ type: 'tool_result',
133
+ ok: true,
134
+ output: {
135
+ display: {
136
+ kind: 'step-list',
137
+ steps: [
138
+ { text: 'Create a.txt containing a', status: 'completed' },
139
+ { text: 'Create b.txt containing b', status: 'in_progress' },
140
+ ],
141
+ },
142
+ },
143
+ });
144
+ });
145
+
146
+ it("shows an ACP agent's plan as a call of its own", () => {
147
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
148
+
149
+ const events = t.update({ sessionUpdate: 'plan', entries: [{ content: 'Read the code', priority: 'high', status: 'pending' }] });
150
+
151
+ expect(events).toMatchObject([
152
+ { type: 'tool_call', name: 'update_plan' },
153
+ { type: 'tool_result', ok: true, output: { display: { kind: 'step-list', steps: [{ text: 'Read the code', status: 'pending' }] } } },
154
+ ]);
155
+ expect((events[0] as { callId: string }).callId).toBe((events[1] as { callId: string }).callId);
156
+ });
157
+ });
158
+
159
+ describe("OpenCode's subagents", () => {
160
+ const launched = (t: AcpTurnTranslator) => {
161
+ t.update({ sessionUpdate: 'tool_call', toolCallId: 'task1', title: 'task', kind: 'think', status: 'pending', rawInput: {} });
162
+ return t.update({
163
+ sessionUpdate: 'tool_call_update',
164
+ toolCallId: 'task1',
165
+ status: 'in_progress',
166
+ rawInput: { description: 'List files', prompt: 'list the files', subagent_type: 'general' },
167
+ });
168
+ };
169
+
170
+ it('show a task OpenCode hands to a subagent as a subagent, whose own tools OpenCode does not report', () => {
171
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
172
+
173
+ expect(launched(t)).toEqual([
174
+ { type: 'subagent_started', id: 'task1', label: 'List files', agentType: 'general', prompt: 'list the files', toolsUnseen: true },
175
+ ]);
176
+ });
177
+
178
+ it("end it with the subagent's answer, without OpenCode's wrapping", () => {
179
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
180
+ launched(t);
181
+ const output = '<task id="ses_1" state="completed">\n<task_result>\nTwo files: a.txt, notes.txt.\n</task_result>\n</task>';
182
+
183
+ expect(
184
+ t.update({
185
+ sessionUpdate: 'tool_call_update',
186
+ toolCallId: 'task1',
187
+ status: 'completed',
188
+ content: [{ type: 'content', content: { type: 'text', text: output } }],
189
+ rawOutput: { output, metadata: { sessionId: 'ses_1' } },
190
+ }),
191
+ ).toEqual([{ type: 'subagent_completed', id: 'task1', text: 'Two files: a.txt, notes.txt.' }]);
192
+ });
193
+
194
+ it('end it with the reason when the subagent failed', () => {
195
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
196
+ launched(t);
197
+
198
+ expect(
199
+ t.update({ sessionUpdate: 'tool_call_update', toolCallId: 'task1', status: 'failed', content: [{ type: 'content', content: { type: 'text', text: 'model refused' } }] }),
200
+ ).toEqual([{ type: 'subagent_completed', id: 'task1', text: '', error: 'model refused' }]);
201
+ });
202
+
203
+ it('keep a subagent OpenCode started in the background running: its end comes later', () => {
204
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
205
+ launched(t);
206
+ const output = '<task id="ses_2" state="running">\n<summary>Background task started</summary>\n<task_result>\nThe task is working in the background.\n</task_result>\n</task>';
207
+
208
+ expect(
209
+ t.update({
210
+ sessionUpdate: 'tool_call_update',
211
+ toolCallId: 'task1',
212
+ status: 'completed',
213
+ content: [{ type: 'content', content: { type: 'text', text: output } }],
214
+ rawOutput: { output },
215
+ }),
216
+ ).toEqual([]);
217
+ });
218
+ });
219
+
220
+
221
+ // OpenCode 2 names the tool `subagent` and answers a background start in prose (2.1.4, recorded).
222
+ describe("OpenCode 2's subagents", () => {
223
+ const input = { agent: 'general', description: 'Run delayed shell command', prompt: 'Run sleep 8, then reply DONE.', background: true };
224
+ const launched = (t: AcpTurnTranslator) => {
225
+ t.update({ sessionUpdate: 'tool_call', toolCallId: 'call_1', title: 'subagent', kind: 'think', status: 'pending', rawInput: {} });
226
+ return t.update({ sessionUpdate: 'tool_call_update', toolCallId: 'call_1', status: 'in_progress', kind: 'think', title: 'subagent', rawInput: input });
227
+ };
228
+
229
+ it('show a subagent call as a subagent of the agent it names', () => {
230
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
231
+
232
+ expect(launched(t)).toEqual([
233
+ { type: 'subagent_started', id: 'call_1', label: 'Run delayed shell command', agentType: 'general', prompt: 'Run sleep 8, then reply DONE.', toolsUnseen: true },
234
+ ]);
235
+ });
236
+
237
+ // OpenCode 2's `acp` reports neither what it does next nor its end, and cannot stop it: nothing would ever close it.
238
+ it('close a subagent OpenCode 2 started in the background, saying it works on there', () => {
239
+ const t = new AcpTurnTranslator('moxxy_', '/repo');
240
+ launched(t);
241
+ const output = 'The subagent is working in the background (sessionID: ses_child). You will be notified automatically when it finishes.';
242
+
243
+ expect(
244
+ t.update({
245
+ sessionUpdate: 'tool_call_update',
246
+ toolCallId: 'call_1',
247
+ status: 'completed',
248
+ content: [{ type: 'content', content: { type: 'text', text: output } }],
249
+ rawOutput: { metadata: { sessionID: 'ses_child', status: 'running', truncated: false } },
250
+ }),
251
+ ).toEqual([{ type: 'subagent_completed', id: 'call_1', text: 'Started in the background. OpenCode 2 does not report what it does there or when it ends.' }]);
252
+ });
253
+ });
@@ -0,0 +1,318 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { AgentStepEvent, ContentBlock, FileDiffDisplay, TokenUsage, ToolImage } from '@moxxy/sdk';
3
+ import { diffPathIn, fileDiffFromPatch, fileDiffFromText, imageToolOutput, parseUnifiedDiff, stepListOutput, stepsFromTodos } from '@moxxy/sdk';
4
+ import type { AcpContentBlock, AcpSessionUpdate, AcpToolCall, AcpToolKind, AcpUsage } from './protocol.js';
5
+
6
+ /** A tool the agent runs itself, as Moxxy names it (`Bash`, `Read`, … so Moxxy's rules and formatting apply). */
7
+ export interface AcpShownCall {
8
+ readonly name: string;
9
+ readonly input: unknown;
10
+ }
11
+
12
+ interface CallState {
13
+ /** The agent's own tool name, from the first `tool_call` (`bash`, `read`, `moxxy_browser_open`). */
14
+ readonly tool: string;
15
+ kind: AcpToolKind | undefined;
16
+ input: Record<string, unknown>;
17
+ shown: AcpShownCall | undefined;
18
+ ended: boolean;
19
+ }
20
+
21
+ /**
22
+ * One agent prompt's `session/update` notifications as Moxxy agent events.
23
+ * ACP announces a tool before its input is known (a `pending` `tool_call`
24
+ * with no or partial `rawInput`, OpenCode's command with its folder only, then
25
+ * an `in_progress` update with the input), so a call is shown once it starts. Calls to Moxxy's own tools (`moxxy_*` for OpenCode) are left
26
+ * out: Moxxy records those when it runs them.
27
+ */
28
+ export class AcpTurnTranslator {
29
+ private readonly calls = new Map<string, CallState>();
30
+ private text = '';
31
+ private thought = '';
32
+ /** Plans the agent reported this prompt; each is shown as a call of its own. */
33
+ private plans = 0;
34
+ private readonly planPrefix = randomUUID().slice(0, 8);
35
+
36
+ /** `cwd`: the project folder, which the paths of file diffs are shown relative to. */
37
+ constructor(
38
+ private readonly moxxyToolPrefix: string,
39
+ private readonly cwd = '',
40
+ ) {}
41
+
42
+ update(update: AcpSessionUpdate): AgentStepEvent[] {
43
+ switch (update.sessionUpdate) {
44
+ case 'agent_message_chunk': {
45
+ const chunk = textOf(update.content);
46
+ if (!chunk) return [];
47
+ const events = this.flushThought();
48
+ this.text += chunk;
49
+ events.push({ type: 'text_delta', delta: chunk });
50
+ return events;
51
+ }
52
+ case 'agent_thought_chunk': {
53
+ const chunk = textOf(update.content);
54
+ if (!chunk) return [];
55
+ this.thought += chunk;
56
+ return [{ type: 'reasoning_delta', delta: chunk }];
57
+ }
58
+ case 'tool_call':
59
+ return this.toolCall(update as AcpToolCall, true);
60
+ case 'tool_call_update':
61
+ return this.toolCall(update as AcpToolCall, false);
62
+ case 'plan':
63
+ return this.plan(update.entries);
64
+ default:
65
+ return [];
66
+ }
67
+ }
68
+
69
+ /** The prompt ended: what the agent said last is its final message. */
70
+ finish(): AgentStepEvent[] {
71
+ return [...this.flushThought(), ...this.flushText('end_turn')];
72
+ }
73
+
74
+ /** The call a permission ask is about, shown first if it is not yet. */
75
+ callFor(toolCall: AcpToolCall): { readonly call: AcpShownCall; readonly events: AgentStepEvent[] } {
76
+ const state = this.calls.get(toolCall.toolCallId);
77
+ if (state?.shown) return { call: state.shown, events: [] };
78
+ const tool = state?.tool ?? toolCall.title ?? 'tool';
79
+ const fresh: CallState = state ?? { tool, kind: toolCall.kind, input: {}, shown: undefined, ended: false };
80
+ fresh.input = { ...fresh.input, ...asRecord(toolCall.rawInput) };
81
+ this.calls.set(toolCall.toolCallId, fresh);
82
+ const events = [...this.flushThought(), ...this.flushText('tool_use'), ...this.show(toolCall.toolCallId, fresh)];
83
+ return { call: fresh.shown ?? shownCall(tool, fresh.kind, fresh.input), events };
84
+ }
85
+
86
+ /** Close the calls still open (the prompt ended without their result). */
87
+ openCalls(): string[] {
88
+ return [...this.calls.entries()].flatMap(([id, state]) => (state.shown && !state.ended ? [id] : []));
89
+ }
90
+
91
+ private toolCall(update: AcpToolCall, first: boolean): AgentStepEvent[] {
92
+ let state = this.calls.get(update.toolCallId);
93
+ if (!state) {
94
+ if (!first && update.title === undefined) return [];
95
+ state = { tool: update.title ?? 'tool', kind: update.kind, input: {}, shown: undefined, ended: false };
96
+ this.calls.set(update.toolCallId, state);
97
+ }
98
+ if (this.isMoxxyTool(state.tool)) return [];
99
+ if (update.kind) state.kind = update.kind;
100
+ const raw = asRecord(update.rawInput);
101
+ if (Object.keys(raw).length > 0) state.input = { ...state.input, ...raw };
102
+
103
+ const events: AgentStepEvent[] = [];
104
+ const done = update.status === 'completed' || update.status === 'failed';
105
+ const started = update.status === 'in_progress' || done || (!first && update.status === undefined && Object.keys(raw).length > 0);
106
+ if (!state.shown && started) {
107
+ events.push(...this.flushThought(), ...this.flushText('tool_use'), ...this.show(update.toolCallId, state));
108
+ }
109
+ if (done && state.shown && !state.ended) {
110
+ state.ended = true;
111
+ const output = toolOutput(update);
112
+ if (SUBAGENT_TOOLS.has(state.tool)) {
113
+ // A subagent started in the background works on: its end comes later, from OpenCode's HTTP API.
114
+ if (update.status === 'completed' && BACKGROUND_TASK.test(output)) return events;
115
+ // OpenCode 2's `acp` serves no such API: nothing else would ever close it.
116
+ if (update.status === 'completed' && BACKGROUND_TASK_V2.test(output)) {
117
+ events.push({ type: 'subagent_completed', id: update.toolCallId, text: BACKGROUND_UNSEEN });
118
+ return events;
119
+ }
120
+ events.push(
121
+ update.status === 'completed'
122
+ ? { type: 'subagent_completed', id: update.toolCallId, text: taskResultOf(output) }
123
+ : { type: 'subagent_completed', id: update.toolCallId, text: '', error: output || 'the subagent failed' },
124
+ );
125
+ return events;
126
+ }
127
+ const diff = update.status === 'completed' ? fileDiffOf(state.tool, state.input, update.rawOutput, this.cwd) : undefined;
128
+ // OpenCode writes its whole todo list each time; the list it wrote is the result shown.
129
+ const steps = update.status === 'completed' && state.tool === 'todowrite' ? stepsFromTodos(state.input.todos) : undefined;
130
+ const images = diff || steps ? [] : toolImages(update);
131
+ const shown =
132
+ steps ? stepListOutput(output, steps)
133
+ : diff ? { forModel: output, display: diff }
134
+ : images.length > 0 ? imageToolOutput(output, images)
135
+ : output;
136
+ events.push(
137
+ update.status === 'completed'
138
+ ? { type: 'tool_result', callId: update.toolCallId, ok: true, output: shown }
139
+ : { type: 'tool_result', callId: update.toolCallId, ok: false, error: output || 'the tool failed' },
140
+ );
141
+ }
142
+ return events;
143
+ }
144
+
145
+ /** An ACP agent's plan (`entries` of `{ content, status }`), whole each time, shown as a call of its own. */
146
+ private plan(entries: unknown): AgentStepEvent[] {
147
+ const steps = stepsFromTodos(entries);
148
+ if (!steps) return [];
149
+ this.plans += 1;
150
+ // Unique in the conversation: a later prompt starts counting again.
151
+ const callId = `plan-${this.planPrefix}-${this.plans}`;
152
+ const forModel = steps.map((step) => `${step.status === 'completed' ? '[x]' : '[ ]'} ${step.text}`).join('\n');
153
+ return [
154
+ ...this.flushThought(),
155
+ ...this.flushText('tool_use'),
156
+ { type: 'tool_call', callId, name: 'update_plan', input: { entries } },
157
+ { type: 'tool_result', callId, ok: true, output: stepListOutput(forModel, steps) },
158
+ ];
159
+ }
160
+
161
+ private show(callId: string, state: CallState): AgentStepEvent[] {
162
+ if (state.shown || this.isMoxxyTool(state.tool)) return [];
163
+ state.shown = shownCall(state.tool, state.kind, state.input);
164
+ if (SUBAGENT_TOOLS.has(state.tool)) return [subagentStarted(callId, state.input)];
165
+ return [{ type: 'tool_call', callId, name: state.shown.name, input: state.shown.input }];
166
+ }
167
+
168
+ private isMoxxyTool(tool: string): boolean {
169
+ return tool.startsWith(this.moxxyToolPrefix);
170
+ }
171
+
172
+ private flushText(stopReason: 'tool_use' | 'end_turn'): AgentStepEvent[] {
173
+ const text = this.text.trim();
174
+ this.text = '';
175
+ return text ? [{ type: 'message', text, stopReason }] : [];
176
+ }
177
+
178
+ private flushThought(): AgentStepEvent[] {
179
+ const text = this.thought.trim();
180
+ this.thought = '';
181
+ return text ? [{ type: 'reasoning', text }] : [];
182
+ }
183
+ }
184
+
185
+ /** OpenCode's tool that hands a task to a subagent (`task` in OpenCode 1, `subagent` in 2); ACP reports the task and its answer, not the subagent's own calls. */
186
+ const SUBAGENT_TOOLS: ReadonlySet<string> = new Set(['task', 'subagent']);
187
+ /** OpenCode 1.19's answer to a `task` call with `background: true`: the subagent's session, still running. */
188
+ const BACKGROUND_TASK = /^<task id="[^"]+" state="running">/;
189
+ /** OpenCode 2's answer to a `subagent` call with `background: true` (2.1.4). */
190
+ const BACKGROUND_TASK_V2 = /^The subagent is working in the background \(sessionID: /;
191
+ const BACKGROUND_UNSEEN = 'Started in the background. OpenCode 2 does not report what it does there or when it ends.';
192
+
193
+ function subagentStarted(callId: string, input: Record<string, unknown>): AgentStepEvent {
194
+ const text = (key: string): string | undefined => (typeof input[key] === 'string' && input[key] ? (input[key] as string) : undefined);
195
+ const prompt = text('prompt');
196
+ return { type: 'subagent_started', id: callId, label: text('description') ?? 'subagent', agentType: text('subagent_type') ?? text('agent') ?? 'general', ...(prompt ? { prompt } : {}), toolsUnseen: true };
197
+ }
198
+
199
+ /** The subagent's answer inside OpenCode's `<task …><task_result>…</task_result></task>` wrapping. */
200
+ function taskResultOf(output: string): string {
201
+ const found = /<task_result>\s*([\s\S]*?)\s*<\/task_result>/.exec(output);
202
+ return found && found[1] !== undefined ? found[1] : output;
203
+ }
204
+
205
+ /** The agent's tool under the name Moxxy knows it by, with Moxxy's input keys. */
206
+ export function shownCall(tool: string, kind: AcpToolKind | undefined, input: Record<string, unknown>): AcpShownCall {
207
+ const pick = (pairs: ReadonlyArray<readonly [string, string]>): Record<string, unknown> =>
208
+ Object.fromEntries(pairs.flatMap(([from, to]) => (input[from] === undefined ? [] : [[to, input[from]]])));
209
+ switch (tool) {
210
+ // OpenCode 2 calls its shell tool `shell`.
211
+ case 'bash':
212
+ case 'shell':
213
+ return { name: 'Bash', input: pick([['command', 'command'], ['description', 'description'], ['timeout', 'timeout']]) };
214
+ case 'read':
215
+ return { name: 'Read', input: pick([['filePath', 'file_path'], ['offset', 'offset'], ['limit', 'limit']]) };
216
+ // An ask about a change names the file as `filepath` and carries its diff.
217
+ case 'write':
218
+ return { name: 'Write', input: pick([['filePath', 'file_path'], ['filepath', 'file_path'], ['content', 'content'], ['diff', 'diff']]) };
219
+ case 'edit':
220
+ return {
221
+ name: 'Edit',
222
+ input: pick([['filePath', 'file_path'], ['filepath', 'file_path'], ['oldString', 'old_string'], ['newString', 'new_string'], ['replaceAll', 'replace_all'], ['diff', 'diff']]),
223
+ };
224
+ case 'glob':
225
+ return { name: 'Glob', input: pick([['pattern', 'pattern'], ['path', 'path']]) };
226
+ case 'grep':
227
+ return { name: 'Grep', input: pick([['pattern', 'pattern'], ['path', 'path'], ['include', 'glob']]) };
228
+ case 'webfetch':
229
+ return { name: 'web_fetch', input: input };
230
+ case 'websearch':
231
+ return { name: 'web_search', input: input };
232
+ case 'todowrite':
233
+ return { name: 'TodoWrite', input: input };
234
+ case 'task':
235
+ case 'subagent':
236
+ return { name: 'Task', input: input };
237
+ case 'skill':
238
+ return { name: 'Skill', input: input };
239
+ case 'apply_patch':
240
+ case 'patch':
241
+ return { name: 'apply_patch', input: input };
242
+ default:
243
+ return { name: kind === 'execute' ? 'Bash' : tool, input: input };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * An OpenCode edit or new file as a diff. An edit's metadata has the unified
249
+ * diff (its `content` has only the replaced text, without line numbers); a
250
+ * write gives the content, and says whether the file `exists`ed before.
251
+ */
252
+ function fileDiffOf(tool: string, input: Record<string, unknown>, rawOutput: unknown, cwd: string): FileDiffDisplay | undefined {
253
+ const metadata = asRecord(asRecord(rawOutput).metadata);
254
+ const file = typeof input.filePath === 'string' ? input.filePath : undefined;
255
+ if (!file) return undefined;
256
+ const path = diffPathIn(cwd, file);
257
+ if (tool === 'edit' && typeof metadata.diff === 'string') {
258
+ const hunks = parseUnifiedDiff(metadata.diff);
259
+ return hunks.length > 0 ? fileDiffFromPatch({ path, mode: 'update', hunks }) : undefined;
260
+ }
261
+ if (tool === 'write' && metadata.exists === false && typeof input.content === 'string') {
262
+ return fileDiffFromText({ path, mode: 'create', before: '', after: input.content });
263
+ }
264
+ return undefined;
265
+ }
266
+
267
+ /** The images of a finished call (an image file OpenCode read, screenshots an MCP tool returned), in order. */
268
+ function toolImages(update: AcpToolCall): ToolImage[] {
269
+ return (update.content ?? []).flatMap((part) => {
270
+ if (part.type !== 'content') return [];
271
+ const block = part.content as { type: string; mimeType?: unknown; data?: unknown };
272
+ return block.type === 'image' && typeof block.mimeType === 'string' && typeof block.data === 'string' ? [{ mediaType: block.mimeType, base64: block.data }] : [];
273
+ });
274
+ }
275
+
276
+ function toolOutput(update: AcpToolCall): string {
277
+ const texts = (update.content ?? []).flatMap((part) => {
278
+ if (part.type === 'content' && part.content.type === 'text' && typeof part.content.text === 'string') return [part.content.text];
279
+ if (part.type === 'diff') return [`${part.path}\n${part.newText}`];
280
+ return [];
281
+ });
282
+ return texts.join('\n');
283
+ }
284
+
285
+ function textOf(content: unknown): string {
286
+ const block = content as { type?: unknown; text?: unknown } | undefined;
287
+ return block && block.type === 'text' && typeof block.text === 'string' ? block.text : '';
288
+ }
289
+
290
+ function asRecord(value: unknown): Record<string, unknown> {
291
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
292
+ }
293
+
294
+ export function toTokenUsage(usage: AcpUsage): TokenUsage {
295
+ return {
296
+ inputTokens: usage.inputTokens ?? 0,
297
+ outputTokens: (usage.outputTokens ?? 0) + (usage.thoughtTokens ?? 0),
298
+ ...(usage.cachedReadTokens ? { cacheReadTokens: usage.cachedReadTokens } : {}),
299
+ ...(usage.cachedWriteTokens ? { cacheCreationTokens: usage.cachedWriteTokens } : {}),
300
+ };
301
+ }
302
+
303
+ /** The person's message as ACP content: text, images, and documents as embedded resources. */
304
+ export function toAcpPrompt(prompt: ReadonlyArray<ContentBlock>): AcpContentBlock[] {
305
+ return prompt.flatMap((block): AcpContentBlock[] => {
306
+ if (block.type === 'text') return block.text ? [{ type: 'text', text: block.text }] : [];
307
+ if (block.type === 'image') return [{ type: 'image', mimeType: block.mediaType, data: block.data }];
308
+ if (block.type === 'document') {
309
+ const name = block.name ?? 'document';
310
+ return [{ type: 'resource', resource: { uri: `attachment:///${encodeURIComponent(name)}`, mimeType: block.mediaType, blob: block.data } }];
311
+ }
312
+ return [];
313
+ });
314
+ }
315
+
316
+ export function promptText(prompt: ReadonlyArray<ContentBlock>): string {
317
+ return prompt.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n');
318
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.