zengate 1.0.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/config.json.example +23 -0
  4. package/index.js +84 -0
  5. package/package.json +57 -0
  6. package/scripts/setup.mjs +24 -0
  7. package/src/bootstrap.js +66 -0
  8. package/src/cli.js +55 -0
  9. package/src/config.js +193 -0
  10. package/src/gateway.js +90 -0
  11. package/src/logger.js +35 -0
  12. package/src/openai/chat-request.js +183 -0
  13. package/src/openai/chat.js +100 -0
  14. package/src/openai/generate.js +143 -0
  15. package/src/openai/media.js +104 -0
  16. package/src/openai/models.js +17 -0
  17. package/src/openai/prompt.js +101 -0
  18. package/src/openai/response-builder.js +135 -0
  19. package/src/openai/responses-request.js +176 -0
  20. package/src/openai/responses-store.js +78 -0
  21. package/src/openai/responses.js +65 -0
  22. package/src/openai/sse-writer.js +37 -0
  23. package/src/openai/stop.js +41 -0
  24. package/src/openai/tool-calls.js +187 -0
  25. package/src/openai/url-guard.js +61 -0
  26. package/src/opencode/backend.js +201 -0
  27. package/src/opencode/binary.js +75 -0
  28. package/src/opencode/catalog.js +83 -0
  29. package/src/opencode/client.js +84 -0
  30. package/src/opencode/events.js +168 -0
  31. package/src/opencode/isolation.js +119 -0
  32. package/src/opencode/model-errors.js +66 -0
  33. package/src/opencode/runner.js +224 -0
  34. package/src/opencode/sse-reader.js +40 -0
  35. package/src/paths.js +40 -0
  36. package/src/server/app.js +96 -0
  37. package/src/server/errors.js +56 -0
  38. package/src/server/limiter.js +72 -0
  39. package/src/server/metrics.js +31 -0
  40. package/src/server/middleware.js +94 -0
  41. package/src/server/slot.js +32 -0
