feat(opencode): support OpenCode v2 alongside v1 sessions (#2042)

This commit is contained in:
Junmo Kim
2026-10-25 13:37:17 +08:00
committed by GitHub
parent 5d8deb2011
commit 33a9a740e8
13 changed files with 3604 additions and 48 deletions
+487 -1
View File
@@ -1,6 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const constructorCalls: Array<{ command: string; args?: string[]; env?: Record<string, string> }> = [];
const spawnCalls: Array<{ command: string; args?: string[]; options?: Record<string, unknown> }> = [];
type FakeChild = {
stdout: { on: (event: string, cb: (chunk: string) => void) => void };
on: (event: string, cb: (arg?: unknown) => void) => void;
kill: () => void;
};
let fakeSpawnImpl: ((command: string, args: string[]) => FakeChild) | null = null;
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof import('node:child_process')>()),
spawn: (command: string, args: string[], options?: Record<string, unknown>) => {
spawnCalls.push({ command, args, options });
if (!fakeSpawnImpl) {
throw new Error('spawn not configured');
}
return fakeSpawnImpl(command, args);
}
}));
function makeFakeChild(stdoutText: string, opts?: { error?: Error; noClose?: boolean }): FakeChild {
const handlers = new Map<string, Array<(arg?: unknown) => void>>();
const stdoutHandlers = new Map<string, Array<(chunk: string) => void>>();
const child: FakeChild = {
stdout: {
on: (event, cb) => {
const list = stdoutHandlers.get(event) ?? [];
list.push(cb);
stdoutHandlers.set(event, list);
}
},
on: (event, cb) => {
const list = handlers.get(event) ?? [];
list.push(cb);
handlers.set(event, list);
},
kill: () => {}
};
queueMicrotask(() => {
for (const cb of stdoutHandlers.get('data') ?? []) cb(stdoutText);
if (opts?.error) {
for (const cb of handlers.get('error') ?? []) cb(opts.error);
} else if (!opts?.noClose) {
for (const cb of handlers.get('close') ?? []) cb();
}
});
return child;
}
function configureSpawn(stdoutText: string, opts?: { error?: Error }): void {
fakeSpawnImpl = () => makeFakeChild(stdoutText, opts);
}
vi.mock('@/agent/backends/acp', () => ({
AcpSdkBackend: vi.fn().mockImplementation(function (
@@ -12,7 +65,57 @@ vi.mock('@/agent/backends/acp', () => ({
})
}));
import { allocateFreePort, createOpencodeBackend } from './opencodeBackend';
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs/promises')>()),
readdir: vi.fn(async () => ['self'])
}));
import { allocateFreePort, createOpencodeBackend, detectOpencodeFlavorVersion, discoverOpencodeV2RestPort, listChildPids, listLoopbackListeners, listLoopbackListenersForPids, parseLsofListeners, parseSsListeners, parseSsPidListeners } from './opencodeBackend';
describe('detectOpencodeFlavorVersion', () => {
beforeEach(() => {
spawnCalls.length = 0;
fakeSpawnImpl = null;
});
it('detects v2 from "opencode v2.0.29" stdout', async () => {
configureSpawn('opencode v2.0.29\n');
await expect(detectOpencodeFlavorVersion('myopencode')).resolves.toBe('v2');
expect(spawnCalls).toEqual([{ command: 'myopencode', args: ['--version'], options: expect.objectContaining({ stdio: ['ignore', 'pipe', 'ignore'] }) }]);
});
it('detects v1 from "1.19.0" stdout', async () => {
configureSpawn('1.19.0\n');
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v1');
});
it('detects v2 from "opencode 2.1.0" stdout without v prefix', async () => {
configureSpawn('opencode 2.1.0\n');
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v2');
});
it('falls back to v1 when stdout is empty', async () => {
configureSpawn('');
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v1');
});
it('falls back to v1 when spawn errors', async () => {
configureSpawn('', { error: new Error('spawn failed') });
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v1');
});
it('spawns through a shell on Windows so .cmd shims resolve', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
try {
configureSpawn('opencode v2.0.29\n');
await expect(detectOpencodeFlavorVersion('opencode')).resolves.toBe('v2');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform });
}
expect(spawnCalls[spawnCalls.length - 1]?.options).toMatchObject({ shell: true, windowsHide: true });
});
});
describe('allocateFreePort', () => {
it('resolves a bindable loopback port number', async () => {
@@ -52,4 +155,387 @@ describe('createOpencodeBackend', () => {
createOpencodeBackend({ cwd: '/tmp/x' });
expect(constructorCalls[0]?.args).toEqual(['acp', '--cwd', '/tmp/x']);
});
it('v1: keeps existing args even when majorVersion is explicitly v1', () => {
createOpencodeBackend({ cwd: '/tmp/x', port: 5555, hostname: '127.0.0.1', majorVersion: 'v1' });
expect(constructorCalls[0]?.args).toEqual([
'acp', '--cwd', '/tmp/x', '--port', '5555', '--hostname', '127.0.0.1'
]);
});
it('v2: passes only [acp] args with spawnCwd and config-option model switch', () => {
createOpencodeBackend({ cwd: '/tmp/x', majorVersion: 'v2' });
const call = constructorCalls[0] as unknown as {
args?: string[];
spawnCwd?: string;
acpModelSwitchViaConfigOption?: boolean;
};
expect(call.args).toEqual(['acp']);
expect(call.spawnCwd).toBe('/tmp/x');
expect(call.acpModelSwitchViaConfigOption).toBe(true);
});
it('advertises OpenCode child session updates only for v2', () => {
createOpencodeBackend({ cwd: '/tmp/x', majorVersion: 'v2' });
createOpencodeBackend({ cwd: '/tmp/x', majorVersion: 'v1' });
const calls = constructorCalls as unknown as Array<{ childSessionUpdates?: boolean }>;
expect(calls[0]?.childSessionUpdates).toBe(true);
expect(calls[1]?.childSessionUpdates).toBeUndefined();
});
it('v2: defaults spawnCwd to the invoked cwd when opts.cwd is absent', () => {
createOpencodeBackend({ majorVersion: 'v2' });
const call = constructorCalls[0] as unknown as { spawnCwd?: string };
expect(typeof call.spawnCwd).toBe('string');
expect(call.spawnCwd?.length).toBeGreaterThan(0);
});
});
describe('parseSsListeners', () => {
it('extracts only 127.0.0.1 LISTEN ports from ss -tln output', () => {
const output = [
'State Recv-Q Send-Q Local Address:Port Peer Address:Port Process',
'LISTEN 0 128 127.0.0.1:40941 0.0.0.0:*',
'LISTEN 0 511 [::1]:40942 [::]:*',
'LISTEN 0 4096 0.0.0.0:22 0.0.0.0:*',
'LISTEN 0 128 127.0.0.1:39999 0.0.0.0:*'
].join('\n');
expect(parseSsListeners(output)).toEqual(new Set([40941, 39999]));
});
it('returns empty set for output without loopback listeners', () => {
expect(parseSsListeners('State Recv-Q Send-Q\nLISTEN 0 128 0.0.0.0:22 0.0.0.0:*')).toEqual(new Set());
expect(parseSsListeners('')).toEqual(new Set());
});
});
describe('parseLsofListeners', () => {
it('extracts only 127.0.0.1 LISTEN ports from lsof output', () => {
const output = [
'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME',
'node 12345 lupin 24u IPv4 0x1234567890abcdef 0t0 TCP 127.0.0.1:40941 (LISTEN)',
'node 12345 lupin 25u IPv6 0x1234567890abcdee 0t0 TCP [::1]:40942 (LISTEN)',
'sshd 1000 root 3u IPv4 0x1234567890abcddf 0t0 TCP *:22 (LISTEN)'
].join('\n');
expect(parseLsofListeners(output)).toEqual(new Set([40941]));
});
it('returns empty set for output without loopback listeners', () => {
expect(parseLsofListeners('COMMAND PID USER\nsshd 1000 root 3u IPv4 *:22 (LISTEN)')).toEqual(new Set());
expect(parseLsofListeners('')).toEqual(new Set());
});
});
describe('listLoopbackListeners', () => {
it('parses ss output on linux', async () => {
const listeners = await listLoopbackListeners({
execImpl: async (command, args) => {
expect(command).toBe('ss');
expect(args).toEqual(['-tln']);
return 'LISTEN 0 128 127.0.0.1:41000 0.0.0.0:*';
}
});
expect(listeners).toEqual(new Set([41000]));
});
it('falls back to lsof on darwin', async () => {
const listeners = await listLoopbackListeners({
platform: 'darwin',
execImpl: async (command, args) => {
expect(command).toBe('lsof');
expect(args).toEqual(['-iTCP', '-sTCP:LISTEN', '-n', '-P']);
return 'node 1 u 4u IPv4 TCP 127.0.0.1:41001 (LISTEN)';
}
});
expect(listeners).toEqual(new Set([41001]));
});
it('returns empty set when both commands fail', async () => {
const listeners = await listLoopbackListeners({
execImpl: async () => {
throw new Error('no ss, no lsof');
}
});
expect(listeners.size).toBe(0);
});
});
describe('discoverOpencodeV2RestPort', () => {
it('returns the first port newly bound since the before snapshot', async () => {
const beforeSnapshot = new Set([1000, 2000]);
let call = 0;
const port = await discoverOpencodeV2RestPort({
beforeSnapshot,
pollIntervalMs: 1,
scanImpl: async () => {
call++;
return call === 1 ? new Set([1000, 2000]) : new Set([1000, 2000, 41234]);
}
});
expect(port).toBe(41234);
});
it('prefers the lowest new port when several appear', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set(),
pollIntervalMs: 1,
scanImpl: async () => new Set([55555, 40000, 47777])
});
expect(port).toBe(40000);
});
it('returns null on timeout when no new port appears', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set([1000]),
timeoutMs: 30,
pollIntervalMs: 10,
scanImpl: async () => new Set([1000])
});
expect(port).toBeNull();
});
it('returns null when scanning always fails', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set(),
timeoutMs: 30,
pollIntervalMs: 10,
scanImpl: async () => {
throw new Error('scan failed');
}
});
expect(port).toBeNull();
});
});
describe('parseSsPidListeners', () => {
it('extracts 127.0.0.1 ports owned by the given pid from ss -tlnp output', () => {
const output = [
'State Recv-Q Send-Q Local Address:Port Peer Address:Port Process',
'LISTEN 0 128 127.0.0.1:41234 0.0.0.0:* users:(("opencode",pid=4242,fd=18))',
'LISTEN 0 128 127.0.0.1:41999 0.0.0.0:* users:(("other",pid=999,fd=11))',
'LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=4242,fd=3))'
].join('\n');
expect(parseSsPidListeners(output, 4242)).toEqual(new Set([41234]));
});
it('returns empty set when the pid owns no loopback listener', () => {
const output = 'LISTEN 0 128 127.0.0.1:41999 0.0.0.0:* users:(("other",pid=999,fd=11))';
expect(parseSsPidListeners(output, 4242)).toEqual(new Set());
});
});
describe('listLoopbackListenersForPids', () => {
it('parses ss -tlnp output filtered by pid on linux', async () => {
const listeners = await listLoopbackListenersForPids([4242], {
platform: 'linux',
execImpl: async (command, args) => {
expect(command).toBe('ss');
expect(args).toEqual(['-tlnp']);
return 'LISTEN 0 128 127.0.0.1:41234 0.0.0.0:* users:(("opencode",pid=4242,fd=18))';
}
});
expect(listeners).toEqual(new Set([41234]));
});
it('falls back to lsof -a -p when ss fails', async () => {
const listeners = await listLoopbackListenersForPids([4242], {
execImpl: async (command, args) => {
if (command === 'ss') {
throw new Error('ss missing');
}
expect(command).toBe('lsof');
expect(args).toEqual(['-a', '-p', '4242', '-iTCP', '-sTCP:LISTEN', '-n', '-P']);
return 'node 4242 u 20u IPv4 TCP 127.0.0.1:41235 (LISTEN)';
}
});
expect(listeners).toEqual(new Set([41235]));
});
it('throws when ss fails and lsof is missing (caller falls back to the diff heuristic)', async () => {
await expect(listLoopbackListenersForPids([4242], {
execImpl: async (command) => {
if (command === 'lsof') {
const err: NodeJS.ErrnoException = new Error('lsof not found');
err.code = 'ENOENT';
throw err;
}
throw new Error('ss failed');
}
})).rejects.toThrow();
});
it('treats a lsof no-match (non-ENOENT failure) as an empty set so a listening sibling survives', async () => {
// macOS: no ss; lsof exits 1 when the pid owns no socket. The ACP
// parent often owns no listener — its failure must not discard the
// serve child's result.
const listeners = await listLoopbackListenersForPids([111, 222], {
execImpl: async (command, args) => {
if (command === 'ss') throw new Error('ss missing');
expect(args).toEqual(['-a', '-p', expect.any(String), '-iTCP', '-sTCP:LISTEN', '-n', '-P']);
// Decisive: pid 111's scan must never contribute a port, so
// the pre-fix Promise.all rejection path (discarding pid 222's
// result and falling back to the diff heuristic) and any
// misattribution both fail this assertion.
if (args[2] === '111') throw new Error('lsof exited 1: no match');
if (args[2] === '222') return 'node 222 u 20u IPv4 TCP 127.0.0.1:41236 (LISTEN)';
throw new Error('unexpected lsof invocation');
}
});
expect(listeners).toEqual(new Set([41236]));
});
});
describe('discoverOpencodeV2RestPort ownerPid preference', () => {
it('prefers a post-snapshot owned port over a pre-existing one owned by the same tree', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set([41234]),
pollIntervalMs: 1,
ownerPids: [4242],
scanImpl: async () => new Set([41234, 40000, 47777]),
scanOwnedImpl: async () => new Set([41234, 47777])
});
expect(port).toBe(47777);
});
it('returns the pid-owned port even when it was already in the before snapshot', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set([41234]),
pollIntervalMs: 1,
ownerPid: 4242,
scanImpl: async () => new Set(),
scanOwnedImpl: async () => new Set([41234])
});
expect(port).toBe(41234);
});
it('falls back to the diff heuristic when the owned scan fails', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set([1000]),
pollIntervalMs: 1,
ownerPid: 4242,
scanImpl: async () => new Set([1000, 41999]),
scanOwnedImpl: async () => {
throw new Error('tools missing');
}
});
expect(port).toBe(41999);
});
it('keeps polling the owned scan and does not fall back while it succeeds but is empty', async () => {
let ownedCalls = 0;
let diffCalls = 0;
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set([1000]),
timeoutMs: 60,
pollIntervalMs: 10,
ownerPid: 4242,
scanImpl: async () => {
diffCalls++;
return new Set([1000, 55555]);
},
scanOwnedImpl: async () => {
ownedCalls++;
return ownedCalls >= 2 ? new Set([47777]) : new Set();
}
});
expect(port).toBe(47777);
expect(ownedCalls).toBeGreaterThanOrEqual(2);
expect(diffCalls).toBe(0);
});
});
describe('listChildPids', () => {
it('parses NUL/space-separated children from a /proc children fixture', async () => {
const pids = await listChildPids(4242, {
platform: 'linux',
readChildrenFile: async (pid: number, task: string) => {
expect(pid).toBe(4242);
expect(typeof task).toBe('string');
return '2017934\u00002017935 2017936';
}
});
expect(pids).toEqual([2017934, 2017935, 2017936]);
});
it('runs pgrep -P on non-linux platforms', async () => {
const pids = await listChildPids(4242, {
platform: 'darwin',
execImpl: async (command, args) => {
expect(command).toBe('pgrep');
expect(args).toEqual(['-P', '4242']);
return '2017934\n2017936\n';
}
});
expect(pids).toEqual([2017934, 2017936]);
});
it('returns an empty array when reading fails (linux)', async () => {
const pids = await listChildPids(4242, {
platform: 'linux',
readChildrenFile: async () => {
throw new Error('ENOENT');
}
});
expect(pids).toEqual([]);
});
it('returns an empty array when pgrep fails (darwin)', async () => {
const pids = await listChildPids(4242, {
platform: 'darwin',
execImpl: async () => {
throw new Error('no such process');
}
});
expect(pids).toEqual([]);
});
});
describe('discoverOpencodeV2RestPort ownerPids union', () => {
it('returns the lowest port owned by any of the given pids', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set(),
pollIntervalMs: 1,
ownerPids: [1000, 4242],
scanImpl: async () => new Set(),
scanOwnedImpl: async (pids) => {
expect(pids).toEqual([1000, 4242]);
return new Set([47777, 34019]);
}
});
expect(port).toBe(34019);
});
it('falls back to the diff heuristic when the owned scan fails', async () => {
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set([1000]),
pollIntervalMs: 1,
ownerPids: [4242],
scanImpl: async () => new Set([1000, 41999]),
scanOwnedImpl: async () => {
throw new Error('tools missing');
}
});
expect(port).toBe(41999);
});
it('keeps polling when the owned scan succeeds but is empty', async () => {
let ownedCalls = 0;
let diffCalls = 0;
const port = await discoverOpencodeV2RestPort({
beforeSnapshot: new Set([1000]),
timeoutMs: 60,
pollIntervalMs: 10,
ownerPids: [4242],
scanImpl: async () => {
diffCalls++;
return new Set([1000, 55555]);
},
scanOwnedImpl: async () => {
ownedCalls++;
return ownedCalls >= 2 ? new Set([47777]) : new Set();
}
});
expect(port).toBe(47777);
expect(ownedCalls).toBeGreaterThanOrEqual(2);
expect(diffCalls).toBe(0);
});
});
+444
View File
@@ -1,9 +1,66 @@
import { execFile, spawn } from 'node:child_process';
import { readdir, readFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
function readFileUtf8(path: string): Promise<string> {
return readFile(path, 'utf8');
}
import { AcpSdkBackend } from '@/agent/backends/acp';
import { buildOpencodeEnv } from './config';
import { getInvokedCwd } from '@/utils/invokedCwd';
import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand';
export type OpencodeFlavorVersion = 'v1' | 'v2';
/**
* Detects whether the installed `opencode` binary speaks the v1 (≤1.x) or
* v2 (≥2.x) CLI contract. v2 dropped the `opencode acp` `--cwd/--port/
* --hostname` flags and redesigned the internal HTTP API (see the
* opencode-v2-compat plan), so the launcher needs the major version before
* spawning. Runs `command --version` (5s timeout) and parses the first
* `v?N.` from stdout; any spawn failure, timeout or unparseable output
* falls back to 'v1' — the pre-v2-aware behavior.
*/
export async function detectOpencodeFlavorVersion(command = getAgentLaunchCommand('opencode')): Promise<'v1' | 'v2'> {
try {
const stdout = await new Promise<string>((resolve, reject) => {
// shell/windowsHide mirror buildAcpStdioSpawnOptions: on Windows,
// npm-style opencode.cmd shims require a shell to resolve, and a
// bare spawn would fail the probe and misclassify v2 as v1.
const child = spawn(command, ['--version'], {
stdio: ['ignore', 'pipe', 'ignore'],
shell: process.platform === 'win32',
windowsHide: process.platform === 'win32'
});
let out = '';
const timer = setTimeout(() => {
child.kill();
reject(new Error('opencode --version timed out'));
}, 5000);
child.stdout?.on('data', (chunk) => {
out += String(chunk);
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
});
child.on('close', () => {
clearTimeout(timer);
resolve(out);
});
});
const match = /v?(\d+)\./.exec(stdout);
const major = match ? Number.parseInt(match[1]!, 10) : Number.NaN;
return major === 2 ? 'v2' : 'v1';
} catch {
return 'v1';
}
}
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
@@ -51,8 +108,24 @@ export function createOpencodeBackend(opts: {
cwd?: string;
port?: number;
hostname?: string;
majorVersion?: 'v1' | 'v2';
}): AcpSdkBackend {
const env = buildOpencodeEnv();
if (opts.majorVersion === 'v2') {
// opencode v2 rejects `acp --cwd/--port/--hostname` ("Unrecognized
// flag"); the working directory is taken from the spawn cwd instead,
// and the internal HTTP API port is bound randomly (post-discovered).
return new AcpSdkBackend({
command: getAgentLaunchCommand('opencode'),
args: ['acp'],
env: filterEnv(env),
spawnCwd: opts.cwd ?? getInvokedCwd(),
acpModelSwitchViaConfigOption: true,
childSessionUpdates: true,
textChunkMode: 'delta',
flavor: 'opencode',
});
}
const args = ['acp', '--cwd', opts.cwd ?? getInvokedCwd()];
if (opts.port !== undefined) {
args.push('--port', String(opts.port));
@@ -69,3 +142,374 @@ export function createOpencodeBackend(opts: {
flavor: 'opencode',
});
}
/**
* Parses `ss -tln` output and returns the set of ports bound to 127.0.0.1.
* Accepts the real header row and arbitrary column spacing; non-loopback
* (0.0.0.0, [::1], [::]) listeners are excluded.
*/
export function parseSsListeners(output: string): Set<number> {
const ports = new Set<number>();
for (const line of output.split('\n')) {
if (!/\bLISTEN\b/.test(line)) continue;
const match = /127\.0\.0\.1:(\d+)/.exec(line);
if (match) {
ports.add(Number.parseInt(match[1]!, 10));
}
}
return ports;
}
/**
* Parses `lsof -iTCP -sTCP:LISTEN -n -P` output (macOS fallback) and returns
* the set of ports bound to 127.0.0.1.
*/
export function parseLsofListeners(output: string): Set<number> {
const ports = new Set<number>();
for (const line of output.split('\n')) {
const match = /127\.0\.0\.1:(\d+)/.exec(line);
if (match) {
ports.add(Number.parseInt(match[1]!, 10));
}
}
return ports;
}
/**
* Lists the child pids of the given process. Linux reads the per-task
* `children` files under `/proc/<pid>/task/` (NUL/space-separated); darwin
* shells out to `pgrep -P`. Any failure (process gone, tool missing,
* unparsable output) yields an empty array — callers treat that as "no
* children known", never as an error.
*/
export async function listChildPids(pid: number, opts?: {
platform?: NodeJS.Platform;
readChildrenFile?: (pid: number, task: string) => Promise<string>;
execImpl?: (command: string, args: string[]) => Promise<string>;
maxDepth?: number;
}): Promise<number[]> {
const platform = opts?.platform ?? process.platform;
const maxDepth = opts?.maxDepth ?? 4;
try {
if (platform === 'linux') {
const readChildren = opts?.readChildrenFile ?? (async (p: number, task: string) =>
await readFileUtf8(`/proc/${p}/task/${task}/children`));
const tasks = await readdir(`/proc/${pid}/task`);
// Union children across ALL tasks: threads can each hold a
// different child (e.g. a helper forked from one task and the
// serve child from another) — stopping at the first nonempty
// task would drop the rest.
const union = new Set<number>();
const direct: number[] = [];
for (const task of tasks) {
try {
const children = await readChildren(pid, task);
const pids = children.trim().split(/[\s\u0000]+/).filter(Boolean);
for (const value of pids) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) union.add(parsed);
direct.push(parsed);
}
} catch {
// Try the next task; fall through to [] if none yields.
}
}
// Descendants too: the npm wrapper spawns the native binary which
// spawns `serve` — the listener owner can be a grandchild.
if (maxDepth > 1) {
for (const child of direct) {
const grandChildren = await listChildPids(child, { ...opts, maxDepth: maxDepth - 1 });
for (const grandChild of grandChildren) union.add(grandChild);
}
}
return [...union];
}
const run = async (command: string, args: string[]): Promise<string> => {
if (opts?.execImpl) {
return await opts.execImpl(command, args);
}
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
return stdout;
};
const output = await run('pgrep', ['-P', String(pid)]);
const direct = output.trim().split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isFinite(value));
// Descendants too: the npm wrapper spawns the native binary which
// spawns `serve` — the listener owner can be a grandchild.
const union = new Set<number>(direct);
if (maxDepth > 1) {
for (const child of direct) {
const grandChildren = await listChildPids(child, { ...opts, maxDepth: maxDepth - 1 });
for (const grandChild of grandChildren) union.add(grandChild);
}
}
return [...union];
} catch {
return [];
}
}
/**
* Parses `ss -tlnp` output and returns the set of 127.0.0.1 ports whose
* process line contains `pid=<pid>` (a line may list several pids).
*/
export function parseSsPidListeners(output: string, pid: number): Set<number> {
const ports = new Set<number>();
for (const line of output.split('\n')) {
if (!/\bLISTEN\b/.test(line)) continue;
const pids = [...line.matchAll(/pid=(\d+)/g)].map((match) => Number.parseInt(match[1]!, 10));
if (!pids.includes(pid)) continue;
const match = /127\.0\.0\.1:(\d+)/.exec(line);
if (match) {
ports.add(Number.parseInt(match[1]!, 10));
}
}
return ports;
}
/**
* Lists loopback TCP listeners owned by the given pid: `ss -tlnp` with
* `pid=<N>` matching first, `lsof -a -p <pid> -iTCP -sTCP:LISTEN -n -P`
* (whose -p filter already scopes the output) as fallback. Throws when both
* tools are unavailable/unparsable so callers can fall back to the
* snapshot-diff heuristic; an empty set means the tools worked and the pid
* owns no loopback listener (yet).
*/
export async function listLoopbackListenersForPids(pids: number[], opts?: {
platform?: NodeJS.Platform;
execImpl?: (command: string, args: string[]) => Promise<string>;
}): Promise<Set<number>> {
if (pids.length === 0) return new Set();
const run = async (command: string, args: string[]): Promise<string> => {
if (opts?.execImpl) {
return await opts.execImpl(command, args);
}
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
return stdout;
};
const ports = new Set<number>();
try {
const output = await run('ss', ['-tlnp']);
for (const pid of pids) {
for (const port of parseSsPidListeners(output, pid)) {
ports.add(port);
}
}
return ports;
} catch {
// -a -p <pid> -iTCP -sTCP:LISTEN -n -P is portable across darwin/linux lsof builds.
// Per-pid aggregation: lsof exits non-zero when a pid owns no socket
// (macOS commonly has no ss; the ACP parent often owns no listener).
// A no-match result is an empty set for that pid — discarding it via
// Promise.all rejection would lose a sibling's successful scan and
// push discovery onto the racy snapshot-diff heuristic.
const owned = await Promise.all(pids.map(async (pid) => {
const lsofArgs = ['-a', '-p', String(pid), '-iTCP', '-sTCP:LISTEN', '-n', '-P'];
try {
return parseLsofListeners(await run('lsof', lsofArgs));
} catch (error) {
// A missing lsof binary is a genuine tool failure: rethrow so
// the caller falls back to the snapshot-diff heuristic.
if (error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === 'ENOENT') {
throw error;
}
return new Set<number>();
}
}));
for (const set of owned) {
for (const port of set) {
ports.add(port);
}
}
return ports;
}
}
/**
* Lists loopback TCP listeners via `ss -tln` (Linux), falling back to
* `lsof -iTCP -sTCP:LISTEN -n -P` (darwin). Both failing yields an empty
* set — callers treat that as "no new ports will ever be discovered" and
* take the compact-unavailable fallback.
*/
export async function listLoopbackListeners(opts?: {
platform?: NodeJS.Platform;
execImpl?: (command: string, args: string[]) => Promise<string>;
}): Promise<Set<number>> {
const platform = opts?.platform ?? process.platform;
const run = async (command: string, args: string[]): Promise<string> => {
if (opts?.execImpl) {
return await opts.execImpl(command, args);
}
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
return stdout;
};
if (platform === 'darwin') {
try {
return parseLsofListeners(await run('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P']));
} catch {
return new Set();
}
}
try {
return parseSsListeners(await run('ss', ['-tln']));
} catch {
try {
return parseLsofListeners(await run('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P']));
} catch {
return new Set();
}
}
}
/**
* Finds the descendant process that actually serves the v2 REST API: its
* command line contains the standalone `serve` argument with `--stdio`.
* Identifying it lets port discovery and credential lookup use a single,
* unambiguous process instead of picking the lowest listener across a tree
* that may also own unrelated listeners (e.g. configured MCP servers).
* Returns null when no descendant matches (caller falls back to the
* tree-wide scan).
*/
export async function findOpencodeServePid(opts: {
rootPids: number[];
platform?: NodeJS.Platform;
readCmdlineFile?: (pid: number) => Promise<string>;
execImpl?: (command: string, args: string[]) => Promise<string>;
maxDepth?: number;
}): Promise<number | null> {
const platform = opts?.platform ?? process.platform;
const isServeCommand = (cmdline: string): boolean =>
/\bserve\b/.test(cmdline) && /--stdio/.test(cmdline);
try {
if (platform === 'linux') {
const readCmdline = opts?.readCmdlineFile ?? (async (p: number) =>
await readFileUtf8(`/proc/${p}/cmdline`));
const seen = new Set<number>();
const queue = [...opts.rootPids];
let depth = 0;
while (queue.length > 0 && depth <= (opts?.maxDepth ?? 4)) {
const pid = queue.shift()!;
if (seen.has(pid)) continue;
seen.add(pid);
try {
if (isServeCommand(await readCmdline(pid))) return pid;
} catch {
// Unreadable pid: skip, keep walking.
}
if (depth < (opts?.maxDepth ?? 4)) {
for (const child of await listChildPids(pid, { platform })) {
if (!seen.has(child)) queue.push(child);
}
}
depth += 1;
}
return null;
}
const run = async (command: string, args: string[]): Promise<string> => {
if (opts?.execImpl) {
return await opts.execImpl(command, args);
}
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
return stdout;
};
const seen = new Set<number>();
const queue = [...opts.rootPids];
let depth = 0;
while (queue.length > 0 && depth <= (opts?.maxDepth ?? 4)) {
const pid = queue.shift()!;
if (seen.has(pid)) continue;
seen.add(pid);
try {
// ps Eww embeds the environment after COMMAND; isolate the
// command portion before matching (mirrors the credential
// reader's env-substring guard).
const psOutput = await run('ps', ['Eww', '-p', String(pid)]);
const commandPortion = psOutput.split(/\s+[A-Za-z_][A-Za-z0-9_]*=/)[0] ?? psOutput;
if (isServeCommand(commandPortion)) return pid;
} catch {
// Unreadable pid: skip, keep walking.
}
if (depth < (opts?.maxDepth ?? 4)) {
for (const child of await listChildPids(pid, { platform, execImpl: opts?.execImpl })) {
if (!seen.has(child)) queue.push(child);
}
}
depth += 1;
}
return null;
} catch {
return null;
}
}
/**
* Discovers the random loopback port opencode v2 bound for its internal HTTP
* API. v2 (unlike v1's `--port` flag) picks the port itself and exposes it
* nowhere (verified 2026-10-15: stdout/stderr/ACP responses all lack it), so
* the caller snapshots listeners before spawning and this polls for the
* first 127.0.0.1 port that appeared since. Returns null on timeout.
*/
export async function discoverOpencodeV2RestPort(opts: {
beforeSnapshot: Set<number>;
timeoutMs?: number;
pollIntervalMs?: number;
ownerPid?: number;
ownerPids?: number[];
scanImpl?: () => Promise<Set<number>>;
scanOwnedImpl?: (pids: number[]) => Promise<Set<number>>;
}): Promise<number | null> {
const timeoutMs = opts.timeoutMs ?? 10_000;
const pollIntervalMs = opts.pollIntervalMs ?? 500;
const scan = opts.scanImpl ?? (() => listLoopbackListeners());
const ownerPids = opts.ownerPids ?? (opts.ownerPid != null ? [opts.ownerPid] : null);
const scanOwned = opts.scanOwnedImpl ?? (ownerPids != null
? () => listLoopbackListenersForPids(ownerPids)
: null);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
// Prefer ownership-verified ports: a hit here cannot belong to any
// other process, even one that bound after the before-snapshot. Only
// an ownership check that is impossible (missing tools / unparsable
// output) falls back to the diff heuristic below; a successful but
// empty owned scan just means the server has not bound yet.
if (scanOwned) {
try {
const owned = [...await scanOwned(ownerPids!)].sort((a, b) => a - b);
if (owned.length > 0) {
// Ownership already excludes other processes, but prefer a
// port that is NEW since the snapshot: our tree could own
// more than one listener, and the oldest one may predate
// this compaction's REST surface.
const fresh = owned.filter((port) => !opts.beforeSnapshot.has(port));
return (fresh[0] ?? owned[0])!;
}
// Ownership check works but the pid owns nothing yet — keep
// polling it; the diff heuristic could only misattribute a
// port another process bound after the snapshot.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
continue;
} catch {
// Ownership check unavailable — fall through to the diff.
}
}
try {
const current = await scan();
const newPorts = [...current]
.filter((port) => !opts.beforeSnapshot.has(port))
.sort((a, b) => a - b);
if (newPorts.length > 0) {
return newPorts[0]!;
}
} catch {
// Transient scan failures (e.g. command missing) are retried until
// the deadline; a persistently broken scan path times out to null.
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
return null;
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { captureCompactionMarkerSnapshot, fetchCompactionResult, splitProviderModel, triggerOpencodeCompact } from './opencodeCompactBridge';
import { captureCompactionMarkerSnapshot, fetchCompactionResult, fetchCompactionResultV2, pollCompactionResultV2, readOpencodeV2ServerPassword, splitProviderModel, triggerOpencodeCompact, triggerOpencodeCompactV2 } from './opencodeCompactBridge';
// `signal` is a required field (see OpencodeCompactCallOpts's doc comment) —
// most tests below don't exercise abort behavior at all, so this is a
@@ -353,3 +353,719 @@ describe('fetchCompactionResult', () => {
});
});
});
describe('triggerOpencodeCompactV2', () => {
it('posts to /api/session/:id/compact with Basic auth and the v2.0.29 payload shape', async () => {
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
const signal = new AbortController().signal;
await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc/1',
providerId: 'anthropic',
modelId: 'claude-sonnet-4-5',
password: 'secret-pw',
signal,
fetchImpl
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('http://127.0.0.1:48273/api/session/ses_abc%2F1/compact');
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>).authorization)
.toBe('Basic ' + Buffer.from('opencode:secret-pw').toString('base64'));
expect((init.headers as Record<string, string>)['content-type']).toBe('application/json');
// v2.0.29 payload schema is {id?, delivery?} — the session's current
// model governs server-side, so no provider/model fields are sent.
expect(JSON.parse(String(init.body))).toEqual({});
});
it('encodes the Basic credentials from the OPENCODE_PASSWORD value', async () => {
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
providerId: 'p',
modelId: 'm',
password: 'user:pass@with/special',
signal: new AbortController().signal,
fetchImpl
});
const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect((init.headers as Record<string, string>).authorization)
.toBe('Basic ' + Buffer.from('opencode:user:pass@with/special').toString('base64'));
});
it('reports kind rejected with status and body on non-2xx responses', async () => {
const fetchImpl = vi.fn(async () => new Response('denied', { status: 401 }));
const rejected = await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
providerId: 'p',
modelId: 'm',
password: 'pw',
signal: new AbortController().signal,
fetchImpl
});
expect(rejected).toEqual({ kind: 'rejected', error: expect.stringMatching(/401/) });
const second = await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
providerId: 'p',
modelId: 'm',
password: 'pw',
signal: new AbortController().signal,
fetchImpl
});
expect(second.kind).toBe('rejected');
});
it('treats 5xx as admission-uncertain (recovery, not definitive failure)', async () => {
const fetchImpl = vi.fn(async () => new Response('boom', { status: 503 }));
const result = await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
providerId: 'p',
modelId: 'm',
password: 'pw',
signal: new AbortController().signal,
fetchImpl
});
expect(result.kind).toBe('transport-error');
});
it('treats 4xx as definitive rejection', async () => {
const fetchImpl = vi.fn(async () => new Response('denied', { status: 400 }));
const result = await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
providerId: 'p',
modelId: 'm',
password: 'pw',
signal: new AbortController().signal,
fetchImpl
});
expect(result.kind).toBe('rejected');
});
it('reports kind transport-error on fetch failures (admission uncertain)', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('connection refused');
});
const result = await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
providerId: 'p',
modelId: 'm',
password: 'pw',
signal: new AbortController().signal,
fetchImpl
});
expect(result).toEqual({ kind: 'transport-error', message: 'connection refused' });
});
});
describe('readOpencodeV2ServerPassword', () => {
it('extracts OPENCODE_PASSWORD from a parsed /proc environ payload (linux)', async () => {
const environ = 'PATH=/usr/bin\0HOME=/home/u\0OPENCODE_PASSWORD=abc123\0TERM=xterm\0';
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'linux',
listDescendantPids: async () => [5150],
readChildEnviron: async () => environ,
readChildCmdline: async () => 'opencode\0serve\0--stdio'
});
expect(password).toBe('abc123');
});
it('returns null when the environ payload has no OPENCODE_PASSWORD', async () => {
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'linux',
listDescendantPids: async () => [5150],
readChildEnviron: async () => 'PATH=/usr/bin\0HOME=/home/u\0'
});
expect(password).toBeNull();
});
it('returns null when environ parsing fails (read error)', async () => {
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'linux',
listDescendantPids: async () => [5150],
readChildEnviron: async () => {
throw new Error('ENOENT');
}
});
expect(password).toBeNull();
});
it('extracts OPENCODE_PASSWORD from ps Eww output on non-linux platforms (descendants)', async () => {
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'darwin',
listDescendantPids: async () => [5150],
execImpl: async (command, args) => {
expect(command).toBe('ps');
expect(args).toEqual(['Eww', '-p', '5150']);
return ' PID TTY STAT TIME COMMAND\n 5150 ? Ss 0:00 /bin/opencode serve --stdio OPENCODE_PASSWORD=darwin-pw PATH=/usr/bin';
}
});
expect(password).toBe('darwin-pw');
});
it('linux path prefers the serve descendant (cmdline) over an inherited-password native child', async () => {
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'linux',
listDescendantPids: async () => [111, 222],
readChildEnviron: async (pid) => pid === 111
? 'OPENCODE_PASSWORD=inherited-pw\0PATH=/usr/bin'
: 'OPENCODE_PASSWORD=generated-pw\0PATH=/usr/bin',
readChildCmdline: async (pid) => pid === 111
? 'opencode\0acp'
: 'opencode\0serve\0--stdio'
});
expect(password).toBe('generated-pw');
});
it('linux serve detection requires the exact serve argument, not an executable path substring', async () => {
// Bot round-5 Minor: an executable under /home/server/ must not flag
// the acp child as serve.
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'linux',
listDescendantPids: async () => [111, 222],
readChildEnviron: async (pid) => pid === 111
? 'OPENCODE_PASSWORD=inherited-pw\0PATH=/usr/bin'
: 'OPENCODE_PASSWORD=generated-pw\0PATH=/usr/bin',
readChildCmdline: async (pid) => pid === 111
? '/home/server/bin/opencode\0acp'
: 'opencode\0serve\0--stdio'
});
expect(password).toBe('generated-pw');
});
it('prefers the serve descendant over one that merely inherited OPENCODE_PASSWORD', async () => {
// Bot round-4 Minor: an npm wrapper tree is wrapper → native acp →
// serve; a user-exported OPENCODE_PASSWORD is inherited by the
// native child while serve gets the freshly generated one.
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'darwin',
listDescendantPids: async () => [111, 222],
execImpl: async (_command, args) => {
if (args[2] === '111') {
return ' PID TTY STAT TIME COMMAND\n 111 ? Ss 0:00 /bin/opencode acp OPENCODE_PASSWORD=inherited-pw PATH=/usr/bin';
}
return ' PID TTY STAT TIME COMMAND\n 222 ? Ss 0:00 /bin/opencode serve --stdio OPENCODE_PASSWORD=generated-pw PATH=/usr/bin';
}
});
expect(password).toBe('generated-pw');
});
it('darwin serve detection matches the command portion only, not inherited env values', async () => {
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'darwin',
listDescendantPids: async () => [111, 222],
execImpl: async (_command, args) => {
if (args[2] === '111') {
// Inherited env contains a `serve` substring (e.g. a path
// segment) AND the executable path contains 'server' —
// neither may flag the acp child as the serve process.
return ' PID TTY STAT TIME COMMAND\n 111 ? Ss 0:00 /home/server/bin/opencode acp OPENCODE_PASSWORD=inherited-pw PRESERVE_DIR=/x/serve-cache PATH=/usr/bin';
}
return ' PID TTY STAT TIME COMMAND\n 222 ? Ss 0:00 /bin/opencode serve --stdio OPENCODE_PASSWORD=generated-pw PATH=/usr/bin';
}
});
expect(password).toBe('generated-pw');
});
it('returns null when pgrep/ps path yields nothing', async () => {
const password = await readOpencodeV2ServerPassword(4242, {
platform: 'darwin',
listDescendantPids: async () => [],
execImpl: async () => {
throw new Error('no such process');
}
});
expect(password).toBeNull();
});
});
describe('triggerOpencodeCompactV2 signal contract', () => {
it('forwards the caller abort signal to the fetch init', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }));
await triggerOpencodeCompactV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
providerId: 'p',
modelId: 'm',
password: 'pw',
signal: controller.signal,
fetchImpl
});
const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(init.signal).toBe(controller.signal);
});
});
describe('fetchCompactionResultV2', () => {
const authHeader = () => 'Basic ' + Buffer.from('opencode:pw').toString('base64');
function fixtureEntry(...entries: unknown[]): Response {
return new Response(JSON.stringify({ data: entries }), { status: 200 });
}
// v2 compaction shape (verified 2026-10-16 against opencode 2.0.29): the
// compaction entry itself carries `status` and the full `summary` text.
const completedEntry = (text: string) => ({
type: 'compaction',
status: 'completed',
reason: 'manual',
summary: text
});
it('GETs /api/session/:id/message with Basic auth and unwraps the {data: [...]} response', async () => {
const fetchImpl = vi.fn(async () => fixtureEntry(completedEntry('v2 summary text')));
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_x/2',
password: 'pw',
fetchImpl
});
expect(result).toEqual({ status: 'success', text: 'v2 summary text' });
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('http://127.0.0.1:48273/api/session/ses_x%2F2/message');
expect(init.method).toBe('GET');
expect((init.headers as Record<string, string>).authorization).toBe(authHeader());
});
it('reports failed when the latest compaction entry carries an error status', async () => {
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
fetchImpl: async () => fixtureEntry({ type: 'compaction', status: 'error', reason: 'manual', error: { data: { message: 'provider exploded' } } })
});
expect(result).toEqual({ status: 'failed', reason: 'provider exploded' });
});
it('reports failed when the completed entry has an empty summary', async () => {
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
fetchImpl: async () => fixtureEntry(completedEntry(' '))
});
expect(result).toEqual({ status: 'failed', reason: 'OpenCode returned an empty compaction summary.' });
});
it('reports unverified on non-2xx responses', async () => {
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
fetchImpl: async () => new Response('nope', { status: 401 })
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('reports unverified when the response wrapper has no data array', async () => {
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
fetchImpl: async () => new Response(JSON.stringify({ data: null }), { status: 200 })
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('reports unverified when no compaction entry exists yet (pending state)', async () => {
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
fetchImpl: async () => fixtureEntry({ type: 'compaction', status: 'pending', reason: 'manual' })
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('correlates by expectedCompactionId when an older completed compaction precedes it (descending order)', async () => {
// Measured 2026-10-16: the messages endpoint returns entries newest
// first, and the compact POST echoes the admitted compaction id.
const olderCompleted = { id: 'msg_old', type: 'compaction', status: 'completed', reason: 'manual', summary: 'stale summary' };
const newerPending = { id: 'msg_new', type: 'compaction', status: 'pending', reason: 'manual' };
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: 'msg_new',
// Descending (newest first), as measured.
fetchImpl: async () => fixtureEntry(newerPending, olderCompleted)
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('without a correlation id but with an exclude list, evaluates the single new compaction entry', async () => {
const olderCompleted = { id: 'msg_old', type: 'compaction', status: 'completed', reason: 'manual', summary: 'stale summary' };
const newerCompleted = { id: 'msg_new', type: 'compaction', status: 'completed', reason: 'manual', summary: 'fresh summary' };
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: null,
excludeCompactionIds: ['msg_old'],
// Descending (newest first), as measured.
fetchImpl: async () => fixtureEntry(newerCompleted, olderCompleted)
});
expect(result).toEqual({ status: 'success', text: 'fresh summary' });
});
it('a completed entry with an empty summary but a providerContext is a native-compaction success', async () => {
// Upstream v2 native provider compaction (verified against the v2
// binary: model.compaction?.type === "native" publishes
// providerContext instead of summary text) legitimately completes
// with summary: "".
const entry = { id: 'msg_native', type: 'compaction', status: 'completed', reason: 'manual', summary: '', providerContext: { version: 1, messages: [] } };
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: 'msg_native',
fetchImpl: async () => fixtureEntry(entry)
});
expect(result).toEqual({ status: 'success', text: '' });
});
it('a completed entry with an empty summary but a native window is a 2.1-native-compaction success', async () => {
// OpenCode 2.1 stores a native compaction's replacement window in
// native.messages (retained messages plus the assistant checkpoint)
// and still completes with summary: "", which must not be reported as
// an empty summary.
const entry = {
id: 'msg_native21',
type: 'compaction',
status: 'completed',
reason: 'manual',
summary: '',
native: { messages: [{ role: 'assistant', content: [{ type: 'text', text: 'checkpoint' }] }] }
};
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: 'msg_native21',
fetchImpl: async () => fixtureEntry(entry)
});
expect(result).toEqual({ status: 'success', text: '' });
});
it('a completed entry with an empty summary and a native record without a window is a failure', async () => {
// OpenCode 2.1 only treats a compaction as native when
// native.messages is present; other native metadata alone does not
// replace the summary.
const entry = { id: 'msg_native21_empty', type: 'compaction', status: 'completed', reason: 'manual', summary: '', native: {} };
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: 'msg_native21_empty',
fetchImpl: async () => fixtureEntry(entry)
});
expect(result).toEqual({ status: 'failed', reason: 'OpenCode returned an empty compaction summary.' });
});
it('reads the v2 top-level error message ({type, message}) as the failure reason', async () => {
// v2.0.29 session-error shape: { type, message } — safeErrorReason
// must read the top-level message, not only the v1 data.message.
const entry = { id: 'msg_fail', type: 'compaction', status: 'failed', reason: 'manual', error: { type: 'compaction.unavailable', message: 'Nothing to compact yet' } };
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: 'msg_fail',
fetchImpl: async () => fixtureEntry(entry)
});
expect(result).toEqual({ status: 'failed', reason: 'Nothing to compact yet' });
});
it('a completed entry with an empty summary and NO providerContext stays failed', async () => {
const entry = { id: 'msg_empty', type: 'compaction', status: 'completed', reason: 'manual', summary: ' ' };
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: 'msg_empty',
fetchImpl: async () => fixtureEntry(entry)
});
expect(result).toEqual({ status: 'failed', reason: 'OpenCode returned an empty compaction summary.' });
});
it('an empty exclude snapshot is a valid basis — a fresh session evaluates the single new entry', async () => {
const entry = { id: 'msg_only', type: 'compaction', status: 'completed', reason: 'manual', summary: 'fresh summary' };
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: null,
excludeCompactionIds: [],
fetchImpl: async () => fixtureEntry(entry)
});
expect(result).toEqual({ status: 'success', text: 'fresh summary' });
});
it('exclude recovery never settles a server auto-compaction as ours', async () => {
// GLM finding: an auto compaction (reason != manual) racing between
// admission and the first poll must not be mistaken for ours — its
// summary would end the wait while OUR manual compaction is pending.
const autoCompleted = { id: 'msg_auto', type: 'compaction', status: 'completed', reason: 'auto', summary: 'auto summary' };
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls++;
return calls === 1
? new Response(JSON.stringify({ data: [autoCompleted] }), { status: 200 })
: new Response(JSON.stringify({ data: [
autoCompleted,
{ id: 'msg_mine', type: 'compaction', status: 'completed', reason: 'manual', summary: 'mine' }
] }), { status: 200 });
});
const result = await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: null,
excludeCompactionIds: [],
pollIntervalMs: 1,
maxAttempts: 5,
delayImpl: async () => {},
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(result).toEqual({ status: 'success', text: 'mine' });
expect(calls).toBe(2);
});
it('ambiguous recovery ends the poll once ALL candidates are terminal (sawPending must not hold forever)', async () => {
// Bot round-4 Minor: with a prior pending observation, sawPending
// disables the stall cap — an all-terminal ambiguous state would
// poll forever. onAmbiguousTerminal ends it.
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls++;
return calls <= 2
? new Response(JSON.stringify({ data: [
{ id: 'msg_a', type: 'compaction', status: 'pending', reason: 'manual' },
{ id: 'msg_b', type: 'compaction', status: 'pending', reason: 'manual' }
] }), { status: 200 })
: new Response(JSON.stringify({ data: [
{ id: 'msg_a', type: 'compaction', status: 'completed', reason: 'manual', summary: 'a done' },
{ id: 'msg_b', type: 'compaction', status: 'completed', reason: 'manual', summary: 'b done' }
] }), { status: 200 });
});
const result = await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: null,
excludeCompactionIds: [],
pollIntervalMs: 1,
maxAttempts: 3,
delayImpl: async () => {},
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
expect(calls).toBe(3);
});
it('with an exclude list and multiple new entries, stays unverified with pending progress', async () => {
const first = { id: 'msg_a', type: 'compaction', status: 'pending', reason: 'manual' };
const second = { id: 'msg_b', type: 'compaction', status: 'pending', reason: 'manual' };
let pendingCalled = false;
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
expectedCompactionId: null,
excludeCompactionIds: ['msg_old'],
onMatchedPending: () => { pendingCalled = true; },
fetchImpl: async () => fixtureEntry(second, first, { id: 'msg_old', type: 'compaction', status: 'completed', reason: 'manual', summary: 'stale' })
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
expect(pendingCalled).toBe(true);
});
it('reports unverified when the fetch itself throws', async () => {
const result = await fetchCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
fetchImpl: async () => {
throw new Error('connection refused');
}
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
});
describe('pollCompactionResultV2', () => {
const authHeader = () => 'Basic ' + Buffer.from('opencode:pw').toString('base64');
function summaryResponse(text: string): Response {
return new Response(JSON.stringify({
data: [{ type: 'compaction', status: 'completed', reason: 'manual', summary: text }]
}), { status: 200 });
}
const emptyResponse = () => new Response(JSON.stringify({ data: [] }), { status: 200 });
it('threads the abort signal into every messages GET', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
expect((init as { signal?: AbortSignal }).signal).toBe(controller.signal);
return summaryResponse('done!');
});
await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
signal: controller.signal,
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('polls until the status is not unverified (unverified → unverified → success)', async () => {
const fetchImpl = vi.fn(async () => emptyResponse())
.mockImplementationOnce(async () => emptyResponse())
.mockImplementationOnce(async () => emptyResponse())
.mockImplementationOnce(async () => summaryResponse('done!'));
const delays: number[] = [];
const result = await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_x',
password: 'pw',
pollIntervalMs: 5,
delayImpl: async (ms) => {
delays.push(ms);
},
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(result).toEqual({ status: 'success', text: 'done!' });
expect(delays).toEqual([5, 5]);
});
it('returns immediately when the signal aborts while waiting', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn(async () => emptyResponse());
const resultPromise = pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
pollIntervalMs: 5,
signal: controller.signal,
delayImpl: async () => {
controller.abort();
},
fetchImpl: fetchImpl as unknown as typeof fetch
});
const result = await resultPromise;
expect(result.status).toBe('unverified');
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('returns immediately when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const fetchImpl = vi.fn(async () => emptyResponse());
const result = await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
pollIntervalMs: 5,
signal: controller.signal,
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(result.status).toBe('unverified');
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('pending progress resets the stall cap — visible server work is not force-released', async () => {
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls++;
// Pending for the first 4 rounds, terminal on the 5th — beyond a
// cap of 3, which must only count rounds WITHOUT pending progress.
return calls >= 5
? new Response(JSON.stringify({ data: [{ id: 'msg_1', type: 'compaction', status: 'completed', reason: 'manual', summary: 'done' }] }), { status: 200 })
: new Response(JSON.stringify({ data: [{ id: 'msg_1', type: 'compaction', status: 'pending', reason: 'manual' }] }), { status: 200 });
});
const result = await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
pollIntervalMs: 1,
maxAttempts: 3,
delayImpl: async () => {},
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(result).toEqual({ status: 'success', text: 'done' });
expect(calls).toBe(5);
});
it('once pending is observed the stall cap never releases the barrier (terminal or teardown ends it)', async () => {
// GPT finding 2: observation failures after a confirmed pending must
// not accumulate toward the cap — confirmed server work holds the
// barrier until terminal or teardown.
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls++;
return calls === 1
? new Response(JSON.stringify({ data: [{ id: 'msg_1', type: 'compaction', status: 'pending', reason: 'manual' }] }), { status: 200 })
: new Response('server exploded', { status: 503 });
});
const controller = new AbortController();
const resultPromise = pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
pollIntervalMs: 1,
maxAttempts: 3,
signal: controller.signal,
delayImpl: async () => {
if (calls >= 6) controller.abort();
},
fetchImpl: fetchImpl as unknown as typeof fetch
});
const result = await resultPromise;
expect(result.status).toBe('unverified');
expect(calls).toBeGreaterThanOrEqual(6);
});
it('gives up with the last unverified result after maxAttempts (barrier escape)', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }));
const delays: number[] = [];
const result = await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
pollIntervalMs: 1,
maxAttempts: 3,
delayImpl: async (ms) => { delays.push(ms); },
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(delays).toEqual([1, 1]);
});
it('polls indefinitely without a signal until a terminal status arrives', async () => {
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls++;
return calls >= 4 ? summaryResponse('late summary') : emptyResponse();
});
const result = await pollCompactionResultV2({
baseUrl: 'http://127.0.0.1:1',
sessionId: 's',
password: 'pw',
pollIntervalMs: 1,
delayImpl: async () => {},
fetchImpl: fetchImpl as unknown as typeof fetch
});
expect(result).toEqual({ status: 'success', text: 'late summary' });
expect(calls).toBe(4);
});
});
+496 -3
View File
@@ -1,3 +1,14 @@
import { listChildPids } from './opencodeBackend';
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
function readFileUtf8(path: string): Promise<string> {
return readFile(path, 'utf8');
}
export type OpencodeCompactResult =
| { ok: true; summaryText?: string }
| { ok: false; error: string };
@@ -133,8 +144,277 @@ export async function triggerOpencodeCompact(opts: OpencodeCompactCallOpts & {
}
}
/**
* v2 variant of `triggerOpencodeCompact` for opencode 2.x: the internal API
* was redesigned (verified 2026-10-15 against 2.0.29) — `POST /api/session/
* :id/compact` replaces `/session/:id/summarize`, and Basic Auth is now
* required (username `opencode`, password from the acp parent's
* `OPENCODE_PASSWORD` env). Non-2xx responses throw (the launcher's v2
* compact path has no result-verification GET to coordinate with, unlike the
* v1 `{ok:false}` contract).
*
* Like the v1 path, `timeout: false` (Bun-only fetch option) disables Bun's
* hardcoded ~5min idle timeout — compaction can legitimately take minutes.
*/
export type OpencodeCompactV2Admission =
| { kind: 'admitted'; compactionId: string | null }
| { kind: 'rejected'; error: string }
| { kind: 'transport-error'; message: string };
export async function triggerOpencodeCompactV2(opts: OpencodeCompactCallOpts & {
providerId: string;
modelId: string;
password: string;
fetchImpl?: FetchLike;
}): Promise<OpencodeCompactV2Admission> {
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
const url = `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}/compact`;
const init: BunFetchInit = {
method: 'POST',
headers: {
authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64'),
'content-type': 'application/json'
},
// v2.0.29 compact payload schema is {id?, delivery?} with
// additionalProperties: false (packages/protocol/openapi.json) — the
// session's current model governs server-side, so no provider/model
// fields are sent.
body: JSON.stringify({}),
// Bun-specific: disables Bun's hardcoded ~5min fetch timeout.
timeout: false,
signal: opts.signal
};
let response: Response;
try {
response = await fetchFn(url, init as RequestInit);
} catch (error) {
// Transport-level failure: admission is UNCERTAIN — the server may
// have accepted the request before the connection died. The caller
// must run identity recovery instead of treating this as a
// definitive rejection.
return { kind: 'transport-error', message: error instanceof Error ? error.message : String(error) };
}
if (!response.ok) {
// 4xx: the server answered and refused — definitive, nothing was
// admitted. 5xx: the server may have admitted the compaction and
// then failed while processing it (side effects already created) —
// admission is UNCERTAIN, so recovery (not failure) applies.
const text = await response.text().catch(() => '');
if (response.status >= 500) {
return { kind: 'transport-error', message: `OpenCode v2 compact request failed after admission risk (${response.status}): ${text.slice(0, 300)}` };
}
return { kind: 'rejected', error: `OpenCode v2 compact request failed (${response.status}): ${text.slice(0, 300)}` };
}
// The admitted compaction message echoes back in the response body
// (measured 2026-10-16: { data: { id: 'msg_…', type: 'compaction', … } }).
// Its id is the correlation key for verification polling.
try {
const payload: unknown = await response.json();
const record = isObjectRecord(payload) && isObjectRecord(payload.data) ? payload.data : null;
const id = record && typeof record.id === 'string' ? record.id : null;
return { kind: 'admitted', compactionId: id };
} catch {
return { kind: 'admitted', compactionId: null };
}
}
/**
* v2 compact POST returns as soon as the compaction marker is created; the
* summary itself is produced asynchronously by the server (verified
* 2026-10-16 against 2.0.29: the immediate follow-up GET carries no
* assistant result). So, unlike the v1 path — whose POST blocks until
* completion and needs a single verification GET — the v2 result must be
* polled until it stops being `unverified`. There is no hard timeout,
* mirroring the v1 philosophy of waiting indefinitely for real compaction
* (compaction can legitimately take minutes). `signal` aborts the wait
* immediately with the current status (the launcher's isCancelled check
* suppresses the result on abort anyway); without a signal this just polls.
*/
export async function pollCompactionResultV2(opts: {
baseUrl: string;
sessionId: string;
password: string;
signal?: AbortSignal;
/** Correlation id of the admitted compaction (see fetchCompactionResultV2). */
expectedCompactionId?: string | null;
/** Pre-POST compaction ids excluded from identity recovery (see fetchCompactionResultV2). */
excludeCompactionIds?: string[] | null;
pollIntervalMs?: number;
/**
* Stall cap, not a total cap. The poll runs inside the FIFO barrier
* (phase 'summarize'), so an entry that never appears must not hold the
* session forever: past this many consecutive rounds WITHOUT observing
* the matched compaction (no pending progress signal), the loop gives up
* with the last unverified result (~10 minutes at the default interval),
* matching v1 where a stalled blocking POST eventually errors out. A
* visible pending compaction is genuine server work — it resets the
* stall counter, and the escape hatch remains switch/exit.
*/
maxAttempts?: number;
delayImpl?: (ms: number) => Promise<void>;
fetchImpl?: FetchLike;
}): Promise<CompactionResult> {
const pollIntervalMs = opts.pollIntervalMs ?? 5000;
const maxAttempts = opts.maxAttempts ?? 120;
const wait = opts.delayImpl ?? (async (ms: number) => {
await new Promise((resolve) => setTimeout(resolve, ms));
});
let stalledRounds = 0;
let ambiguousTerminal = false;
// Once the admitted compaction has been OBSERVED in a non-terminal
// state, server work is confirmed running: the stall cap must not
// license releasing the barrier onto it — only a terminal outcome or
// an explicit teardown ends the wait (escape hatch: switch/exit).
let sawPending = false;
for (;;) {
const result = await fetchCompactionResultV2({
baseUrl: opts.baseUrl,
sessionId: opts.sessionId,
password: opts.password,
signal: opts.signal,
expectedCompactionId: opts.expectedCompactionId,
excludeCompactionIds: opts.excludeCompactionIds,
onMatchedPending: () => { stalledRounds = 0; sawPending = true; },
onAmbiguousTerminal: () => { ambiguousTerminal = true; },
fetchImpl: opts.fetchImpl
});
if (result.status !== 'unverified') {
return result;
}
if (ambiguousTerminal) {
return result;
}
stalledRounds += 1;
if (opts.signal?.aborted) {
return result;
}
if (!sawPending && stalledRounds >= maxAttempts) {
return result;
}
await wait(pollIntervalMs);
// Abort during the wait must not trigger another fetch round.
if (opts.signal?.aborted) {
return result;
}
}
}
/**
* Extracts the OPENCODE_PASSWORD value from a NUL-separated `/proc/<pid>/
* environ` payload. Exported for fixture tests.
*/
export function parseOpencodeEnvironPassword(environ: string): string | null {
for (const entry of environ.split('\0')) {
if (entry.startsWith('OPENCODE_PASSWORD=')) {
const value = entry.slice('OPENCODE_PASSWORD='.length);
return value.length > 0 ? value : null;
}
}
return null;
}
/**
* Extracts the OPENCODE_PASSWORD value from `ps Eww -p <pid>` output (the
* non-Linux fallback; Eww prints the full environment in COMMAND). Exported
* for fixture tests.
*/
export function parsePsEnvironPassword(output: string): string | null {
const match = /(?:^|\s)OPENCODE_PASSWORD=([^\s]+)/.exec(output);
if (!match) return null;
return match[1]!.length > 0 ? match[1]! : null;
}
/**
* Reads the OPENCODE_PASSWORD the v2 `opencode acp` parent generated and
* injected into its `serve --stdio` child's environment (verified
* 2026-10-15: the parent acp process's own env does not carry it — only the
* child's). On Linux, resolves the first child via the per-task `children`
* files under `/proc/<acpPid>/task/`; elsewhere falls back to `pgrep -P` +
* `ps Eww`. Returns null on
* any failure — callers disable the v2 compact bridge rather than guessing.
*/
export async function readOpencodeV2ServerPassword(acpPid: number, opts?: {
platform?: NodeJS.Platform;
listDescendantPids?: (acpPid: number) => Promise<number[]>;
readChildEnviron?: (childPid: number) => Promise<string>;
readChildCmdline?: (childPid: number) => Promise<string>;
execImpl?: (command: string, args: string[]) => Promise<string>;
}): Promise<string | null> {
const platform = opts?.platform ?? process.platform;
// Preference order among descendants carrying OPENCODE_PASSWORD: the
// `serve` process. With an npm wrapper the tree is wrapper → native acp
// → serve, and a user-exported OPENCODE_PASSWORD is INHERITED by the
// native child while serve gets the freshly generated one — accepting
// the first match would report the inherited credential and every
// /compact would 401.
const preferServe = (candidates: Array<{ password: string; isServe: boolean }>): string | null => {
const serve = candidates.find((candidate) => candidate.isServe);
return (serve ?? candidates[0])?.password ?? null;
};
try {
const descendants = opts?.listDescendantPids ?? (async (pid: number) =>
await listChildPids(pid, { platform }));
if (platform === 'linux') {
const readEnviron = opts?.readChildEnviron ?? (async (childPid: number) =>
await readFile(`/proc/${childPid}/environ`, 'utf8'));
const readCmdline = opts?.readChildCmdline ?? (async (childPid: number) =>
await readFile(`/proc/${childPid}/cmdline`, 'utf8'));
const candidates: Array<{ password: string; isServe: boolean }> = [];
for (const childPid of await descendants(acpPid)) {
try {
const password = parseOpencodeEnvironPassword(await readEnviron(childPid));
if (!password) continue;
const cmdline = await readCmdline(childPid).catch(() => '');
// Exact serve argument, not a substring: an executable
// under /home/server/ must not flag an `acp` child.
candidates.push({ password, isServe: /\bserve\b/.test(cmdline) && /--stdio/.test(cmdline) });
} catch {
// Fall through if this child yields nothing.
}
}
return preferServe(candidates);
}
const run = async (command: string, args: string[]): Promise<string> => {
if (opts?.execImpl) {
return await opts.execImpl(command, args);
}
const { stdout } = await execFileAsync(command, args, { timeout: 5000 });
return stdout;
};
// Iterate EVERY descendant (recursive — the npm wrapper makes serve a
// grandchild) via a single `ps Eww` per pid, which carries both the
// environment and the command line.
const candidates: Array<{ password: string; isServe: boolean }> = [];
for (const child of await descendants(acpPid)) {
try {
const psOutput = await run('ps', ['Eww', '-p', String(child)]);
const password = parsePsEnvironPassword(psOutput);
if (!password) continue;
// `ps Eww` embeds the environment after COMMAND; match `serve`
// against the command portion only, or an inherited env value
// containing the substring would misflag the candidate.
const commandPortion = psOutput.split(/\s+[A-Za-z_][A-Za-z0-9_]*=/)[0] ?? psOutput;
candidates.push({ password, isServe: /\bserve\b/.test(commandPortion) && /--stdio/.test(commandPortion) });
} catch {
// Try the next descendant.
}
}
return preferServe(candidates);
} catch {
return null;
}
}
type OpencodeMessagePart = { type?: unknown; text?: unknown; auto?: unknown };
type OpencodeMessageEntry = {
id?: unknown;
type?: unknown;
status?: unknown;
reason?: unknown;
summary?: unknown;
providerContext?: unknown;
native?: unknown;
error?: unknown;
info?: {
id?: unknown;
role?: unknown;
@@ -190,11 +470,16 @@ function safeErrorReason(value: unknown): string | null {
// OpenCode persists provider failures as `{ name, data: { message, ... } }`.
// Read only that scalar message: serializing error/data would risk exposing
// request headers, provider metadata, or other sensitive fields.
// v2 compaction errors (verified against v2.0.29,
// packages/schema/src/session-error.ts) are `{ type, message }` — read
// the top-level message scalar the same way.
const message = typeof value === 'string'
? value
: isObjectRecord(value) && isObjectRecord(value.data) && typeof value.data.message === 'string'
? value.data.message
: null;
: isObjectRecord(value) && typeof value.message === 'string'
? value.message
: isObjectRecord(value) && isObjectRecord(value.data) && typeof value.data.message === 'string'
? value.data.message
: null;
if (!message) return 'OpenCode reported a compaction error.';
const normalized = message.trim().replace(/\s+/g, ' ');
return normalized ? normalized.slice(0, 200) : 'OpenCode reported a compaction error.';
@@ -281,3 +566,211 @@ export async function fetchCompactionResult(opts: OpencodeCompactCallOpts & {
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
}
/**
* Captures the ids of compaction entries that already exist BEFORE a compact
* POST — the v2 analogue of v1's `captureCompactionMarkerSnapshot` boundary.
* Used to recover identity when the POST response cannot be read: any
* compaction entry appearing afterwards must be ours. Null = the snapshot
* itself failed (caller then has no recovery basis).
*/
export async function captureCompactionIdsV2(opts: {
baseUrl: string;
sessionId: string;
password: string;
signal?: AbortSignal;
fetchImpl?: FetchLike;
}): Promise<string[] | null> {
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
const url = `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}/message`;
try {
const response = await fetchFn(url, {
method: 'GET',
signal: opts.signal,
headers: {
authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64')
}
});
if (!response.ok) return null;
const payload: unknown = await response.json().catch(() => null);
const entries = Array.isArray(payload)
? payload as OpencodeMessageEntry[]
: isObjectRecord(payload) && Array.isArray(payload.data)
? payload.data as OpencodeMessageEntry[]
: null;
if (!entries) return null;
return entries
.filter((entry) => entry.type === 'compaction')
.map((entry) => (isObjectRecord(entry) && typeof entry.id === 'string' ? entry.id : null))
.filter((id): id is string => id !== null);
} catch {
return null;
}
}
/**
* v2 counterpart of `fetchCompactionResult`. opencode v2 redesigned the
* messages endpoint (verified 2026-10-15): `GET /api/session/:id/message`
* with Basic auth, wrapping the message entries in a `{ data: [...] }`
* envelope. There is no pre-POST marker snapshot on the v2 path (the v1
* `captureCompactionMarkerSnapshot` boundary does not exist there), so the
* newest manual compaction marker in the list is treated as this request's
* marker and the same v1 parsing rules apply: exactly one assistant result
* linked to it, terminal only, summary flag required; anything else stays
* `unverified` rather than becoming a false completion.
*/
export async function fetchCompactionResultV2(opts: {
baseUrl: string;
sessionId: string;
password: string;
/** Aborts an in-flight messages GET. Required for the poll loop (see pollCompactionResultV2). */
signal?: AbortSignal;
/**
* Correlation id of the compaction this request admitted (echoed by the
* compact POST response). The messages endpoint returns entries newest
* first (measured 2026-10-16), so an older completed compaction must not
* be mistaken for this request's outcome. Null = the POST response could
* not be read; recovery then runs through `excludeCompactionIds`.
*/
expectedCompactionId?: string | null;
/**
* Compaction entry ids that already existed BEFORE the POST (the v2
* analogue of v1's marker snapshot). Used only when
* `expectedCompactionId` is null: candidates are the entries absent from
* this list — compactions the POST could have admitted. One candidate is
* evaluated; several mean ours is among them (pending-progress, keep
* waiting); none means no visibility yet (stall counting applies).
*/
excludeCompactionIds?: string[] | null;
/**
* Invoked when the matched compaction entry exists but has not reached a
* terminal outcome (pending/running). The poll loop treats this as
* progress evidence: visible server work keeps the barrier legitimately
* held, so the attempt cap counts consecutive rounds WITHOUT such a
* signal rather than total rounds.
*/
onMatchedPending?: () => void;
/** Invoked when snapshot-recovery candidates ALL reached terminal states — ends the poll (see pollCompactionResultV2). */
onAmbiguousTerminal?: () => void;
fetchImpl?: FetchLike;
}): Promise<CompactionResult> {
const unverified: CompactionResult = { status: 'unverified', reason: 'Compaction result could not be verified.' };
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
const url = `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}/message`;
try {
const response = await fetchFn(url, {
method: 'GET',
signal: opts.signal,
headers: {
authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64')
}
});
if (!response.ok) return unverified;
const payload: unknown = await response.json().catch(() => null);
const entries = Array.isArray(payload)
? payload as OpencodeMessageEntry[]
: isObjectRecord(payload) && Array.isArray(payload.data)
? payload.data as OpencodeMessageEntry[]
: null;
if (!entries) return unverified;
// v2 compaction shape (verified 2026-10-16/18 against opencode
// v2.0.29, release commit 042d0832f; schema:
// packages/protocol/openapi.json → Session.Message.Compaction.*):
// the compaction entry itself carries the outcome inline —
// `{ type: 'compaction', status: 'running'|'completed'|'failed',
// reason: 'auto'|'manual', summary, providerContext }`.
// OpenCode 2.1 dropped providerContext in favor of a generic `native`
// record (packages/schema/src/session-message.ts at v2.1.2). There is
// no assistant message linked via parentID like v1, so the v1 parsing
// rules do not apply here.
const compactionEntries = entries.filter((entry) => entry.type === 'compaction');
let candidates = compactionEntries;
if (opts.expectedCompactionId) {
candidates = compactionEntries.filter((entry) => isObjectRecord(entry) && entry.id === opts.expectedCompactionId);
} else if (opts.excludeCompactionIds) {
// Identity recovery: only compactions that did not exist before
// the POST can be ours. An EMPTY snapshot is a valid basis too
// (fresh session — every entry afterwards is ours).
const exclude = new Set(opts.excludeCompactionIds);
candidates = compactionEntries.filter((entry) => !(isObjectRecord(entry) && typeof entry.id === 'string' && exclude.has(entry.id)));
// Server-initiated auto-compactions (reason !== 'manual') racing
// between admission and the first poll must never be settled as
// ours: their summary would end the wait while OUR compaction is
// still running. When the entries carry a reason field at all,
// restrict candidates to manual ones; an auto-only pool means no
// visibility into ours yet (stall counting applies).
// v2.0.29 always stamps reason (auto|manual — verified against
// the release schema); entries without one are an unknown shape,
// so settlement is refused rather than guessed.
const withReason = candidates.filter((entry) => isObjectRecord(entry) && typeof entry.reason === 'string');
if (withReason.length > 0) {
candidates = candidates.filter((entry) => (entry as { reason?: unknown }).reason === 'manual');
} else {
candidates = [];
}
}
const latestCompaction = candidates[0] ?? null;
if (!latestCompaction) return unverified;
if (candidates.length > 1) {
// Ambiguous: ours is among the new entries. Pending progress (and
// thus a stall-counter reset) is only honest while at least one
// candidate is still non-terminal. Once ALL of them reached a
// terminal state the outcome can no longer change — signal the
// poll to END (otherwise sawPending would hold the barrier
// forever on entries that will never change).
const anyNonTerminal = candidates.some((entry) => {
const record = isObjectRecord(entry) ? entry : null;
const candidateStatus = record && typeof record.status === 'string' ? record.status : undefined;
// Same whitelist as the single-candidate path: an unrecognized
// status must not claim pending progress.
return candidateStatus === 'pending' || candidateStatus === 'running';
});
if (anyNonTerminal) {
opts.onMatchedPending?.();
} else {
opts.onAmbiguousTerminal?.();
}
return unverified;
}
const entryRecord = isObjectRecord(latestCompaction) ? latestCompaction : null;
const status = entryRecord && typeof entryRecord.status === 'string' ? entryRecord.status : undefined;
const summary = entryRecord && typeof entryRecord.summary === 'string' ? entryRecord.summary : undefined;
if (status === 'error' || status === 'failed') {
const reason = safeErrorReason(latestCompaction.error) ?? 'OpenCode compaction failed.';
return { status: 'failed', reason };
}
if (status === 'completed') {
if (summary && summary.trim().length > 0) {
return { status: 'success', text: summary };
}
// Native provider compaction (verified 2026-10-18 against the v2
// binary: `model.compaction?.type === "native"` skips the summary
// prompt and publishes providerContext instead) legitimately
// completes with an empty summary — the context checkpoint lives
// in providerContext. OpenCode 2.1 stores the replacement window in
// `native.messages` instead and recognizes a native compaction by
// `native.messages !== undefined` (isNativeCompaction in
// packages/core/src/session/history.ts at v2.1.2), so accept either
// shape with the same test.
const nativeRecord = isObjectRecord(entryRecord?.native) ? entryRecord.native : null;
if (isObjectRecord(entryRecord?.providerContext) || nativeRecord?.messages !== undefined) {
return { status: 'success', text: '' };
}
return { status: 'failed', reason: 'OpenCode returned an empty compaction summary.' };
}
// Matched but not terminal. Progress credit (stall-counter reset) is
// only honest for KNOWN non-terminal statuses: an unrecognized status
// string (or a missing one) must not hold the barrier forever — the
// stall cap then bounds the hold instead.
if (status === 'pending' || status === 'running') {
opts.onMatchedPending?.();
}
return unverified;
} catch {
return unverified;
}
}
@@ -87,6 +87,8 @@ function subscribe(options: {
* stop the loop itself.
*/
stopAfterDelays?: number;
headers?: Record<string, string>;
mode?: 'v1' | 'v2';
}) {
let subscription: { close: () => void } | null = null;
subscription = subscribeToOpencodeEvents({
@@ -94,6 +96,8 @@ function subscribe(options: {
directory: SESSION_CWD,
sessionId: 'ses_ours',
onRetry: options.onRetry ?? (() => {}),
headers: options.headers,
mode: options.mode,
fetchImpl: options.fetchImpl,
sleep: async (ms: number) => {
options.delays?.push(ms);
@@ -160,6 +164,46 @@ describe('subscribeToOpencodeEvents', () => {
sse.close();
});
it('v2 mode: hits /api/event and unwraps the {data} envelope before retry parsing', async () => {
// Measured 2026-10-16 (opencode 2.0.29): /event answers with the SPA
// HTML fallback; the real stream is /api/event and every payload is
// { id, type, data: { …v1-shaped body… } }.
const seen: Array<string | null> = [];
const sse = createSseStream();
const fetchImpl = vi.fn(async (url: string, _init?: RequestInit) => {
seen.push(url);
return new Response(sse.stream, { status: 200, headers: { 'content-type': 'text/event-stream' } });
});
const retries: number[] = [];
const subscription = subscribe({ fetchImpl, onRetry: (retry) => retries.push(retry.attempt), mode: 'v2' });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
expect(seen[0]).toBe(EXPECTED_URL.replace('/event?', '/api/event?'));
// Real v2 shape (measured): the body inside `data` has NO type
// field — the discriminator lives on the envelope only.
sse.push({
id: 'evt_a',
type: 'session.status',
data: { sessionID: 'ses_ours', status: { type: 'retry', attempt: 1, message: 'Rate limit exceeded: free-models-per-day.', next: NOW + 30_000 } }
});
await vi.waitFor(() => expect(retries).toEqual([1]));
await subscription.close();
sse.close();
});
it('merges caller headers into every connect attempt (v2 Basic auth)', async () => {
const sse = createSseStream();
const fetchImpl = vi.fn(async (_url: string, _init?: RequestInit) => new Response(sse.stream, { status: 200 }));
const auth = 'Basic ' + Buffer.from('opencode:pw').toString('base64');
const subscription = subscribe({ fetchImpl, headers: { authorization: auth } });
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1));
expect(fetchImpl.mock.calls[0][1]?.headers).toMatchObject({ authorization: auth, accept: 'text/event-stream' });
await subscription.close();
sse.close();
});
it('reports every new retry attempt once, ignoring repeats of the same attempt', async () => {
const sse = createSseStream();
const fetchImpl = vi.fn(async (_url: string, _init?: RequestInit) => new Response(sse.stream, { status: 200 }));
+29 -1
View File
@@ -37,6 +37,19 @@ export type OpencodeEventStreamOptions = {
* web timeline folds), not as a failure — see its call site.
*/
onRetry: (retry: OpencodeRetryStatus) => void;
/**
* Extra request headers merged into every (re)connect attempt. Required
* for opencode v2: the internal server is Basic-auth protected, and a
* headerless subscription is rejected with 401 before any event arrives.
*/
headers?: Record<string, string>;
/**
* Wire dialect. v2 (measured 2026-10-16 against opencode 2.0.29): the
* stream lives at `/api/event` (the v1 `/event` path answers with the
* SPA HTML fallback) and every JSON payload wraps its v1-shaped body in
* a `data` envelope (`{ id, type, data: { … } }`). Default v1.
*/
mode?: 'v1' | 'v2';
fetchImpl?: FetchLike;
sleep?: (ms: number) => Promise<void>;
};
@@ -226,6 +239,8 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
directory,
sessionId,
onRetry,
headers = {},
mode = 'v1',
fetchImpl = fetch as FetchLike,
sleep = (ms: number) => new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms);
@@ -233,7 +248,7 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
})
} = options;
const url = `${baseUrl}/event?directory=${encodeURIComponent(directory)}`;
const url = `${baseUrl}${mode === 'v2' ? '/api/event' : '/event'}?directory=${encodeURIComponent(directory)}`;
const controller = new AbortController();
let closed = false;
// Structurally typed to just what close() needs: the global and
@@ -348,6 +363,18 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
logger.debug('[opencode-events] ignoring unparsable event payload', error);
return;
}
// v2 wraps the v1-shaped body in an envelope: { id, type, data },
// where `type` lives on the ENVELOPE and `data` carries only the
// body (measured: { id, type: 'session.status', data: { sessionID,
// status } }). Re-attach the envelope type so the downstream v1
// handling (event.type + properties) works unchanged on both
// dialects; unwrapping `data` alone would strip the discriminator
// and every retry event would be silently rejected. The v2 body also
// carries its fields directly (no v1 `properties` wrapper), so the
// body becomes `properties`.
if (mode === 'v2' && isObject(payload) && isObject(payload.data)) {
payload = { type: payload.type, properties: payload.data };
}
// Dispatched outside the parse guard on purpose: everything
// downstream of handleEvent is HAPI's own code (the launcher's
// report, the hub client), and a throw from there is a bug in this
@@ -425,6 +452,7 @@ export function subscribeToOpencodeEvents(options: OpencodeEventStreamOptions):
signal: controller.signal,
headers: {
accept: 'text/event-stream',
...headers,
...(lastEventId ? { 'last-event-id': lastEventId } : {})
}
});