@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,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { OpenCodeEvent } from './opencode-api.js';
3
+ import { OpenCodeHelpers } from './opencode-helpers.js';
4
+
5
+ const PARENT = 'ses_parent';
6
+ const CHILD = 'ses_child';
7
+
8
+ const event = (type: string, properties: Record<string, unknown>): OpenCodeEvent => ({ type, properties });
9
+ const taskPart = (background: boolean) =>
10
+ event('message.part.updated', {
11
+ sessionID: PARENT,
12
+ part: {
13
+ type: 'tool',
14
+ tool: 'task',
15
+ callID: 'call_1',
16
+ sessionID: PARENT,
17
+ state: { status: 'running', title: 'inspect', input: { description: 'inspect', prompt: 'p', ...(background ? { background: true } : {}) }, metadata: { sessionId: CHILD, ...(background ? { background: true } : {}) } },
18
+ },
19
+ });
20
+ const childText = (id: string, text: string, finished = true) =>
21
+ event('message.part.updated', { sessionID: CHILD, part: { type: 'text', id, sessionID: CHILD, text, ...(finished ? { time: { start: 1, end: 2 } } : {}) } });
22
+ const childIdle = event('session.status', { sessionID: CHILD, status: { type: 'idle' } });
23
+
24
+ describe("OpenCode's subagents, read from its events", () => {
25
+ it('ends a background subagent that failed with the reason OpenCode gives', () => {
26
+ const helpers = new OpenCodeHelpers(() => PARENT);
27
+ helpers.take(taskPart(true));
28
+ helpers.take(event('session.error', { sessionID: CHILD, error: { name: 'APIError', data: { message: 'rate limited' } } }));
29
+
30
+ expect(helpers.take(childIdle)).toEqual({ steps: [{ type: 'subagent_completed', id: 'call_1', text: '', error: 'rate limited' }], workChanged: true });
31
+ expect(helpers.running()).toEqual([]);
32
+ });
33
+
34
+ it('keeps what a subagent did before the task call named its session, and shows it once named', () => {
35
+ const helpers = new OpenCodeHelpers(() => PARENT);
36
+ helpers.take(event('session.created', { sessionID: CHILD, info: { id: CHILD, parentID: PARENT } }));
37
+ helpers.take(childText('prt_task', 'the task itself', false));
38
+ helpers.take(childText('prt_1', 'Looking.'));
39
+
40
+ expect(helpers.take(taskPart(true))).toEqual({ steps: [{ type: 'subagent_message', id: 'call_1', text: 'Looking.' }], workChanged: true });
41
+ });
42
+
43
+ it("ignores other conversations' sessions", () => {
44
+ const helpers = new OpenCodeHelpers(() => PARENT);
45
+ helpers.take(taskPart(true));
46
+
47
+ expect(helpers.take(event('message.part.updated', { sessionID: 'ses_other', part: { type: 'text', id: 'x', text: 'hi', time: { end: 1 } } }))).toEqual({ steps: [], workChanged: false });
48
+ expect(helpers.running()).toEqual([{ id: 'call_1', kind: 'agent', label: 'inspect' }]);
49
+ });
50
+
51
+ it('leaves the end of a subagent the turn waits for to its task call', () => {
52
+ const helpers = new OpenCodeHelpers(() => PARENT);
53
+ helpers.take(taskPart(false));
54
+
55
+ expect(helpers.running()).toEqual([]);
56
+ expect(helpers.take(childIdle).steps).toEqual([]);
57
+ });
58
+
59
+ it('names the background work OpenCode prompted the conversation about, and whether it failed', () => {
60
+ const helpers = new OpenCodeHelpers(() => PARENT);
61
+ helpers.take(taskPart(true));
62
+ const result = event('message.part.updated', { sessionID: PARENT, part: { type: 'text', synthetic: true, text: `<task id="${CHILD}" state="error">\n<summary>Background task failed: inspect</summary></task>` } });
63
+
64
+ expect(helpers.take(result).finished).toEqual({ workId: 'call_1', label: 'inspect', failed: true });
65
+ });
66
+
67
+ it('stops only background work that still runs', () => {
68
+ const helpers = new OpenCodeHelpers(() => PARENT);
69
+ helpers.take(taskPart(true));
70
+
71
+ expect(helpers.stop('call_9')).toBeUndefined();
72
+ expect(helpers.stop('call_1')).toBe(CHILD);
73
+ expect(helpers.take(childIdle).steps).toEqual([{ type: 'subagent_completed', id: 'call_1', text: '', stopped: true }]);
74
+ expect(helpers.stop('call_1')).toBeUndefined();
75
+ });
76
+ });
@@ -0,0 +1,195 @@
1
+ import type { AgentBackgroundWork, AgentStepEvent } from '@moxxy/sdk';
2
+ import type { OpenCodeEvent } from './opencode-api.js';
3
+ import { shownCall } from './translate.js';
4
+
5
+ /** What one OpenCode event meant for the conversation. */
6
+ export interface HelperNews {
7
+ /** What subagents did: their tools, what they said, how they ended. */
8
+ readonly steps: AgentStepEvent[];
9
+ /** The background work list changed. */
10
+ readonly workChanged: boolean;
11
+ /** OpenCode prompted the conversation's session itself with a background subagent's result: it starts a turn of its own. */
12
+ readonly finished?: { readonly workId: string; readonly label: string; readonly failed: boolean };
13
+ /** The conversation's session started or stopped working. */
14
+ readonly status?: 'busy' | 'idle';
15
+ /** OpenCode waits for the person's account limit (its free tier, its Go plan) to reset before it tries again. */
16
+ readonly limitReached?: true;
17
+ }
18
+
19
+ interface Helper {
20
+ /** The `task` call that started it: the id of its `subagent_*` events and of its background work. */
21
+ readonly callId: string;
22
+ readonly label: string;
23
+ readonly background: boolean;
24
+ /** Its last finished text, its answer once it ends. */
25
+ said: string;
26
+ error: string | undefined;
27
+ stopping: boolean;
28
+ done: boolean;
29
+ /** Its tool parts already shown. */
30
+ readonly shownTools: Set<string>;
31
+ readonly shownTexts: Set<string>;
32
+ }
33
+
34
+ /** Events of a subagent session OpenCode reported before the `task` call naming it; kept until it does. */
35
+ const MAX_EARLY_EVENTS = 64;
36
+ const TASK_TOOL = 'task';
37
+ const ABORTED = 'MessageAbortedError';
38
+ const TASK_RESULT = /^<task id="([^"]+)" state="([a-z]+)">/;
39
+ /** Why OpenCode 1.19 retries when an account limit, not the provider, holds it up. */
40
+ const LIMIT_REASONS = new Set(['free_tier_limit', 'account_rate_limit']);
41
+
42
+ const quiet: HelperNews = { steps: [], workChanged: false };
43
+
44
+ /**
45
+ * The subagents OpenCode runs in one conversation's session, read from its
46
+ * HTTP API's events: ACP reports a `task` call and its answer, not what the
47
+ * subagent does, nor the end of one working in the background (OpenCode 1.19,
48
+ * `task` with `background: true`). Each subagent works in a session of its
49
+ * own, named by the conversation's `task` tool part.
50
+ */
51
+ export class OpenCodeHelpers {
52
+ /** By the subagent's session id. */
53
+ private readonly helpers = new Map<string, Helper>();
54
+ private readonly early = new Map<string, OpenCodeEvent[]>();
55
+
56
+ constructor(private readonly parent: () => string) {}
57
+
58
+ take(event: OpenCodeEvent): HelperNews {
59
+ const session = sessionOf(event);
60
+ if (!session) return quiet;
61
+ if (session === this.parent()) return this.fromParent(event);
62
+ if (event.type === 'session.created') {
63
+ const info = record(event.properties.info);
64
+ if (info.parentID === this.parent() && !this.helpers.has(session)) this.early.set(session, []);
65
+ return quiet;
66
+ }
67
+ const helper = this.helpers.get(session);
68
+ if (!helper) {
69
+ const kept = this.early.get(session);
70
+ if (kept && kept.length < MAX_EARLY_EVENTS) kept.push(event);
71
+ return quiet;
72
+ }
73
+ return this.fromHelper(helper, event);
74
+ }
75
+
76
+ /** What runs in the background now. */
77
+ running(): AgentBackgroundWork[] {
78
+ return [...this.helpers.values()].flatMap((helper) => (helper.background && !helper.done ? [{ id: helper.callId, kind: 'agent' as const, label: helper.label }] : []));
79
+ }
80
+
81
+ /** Whether the subagent of the `task` call `callId` works in the background. */
82
+ inBackground(callId: string): boolean {
83
+ for (const helper of this.helpers.values()) if (helper.callId === callId) return helper.background;
84
+ return false;
85
+ }
86
+
87
+ /** The session of the background subagent `workId` that still works, marked as being stopped by the person. */
88
+ stop(workId: string): string | undefined {
89
+ for (const [session, helper] of this.helpers) {
90
+ if (helper.callId !== workId || !helper.background || helper.done) continue;
91
+ helper.stopping = true;
92
+ return session;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /** Stopping it failed: its end is reported as it comes. */
98
+ unstop(workId: string): void {
99
+ for (const helper of this.helpers.values()) if (helper.callId === workId) helper.stopping = false;
100
+ }
101
+
102
+ /** The OpenCode running them is gone, and they with it. */
103
+ clear(): void {
104
+ this.helpers.clear();
105
+ this.early.clear();
106
+ }
107
+
108
+ private fromParent(event: OpenCodeEvent): HelperNews {
109
+ if (event.type === 'session.status') {
110
+ const status = record(event.properties.status);
111
+ if (status.type === 'retry') return { ...quiet, status: 'busy', ...(LIMIT_REASONS.has(String(record(status.action).reason)) ? { limitReached: true as const } : {}) };
112
+ return status.type === 'idle' || status.type === 'busy' ? { ...quiet, status: status.type } : quiet;
113
+ }
114
+ if (event.type !== 'message.part.updated') return quiet;
115
+ const part = record(event.properties.part);
116
+ if (part.type === 'tool' && part.tool === TASK_TOOL && typeof part.callID === 'string') return this.named(part.callID, record(part.state));
117
+ if (part.type === 'text' && part.synthetic === true && typeof part.text === 'string') {
118
+ const result = TASK_RESULT.exec(part.text);
119
+ const helper = result?.[1] ? this.helpers.get(result[1]) : undefined;
120
+ if (helper?.background) return { ...quiet, finished: { workId: helper.callId, label: helper.label, failed: result?.[2] !== 'completed' } };
121
+ }
122
+ return quiet;
123
+ }
124
+
125
+ /** The conversation's `task` call names its subagent's session once it runs. */
126
+ private named(callId: string, state: Record<string, unknown>): HelperNews {
127
+ const metadata = record(state.metadata);
128
+ const session = typeof metadata.sessionId === 'string' ? metadata.sessionId : undefined;
129
+ if (!session || this.helpers.has(session)) return quiet;
130
+ const input = record(state.input);
131
+ const label = text(input.description) ?? text(state.title) ?? 'subagent';
132
+ const background = metadata.background === true || input.background === true;
133
+ const helper: Helper = { callId, label, background, said: '', error: undefined, stopping: false, done: false, shownTools: new Set(), shownTexts: new Set() };
134
+ this.helpers.set(session, helper);
135
+ const early = this.early.get(session) ?? [];
136
+ this.early.delete(session);
137
+ const steps = early.flatMap((event) => this.fromHelper(helper, event).steps);
138
+ return { ...quiet, steps, workChanged: background };
139
+ }
140
+
141
+ private fromHelper(helper: Helper, event: OpenCodeEvent): HelperNews {
142
+ if (helper.done) return quiet;
143
+ if (event.type === 'session.error') {
144
+ const error = record(event.properties.error);
145
+ if (error.name === ABORTED) helper.stopping = true;
146
+ else helper.error = text(record(error.data).message) ?? text(error.name) ?? 'the subagent failed';
147
+ return quiet;
148
+ }
149
+ if (event.type === 'session.status') {
150
+ // A subagent the turn waits for ends with its `task` call, which ACP reports.
151
+ if (record(event.properties.status).type !== 'idle' || !helper.background) return quiet;
152
+ helper.done = true;
153
+ const end: AgentStepEvent = helper.stopping
154
+ ? { type: 'subagent_completed', id: helper.callId, text: helper.said, stopped: true }
155
+ : { type: 'subagent_completed', id: helper.callId, text: helper.said, ...(helper.error ? { error: helper.error } : {}) };
156
+ return { ...quiet, steps: [end], workChanged: true };
157
+ }
158
+ if (event.type !== 'message.part.updated') return quiet;
159
+ const part = record(event.properties.part);
160
+ const partId = text(part.id);
161
+ if (!partId) return quiet;
162
+ if (part.type === 'tool' && typeof part.tool === 'string') {
163
+ const state = record(part.state);
164
+ const input = record(state.input);
165
+ if (helper.shownTools.has(partId) || state.status === 'pending' || Object.keys(input).length === 0) return quiet;
166
+ helper.shownTools.add(partId);
167
+ const shown = shownCall(part.tool, undefined, input);
168
+ return { ...quiet, steps: [{ type: 'subagent_tool_call', id: helper.callId, name: shown.name, input: shown.input }] };
169
+ }
170
+ // What the subagent said, once a part of it is finished; its task, the first message of its session, has no time.
171
+ if (part.type === 'text' && part.synthetic !== true && record(part.time).end !== undefined) {
172
+ const said = text(part.text)?.trim();
173
+ if (!said || helper.shownTexts.has(partId)) return quiet;
174
+ helper.shownTexts.add(partId);
175
+ helper.said = said;
176
+ return { ...quiet, steps: [{ type: 'subagent_message', id: helper.callId, text: said }] };
177
+ }
178
+ return quiet;
179
+ }
180
+ }
181
+
182
+ function sessionOf(event: OpenCodeEvent): string | undefined {
183
+ const { sessionID } = event.properties;
184
+ if (typeof sessionID === 'string') return sessionID;
185
+ const fromPart = record(event.properties.part).sessionID;
186
+ return typeof fromPart === 'string' ? fromPart : undefined;
187
+ }
188
+
189
+ function record(value: unknown): Record<string, unknown> {
190
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
191
+ }
192
+
193
+ function text(value: unknown): string | undefined {
194
+ return typeof value === 'string' && value ? value : undefined;
195
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The part of the Agent Client Protocol (ACP, protocol version 1) Moxxy
3
+ * speaks, written by hand from recorded `opencode acp` 1.18.35 sessions.
4
+ * Agents send more than this; anything not listed here is left alone.
5
+ */
6
+
7
+ export const ACP_PROTOCOL_VERSION = 1;
8
+
9
+ export interface AcpSelectOption {
10
+ readonly value: string;
11
+ readonly name?: string;
12
+ }
13
+
14
+ /** A session setting the agent offers (`model`, `mode`, `effort`), changed with `session/set_config_option`. */
15
+ export interface AcpConfigOption {
16
+ readonly id: string;
17
+ readonly name?: string;
18
+ /** `model`, `mode`, `thought_level`, … */
19
+ readonly category?: string;
20
+ readonly type: string;
21
+ readonly currentValue: string;
22
+ readonly options?: ReadonlyArray<AcpSelectOption>;
23
+ }
24
+
25
+ /** What `initialize` answers, as far as Moxxy reads it. */
26
+ export interface AcpInitializeResponse {
27
+ readonly agentCapabilities?: { readonly sessionCapabilities?: { readonly list?: object } };
28
+ }
29
+
30
+ /** One page of `session/list`. */
31
+ export interface AcpSessionList {
32
+ readonly sessions: ReadonlyArray<{ readonly sessionId: string; readonly cwd?: string }>;
33
+ readonly nextCursor?: string;
34
+ }
35
+
36
+ export interface AcpSessionResponse {
37
+ readonly sessionId?: string;
38
+ readonly configOptions?: ReadonlyArray<AcpConfigOption>;
39
+ }
40
+
41
+ export type AcpStopReason = 'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled';
42
+
43
+ export interface AcpUsage {
44
+ /** Prompt tokens not served from the cache. */
45
+ readonly inputTokens?: number;
46
+ readonly outputTokens?: number;
47
+ readonly thoughtTokens?: number;
48
+ readonly cachedReadTokens?: number;
49
+ readonly cachedWriteTokens?: number;
50
+ readonly totalTokens?: number;
51
+ }
52
+
53
+ export interface AcpPromptResponse {
54
+ readonly stopReason: AcpStopReason;
55
+ readonly usage?: AcpUsage;
56
+ }
57
+
58
+ export type AcpContentBlock =
59
+ | { readonly type: 'text'; readonly text: string }
60
+ | { readonly type: 'image'; readonly mimeType: string; readonly data: string }
61
+ | {
62
+ readonly type: 'resource';
63
+ readonly resource: { readonly uri: string; readonly mimeType?: string; readonly text?: string; readonly blob?: string };
64
+ };
65
+
66
+ export type AcpToolKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
67
+
68
+ export type AcpToolStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
69
+
70
+ export type AcpToolCallContent =
71
+ | { readonly type: 'content'; readonly content: { readonly type: string; readonly text?: string } }
72
+ | { readonly type: 'diff'; readonly path: string; readonly oldText?: string | null; readonly newText: string }
73
+ | { readonly type: 'terminal'; readonly terminalId: string };
74
+
75
+ /** A `tool_call` or `tool_call_update`: each update carries only what changed. */
76
+ export interface AcpToolCall {
77
+ readonly toolCallId: string;
78
+ readonly title?: string;
79
+ readonly kind?: AcpToolKind;
80
+ readonly status?: AcpToolStatus;
81
+ readonly rawInput?: unknown;
82
+ readonly rawOutput?: unknown;
83
+ readonly content?: ReadonlyArray<AcpToolCallContent>;
84
+ readonly locations?: ReadonlyArray<{ readonly path: string }>;
85
+ }
86
+
87
+ export type AcpSessionUpdate =
88
+ | { readonly sessionUpdate: 'agent_message_chunk'; readonly messageId?: string; readonly content: { readonly type: string; readonly text?: string } }
89
+ | { readonly sessionUpdate: 'agent_thought_chunk'; readonly messageId?: string; readonly content: { readonly type: string; readonly text?: string } }
90
+ | ({ readonly sessionUpdate: 'tool_call' } & AcpToolCall)
91
+ | ({ readonly sessionUpdate: 'tool_call_update' } & AcpToolCall)
92
+ | { readonly sessionUpdate: string; readonly [key: string]: unknown };
93
+
94
+ export type AcpPermissionOptionKind = 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
95
+
96
+ export interface AcpPermissionRequest {
97
+ readonly sessionId: string;
98
+ readonly toolCall: AcpToolCall;
99
+ readonly options: ReadonlyArray<{ readonly optionId: string; readonly kind: AcpPermissionOptionKind; readonly name?: string }>;
100
+ }
101
+
102
+ export type AcpPermissionOutcome =
103
+ | { readonly outcome: { readonly outcome: 'selected'; readonly optionId: string } }
104
+ | { readonly outcome: { readonly outcome: 'cancelled' } };
@@ -0,0 +1,107 @@
1
+ /** OpenCode's permission rules as `opencode debug agent <name>` reports them, read and turned into asks. */
2
+ import { describe, expect, it } from 'vitest';
3
+ import { askingConfig, askingRules, asksOutsideProject, openCodeOwnAutoApprove, runsWithoutAsking, type OpenCodeRule } from './rules.js';
4
+
5
+ const rule = (permission: string, pattern: string, action: OpenCodeRule['action']): OpenCodeRule => ({ permission, pattern, action });
6
+
7
+ /** OpenCode 1.18's defaults for the build agent, abridged. */
8
+ const DEFAULTS: OpenCodeRule[] = [
9
+ rule('*', '*', 'allow'),
10
+ rule('doom_loop', '*', 'ask'),
11
+ rule('external_directory', '*', 'ask'),
12
+ rule('read', '*', 'allow'),
13
+ rule('read', '*.env', 'ask'),
14
+ ];
15
+
16
+ describe('reading how OpenCode runs', () => {
17
+ it('runs without asking on its defaults', () => {
18
+ expect(runsWithoutAsking(DEFAULTS)).toBe(true);
19
+ });
20
+
21
+ it('asks when the person set it to ask before edits or commands', () => {
22
+ expect(runsWithoutAsking([...DEFAULTS, rule('edit', '*', 'ask')])).toBe(false);
23
+ expect(runsWithoutAsking([...DEFAULTS, rule('bash', '*', 'ask')])).toBe(false);
24
+ });
25
+
26
+ it('counts only the rule for any input, not one for a single command', () => {
27
+ expect(runsWithoutAsking([...DEFAULTS, rule('bash', 'git push *', 'ask')])).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe('the rules that make OpenCode ask', () => {
32
+ it('turns what OpenCode allows into asks, before edits, commands and web requests', () => {
33
+ expect(askingRules(DEFAULTS)).toEqual({ edit: { '*': 'ask' }, bash: { '*': 'ask' }, webfetch: 'ask', websearch: 'ask' });
34
+ });
35
+
36
+ it('keeps what the person denied or set to ask, in their order', () => {
37
+ const rules = [...DEFAULTS, rule('bash', '*', 'allow'), rule('bash', 'git push *', 'deny'), rule('bash', 'git status', 'allow'), rule('webfetch', '*', 'deny')];
38
+
39
+ expect(askingRules(rules)).toEqual({
40
+ edit: { '*': 'ask' },
41
+ bash: { '*': 'ask', 'git push *': 'deny', 'git status': 'ask' },
42
+ webfetch: 'deny',
43
+ websearch: 'ask',
44
+ });
45
+ expect(Object.keys((askingRules(rules) as { bash: object }).bash)).toEqual(['*', 'git push *', 'git status']);
46
+ });
47
+
48
+ it('keeps a later rule for the same pattern, in its later place', () => {
49
+ const rules = [...DEFAULTS, rule('bash', 'rm *', 'deny'), rule('bash', 'ls', 'allow'), rule('bash', 'rm *', 'allow')];
50
+
51
+ expect(Object.entries((askingRules(rules) as { bash: object }).bash)).toEqual([
52
+ ['*', 'ask'],
53
+ ['ls', 'ask'],
54
+ ['rm *', 'ask'],
55
+ ]);
56
+ });
57
+
58
+ it('leaves edits denied where the agent denies them (OpenCode plan)', () => {
59
+ expect((askingRules([...DEFAULTS, rule('edit', '*', 'deny')]) as { edit: unknown }).edit).toEqual({ '*': 'deny' });
60
+ });
61
+
62
+ it('hands them to the agent the person runs, for this OpenCode only', () => {
63
+ expect(JSON.parse(askingConfig('build', DEFAULTS))).toEqual({ agent: { build: { permission: askingRules(DEFAULTS) } } });
64
+ });
65
+
66
+ it('keeps the inline config the person already passes OpenCode', () => {
67
+ const inline = JSON.stringify({ model: 'openai/gpt-6-luna', agent: { build: { temperature: 0.2, permission: { bash: 'allow' } }, review: { mode: 'subagent' } } });
68
+
69
+ expect(JSON.parse(askingConfig('build', DEFAULTS, inline))).toEqual({
70
+ model: 'openai/gpt-6-luna',
71
+ agent: { build: { temperature: 0.2, permission: askingRules(DEFAULTS) }, review: { mode: 'subagent' } },
72
+ });
73
+ });
74
+ });
75
+
76
+ describe('asks about work outside the project', () => {
77
+ it('knows them by the folder a file or a command names', () => {
78
+ expect(asksOutsideProject({ filepath: '/elsewhere/a.txt', parentDir: '/elsewhere' })).toBe(true);
79
+ expect(asksOutsideProject({ command: 'ls /elsewhere', directories: ['/elsewhere'], patterns: ['/elsewhere/*'] })).toBe(true);
80
+ });
81
+
82
+ it('leaves every other ask alone', () => {
83
+ expect(asksOutsideProject({ filepath: '/work/app/a.txt', diff: '+x' })).toBe(false);
84
+ expect(asksOutsideProject({ command: 'git push' })).toBe(false);
85
+ expect(asksOutsideProject(undefined)).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("the auto-approve of the person's own OpenCode", () => {
90
+ it("is read from the build agent's rules in the conversation folder: on when they ask for nothing", async () => {
91
+ const read: Array<{ cwd: string; agent: string }> = [];
92
+ const own = (rules: OpenCodeRule[] | undefined) =>
93
+ openCodeOwnAutoApprove('/work/app', async (opts) => {
94
+ read.push(opts);
95
+ return rules;
96
+ });
97
+
98
+ expect(await own(DEFAULTS)).toBe(true);
99
+ expect(await own([...DEFAULTS, rule('edit', '*', 'ask')])).toBe(false);
100
+ expect(await own(undefined)).toBeUndefined();
101
+ expect(read[0]).toEqual({ cwd: '/work/app', agent: 'build' });
102
+ });
103
+
104
+ it('is unknown when OpenCode cannot be asked', async () => {
105
+ expect(await openCodeOwnAutoApprove('/work/app', async () => Promise.reject(new Error('spawn opencode ENOENT')))).toBeUndefined();
106
+ });
107
+ });
package/src/rules.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * OpenCode's permission rules, as `opencode debug agent <name>` reports them:
3
+ * an ordered list in which the last rule matching a permission and input wins.
4
+ */
5
+ export interface OpenCodeRule {
6
+ readonly permission: string;
7
+ readonly pattern: string;
8
+ readonly action: 'allow' | 'ask' | 'deny';
9
+ }
10
+
11
+ type Action = OpenCodeRule['action'];
12
+
13
+ /** Reads the rules of one of the person's OpenCode agents; `undefined` when OpenCode cannot say. */
14
+ export type ReadAgentRules = (opts: { readonly cwd: string; readonly agent: string }) => Promise<ReadonlyArray<OpenCodeRule> | undefined>;
15
+
16
+ /** Reads the names of the person's own MCP servers that are on; `undefined` when the agent cannot say. */
17
+ export type ReadAgentMcpServers = (opts: { readonly cwd: string }) => Promise<ReadonlyArray<string> | undefined>;
18
+
19
+ /** Permissions whose rules take an input pattern (a command, a path). */
20
+ const PATTERNED = ['edit', 'bash'] as const;
21
+ /** Permissions with one action for every input. */
22
+ const UNPATTERNED = ['webfetch', 'websearch'] as const;
23
+
24
+ /** Whether OpenCode edits files and runs commands without asking anyone. */
25
+ export function runsWithoutAsking(rules: ReadonlyArray<OpenCodeRule>): boolean {
26
+ return PATTERNED.every((permission) => anyInput(rules, permission) === 'allow');
27
+ }
28
+
29
+ /**
30
+ * Whether the person's own OpenCode runs without asking in `cwd`, from its
31
+ * default `build` agent's rules; `undefined` when OpenCode cannot be asked.
32
+ */
33
+ export async function openCodeOwnAutoApprove(cwd: string, readRules: ReadAgentRules): Promise<boolean | undefined> {
34
+ const rules = await readRules({ cwd, agent: 'build' }).catch(() => undefined);
35
+ return rules ? runsWithoutAsking(rules) : undefined;
36
+ }
37
+
38
+ /**
39
+ * The permission config that makes OpenCode ask before what it would do on its
40
+ * own. Each permission's rules are repeated in their order with every allow
41
+ * turned into an ask, so what the person denied stays denied. Given as the
42
+ * agent's own permissions, they come after every other rule and win.
43
+ */
44
+ export function askingRules(rules: ReadonlyArray<OpenCodeRule>): Record<string, Action | Record<string, Action>> {
45
+ const config: Record<string, Action | Record<string, Action>> = {};
46
+ for (const permission of PATTERNED) {
47
+ const patterns = new Map<string, Action>();
48
+ for (const rule of rules) {
49
+ if (rule.permission !== permission && !(rule.pattern === '*' && matches(rule.permission, permission))) continue;
50
+ // An earlier rule for the same pattern never wins over this one.
51
+ patterns.delete(rule.pattern);
52
+ patterns.set(rule.pattern, asked(rule.action));
53
+ }
54
+ if (!patterns.has('*')) patterns.set('*', 'ask');
55
+ config[permission] = Object.fromEntries(patterns);
56
+ }
57
+ for (const permission of UNPATTERNED) config[permission] = asked(anyInput(rules, permission) ?? 'allow');
58
+ return config;
59
+ }
60
+
61
+ /**
62
+ * `OPENCODE_CONFIG_CONTENT` for an OpenCode that asks: the agent's permissions,
63
+ * nothing written to disk. Inline config the person already passes is kept;
64
+ * its rules are already among those read, so this agent's permissions replace them.
65
+ */
66
+ export function askingConfig(agent: string, rules: ReadonlyArray<OpenCodeRule>, inline?: string): string {
67
+ let config: { agent?: Record<string, Record<string, unknown>> } & Record<string, unknown> = {};
68
+ try {
69
+ const parsed: unknown = inline ? JSON.parse(inline) : {};
70
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) config = parsed as typeof config;
71
+ } catch {
72
+ // OpenCode skips inline config it cannot read; so does Moxxy.
73
+ }
74
+ const agents = config.agent ?? {};
75
+ return JSON.stringify({ ...config, agent: { ...agents, [agent]: { ...agents[agent], permission: askingRules(rules) } } });
76
+ }
77
+
78
+ /**
79
+ * Whether an ask is OpenCode's `external_directory`, which it puts to the
80
+ * person even on its defaults: about a file it names the parent folder
81
+ * (`parentDir`), about a command the folders (`directories`).
82
+ */
83
+ export function asksOutsideProject(rawInput: unknown): boolean {
84
+ if (typeof rawInput !== 'object' || rawInput === null) return false;
85
+ const input = rawInput as Record<string, unknown>;
86
+ return typeof input.parentDir === 'string' || Array.isArray(input.directories);
87
+ }
88
+
89
+ /** The action for an input no narrower rule names. */
90
+ function anyInput(rules: ReadonlyArray<OpenCodeRule>, permission: string): Action | undefined {
91
+ return rules.findLast((rule) => rule.pattern === '*' && matches(rule.permission, permission))?.action;
92
+ }
93
+
94
+ function asked(action: Action): Action {
95
+ return action === 'allow' ? 'ask' : action;
96
+ }
97
+
98
+ function matches(pattern: string, name: string): boolean {
99
+ if (!pattern.includes('*')) return pattern === name;
100
+ const source = pattern.split('*').map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
101
+ return new RegExp(`^${source}$`).test(name);
102
+ }