@@ -0,0 +1,201 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import net from 'node:net';
3
+ import { createOpencodeClient } from './client.js';
4
+ import { resolveOpencodeBinary, spawnCommand } from './binary.js';
5
+ import { backendEnv, createIsolatedRoot, randomPassword, removeIsolatedRoot, sweepStaleRoots } from './isolation.js';
6
+
7
+ const START_TIMEOUT_MS = 60000;
8
+ const MAX_RESTART_DELAY_MS = 30000;
9
+ const ANSI = /\x1b\[[0-9;]*m/g;
10
+
11
+ function freePort() {
12
+ return new Promise((resolve, reject) => {
13
+ const server = net.createServer();
14
+ server.unref();
15
+ server.once('error', reject);
16
+ server.listen(0, '127.0.0.1', () => {
17
+ const { port } = server.address();
18
+ server.close(() => resolve(port));
19
+ });
20
+ });
21
+ }
22
+
23
+ function killTree(child) {
24
+ if (!child?.pid || child.exitCode !== null) return;
25
+ if (process.platform === 'win32') {
26
+ execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {});
27
+ return;
28
+ }
29
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
30
+ setTimeout(() => {
31
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ }
32
+ }, 3000).unref();
33
+ }
34
+
35
+ async function waitHealthy(client, deadline, isDead) {
36
+ let lastError = null;
37
+ while (Date.now() < deadline) {
38
+ if (isDead()) throw new Error('OpenCode exited during startup');
39
+ try {
40
+ const health = await client.health();
41
+ if (health?.healthy === true) return health;
42
+ } catch (error) {
43
+ lastError = error;
44
+ }
45
+ await new Promise((resolve) => setTimeout(resolve, 250));
46
+ }
47
+ throw new Error(`OpenCode did not become healthy in ${START_TIMEOUT_MS / 1000}s${lastError ? `: ${lastError.message}` : ''}`);
48
+ }
49
+
50
+ function pipeLogs(stream, logger) {
51
+ let pending = '';
52
+ stream.setEncoding('utf8');
53
+ stream.on('data', (chunk) => {
54
+ pending += chunk;
55
+ const lines = pending.split(/\r?\n/);
56
+ pending = lines.pop();
57
+ for (const line of lines) {
58
+ const text = line.replace(ANSI, '').trim();
59
+ if (text) logger.debug(`[opencode] ${text}`);
60
+ }
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Starts and supervises a private `opencode serve` bound to 127.0.0.1 on a
66
+ * free port with a random password. It restarts with backoff if it crashes.
67
+ */
68
+ export function createManagedBackend({ opencodePath, logger }) {
69
+ let client = null;
70
+ let child = null;
71
+ let dirs = null;
72
+ let version = null;
73
+ let stopping = false;
74
+ let restartDelay = 1000;
75
+ let ready = false;
76
+ let everReady = false;
77
+
78
+ async function launch() {
79
+ const binary = resolveOpencodeBinary(opencodePath);
80
+ dirs = createIsolatedRoot();
81
+ const port = await freePort();
82
+ const password = randomPassword();
83
+ const { command, args, windowsVerbatimArguments } = spawnCommand(binary.path, ['serve', '--hostname', '127.0.0.1', '--port', String(port)]);
84
+ logger.debug('Starting OpenCode backend', { binary: binary.path, source: binary.source, port });
85
+ const proc = spawn(command, args, {
86
+ cwd: dirs.workspace,
87
+ env: backendEnv(dirs, password),
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ detached: process.platform !== 'win32',
90
+ windowsHide: true,
91
+ windowsVerbatimArguments,
92
+ });
93
+ child = proc;
94
+ pipeLogs(proc.stdout, logger);
95
+ pipeLogs(proc.stderr, logger);
96
+ let dead = false;
97
+ proc.once('error', (error) => { dead = true; logger.error('Could not start OpenCode', { error: error.message }); });
98
+ proc.once('exit', (code, signal) => {
99
+ dead = true;
100
+ ready = false;
101
+ if (!stopping && everReady) onCrash(code, signal, dirs.root);
102
+ });
103
+ const candidate = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}`, username: 'opencode', password });
104
+ const health = await waitHealthy(candidate, Date.now() + START_TIMEOUT_MS, () => dead);
105
+ client = candidate;
106
+ version = health.version || null;
107
+ ready = true;
108
+ everReady = true;
109
+ restartDelay = 1000;
110
+ logger.info(`OpenCode ${version || ''} backend ready (managed, isolated, tools disabled)`.replace(' ', ' '));
111
+ }
112
+
113
+ function onCrash(code, signal, root) {
114
+ // Ctrl+C and service managers signal the whole process group, so OpenCode
115
+ // can exit just before the gateway starts stopping. Only warn if the
116
+ // restart actually goes ahead.
117
+ const reason = signal || code;
118
+ logger.debug(`OpenCode backend exited (${reason})`);
119
+ removeIsolatedRoot(root);
120
+ const delay = restartDelay;
121
+ restartDelay = Math.min(MAX_RESTART_DELAY_MS, restartDelay * 2);
122
+ setTimeout(() => {
123
+ if (stopping) return;
124
+ logger.warn(`OpenCode backend exited (${reason}); restarting after ${delay / 1000}s`);
125
+ launch().catch((error) => {
126
+ logger.error('OpenCode restart failed', { error: error.message });
127
+ killTree(child);
128
+ });
129
+ }, delay).unref();
130
+ }
131
+
132
+ return Object.freeze({
133
+ mode: 'managed',
134
+ async start() {
135
+ const swept = sweepStaleRoots();
136
+ if (swept) logger.debug(`Removed ${swept} stale backend directories`);
137
+ try {
138
+ await launch();
139
+ } catch (error) {
140
+ stopping = true;
141
+ killTree(child);
142
+ removeIsolatedRoot(dirs?.root);
143
+ throw error;
144
+ }
145
+ },
146
+ getClient() {
147
+ if (!client) throw new Error('OpenCode backend is not started');
148
+ return client;
149
+ },
150
+ isReady: () => ready,
151
+ version: () => version,
152
+ ownsAllSessions: true,
153
+ async stop() {
154
+ stopping = true;
155
+ ready = false;
156
+ if (child && child.exitCode === null) {
157
+ const exited = new Promise((resolve) => child.once('exit', resolve));
158
+ killTree(child);
159
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 5000).unref())]);
160
+ }
161
+ removeIsolatedRoot(dirs?.root);
162
+ },
163
+ });
164
+ }
165
+
166
+ /** Uses an OpenCode server someone else runs (OPENCODE_SERVER_URL). */
167
+ export function createAttachedBackend({ url, username, password, logger }) {
168
+ const client = createOpencodeClient({ baseUrl: url, username, password });
169
+ let ready = false;
170
+ let version = null;
171
+ let timer = null;
172
+
173
+ async function probe() {
174
+ try {
175
+ const health = await client.health();
176
+ ready = health?.healthy === true;
177
+ version = health?.version || version;
178
+ } catch {
179
+ ready = false;
180
+ }
181
+ return ready;
182
+ }
183
+
184
+ return Object.freeze({
185
+ mode: 'attached',
186
+ async start() {
187
+ if (!(await probe())) throw new Error(`OpenCode server at ${url} is not healthy (GET /global/health). Check the URL and credentials.`);
188
+ logger.info(`Using OpenCode ${version || ''} at ${url}`.replace(' ', ' '));
189
+ logger.warn('Attached mode: the gateway rejects tool permissions for its own sessions, but the server\'s own configuration still applies. Prefer the managed backend.');
190
+ timer = setInterval(probe, 10000);
191
+ timer.unref();
192
+ },
193
+ getClient: () => client,
194
+ isReady: () => ready,
195
+ version: () => version,
196
+ ownsAllSessions: false,
197
+ async stop() {
198
+ clearInterval(timer);
199
+ },
200
+ });
201
+ }
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ function isFile(file) {
8
+ try {
9
+ return fs.statSync(file).isFile();
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ /** The binary shipped by the official `opencode-ai` npm package (a dependency). */
16
+ function bundledBinary() {
17
+ try {
18
+ const root = path.dirname(require.resolve('opencode-ai/package.json'));
19
+ const binary = path.join(root, 'bin', 'opencode.exe');
20
+ return isFile(binary) ? binary : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function searchPath(names) {
27
+ for (const dir of (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)) {
28
+ for (const name of names) {
29
+ const candidate = path.join(dir, name);
30
+ if (isFile(candidate)) return candidate;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Locate the OpenCode CLI: explicit OPENCODE_PATH, then the bundled
38
+ * `opencode-ai` package, then PATH. Never guesses other directories.
39
+ * @param {string} configured OPENCODE_PATH ('' for automatic)
40
+ * @returns {{ path: string, source: 'config'|'bundled'|'PATH' }}
41
+ */
42
+ export function resolveOpencodeBinary(configured) {
43
+ const windowsNames = ['opencode.exe', 'opencode.cmd', 'opencode.bat', 'opencode'];
44
+ if (configured) {
45
+ const looksLikePath = configured.includes('/') || configured.includes('\\');
46
+ if (looksLikePath) {
47
+ const full = path.resolve(configured);
48
+ if (isFile(full)) return { path: full, source: 'config' };
49
+ throw new Error(`OPENCODE_PATH does not point to a file: ${full}`);
50
+ }
51
+ const names = process.platform === 'win32' ? windowsNames.map((n) => n.replace('opencode', configured)) : [configured];
52
+ const found = searchPath(names);
53
+ if (found) return { path: found, source: 'config' };
54
+ throw new Error(`OPENCODE_PATH '${configured}' was not found on PATH`);
55
+ }
56
+ const bundled = bundledBinary();
57
+ if (bundled) return { path: bundled, source: 'bundled' };
58
+ const found = searchPath(process.platform === 'win32' ? windowsNames : ['opencode']);
59
+ if (found) return { path: found, source: 'PATH' };
60
+ throw new Error('OpenCode CLI not found. Run `npm ci` (it installs the bundled opencode-ai package) or set OPENCODE_PATH.');
61
+ }
62
+
63
+ /**
64
+ * Build spawn arguments. Windows .cmd/.bat shims need cmd.exe; the command
65
+ * line is fully quoted and every argument is gateway-controlled.
66
+ */
67
+ export function spawnCommand(binary, args) {
68
+ const lower = binary.toLowerCase();
69
+ if (process.platform === 'win32' && (lower.endsWith('.cmd') || lower.endsWith('.bat'))) {
70
+ if (/["\r\n%]/.test(binary)) throw new Error('OPENCODE_PATH contains characters that cannot be run safely');
71
+ const line = [`"${binary}"`, ...args.map((arg) => `"${arg}"`)].join(' ');
72
+ return { command: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], windowsVerbatimArguments: true };
73
+ }
74
+ return { command: binary, args, windowsVerbatimArguments: false };
75
+ }
@@ -0,0 +1,83 @@
1
+ import { ApiError } from '../server/errors.js';
2
+
3
+ const CACHE_MS = 60000;
4
+ const MODALITIES = ['text', 'image', 'audio', 'video', 'pdf'];
5
+
6
+ /**
7
+ * Model catalog read from the backend's /config/providers. Models from the
8
+ * built-in `opencode` provider (keyless Zen) are exposed by their bare id;
9
+ * any other provider the backend knows about is exposed as `provider/model`.
10
+ */
11
+ export function createCatalog({ getClient, logger }) {
12
+ let cache = null;
13
+ let cachedAt = 0;
14
+ let inflight = null;
15
+
16
+ async function refresh() {
17
+ const payload = await getClient().providers();
18
+ const models = [];
19
+ for (const provider of Array.isArray(payload?.providers) ? payload.providers : []) {
20
+ for (const [key, raw] of Object.entries(provider?.models || {})) {
21
+ const model = normalizeModel(provider.id, key, raw);
22
+ if (model) models.push(model);
23
+ }
24
+ }
25
+ models.sort((a, b) => a.id.localeCompare(b.id));
26
+ cache = { list: models, byId: new Map(models.map((model) => [model.id, model])) };
27
+ cachedAt = Date.now();
28
+ return cache;
29
+ }
30
+
31
+ async function load() {
32
+ if (cache && Date.now() - cachedAt < CACHE_MS) return cache;
33
+ inflight ??= refresh().finally(() => { inflight = null; });
34
+ try {
35
+ return await inflight;
36
+ } catch (error) {
37
+ if (cache) {
38
+ logger.warn('Model catalog refresh failed; serving the cached list', { error: error.message });
39
+ return cache;
40
+ }
41
+ throw new ApiError(503, 'The OpenCode backend is not ready yet; retry in a few seconds.', { code: 'backend_unavailable', retryAfter: 2 });
42
+ }
43
+ }
44
+
45
+ return Object.freeze({
46
+ list: async () => (await load()).list,
47
+ /** Resolve a client model id (bare, `opencode/`-prefixed, or provider/model). */
48
+ async resolve(requested) {
49
+ const { byId, list } = await load();
50
+ const id = String(requested || '').trim();
51
+ const model = byId.get(id) || byId.get(id.replace(/^opencode\//, ''));
52
+ if (model) return model;
53
+ const hint = list.slice(0, 12).map((m) => m.id).join(', ');
54
+ throw new ApiError(404, `The model '${id}' does not exist or is not available keylessly. Available: ${hint}${list.length > 12 ? ', ...' : ''} (see GET /v1/models).`, { param: 'model', code: 'model_not_found' });
55
+ },
56
+ invalidate() { cachedAt = 0; },
57
+ });
58
+ }
59
+
60
+ function normalizeModel(providerId, key, raw) {
61
+ if (!raw || typeof raw !== 'object' || !providerId) return null;
62
+ const modelId = String(raw.id || key);
63
+ if (raw.status === 'deprecated') return null;
64
+ const input = raw.capabilities?.input || {};
65
+ const created = Date.parse(raw.release_date || '');
66
+ return Object.freeze({
67
+ id: providerId === 'opencode' ? modelId : `${providerId}/${modelId}`,
68
+ providerID: providerId,
69
+ modelID: modelId,
70
+ name: raw.name || modelId,
71
+ created: Number.isFinite(created) ? Math.floor(created / 1000) : 0,
72
+ input: Object.freeze(MODALITIES.filter((kind) => input[kind] === true || (kind === 'text' && input.text !== false))),
73
+ reasoning: raw.capabilities?.reasoning === true,
74
+ variants: Object.freeze(Object.keys(raw.variants || {})),
75
+ contextWindow: raw.limit?.context ?? null,
76
+ maxOutputTokens: raw.limit?.output ?? null,
77
+ });
78
+ }
79
+
80
+ /** The public OpenAI model object. */
81
+ export function toOpenAIModel(model) {
82
+ return { id: model.id, object: 'model', created: model.created, owned_by: model.providerID };
83
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Thin client for the documented `opencode serve` HTTP API
3
+ * (https://opencode.ai/docs/server/). Plain fetch keeps connections pooled
4
+ * and avoids coupling the gateway to a specific SDK major version.
5
+ */
6
+ export class BackendError extends Error {
7
+ constructor(message, { status = 0, body = null, cause } = {}) {
8
+ super(message, cause ? { cause } : undefined);
9
+ this.name = 'BackendError';
10
+ this.status = status;
11
+ this.body = body;
12
+ }
13
+ }
14
+
15
+ const DEFAULT_TIMEOUT_MS = 30000;
16
+
17
+ export function basicAuthHeader(username, password) {
18
+ if (!password) return null;
19
+ return `Basic ${Buffer.from(`${username || 'opencode'}:${password}`).toString('base64')}`;
20
+ }
21
+
22
+ /**
23
+ * @param {{ baseUrl: string, username?: string, password?: string, fetchImpl?: typeof fetch }} options
24
+ */
25
+ export function createOpencodeClient({ baseUrl, username, password, fetchImpl = fetch }) {
26
+ const base = String(baseUrl).replace(/\/+$/, '');
27
+ const auth = basicAuthHeader(username, password);
28
+ const headers = (extra) => ({ accept: 'application/json', ...(auth ? { authorization: auth } : {}), ...extra });
29
+
30
+ async function request(method, path, { body, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
31
+ const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null;
32
+ const combined = [signal, timeout].filter(Boolean);
33
+ let response;
34
+ try {
35
+ response = await fetchImpl(`${base}${path}`, {
36
+ method,
37
+ redirect: 'error',
38
+ headers: headers(body === undefined ? {} : { 'content-type': 'application/json' }),
39
+ body: body === undefined ? undefined : JSON.stringify(body),
40
+ signal: combined.length > 1 ? AbortSignal.any(combined) : combined[0],
41
+ });
42
+ } catch (error) {
43
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
44
+ throw new BackendError(`OpenCode backend unreachable at ${base}: ${error?.cause?.code || error?.message}`, { cause: error });
45
+ }
46
+ const text = await response.text();
47
+ let parsed = null;
48
+ if (text) {
49
+ try { parsed = JSON.parse(text); } catch { parsed = text; }
50
+ }
51
+ if (!response.ok) {
52
+ throw new BackendError(`OpenCode ${method} ${path} failed with HTTP ${response.status}`, { status: response.status, body: parsed });
53
+ }
54
+ return parsed;
55
+ }
56
+
57
+ const id = (value) => encodeURIComponent(value);
58
+
59
+ return Object.freeze({
60
+ baseUrl: base,
61
+ health: (options) => request('GET', '/global/health', { timeoutMs: 3000, ...options }),
62
+ providers: (options) => request('GET', '/config/providers', options),
63
+ agents: (options) => request('GET', '/agent', options),
64
+ createSession: (options) => request('POST', '/session', { body: { title: 'openai-gateway' }, ...options }),
65
+ deleteSession: (sessionId, options) => request('DELETE', `/session/${id(sessionId)}`, options),
66
+ abortSession: (sessionId, options) => request('POST', `/session/${id(sessionId)}/abort`, { timeoutMs: 5000, ...options }),
67
+ messages: (sessionId, options) => request('GET', `/session/${id(sessionId)}/message`, options),
68
+ /** Blocks until the assistant turn finishes; returns { info, parts }. */
69
+ prompt: (sessionId, body, options) => request('POST', `/session/${id(sessionId)}/message`, { body, timeoutMs: 0, ...options }),
70
+ replyPermission: (requestId, reply, message) =>
71
+ request('POST', `/permission/${id(requestId)}/reply`, { body: { reply, ...(message ? { message } : {}) }, timeoutMs: 5000 }),
72
+ rejectQuestion: (requestId) => request('POST', `/question/${id(requestId)}/reject`, { body: {}, timeoutMs: 5000 }),
73
+ pendingPermissions: () => request('GET', '/permission', { timeoutMs: 5000 }),
74
+ pendingQuestions: () => request('GET', '/question', { timeoutMs: 5000 }),
75
+ /** Opens the server-sent event stream; caller consumes response.body. */
76
+ async openEvents(signal) {
77
+ const response = await fetchImpl(`${base}/event`, { headers: headers({ accept: 'text/event-stream' }), signal, redirect: 'error' });
78
+ if (!response.ok || !response.body) {
79
+ throw new BackendError(`OpenCode event stream failed with HTTP ${response.status}`, { status: response.status });
80
+ }
81
+ return response;
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,168 @@
1
+ import { readSseFrames } from './sse-reader.js';
2
+
3
+ /**
4
+ * Sent back to the model whenever it tries to use an OpenCode tool. OpenCode
5
+ * lets the turn continue with this feedback, so the model answers directly.
6
+ */
7
+ export const TOOL_REJECTION_MESSAGE = 'OpenCode tools are disabled by this gateway. Do not call OpenCode tools; answer directly. '
8
+ + 'If the user supplied their own functions, use the function-call format described in the system instructions.';
9
+
10
+ const MAX_BACKOFF_MS = 5000;
11
+
12
+ async function* readChunks(reader) {
13
+ for (;;) {
14
+ const { done, value } = await reader.read();
15
+ if (done) return;
16
+ yield value;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * One shared subscription to the backend's /event stream, fanned out to
22
+ * per-session listeners. It also enforces the gateway's security policy:
23
+ * every OpenCode tool permission request and question from a gateway session
24
+ * is rejected, so no command, file edit or fetch ever runs on the host.
25
+ *
26
+ * @param {{ getClient: () => ReturnType<import('./client.js').createOpencodeClient>, logger: any, ownsAllSessions: boolean }} options
27
+ */
28
+ export function createEventHub({ getClient, logger, ownsAllSessions }) {
29
+ const listeners = new Map();
30
+ const owned = new Set();
31
+ const children = new Map();
32
+ let connected = false;
33
+ let stopped = false;
34
+ let controller = null;
35
+ let connectedWaiters = [];
36
+ let loop = null;
37
+
38
+ const isOurs = (sessionId) => ownsAllSessions || owned.has(sessionId);
39
+
40
+ function setConnected(value) {
41
+ connected = value;
42
+ if (value) {
43
+ connectedWaiters.forEach((resolve) => resolve(true));
44
+ connectedWaiters = [];
45
+ }
46
+ }
47
+
48
+ async function rejectPermission(id, sessionId) {
49
+ try {
50
+ await getClient().replyPermission(id, 'reject', TOOL_REJECTION_MESSAGE);
51
+ logger.debug('Rejected OpenCode tool permission', { sessionId });
52
+ } catch (error) {
53
+ logger.warn('Failed to reject an OpenCode tool permission', { sessionId, error: error.message });
54
+ }
55
+ }
56
+
57
+ async function rejectQuestion(id, sessionId) {
58
+ try {
59
+ await getClient().rejectQuestion(id);
60
+ } catch (error) {
61
+ logger.warn('Failed to reject an OpenCode question', { sessionId, error: error.message });
62
+ }
63
+ }
64
+
65
+ /** Sessions spawned by an owned session (e.g. subagents) are owned too. */
66
+ function adoptChild(info) {
67
+ if (!info?.id || !info.parentID || !owned.has(info.parentID) || owned.has(info.id)) return;
68
+ owned.add(info.id);
69
+ children.set(info.parentID, [...(children.get(info.parentID) || []), info.id]);
70
+ }
71
+
72
+ function dispatch(event) {
73
+ const props = event?.properties || {};
74
+ if (event.type === 'session.created' || event.type === 'session.updated') adoptChild(props.info);
75
+ const sessionId = props.sessionID || props.info?.sessionID || props.part?.sessionID;
76
+ if (event.type === 'permission.asked' && isOurs(sessionId)) void rejectPermission(props.id, sessionId);
77
+ else if (event.type === 'question.asked' && isOurs(sessionId)) void rejectQuestion(props.id, sessionId);
78
+ const set = sessionId ? listeners.get(sessionId) : null;
79
+ if (!set) return;
80
+ for (const listener of set) {
81
+ try { listener(event); } catch (error) { logger.error('Event listener failed', { error: error.message }); }
82
+ }
83
+ }
84
+
85
+ /** Catch permission requests raised while the stream was down. */
86
+ async function sweepPending() {
87
+ const client = getClient();
88
+ for (const [list, reject] of [[client.pendingPermissions, rejectPermission], [client.pendingQuestions, rejectQuestion]]) {
89
+ try {
90
+ const pending = await list();
91
+ for (const item of Array.isArray(pending) ? pending : []) {
92
+ if (item?.id && isOurs(item.sessionID)) await reject(item.id, item.sessionID);
93
+ }
94
+ } catch (error) {
95
+ logger.debug('Pending request sweep failed', { error: error.message });
96
+ }
97
+ }
98
+ }
99
+
100
+ async function runLoop() {
101
+ let backoff = 250;
102
+ while (!stopped) {
103
+ controller = new AbortController();
104
+ try {
105
+ const response = await getClient().openEvents(controller.signal);
106
+ // fetch() can drop its abort link once its Request is garbage
107
+ // collected, so stop() also cancels the body directly.
108
+ const reader = response.body.getReader();
109
+ controller.signal.addEventListener('abort', () => { reader.cancel().catch(() => {}); }, { once: true });
110
+ setConnected(true);
111
+ backoff = 250;
112
+ void sweepPending();
113
+ for await (const frame of readSseFrames(readChunks(reader))) {
114
+ let event;
115
+ try { event = JSON.parse(frame.data); } catch { continue; }
116
+ if (event && typeof event.type === 'string') dispatch(event);
117
+ }
118
+ if (!stopped) logger.warn('OpenCode event stream ended; reconnecting');
119
+ } catch (error) {
120
+ if (!stopped) logger.debug('OpenCode event stream unavailable', { error: error.message });
121
+ }
122
+ setConnected(false);
123
+ if (stopped) break;
124
+ await new Promise((resolve) => setTimeout(resolve, backoff).unref());
125
+ backoff = Math.min(MAX_BACKOFF_MS, backoff * 2);
126
+ }
127
+ }
128
+
129
+ return Object.freeze({
130
+ start() {
131
+ if (!loop) loop = runLoop();
132
+ },
133
+ /** Register a listener for one session; returns an unsubscribe function. */
134
+ subscribe(sessionId, listener) {
135
+ owned.add(sessionId);
136
+ const set = listeners.get(sessionId) || new Set();
137
+ set.add(listener);
138
+ listeners.set(sessionId, set);
139
+ return () => {
140
+ set.delete(listener);
141
+ if (set.size === 0) listeners.delete(sessionId);
142
+ };
143
+ },
144
+ release(sessionId) {
145
+ for (const child of children.get(sessionId) || []) owned.delete(child);
146
+ children.delete(sessionId);
147
+ owned.delete(sessionId);
148
+ listeners.delete(sessionId);
149
+ },
150
+ isConnected: () => connected,
151
+ waitConnected(timeoutMs) {
152
+ if (connected) return Promise.resolve(true);
153
+ return new Promise((resolve) => {
154
+ const timer = setTimeout(() => {
155
+ connectedWaiters = connectedWaiters.filter((fn) => fn !== done);
156
+ resolve(false);
157
+ }, timeoutMs);
158
+ const done = (value) => { clearTimeout(timer); resolve(value); };
159
+ connectedWaiters.push(done);
160
+ });
161
+ },
162
+ async stop() {
163
+ stopped = true;
164
+ controller?.abort();
165
+ await loop?.catch(() => {});
166
+ },
167
+ });
168
+ }