Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 21 additions & 34 deletions src/commands/x/provider.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,12 +58,12 @@ Two independent axes: which host CLIs run the ruflo loop, and which LLM the
routers use (aqe + ruflo). Mirrors \`ak x mcp\`: detect → persist to kit.json →
idempotent heal. \`ak sync\` reapplies your choice.

Host model — three managed host integrations, two routing hosts:
Host model — three managed hosts, all eligible for explicit activity routing:
claude, codex routing hosts: env-wired, one is primary, dual-host seeds the
per-activity routing policy (they drive QE work)
opencode managed integration host: config-file wiring (opencode.json
opencode config-file wiring (opencode.json
MCP + skills + permissions), lifecycle plugin, converted ruflo
agents, platform skill — never a routing target, never primary,
agents, platform skill — routable through ak run, never primary,
never an aqe provider
\`pick\` manages ALL THREE: enable/disable opencode here exactly like claude/codex.

Expand All@@ -78,7 +78,7 @@ Subcommands:
Options (pick, all optional — omit for interactive):
--host <csv> the complete desired enabled-host set, e.g.
claude,codex or claude,opencode (opencode is
wired + guided, never routed; excluding an
wired + guided; excluding an
enabled host here DISABLES it — ak-managed
wiring is stripped, user config preserved)
--primary-host claude|codex which host leads (default claude; routing hosts
Expand DownExpand Up@@ -164,10 +164,7 @@ async function status({ flags, cwd }) {
: dflt ? 'enabled (default — ruflo default-on, no env written)'
: d.wired ? 'enabled, wired'
: 'enabled, not wired → ak sync';
// The two host tiers, visible rather than implicit: claude/codex are the
// routing pair (primary + per-activity policy); opencode is a managed
// integration host — wired + guided, never a routing target.
const tier = h.id === 'opencode' ? dim(' · integration host (never routed)')
const tier = h.id === 'opencode' ? dim(' · routing host (ak run; never primary/AQE)')
: dim(' · routing host');
// auth/billing axis — subscription ($0) vs metered key, per host.
const auth = d.present ? hostAuthState(h.id, { present: true }) : null;
Expand DownExpand Up@@ -382,16 +379,12 @@ async function maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProv
async function pick({ flags, cwd, pkgRoot }) {
const cfg = loadKitConfig();
const hosts = await detectHosts(cwd);
// pick manages all managed host integrations (ADR-0016/0017):
// routing hosts — primary + per-activity policy seeds (registry capability)
// integration hosts — config-file wiring via opencode.mjs's owner module;
// never primary, never routed, never aqe
// The split is DERIVED from the host descriptors' capability flag, not a
// hardcoded id list (the seam issue #71's capability registry lands on).
// Routing eligibility is capability-derived. OpenCode retains its independent
// lifecycle wiring even though it is now an execution host; it is never a
// primary/AQE host because those are separate registry capabilities.
// --host is the complete desired enabled-host set on BOTH tiers; excluding an
// enabled host disables it (ak-managed wiring stripped, user config kept).
const ROUTING = new Set(routableHostIds());
const INTEGRATION = new Set(HOSTS.map((h) => h.id).filter((id) => !ROUTING.has(id)));
const prevOpencode = !!cfg.providers?.hosts?.opencode || cfg.providers?.opencodeMcp === 'ak';
let enabled;
let aqeProvider = cfg.providers.aqeProvider ?? null;
Expand DownExpand Up@@ -422,20 +415,16 @@ async function pick({ flags, cwd, pkgRoot }) {
}
if (flags.provider !== undefined) models = parseModels(flags.provider);
} else {
const installedRouting = HOSTS.filter((h) => hosts[h.id].present && ROUTING.has(h.id)).map((h) => h.id);
const installedIntegration = HOSTS.filter((h) => hosts[h.id].present && INTEGRATION.has(h.id)).map((h) => h.id);
if (installedRouting.length === 0 && installedIntegration.length === 0) { fail('no frontier CLI (claude/codex/opencode) found on PATH'); return 1; }
if (installedIntegration.length) {
console.log(`Installed hosts: ${[...installedRouting, ...installedIntegration].join(', ')}`
+ dim(` (${installedIntegration.join(', ')} = integration host — wired + guided, never a routing target)`));
} else {
console.log(`Installed hosts: ${installedRouting.join(', ')}`);
}
const installedRouting = HOSTS.filter((h) => hosts[h.id].present && ROUTING.has(h.id)
&& (h.id !== 'opencode' || cfg.providers.hosts.opencode)).map((h) => h.id);
const installedOpenCode = hosts.opencode?.present && !cfg.providers.hosts.opencode;
if (installedRouting.length === 0 && !installedOpenCode) { fail('no frontier CLI (claude/codex/opencode) found on PATH'); return 1; }
console.log(`Installed hosts: ${installedRouting.join(', ') || 'none'}${installedOpenCode ? dim(' (opencode is available; type it to opt in)') : ''}`);
// Default: every currently ENABLED host (even one temporarily absent from
// PATH — a bare enter must never tear down an enabled host it simply can't
// see right now) ∪ newly detected routing hosts. An installed-but-disabled
// integration host is opt-in by typing it — a bare enter must not opt a
// third host's config home in sight unseen either (codex-review r3).
// OpenCode host remains opt-in by typing it — a bare enter must not opt a
// third host's config home in sight unseen either.
const enabledHosts = HOSTS.filter((h) => cfg.providers.hosts[h.id]).map((h) => h.id);
const dflt = [...new Set([...enabledHosts, ...installedRouting])];
const absentEnabled = enabledHosts.filter((h) => !hosts[h].present);
Expand DownExpand Up@@ -469,13 +458,11 @@ async function pick({ flags, cwd, pkgRoot }) {
fail(`unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`);
return 2;
}
// Split the tiers: routing hosts drive primary/seeds; integration hosts are
// wired + guided. The routing pair needs at least one member (primaryHost
// must be enabled) — fall back to claude, keeping any integration choice.
// The routing set needs at least one primary-capable member; OpenCode remains
// routable but cannot satisfy that primary-host invariant on its own.
const routing = enabled.filter((h) => ROUTING.has(h));
const integrations = enabled.filter((h) => INTEGRATION.has(h));
if (!routing.length) routing.push('claude');
enabled = [...routing, ...integrations];
if (!routing.some((h) => PRIMARY_HOSTS.includes(h))) routing.unshift('claude');
enabled = [...new Set(routing)];
// primary host — which host leads (default claude); must be a ROUTING host.
let primaryHost = prevPrimary;
if (flags['primary-host'] !== undefined) {
Expand DownExpand Up@@ -519,7 +506,7 @@ async function pick({ flags, cwd, pkgRoot }) {
hosts: {
claude: routing.includes('claude'),
codex: routing.includes('codex'),
opencode: integrations.includes('opencode'),
opencode: enabled.includes('opencode'),
},
aqeProvider,
aqeFallback,
Expand All@@ -546,7 +533,7 @@ async function pick({ flags, cwd, pkgRoot }) {
for (const w of warnings) warn(w);
cfg.providers.dualRouting = { ...cfg.providers.dualRouting, ...policy };
}
const prunedRoutes = pruneRoutesForHosts(cfg.providers.dualRouting, { hosts: routing });
const prunedRoutes = pruneRoutesForHosts(cfg.providers.dualRouting, { hosts: enabled });
cfg.providers.dualRouting = prunedRoutes.policy;
for (const message of prunedRoutes.warnings) warn(message);

Expand Down
2 changes: 1 addition & 1 deletion src/lib/adapters/registries.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -139,7 +139,7 @@ const hostEntries = [
{
id: 'opencode', label: 'OpenCode',
install: { bin: 'opencode', npmPackage: 'opencode-ai', externalInstallPolicy: 'detect-never-overwrite' },
capabilities: { canDriveSession: true, canBePrimary: false, canRouteActivities: false, commandStatusline: false, transcripts: true, usage: false, nativeMcpConfig: true, nativeGuidance: true },
capabilities: { canDriveSession: true, canBePrimary: false, canRouteActivities: true, commandStatusline: false, transcripts: true, usage: false, nativeMcpConfig: true, nativeGuidance: true },
auth: { apiKeyEnv: [], loginFile: ['.local', 'share', 'opencode', 'auth.json'], keyOverridesLogin: false },
legacy: {
guidanceFile: 'agents-opencode', configFormat: 'json',
Expand Down
102 changes: 79 additions & 23 deletions src/lib/execution/opencode.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,31 @@ function defaultReservePort() {
});
}

async function waitForChildClose(child, timeoutMs) {
if (!child?.once || child.exitCode != null || child.signalCode != null) return true;
let timer;
try {
return await Promise.race([
new Promise((resolve) => child.once('close', () => resolve(true))),
new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
]);
} finally { clearTimeout(timer); }
}

/** Terminate only the direct server child. A TERM that does not produce a close
* event becomes explicit orphan evidence after one bounded KILL fallback. */
async function stopChild(child, { terminationGraceMs, forceGraceMs }) {
if (!child?.kill || child.exitCode != null || child.signalCode != null) return { stopped: true };
if (!child.once) {
try { child.kill('SIGTERM'); } catch { return { stopped: false }; }
return { stopped: true };
}
try { child.kill('SIGTERM'); } catch { return { stopped: false }; }
if (await waitForChildClose(child, terminationGraceMs)) return { stopped: true };
try { child.kill('SIGKILL'); } catch { return { stopped: false }; }
return { stopped: await waitForChildClose(child, forceGraceMs) };
}

function templateText(readFileSync = fs.readFileSync) {
return readFileSync(TEMPLATE_PATH, 'utf8');
}
Expand DownExpand Up@@ -70,15 +95,30 @@ async function requestJson(fetchFn, endpoint, password, pathname,
}

async function requestNoContent(fetchFn, endpoint, password, pathname,
{ method = 'POST', body} = /** @type {{method?:string, body?:any}} */ ({})) {
{ method = 'POST', body, signal } = /** @type {{method?:string, body?:any, signal?:AbortSignal}} */ ({})) {
const headers = basicHeaders(password);
if (body !== undefined) headers['content-type'] = 'application/json';
const response = await fetchFn(`${endpoint}${pathname}`, {
method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), ...(signal ? { signal } : {}),
});
if (!response?.ok) throw new Error(`${method} ${pathname} failed with HTTP ${response?.status ?? 'unknown'}`);
}

async function requestWithin(fetchFn, endpoint, password, pathname, options, timeoutMs) {
const controller = new AbortController();
let timer;
try {
return await Promise.race([
requestNoContent(fetchFn, endpoint, password, pathname, { ...options, signal: controller.signal }),
new Promise((_, reject) => { timer = setTimeout(() => {
controller.abort();
const error = Object.assign(new Error(`${options.method ?? 'POST'} ${pathname} timed out`), { code: 'ETIMEDOUT' });
reject(error);
}, timeoutMs); }),
]);
} finally { clearTimeout(timer); }
}

async function waitForHealth(fetchFn, endpoint, password, { attempts = 40, wait = delay } = {}) {
let lastError = null;
for (let attempt = 0; attempt < attempts; attempt++) {
Expand All@@ -99,9 +139,11 @@ function normalizeEvent(value) {
/** Read only the first terminal session event. The SSE parsing is deliberately
* tolerant of chunk boundaries but rejects malformed data rather than inventing
* completion. */
async function waitForTerminalEvent(response, sessionId) {
async function waitForTerminalEvent(response, sessionId, { signal } = /** @type {{signal?:AbortSignal}} */ ({})) {
if (!response?.ok || !response.body?.getReader) throw new Error('GET /global/event did not return an SSE body');
const reader = response.body.getReader();
const abort = () => { void reader.cancel(); };
signal?.addEventListener?.('abort', abort, { once: true });
const decoder = new TextDecoder();
let buffer = '';
let data = [];
Expand All@@ -119,18 +161,22 @@ async function waitForTerminalEvent(response, sessionId) {
if (event?.type === 'session.status' && properties.sessionID === sessionId && properties.status?.type === 'idle') return { type: 'idle' };
return null;
};
for (;;) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
const terminal = await consume(line);
if (terminal) { await reader.cancel(); return terminal; }
try {
for (;;) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
const terminal = await consume(line);
if (terminal) { await reader.cancel(); return terminal; }
}
if (done) break;
}
if (done) break;
throw new Error(signal?.aborted ? 'OpenCode SSE stream cancelled' : 'OpenCode SSE stream ended before the session became terminal');
} finally {
signal?.removeEventListener?.('abort', abort);
}
throw new Error('OpenCode SSE stream ended before the session became terminal');
}

function assistantFrom(messages) {
Expand All@@ -144,6 +190,7 @@ function assistantFrom(messages) {

function errorCategory(error) {
if (error?.name === 'ProviderAuthError') return 'auth_required';
if (error?.name === 'ProtocolError') return 'protocol_error';
return 'worker_error';
}

Expand All@@ -161,6 +208,8 @@ function terminalResult(state, observation, clock) {
status = 'timed_out'; exitCategory = 'timeout'; failure = { reason: 'timeout' };
} else if (observation.type === 'cancelled') {
status = 'cancelled'; exitCategory = 'cancelled'; failure = { reason: 'cancelled' };
} else if (observation.type === 'orphaned') {
status = 'failed'; exitCategory = 'orphaned'; failure = { reason: 'owned OpenCode server did not terminate' };
} else if (observation.type === 'error' || assistant?.error) {
const error = observation.error ?? assistant?.error ?? null;
status = 'failed'; exitCategory = errorCategory(error); failure = error ?? { reason: 'OpenCode session failed' };
Expand All@@ -186,9 +235,11 @@ function terminalResult(state, observation, clock) {
*/
export function createOpenCodeExecutionAdapter({
fetchFn = globalThis.fetch, spawnFn = nodeSpawn, haveFn = have, reservePort = defaultReservePort,
secret = defaultSecret, wait = delay, clock = nowIso,
secret = defaultSecret, wait = delay, clock = nowIso, terminationGraceMs = 1_500, forceGraceMs = 1_500,
} = {}) {
if (typeof fetchFn !== 'function') throw new TypeError('fetchFn is required');
if (!Number.isInteger(terminationGraceMs) || terminationGraceMs < 1) throw new TypeError('terminationGraceMs must be a positive integer');
if (!Number.isInteger(forceGraceMs) || forceGraceMs < 1) throw new TypeError('forceGraceMs must be a positive integer');
const adapter = {
id: 'opencode-server',
async readiness() {
Expand All@@ -200,7 +251,7 @@ export function createOpenCodeExecutionAdapter({
if (!path.isAbsolute(cwd)) throw new TypeError('OpenCode worker cwd must be absolute');
return { worker, cwd, prompt: renderOpenCodeWorkerPrompt(worker), startedAt: clock() };
},
async launch(state) {
async launch(state, { timeoutMs = 120_000 } = {}) {
const port = await reservePort();
const password = secret();
const endpoint = `http://${LOOPBACK}:${port}`;
Expand All@@ -216,14 +267,15 @@ export function createOpenCodeExecutionAdapter({
});
if (typeof session?.id !== 'string' || !session.id) throw new Error('OpenCode created a session without an id');
const headers = basicHeaders(password);
const eventResponse = await fetchFn(`${endpoint}/global/event`, { headers });
const terminal = waitForTerminalEvent(eventResponse, session.id);
await requestNoContent(fetchFn, endpoint, password, `/session/${encodeURIComponent(session.id)}/prompt_async`, {
const eventAbort = new AbortController();
const eventResponse = await fetchFn(`${endpoint}/global/event`, { headers, signal: eventAbort.signal });
const terminal = waitForTerminalEvent(eventResponse, session.id, { signal: eventAbort.signal });
await requestWithin(fetchFn, endpoint, password, `/session/${encodeURIComponent(session.id)}/prompt_async`, {
body: { agent: 'build', ...(state.worker.configuredModel ? { model: state.worker.configuredModel } : {}), parts: [{ type: 'text', text: state.prompt }] },
});
return { ...state, endpoint, password, child, sessionId: session.id, terminal };
}, timeoutMs);
return { ...state, endpoint, password, child, sessionId: session.id, terminal, eventAbort };
} catch (error) {
try { child.kill('SIGTERM'); } catch { /* cleanup is best-effort */ }
await stopChild(child, { terminationGraceMs, forceGraceMs });
throw error;
}
},
Expand All@@ -242,16 +294,20 @@ export function createOpenCodeExecutionAdapter({
},
interpret(state, observation) { return terminalResult(state, observation, clock); },
async cancel(state) {
state?.eventAbort?.abort();
if (state?.sessionId) {
try { await requestNoContent(fetchFn, state.endpoint, state.password, `/session/${encodeURIComponent(state.sessionId)}/abort`); } catch { /* cleanup records the final truth */ }
}
try { state?.child?.kill?.('SIGTERM'); return { type: 'cancelled' }; } catch { return { type: 'cancelled', orphaned: true }; }
const stopped = await stopChild(state?.child, { terminationGraceMs, forceGraceMs });
return stopped.stopped ? { type: 'cancelled' } : { type: 'cancelled', orphaned: true };
},
async cleanup(state) {
state?.eventAbort?.abort();
if (state?.endpoint) {
try { await requestNoContent(fetchFn, state.endpoint, state.password, '/instance/dispose'); } catch { /* child termination remains the fallback */ }
}
try { state?.child?.kill?.('SIGTERM'); return { cleaned: true }; } catch { return { cleaned: false, orphaned: true }; }
const stopped = await stopChild(state?.child, { terminationGraceMs, forceGraceMs });
return stopped.stopped ? { cleaned: true } : { cleaned: false, orphaned: true };
},
};
return validateExecutionAdapter(adapter);
Expand Down
Loading
Loading