integrations/agent-plugins/opencode/index.js
1
import { spawn } from "node:child_process";
2
import { readFileSync } from "node:fs";
4
const packageVersion = JSON.parse(
5
readFileSync(new URL("./package.json", import.meta.url), "utf8")
6
).version;
8
export function connectMCP(child) {
9
const pending = new Map();
10
let nextID = 1;
11
let buffer = "";
12
let terminalError;
14
function failConnection(error) {
15
if (terminalError) return;
16
terminalError = error;
17
for (const waiter of pending.values()) waiter.reject(error);
18
pending.clear();
19
}
21
child.stderr.on("data", (chunk) => {
22
const text = chunk.toString("utf8");
23
for (const line of text.split("\n")) {
24
if (line) process.stderr.write(`[koment-mcp] ${line}\n`);
25
}
26
});
28
child.stdout.on("data", (chunk) => {
29
buffer += chunk.toString("utf8");
30
let newline;
31
while ((newline = buffer.indexOf("\n")) !== -1) {
32
const line = buffer.slice(0, newline);
33
buffer = buffer.slice(newline + 1);
34
if (!line) continue;
35
let message;
36
try {
37
message = JSON.parse(line);
38
} catch (error) {
39
failConnection(new Error("koment MCP emitted invalid JSON", { cause: error }));
40
child.kill();
41
return;
42
}
43
if (!("id" in message)) {
44
continue;
45
}
46
const id = message.id;
47
const waiter = pending.get(id);
48
if (!waiter) {
49
failConnection(new Error(`koment MCP returned unexpected response id ${id}`));
50
child.kill();
51
return;
52
}
53
pending.delete(id);
54
if (message.error) {
55
waiter.reject(new Error(message.error.message || "mcp error"));
56
} else {
57
waiter.resolve(message.result ?? {});
58
}
59
}
60
});
62
child.on("error", failConnection);
63
child.stdin.on("error", failConnection);
64
child.stdout.on("error", failConnection);
65
child.stderr.on("error", failConnection);
66
child.on("exit", (code, signal) => {
67
const outcome = signal ? `signal=${signal}` : `code=${code}`;
68
failConnection(new Error(`koment mcp exited (${outcome})`));
69
});
71
function request(method, params) {
72
if (terminalError) return Promise.reject(terminalError);
73
const id = nextID++;
74
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params: params || {} }) + "\n";
75
return new Promise((resolve, reject) => {
76
pending.set(id, { resolve, reject });
77
child.stdin.write(payload, (err) => {
78
if (err) failConnection(err);
79
});
80
});
81
}
83
function notify(method, params) {
84
if (terminalError) return Promise.reject(terminalError);
85
const payload = JSON.stringify({ jsonrpc: "2.0", method, params: params || {} }) + "\n";
86
return new Promise((resolve, reject) => {
87
child.stdin.write(payload, (error) => {
88
if (error) {
89
failConnection(error);
90
reject(error);
91
return;
92
}
93
resolve();
94
});
95
});
96
}
98
return {
99
child,
100
request,
101
initialize() {
102
return request("initialize", {
103
protocolVersion: "2024-11-05",
104
capabilities: {},
105
clientInfo: { name: "@koment/opencode-koment", version: packageVersion },
106
});
107
},
108
notify,
109
async callTool(name, args) {
110
const result = await request("tools/call", { name, arguments: args || {} });
111
if (Array.isArray(result.content)) {
112
for (const block of result.content) {
113
if (block && block.type === "text" && block.text) {
114
process.stderr.write(`[koment-mcp:${name}] ${block.text}\n`);
115
}
116
}
117
}
118
return result;
119
},
120
close() {
121
if (terminalError) return Promise.reject(terminalError);
122
return new Promise((resolve, reject) => {
123
child.stdin.end((error) => {
124
if (error) reject(error);
125
else resolve();
126
});
127
});
128
},
129
};
130
}
132
function startMCP(directory) {
133
return connectMCP(
134
spawn("koment", ["mcp", "--write"], {
135
cwd: directory,
136
env: process.env,
137
stdio: ["pipe", "pipe", "pipe"],
138
})
139
);
140
}
142
function run(cmd, args, { cwd, stdin } = {}) {
143
return new Promise((resolve, reject) => {
144
const child = spawn(cmd, args, {
145
cwd,
146
env: process.env,
147
stdio: ["pipe", "pipe", "pipe"],
148
});
149
let stdout = "";
150
let stderr = "";
151
child.stdout.on("data", (chunk) => (stdout += chunk.toString("utf8")));
152
child.stderr.on("data", (chunk) => (stderr += chunk.toString("utf8")));
153
child.on("error", reject);
154
if (stdin !== undefined && stdin !== null) {
155
child.stdin.end(stdin);
156
} else {
157
child.stdin.end();
158
}
159
child.on("close", (code) => {
160
if (code === 0) resolve({ stdout, stderr });
161
else {
162
const error = new Error("koment " + args.join(" ") + " exited with code " + code);
163
error.stdout = stdout;
164
error.stderr = stderr;
165
error.code = code;
166
reject(error);
167
}
168
});
169
});
170
}
172
function deny(reason) {
173
throw new Error(reason);
174
}
176
export default async ({ directory }) => {
177
let mcp;
178
try {
179
mcp = startMCP(directory);
180
await mcp.initialize();
181
await mcp.notify("notifications/initialized");
182
} catch (err) {
183
throw new Error(
184
`koment plugin failed to start the MCP server in ${directory}:\n` +
185
(err && err.message ? err.message : String(err)) +
186
"\nInstall the \`koment\` binary on PATH: https://github.com/koment-dev/koment/releases"
187
);
188
}
190
return {
191
"tool.execute.before": async (input, output) => {
192
const tool = input.tool;
193
if (tool !== "edit" && tool !== "write") return;
194
const args = output.args ?? {};
195
const filePath = args.filePath ?? args.path ?? args.file ?? "";
196
const content = args.content ?? args.newContent ?? args.text ?? "";
197
if (!filePath || typeof content !== "string") return;
198
try {
199
const result = await mcp.callTool("koment_pre_tool", {
200
tool_name: "opencode_edit",
201
filePath,
202
content,
203
});
204
const structured = result.structuredContent || {};
205
if (structured.decision === "deny") {
206
deny(structured.reason || "koment policy denied this edit");
207
}
208
} catch (err) {
209
deny("koment pre-tool MCP call failed: " + (err && err.message ? err.message : String(err)));
210
}
211
},
212
dispose: async () => {
213
const failures = [];
214
for (const command of [
215
["check"],
216
["comments", "check"],
217
["agents", "check"],
218
]) {
219
try {
220
await run("koment", command, { cwd: directory });
221
} catch (err) {
222
failures.push(
223
"koment " + command.join(" ") + ":\n" + (err.stderr || err.message || String(err))
224
);
225
}
226
}
227
try {
228
await mcp.close();
229
} catch (err) {
230
failures.push("close koment mcp:\n" + (err.message || String(err)));
231
}
232
if (failures.length > 0) {
233
deny("koment policy gate failed:\n" + failures.join("\n"));
234
}
235
},
236
};
237
};