zengate 1.0.4 → 1.1.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.
@@ -3,21 +3,37 @@ const MAX_ENTRY_CHARS = 4 * 1024 * 1024;
3
3
 
4
4
  /**
5
5
  * In-memory store behind previous_response_id and GET/DELETE
6
- * /v1/responses/{id}. Bounded (LRU + 1h TTL) and never persisted: a gateway
7
- * restart forgets stored responses, which clients see as "not found".
8
- * @param {{ maxEntries: number }} options
6
+ * /v1/responses/{id}. Bounded by entry count, by an approximate memory budget
7
+ * (JSON size of each entry's full history, so chained turns that share
8
+ * messages are counted conservatively) and by a 1h TTL, evicting least
9
+ * recently used entries first. Never persisted: a gateway restart forgets
10
+ * stored responses, which clients see as "not found".
11
+ * @param {{ maxEntries: number, maxChars?: number }} options
9
12
  */
10
- export function createResponsesStore({ maxEntries }) {
13
+ export function createResponsesStore({ maxEntries, maxChars = Infinity }) {
11
14
  const entries = new Map();
12
15
  const items = new Map();
16
+ // Chained turns reuse the same message objects, so each is sized once.
17
+ const messageSizes = new WeakMap();
18
+ let chars = 0;
13
19
 
14
20
  const expired = (entry) => Date.now() - entry.at > TTL_MS;
15
21
 
22
+ const messageSize = (message) => {
23
+ let size = messageSizes.get(message);
24
+ if (size === undefined) {
25
+ size = JSON.stringify(message).length;
26
+ messageSizes.set(message, size);
27
+ }
28
+ return size;
29
+ };
30
+
16
31
  function evict(id) {
17
32
  const entry = entries.get(id);
18
33
  if (!entry) return;
19
34
  for (const itemId of entry.itemIds) items.delete(itemId);
20
35
  entries.delete(id);
36
+ chars -= entry.size;
21
37
  }
22
38
 
23
39
  function get(id) {
@@ -50,11 +66,14 @@ export function createResponsesStore({ maxEntries }) {
50
66
  */
51
67
  save(response, history) {
52
68
  if (maxEntries <= 0) return;
53
- if (JSON.stringify(history).length > MAX_ENTRY_CHARS) return;
69
+ const size = history.reduce((sum, message) => sum + messageSize(message), JSON.stringify(response).length);
70
+ if (size > Math.min(MAX_ENTRY_CHARS, maxChars)) return;
71
+ evict(response.id);
54
72
  const itemIds = response.output.map((item) => item.id);
55
73
  for (const item of response.output) items.set(item.id, { item, responseId: response.id });
56
- entries.set(response.id, { response, history, itemIds, owner, at: Date.now() });
57
- while (entries.size > maxEntries) evict(entries.keys().next().value);
74
+ entries.set(response.id, { response, history, itemIds, owner, at: Date.now(), size });
75
+ chars += size;
76
+ while (entries.size > maxEntries || chars > maxChars) evict(entries.keys().next().value);
58
77
  },
59
78
  response: (id) => owned(id, owner)?.response ?? null,
60
79
  history: (id) => owned(id, owner)?.history ?? null,
@@ -74,5 +93,6 @@ export function createResponsesStore({ maxEntries }) {
74
93
  enabled: maxEntries > 0,
75
94
  scope,
76
95
  size: () => entries.size,
96
+ chars: () => chars,
77
97
  });
78
98
  }
@@ -1,6 +1,8 @@
1
1
  import { ApiError } from '../server/errors.js';
2
2
  import { generate } from './generate.js';
3
+ import { setIgnoredParams } from './ignored-params.js';
3
4
  import { buildPrompt } from './prompt.js';
5
+ import { inlineRemoteAttachments } from './remote-media.js';
4
6
  import { createResponseBuilder } from './response-builder.js';
5
7
  import { parseResponsesRequest } from './responses-request.js';
6
8
  import { openSse } from './sse-writer.js';
@@ -21,9 +23,10 @@ export function responsesHandlers({ runner, catalog, store, limits }) {
21
23
  const model = await catalog.resolve(request.model);
22
24
  const prompt = buildPrompt(request, model);
23
25
  await assertPublicUrls(prompt.parts);
24
- if (request.ignored.length) res.set('x-gateway-ignored-params', request.ignored.join(','));
26
+ setIgnoredParams(res, request.ignored);
25
27
 
26
28
  await req.withSlot(async (signal) => {
29
+ const ready = await inlineRemoteAttachments(prompt, { ...limits, signal });
27
30
  const sse = request.stream ? openSse(res) : null;
28
31
  const builder = createResponseBuilder({
29
32
  model: model.id,
@@ -33,7 +36,7 @@ export function responsesHandlers({ runner, catalog, store, limits }) {
33
36
  builder.start();
34
37
  try {
35
38
  const result = await generate({
36
- runner, prompt, request, signal,
39
+ runner, prompt: ready, request, signal, onFirstToken: req.markFirstToken,
37
40
  onText: (text) => builder.textDelta(text),
38
41
  onReasoning: (text) => builder.reasoningDelta(text),
39
42
  });
@@ -5,10 +5,38 @@
5
5
  */
6
6
  export function createStopFilter(stops) {
7
7
  const sequences = stops.filter(Boolean);
8
- const hold = sequences.reduce((max, s) => Math.max(max, s.length - 1), 0);
9
8
  let pending = '';
10
9
  let stopped = false;
11
10
 
11
+ /** Index of the earliest complete stop sequence in `pending`, or -1. */
12
+ function firstMatch() {
13
+ let cut = -1;
14
+ for (const sequence of sequences) {
15
+ const index = pending.indexOf(sequence);
16
+ if (index >= 0 && (cut < 0 || index < cut)) cut = index;
17
+ }
18
+ return cut;
19
+ }
20
+
21
+ /**
22
+ * Index of the earliest position where `pending` ends with the start of a
23
+ * stop sequence that more text could complete, or `pending.length`.
24
+ */
25
+ function firstPartial() {
26
+ for (let i = 0; i < pending.length; i++) {
27
+ const tail = pending.slice(i);
28
+ if (sequences.some((s) => s.length > tail.length && s.startsWith(tail))) return i;
29
+ }
30
+ return pending.length;
31
+ }
32
+
33
+ function stopAt(cut) {
34
+ stopped = true;
35
+ const out = pending.slice(0, cut);
36
+ pending = '';
37
+ return out;
38
+ }
39
+
12
40
  return {
13
41
  get stopped() { return stopped; },
14
42
  /** @returns {string} text that can be emitted now */
@@ -16,24 +44,20 @@ export function createStopFilter(stops) {
16
44
  if (stopped || !text) return '';
17
45
  if (!sequences.length) return text;
18
46
  pending += text;
19
- let cut = -1;
20
- for (const sequence of sequences) {
21
- const index = pending.indexOf(sequence);
22
- if (index >= 0 && (cut < 0 || index < cut)) cut = index;
23
- }
24
- if (cut >= 0) {
25
- stopped = true;
26
- const out = pending.slice(0, cut);
27
- pending = '';
28
- return out;
29
- }
30
- const safe = Math.max(0, pending.length - hold);
47
+ const cut = firstMatch();
48
+ // A longer sequence that starts earlier may still complete, so a
49
+ // match is final only when no partial one begins before it.
50
+ const partial = firstPartial();
51
+ if (cut >= 0 && cut <= partial) return stopAt(cut);
52
+ const safe = cut >= 0 ? Math.min(cut, partial) : partial;
31
53
  const out = pending.slice(0, safe);
32
54
  pending = pending.slice(safe);
33
55
  return out;
34
56
  },
35
57
  flush() {
36
- const out = stopped ? '' : pending;
58
+ if (stopped) return '';
59
+ const cut = firstMatch();
60
+ const out = cut >= 0 ? pending.slice(0, cut) : pending;
37
61
  pending = '';
38
62
  return out;
39
63
  },
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
+ import { escapeFrames } from './markup.js';
2
3
 
3
4
  /**
4
5
  * OpenAI function calling, emulated over a plain-text protocol.
@@ -49,11 +50,14 @@ export function toolInstructions(tools, choice, parallel) {
49
50
  return lines.join('\n');
50
51
  }
51
52
 
52
- /** Render a past call in the same syntax, so history teaches the protocol. */
53
+ /**
54
+ * Render a past call in the same syntax, so history teaches the protocol.
55
+ * Its name and arguments come from the client, so frame tags are escaped.
56
+ */
53
57
  export function renderToolCall(call) {
54
58
  let args = call.arguments;
55
59
  try { args = JSON.parse(call.arguments); } catch { /* keep raw string */ }
56
- return `${OPEN}${JSON.stringify({ name: call.name, arguments: args })}${CLOSE}`;
60
+ return `${OPEN}${escapeFrames(JSON.stringify({ name: call.name, arguments: args }))}${CLOSE}`;
57
61
  }
58
62
 
59
63
  /** Length of a trailing fragment that could still grow into an opening tag. */
@@ -162,6 +166,8 @@ export function createToolCallParser(tools) {
162
166
  let capturing = false;
163
167
 
164
168
  return {
169
+ /** True while a call block is open and its text is held back. */
170
+ get capturing() { return capturing; },
165
171
  push(text) {
166
172
  pending += text;
167
173
  if (capturing) return '';
@@ -3,59 +3,127 @@ import net from 'node:net';
3
3
  import { invalidRequest } from '../server/errors.js';
4
4
 
5
5
  /**
6
- * Attachment URLs are fetched by OpenCode on this host, so a client could
7
- * otherwise point them at internal services (SSRF). Only public addresses
8
- * are allowed; this blocks loopback, private, link-local, CGNAT, multicast
9
- * and other special-purpose ranges.
6
+ * Attachment URLs are fetched by the gateway, so a client could otherwise
7
+ * point them at internal services (SSRF). Only public addresses are allowed;
8
+ * this blocks loopback, private, link-local, CGNAT, multicast and other
9
+ * special-purpose ranges, including IPv6 forms that embed an IPv4 address.
10
10
  */
11
- const BLOCKED = new net.BlockList();
11
+ const BLOCKED_V4 = new net.BlockList();
12
12
  [
13
13
  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
14
- ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
15
- ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
16
- ].forEach(([address, prefix]) => BLOCKED.addSubnet(address, prefix, 'ipv4'));
14
+ ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
15
+ ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
16
+ ].forEach(([address, prefix]) => BLOCKED_V4.addSubnet(address, prefix, 'ipv4'));
17
+
18
+ const BLOCKED_V6 = new net.BlockList();
17
19
  [
18
- ['::', 128], ['::1', 128], ['64:ff9b::', 96], ['100::', 64], ['2001:db8::', 32], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
19
- ].forEach(([address, prefix]) => BLOCKED.addSubnet(address, prefix, 'ipv6'));
20
+ // ::/96 also covers the deprecated IPv4-compatible form (::a.b.c.d).
21
+ ['::', 96], ['64:ff9b:1::', 48], ['100::', 64], ['2001::', 23], ['2001:db8::', 32],
22
+ ['2002::', 16], ['3fff::', 20], ['5f00::', 16], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8],
23
+ ].forEach(([address, prefix]) => BLOCKED_V6.addSubnet(address, prefix, 'ipv6'));
20
24
 
21
25
  const LOOKUP_TIMEOUT_MS = 5000;
26
+ /** Most attachments one request may send as URLs. */
27
+ export const MAX_REMOTE_PARTS = 16;
28
+
29
+ /** The eight 16-bit groups of a valid IPv6 address (which may end in dotted IPv4). */
30
+ function ipv6Groups(address) {
31
+ let text = address.toLowerCase();
32
+ const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
33
+ if (dotted) {
34
+ const [a, b, c, d] = dotted[1].split('.').map(Number);
35
+ text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
36
+ }
37
+ const [head, tail] = text.split('::');
38
+ const left = head ? head.split(':') : [];
39
+ const right = tail ? tail.split(':') : [];
40
+ const fill = text.includes('::') ? 8 - left.length - right.length : 0;
41
+ return [...left, ...Array(fill).fill('0'), ...right].map((group) => parseInt(group, 16));
42
+ }
43
+
44
+ /**
45
+ * The IPv4 address inside an IPv4-mapped (::ffff:0:0/96), IPv4-translated
46
+ * (::ffff:0:0:0/96) or NAT64 well-known-prefix (64:ff9b::/96) address.
47
+ */
48
+ function embeddedIpv4(groups) {
49
+ const mapped = groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff;
50
+ const translated = groups.slice(0, 4).every((g) => g === 0) && groups[4] === 0xffff && groups[5] === 0;
51
+ const nat64 = groups[0] === 0x64 && groups[1] === 0xff9b && groups.slice(2, 6).every((g) => g === 0);
52
+ if (!mapped && !translated && !nat64) return null;
53
+ return [groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff].join('.');
54
+ }
22
55
 
23
56
  export function isBlockedAddress(address) {
24
- const family = net.isIP(address);
25
- if (family === 0) return true;
26
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
27
- if (mapped) return BLOCKED.check(mapped[1], 'ipv4');
28
- return BLOCKED.check(address, family === 4 ? 'ipv4' : 'ipv6');
57
+ const plain = String(address).replace(/%.*$/, '');
58
+ const family = net.isIP(plain);
59
+ if (family === 4) return BLOCKED_V4.check(plain, 'ipv4');
60
+ if (family !== 6) return true;
61
+ const v4 = embeddedIpv4(ipv6Groups(plain));
62
+ return v4 ? BLOCKED_V4.check(v4, 'ipv4') : BLOCKED_V6.check(plain, 'ipv6');
29
63
  }
30
64
 
31
65
  async function resolve(hostname) {
32
66
  const literal = hostname.replace(/^\[|\]$/g, '');
33
- if (net.isIP(literal)) return [literal];
34
- const lookup = dns.lookup(literal, { all: true, verbatim: true });
35
- const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('DNS lookup timed out')), LOOKUP_TIMEOUT_MS).unref());
36
- const records = await Promise.race([lookup, timeout]);
37
- return records.map((record) => record.address);
67
+ if (net.isIP(literal)) return [{ address: literal, family: net.isIP(literal) }];
68
+ let timer;
69
+ const timeout = new Promise((_, reject) => {
70
+ timer = setTimeout(() => reject(new Error('DNS lookup timed out')), LOOKUP_TIMEOUT_MS);
71
+ timer.unref();
72
+ });
73
+ try {
74
+ return await Promise.race([dns.lookup(literal, { all: true, verbatim: true }), timeout]);
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Every address `hostname` resolves to, provided all of them are public.
82
+ * @param {string} hostname
83
+ * @param {(address: string) => boolean} [isBlocked]
84
+ * @returns {Promise<{ address: string, family: number }[]>}
85
+ */
86
+ export async function resolvePublic(hostname, isBlocked = isBlockedAddress) {
87
+ // One message for both failures, so clients cannot probe which internal names exist.
88
+ const records = await resolve(hostname).catch(() => []);
89
+ if (!records.length || records.some((record) => isBlocked(record.address))) {
90
+ throw invalidRequest(`Attachment host '${hostname}' is not a public address.`, null, 'invalid_attachment_url');
91
+ }
92
+ return records;
93
+ }
94
+
95
+ /**
96
+ * A `lookup` for http(s).request that only ever connects to the addresses it
97
+ * has just checked, so DNS rebinding between check and connect cannot reach
98
+ * an internal address.
99
+ * @param {(address: string) => boolean} [isBlocked]
100
+ */
101
+ export function publicLookup(isBlocked = isBlockedAddress) {
102
+ return (hostname, options, callback) => {
103
+ resolvePublic(hostname, isBlocked).then((records) => {
104
+ const wanted = options?.family ? records.filter((record) => record.family === options.family) : records;
105
+ if (!wanted.length) {
106
+ callback(Object.assign(new Error(`No IPv${options.family} address for ${hostname}`), { code: 'ENOTFOUND' }));
107
+ } else if (options?.all) {
108
+ callback(null, wanted);
109
+ } else {
110
+ callback(null, wanted[0].address, wanted[0].family);
111
+ }
112
+ }, callback);
113
+ };
38
114
  }
39
115
 
40
116
  /**
41
- * Reject prompt parts whose https URL resolves to a non-public address.
117
+ * Reject prompt parts whose https URL resolves to a non-public address. This
118
+ * is a fast early check; the fetch itself re-checks through publicLookup.
42
119
  * @param {{ url?: string }[]} parts OpenCode prompt parts
43
120
  */
44
121
  export async function assertPublicUrls(parts) {
45
- const hosts = new Set();
46
- for (const part of parts) {
47
- if (typeof part.url !== 'string' || !part.url.startsWith('https:')) continue;
48
- hosts.add(new URL(part.url).hostname);
122
+ const remote = parts.filter((part) => typeof part.url === 'string' && part.url.startsWith('https:'));
123
+ // Checked before resolving anything, so one request cannot queue thousands of DNS lookups.
124
+ if (remote.length > MAX_REMOTE_PARTS) {
125
+ throw invalidRequest(`At most ${MAX_REMOTE_PARTS} attachments may be URLs; send the rest as data URIs.`, null, 'too_many_attachment_urls');
49
126
  }
50
- await Promise.all([...hosts].map(async (hostname) => {
51
- let addresses;
52
- try {
53
- addresses = await resolve(hostname);
54
- } catch {
55
- throw invalidRequest(`Could not resolve attachment host '${hostname}'.`, null, 'invalid_attachment_url');
56
- }
57
- if (!addresses.length || addresses.some(isBlockedAddress)) {
58
- throw invalidRequest(`Attachment host '${hostname}' is not a public address.`, null, 'invalid_attachment_url');
59
- }
60
- }));
127
+ const hosts = new Set(remote.map((part) => new URL(part.url).hostname));
128
+ await Promise.all([...hosts].map((hostname) => resolvePublic(hostname)));
61
129
  }
@@ -0,0 +1,42 @@
1
+ import { CHARS_PER_TOKEN } from './length-limit.js';
2
+
3
+ export const NO_USAGE = Object.freeze({ input: 0, output: 0, reasoning: 0, cacheRead: 0 });
4
+
5
+ export const estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN);
6
+
7
+ /**
8
+ * Input tokens OpenCode adds on top of the prompt text (its system prompt and
9
+ * tool list), as last measured for each model. Model ids come from the
10
+ * catalog, so the map stays small.
11
+ */
12
+ const overheadByModel = new Map();
13
+ let lastOverhead = 0;
14
+
15
+ const modelKey = (prompt) => `${prompt.model?.providerID}/${prompt.model?.modelID}`;
16
+
17
+ function promptTokens(prompt) {
18
+ const texts = [prompt.system || '', ...prompt.parts.filter((part) => part.type === 'text').map((part) => part.text)];
19
+ return texts.reduce((sum, text) => sum + estimateTokens(text), 0);
20
+ }
21
+
22
+ /** Remember how far a finished turn's real input count was above the prompt text alone. */
23
+ export function learnPromptOverhead(prompt, usage) {
24
+ const measured = usage.input + usage.cacheRead;
25
+ if (!(measured > 0)) return;
26
+ lastOverhead = Math.max(0, measured - promptTokens(prompt));
27
+ overheadByModel.set(modelKey(prompt), lastOverhead);
28
+ }
29
+
30
+ /**
31
+ * Usage for a turn cut off before OpenCode reported any: the output side
32
+ * from the text that was produced, the input side from the prompt plus the
33
+ * overhead last measured for this model (or any model, before the first).
34
+ */
35
+ export function estimateUsage(prompt, content, reasoning) {
36
+ return {
37
+ ...NO_USAGE,
38
+ input: promptTokens(prompt) + (overheadByModel.get(modelKey(prompt)) ?? lastOverhead),
39
+ output: estimateTokens(content),
40
+ reasoning: estimateTokens(reasoning),
41
+ };
42
+ }
@@ -1,22 +1,45 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
- import net from 'node:net';
3
2
  import { createOpencodeClient } from './client.js';
4
3
  import { resolveOpencodeBinary, spawnCommand } from './binary.js';
5
- import { backendEnv, createIsolatedRoot, randomPassword, removeIsolatedRoot, sweepStaleRoots } from './isolation.js';
4
+ import {
5
+ backendEnv, createIsolatedRoot, HEARTBEAT_MS, randomPassword, recordBackendPid, removeIsolatedRoot, sweepStaleRoots, touchIsolatedRoot,
6
+ } from './isolation.js';
6
7
 
7
8
  const START_TIMEOUT_MS = 60000;
9
+ // Sweeping now and then (not only at start) also clears trees that were
10
+ // still too fresh to judge when this gateway started.
11
+ const SWEEP_EVERY_BEATS = 10;
8
12
  const MAX_RESTART_DELAY_MS = 30000;
9
13
  const ANSI = /\x1b\[[0-9;]*m/g;
10
14
 
11
- function freePort() {
15
+ const LISTENING = /listening on http:\/\/127\.0\.0\.1:(\d{1,5})\b/;
16
+
17
+ /**
18
+ * The port OpenCode reports once it has bound it. OpenCode picks the port
19
+ * itself (--port 0), so no other process can take it between being chosen
20
+ * and being bound and then pose as the backend.
21
+ */
22
+ function boundPort(proc, timeoutMs) {
12
23
  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
- });
24
+ let seen = '';
25
+ const onData = (chunk) => {
26
+ seen = `${seen}${chunk}`.slice(-4096);
27
+ const port = Number(LISTENING.exec(seen.replace(ANSI, ''))?.[1]);
28
+ if (port > 0 && port < 65536) finish(null, port);
29
+ };
30
+ const onExit = () => finish(new Error('OpenCode exited during startup'));
31
+ const timer = setTimeout(() => finish(new Error(`OpenCode did not start listening in ${timeoutMs / 1000}s`)), timeoutMs);
32
+ function finish(error, port) {
33
+ clearTimeout(timer);
34
+ proc.stdout.off('data', onData);
35
+ proc.off('exit', onExit);
36
+ proc.off('error', onExit);
37
+ if (error) reject(error);
38
+ else resolve(port);
39
+ }
40
+ proc.stdout.on('data', onData);
41
+ proc.once('exit', onExit);
42
+ proc.once('error', onExit);
20
43
  });
21
44
  }
22
45
 
@@ -63,7 +86,7 @@ function pipeLogs(stream, logger) {
63
86
 
64
87
  /**
65
88
  * 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.
89
+ * port it picks itself, with a random password. It restarts with backoff if it crashes.
67
90
  */
68
91
  export function createManagedBackend({ opencodePath, logger }) {
69
92
  let client = null;
@@ -73,15 +96,16 @@ export function createManagedBackend({ opencodePath, logger }) {
73
96
  let stopping = false;
74
97
  let restartDelay = 1000;
75
98
  let ready = false;
76
- let everReady = false;
99
+ let heartbeat = null;
77
100
 
78
101
  async function launch() {
79
102
  const binary = resolveOpencodeBinary(opencodePath);
80
103
  dirs = createIsolatedRoot();
81
- const port = await freePort();
104
+ const { root } = dirs;
105
+ const deadline = Date.now() + START_TIMEOUT_MS;
82
106
  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 });
107
+ const { command, args, windowsVerbatimArguments } = spawnCommand(binary.path, ['serve', '--hostname', '127.0.0.1', '--port', '0']);
108
+ logger.debug('Starting OpenCode backend', { binary: binary.path, source: binary.source });
85
109
  const proc = spawn(command, args, {
86
110
  cwd: dirs.workspace,
87
111
  env: backendEnv(dirs, password),
@@ -91,54 +115,94 @@ export function createManagedBackend({ opencodePath, logger }) {
91
115
  windowsVerbatimArguments,
92
116
  });
93
117
  child = proc;
118
+ if (proc.pid) recordBackendPid(root, proc.pid);
94
119
  pipeLogs(proc.stdout, logger);
95
120
  pipeLogs(proc.stderr, logger);
96
121
  let dead = false;
122
+ let served = false;
97
123
  proc.once('error', (error) => { dead = true; logger.error('Could not start OpenCode', { error: error.message }); });
98
124
  proc.once('exit', (code, signal) => {
99
125
  dead = true;
100
- ready = false;
101
- if (!stopping && everReady) onCrash(code, signal, dirs.root);
126
+ if (child === proc) ready = false;
127
+ // A backend that dies while starting is handled by whoever is
128
+ // waiting for launch(); only a crash after it served is restarted here.
129
+ if (!stopping && served) onCrash(signal || code, root);
102
130
  });
131
+ const port = await boundPort(proc, START_TIMEOUT_MS);
103
132
  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);
133
+ const health = await waitHealthy(candidate, deadline, () => dead);
105
134
  client = candidate;
106
135
  version = health.version || null;
107
136
  ready = true;
108
- everReady = true;
137
+ served = true;
109
138
  restartDelay = 1000;
110
139
  logger.info(`OpenCode ${version || ''} backend ready (managed, isolated, tools disabled)`.replace(' ', ' '));
111
140
  }
112
141
 
113
- function onCrash(code, signal, root) {
142
+ /** Kill the current backend, if any, and wait (briefly) until it is gone. */
143
+ async function terminate() {
144
+ const proc = child;
145
+ if (!proc?.pid || proc.exitCode !== null || proc.signalCode !== null) return;
146
+ const exited = new Promise((resolve) => proc.once('exit', resolve));
147
+ killTree(proc);
148
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 5000).unref())]);
149
+ }
150
+
151
+ /** Relaunch with exponential backoff until a backend is up or the gateway stops. */
152
+ async function restart(reason) {
153
+ let why = reason;
154
+ while (!stopping) {
155
+ const delay = restartDelay;
156
+ restartDelay = Math.min(MAX_RESTART_DELAY_MS, restartDelay * 2);
157
+ await new Promise((resolve) => setTimeout(resolve, delay).unref());
158
+ if (stopping) return;
159
+ logger.warn(`OpenCode backend ${why}; restarting after ${delay / 1000}s`);
160
+ try {
161
+ await launch();
162
+ return;
163
+ } catch (error) {
164
+ if (stopping) {
165
+ removeIsolatedRoot(dirs?.root);
166
+ return;
167
+ }
168
+ logger.error('OpenCode restart failed', { error: error.message });
169
+ await terminate();
170
+ removeIsolatedRoot(dirs?.root);
171
+ why = 'failed to restart';
172
+ }
173
+ }
174
+ }
175
+
176
+ function onCrash(reason, root) {
114
177
  // Ctrl+C and service managers signal the whole process group, so OpenCode
115
178
  // can exit just before the gateway starts stopping. Only warn if the
116
179
  // restart actually goes ahead.
117
- const reason = signal || code;
118
180
  logger.debug(`OpenCode backend exited (${reason})`);
119
181
  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();
182
+ void restart(`exited (${reason})`);
130
183
  }
131
184
 
132
185
  return Object.freeze({
133
186
  mode: 'managed',
134
187
  async start() {
135
- const swept = sweepStaleRoots();
136
- if (swept) logger.debug(`Removed ${swept} stale backend directories`);
188
+ const sweep = async () => {
189
+ const swept = await sweepStaleRoots();
190
+ if (swept) logger.debug(`Removed ${swept} stale backend directories`);
191
+ };
192
+ await sweep();
193
+ let beats = 0;
194
+ heartbeat = setInterval(() => {
195
+ if (dirs) touchIsolatedRoot(dirs.root);
196
+ beats += 1;
197
+ if (beats % SWEEP_EVERY_BEATS === 0) void sweep();
198
+ }, HEARTBEAT_MS);
199
+ heartbeat.unref();
137
200
  try {
138
201
  await launch();
139
202
  } catch (error) {
140
203
  stopping = true;
141
- killTree(child);
204
+ clearInterval(heartbeat);
205
+ await terminate();
142
206
  removeIsolatedRoot(dirs?.root);
143
207
  throw error;
144
208
  }
@@ -153,11 +217,8 @@ export function createManagedBackend({ opencodePath, logger }) {
153
217
  async stop() {
154
218
  stopping = true;
155
219
  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
- }
220
+ clearInterval(heartbeat);
221
+ await terminate();
161
222
  removeIsolatedRoot(dirs?.root);
162
223
  },
163
224
  });
@@ -65,6 +65,7 @@ export function createOpencodeClient({ baseUrl, username, password, fetchImpl =
65
65
  deleteSession: (sessionId, options) => request('DELETE', `/session/${id(sessionId)}`, options),
66
66
  abortSession: (sessionId, options) => request('POST', `/session/${id(sessionId)}/abort`, { timeoutMs: 5000, ...options }),
67
67
  messages: (sessionId, options) => request('GET', `/session/${id(sessionId)}/message`, options),
68
+ message: (sessionId, messageId, options) => request('GET', `/session/${id(sessionId)}/message/${id(messageId)}`, options),
68
69
  /** Blocks until the assistant turn finishes; returns { info, parts }. */
69
70
  prompt: (sessionId, body, options) => request('POST', `/session/${id(sessionId)}/message`, { body, timeoutMs: 0, ...options }),
70
71
  replyPermission: (requestId, reply, message) =>