feat: Add Hermes and Kimi runtime adapters (#71)

This commit is contained in:
nettee 2026-04-29 20:53:44 +08:00 committed by GitHub
parent 6de39f065e
commit f24bb669a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1688 additions and 36 deletions

10
.gitignore vendored
View file

@ -1,5 +1,5 @@
node_modules
dist
node_modules/
dist/
.DS_Store
*.log
.vite
@ -17,7 +17,11 @@ tsconfig.tsbuildinfo
.claude-sessions/*
.cursor/*
.cursor/
.agents/
.opencode/
.claude/
.codex/
# Commander task scratchpad; keep local task notes out of git by default.
.task/

422
daemon/acp.js Normal file
View file

@ -0,0 +1,422 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
const ACP_PROTOCOL_VERSION = 1;
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_STAGE_TIMEOUT_MS = 180_000;
function sendRpc(writable, id, method, params) {
writable.write(
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
);
}
function sendRpcResult(writable, id, result) {
writable.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
}
function isJsonRpcId(value) {
return typeof value === 'number' || typeof value === 'string';
}
function rpcErrorMessage(raw) {
if (!raw || typeof raw !== 'object' || !raw.error || typeof raw.error !== 'object') {
return '';
}
const message =
typeof raw.error.message === 'string'
? raw.error.message
: typeof raw.error.code === 'number'
? String(raw.error.code)
: 'json-rpc error';
return typeof raw.id === 'number'
? `json-rpc id ${raw.id}: ${message}`
: message;
}
function formatUsage(usage) {
if (!usage || typeof usage !== 'object') return null;
const out = {};
if (typeof usage.inputTokens === 'number') out.input_tokens = usage.inputTokens;
if (typeof usage.outputTokens === 'number') out.output_tokens = usage.outputTokens;
if (typeof usage.cachedReadTokens === 'number') {
out.cached_read_tokens = usage.cachedReadTokens;
}
if (typeof usage.thoughtTokens === 'number') out.thought_tokens = usage.thoughtTokens;
if (typeof usage.totalTokens === 'number') out.total_tokens = usage.totalTokens;
return Object.keys(out).length > 0 ? out : null;
}
function choosePermissionOutcome(options) {
const list = Array.isArray(options) ? options : [];
const approveForSession = list.find((option) => option?.optionId === 'approve_for_session');
if (approveForSession) return 'approve_for_session';
const allowAlways = list.find((option) => option?.kind === 'allow_always');
if (allowAlways?.optionId) return allowAlways.optionId;
const allowOnce = list.find((option) => option?.kind === 'allow_once');
if (allowOnce?.optionId) return allowOnce.optionId;
return null;
}
function normalizeModels(models, defaultModelOption) {
const available = Array.isArray(models?.availableModels) ? models.availableModels : [];
const currentModelId =
typeof models?.currentModelId === 'string' ? models.currentModelId : null;
const seen = new Set([defaultModelOption.id]);
const out = [defaultModelOption];
for (const model of available) {
const id = typeof model?.modelId === 'string' ? model.modelId.trim() : '';
if (!id || seen.has(id)) continue;
seen.add(id);
const name = typeof model?.name === 'string' ? model.name.trim() : '';
const isCurrent = id === currentModelId;
const labelBase = name && name !== id ? `${name} (${id})` : id;
out.push({ id, label: isCurrent ? `${labelBase} • current` : labelBase });
}
return out;
}
function createJsonLineStream(onMessage) {
let buffer = '';
return {
feed(chunk) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
onMessage(JSON.parse(trimmed), trimmed);
} catch {
// Ignore non-JSON log lines on stdout.
}
}
},
flush() {
const trimmed = buffer.trim();
buffer = '';
if (!trimmed) return;
try {
onMessage(JSON.parse(trimmed), trimmed);
} catch {
// Ignore trailing non-JSON log lines on stdout.
}
},
};
}
export async function detectAcpModels({
bin,
args,
cwd = process.cwd(),
timeoutMs = DEFAULT_TIMEOUT_MS,
clientName = 'open-design-detect',
clientVersion = 'runtime-adapter',
defaultModelOption = { id: 'default', label: 'Default (CLI config)' },
}) {
return await new Promise((resolve, reject) => {
const child = spawn(bin, args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
let settled = false;
let stderrBuf = '';
let expectedId = 1;
let nextId = 2;
const finish = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
child.stdin.end();
} catch {}
fn(value);
};
const fail = (message) => {
finish(reject, new Error(message));
if (!child.killed) child.kill('SIGTERM');
};
const writeRpc = (id, method, params) => {
try {
sendRpc(child.stdin, id, method, params);
} catch (err) {
fail(`stdin write failed: ${err.message}`);
}
};
const sendSessionNew = () => {
expectedId = nextId;
writeRpc(nextId, 'session/new', {
cwd: path.resolve(cwd),
mcpServers: [],
});
nextId += 1;
};
const parser = createJsonLineStream((raw) => {
const rpcErr = rpcErrorMessage(raw);
if (rpcErr) {
fail(rpcErr);
return;
}
if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') return;
if (expectedId === 1) {
sendSessionNew();
return;
}
if (expectedId === 2) {
const models = normalizeModels(raw.result.models, defaultModelOption);
finish(resolve, models);
if (!child.killed) child.kill('SIGTERM');
}
});
child.stdout.on('data', (chunk) => parser.feed(chunk));
child.stdout.on('close', () => parser.flush());
child.stdin.on('error', (err) => fail(`stdin error: ${err.message}`));
child.stderr.on('data', (chunk) => {
stderrBuf = `${stderrBuf}${chunk}`.slice(-16_000);
});
child.on('error', (err) => fail(`spawn failed: ${err.message}`));
child.on('close', (code, signal) => {
parser.flush();
if (!settled) {
const errTail = stderrBuf.trim();
const suffix = errTail ? ` stderr=${errTail}` : '';
fail(`ACP model detection exited code=${code} signal=${signal ?? 'none'}${suffix}`);
}
});
const timer = setTimeout(() => {
fail(`ACP model detection timed out after ${timeoutMs}ms`);
}, timeoutMs);
writeRpc(1, 'initialize', {
protocolVersion: ACP_PROTOCOL_VERSION,
clientCapabilities: { terminal: false },
clientInfo: { name: clientName, version: clientVersion },
});
});
}
export function attachAcpSession({
child,
prompt,
cwd,
model,
send,
clientName = 'open-design',
clientVersion = 'runtime-adapter',
stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
}) {
const runStartedAt = Date.now();
const effectiveCwd = path.resolve(cwd || process.cwd());
let expectedId = 1;
let nextId = 2;
let promptRequestId = null;
let sessionId = null;
let activeModel = null;
let emittedThinkingStart = false;
let emittedFirstTokenStatus = false;
let finished = false;
let fatal = false;
let stageTimer = null;
const resetStageTimer = (label) => {
clearTimeout(stageTimer);
stageTimer = setTimeout(() => {
fail(`ACP ${label} timed out after ${stageTimeoutMs}ms`);
}, stageTimeoutMs);
};
const clearStageTimer = () => {
clearTimeout(stageTimer);
stageTimer = null;
};
const fail = (message) => {
if (finished) return;
finished = true;
fatal = true;
clearStageTimer();
send('error', { message });
if (!child.killed) child.kill('SIGTERM');
};
const writeRpc = (id, method, params, timeoutLabel) => {
resetStageTimer(timeoutLabel);
try {
sendRpc(child.stdin, id, method, params);
} catch (err) {
fail(`stdin write failed: ${err.message}`);
}
};
const sendPrompt = () => {
promptRequestId = nextId;
expectedId = promptRequestId;
writeRpc(
promptRequestId,
'session/prompt',
{
sessionId,
prompt: [{ type: 'text', text: prompt }],
},
'session/prompt',
);
nextId += 1;
};
const replyPermission = (raw) => {
const optionId = choosePermissionOutcome(raw.params?.options);
if (!optionId || !isJsonRpcId(raw.id)) {
fail(`unhandled ACP permission request: ${JSON.stringify(raw)}`);
return;
}
resetStageTimer('session/request_permission');
try {
sendRpcResult(child.stdin, raw.id, {
outcome: { outcome: 'selected', optionId },
});
} catch (err) {
fail(`stdin write failed: ${err.message}`);
}
};
const parser = createJsonLineStream((raw, rawLine) => {
resetStageTimer('response');
const rpcErr = rpcErrorMessage(raw);
if (rpcErr) {
fail(rpcErr);
return;
}
if (raw.method === 'session/request_permission') {
replyPermission(raw);
return;
}
if (raw.method === 'session/update' && raw.params?.update) {
const update = raw.params.update;
if (update.sessionUpdate === 'agent_thought_chunk') {
const text = update.content?.text;
if (typeof text === 'string' && text.length > 0) {
if (!emittedThinkingStart) {
emittedThinkingStart = true;
send('agent', { type: 'thinking_start' });
}
send('agent', { type: 'thinking_delta', delta: text });
}
return;
}
if (update.sessionUpdate === 'agent_message_chunk') {
const text = update.content?.text;
if (typeof text === 'string' && text.length > 0) {
if (!emittedFirstTokenStatus) {
emittedFirstTokenStatus = true;
send('agent', {
type: 'status',
label: 'streaming',
ttftMs: Date.now() - runStartedAt,
});
}
send('agent', { type: 'text_delta', delta: text });
}
return;
}
return;
}
if (raw.id !== expectedId || !raw.result || typeof raw.result !== 'object') {
return;
}
if (expectedId === 1) {
expectedId = nextId;
writeRpc(
nextId,
'session/new',
{
cwd: effectiveCwd,
mcpServers: [],
},
'session/new',
);
nextId += 1;
return;
}
if (expectedId === 2) {
sessionId = typeof raw.result.sessionId === 'string' ? raw.result.sessionId : null;
activeModel =
typeof raw.result.models?.currentModelId === 'string'
? raw.result.models.currentModelId
: null;
if (sessionId && activeModel) {
send('agent', { type: 'status', label: 'model', model: activeModel });
}
if (sessionId && model && model !== 'default') {
expectedId = nextId;
writeRpc(
nextId,
'session/set_model',
{
sessionId,
modelId: model,
},
'session/set_model',
);
nextId += 1;
return;
}
if (!sessionId) {
fail(`invalid session/new response: ${rawLine}`);
return;
}
sendPrompt();
return;
}
if (promptRequestId !== null && raw.id === promptRequestId) {
const usage = formatUsage(raw.result.usage);
if (usage) {
send('agent', {
type: 'usage',
usage,
durationMs: Date.now() - runStartedAt,
});
}
finished = true;
clearStageTimer();
child.stdin.end();
return;
}
if (sessionId && model && model !== 'default' && raw.id === expectedId) {
activeModel = model;
send('agent', { type: 'status', label: 'model', model: activeModel });
sendPrompt();
}
});
child.stdout.on('data', (chunk) => parser.feed(chunk));
child.on('close', () => {
clearStageTimer();
parser.flush();
});
child.on('error', (err) => fail(err.message));
child.stdin.on('error', (err) => fail(`stdin error: ${err.message}`));
writeRpc(1, 'initialize', {
protocolVersion: ACP_PROTOCOL_VERSION,
clientCapabilities: { terminal: false },
clientInfo: { name: clientName, version: clientVersion },
}, 'initialize');
return {
hasFatalError() {
return fatal;
},
};
}

View file

@ -3,6 +3,7 @@ import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
import { detectAcpModels } from './acp.js';
const execFileP = promisify(execFile);
@ -28,10 +29,12 @@ const agentCapabilities = new Map();
// and as the fallback for the others.
// - `reasoningOptions` : optional reasoning-effort presets (currently
// only Codex exposes this knob).
// - `buildArgs(prompt, imagePaths, extraAllowedDirs, options)` returns
// argv for the child process. `options = { model, reasoning }` carries
// whatever the user picked in the model menu — agents that don't take a
// model flag ignore them.
// - `buildArgs(prompt, imagePaths, extraAllowedDirs, options, runtimeContext)`
// returns argv for the child process. `options = { model, reasoning }`
// carries whatever the user picked in the model menu — agents that don't
// take a model flag ignore them. `runtimeContext` currently carries
// runtime execution details like `{ cwd }` for CLIs that need an explicit
// workspace flag in addition to process cwd.
//
// Every model list is prefixed with a synthetic `'default'` entry meaning
// "let the CLI pick" — the agent runs with no `--model` flag, so the
@ -47,6 +50,9 @@ const agentCapabilities = new Map();
// - 'claude-stream-json' : line-delimited JSON emitted by Claude Code's
// `--output-format stream-json`. Daemon parses it into typed events
// (text / thinking / tool_use / tool_result / status) for the UI.
// - 'acp-json-rpc' : ACP JSON-RPC over stdio. Daemon drives the
// initialize/session/new/session/prompt lifecycle and maps updates into
// typed UI events.
// - 'plain' (default) : raw text, forwarded chunk-by-chunk.
//
// Permission posture: the daemon spawns each CLI with cwd pinned to the
@ -155,12 +161,12 @@ export const AGENT_DEFS = [
{ id: 'high', label: 'High' },
],
// Prompt delivered via stdin (`codex exec -`) to avoid Windows
// `spawn ENAMETOOLONG` — CreateProcess caps argv at ~32 KB and the
// composed prompt easily exceeds that. `--full-auto` keeps Codex in
// its workspace-write sandbox while skipping interactive permission
// prompts in the no-TTY web UI.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['exec', '--full-auto'];
// `spawn ENAMETOOLONG` while keeping Codex on its structured JSON stream.
buildArgs: (_prompt, _imagePaths, _extra, options = {}, runtimeContext = {}) => {
const args = ['exec', '--json', '--skip-git-repo-check', '--full-auto'];
if (runtimeContext.cwd) {
args.push('-C', runtimeContext.cwd);
}
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
@ -173,7 +179,8 @@ export const AGENT_DEFS = [
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
streamFormat: 'json-event-stream',
eventParser: 'codex',
},
{
id: 'gemini',
@ -190,14 +197,15 @@ export const AGENT_DEFS = [
// Windows (CreateProcess limit ~32 KB) for any non-trivial prompt.
// `--yolo` skips interactive approval prompts in the no-TTY web UI.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['--yolo'];
const args = ['--output-format', 'stream-json', '--skip-trust', '--yolo'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
streamFormat: 'json-event-stream',
eventParser: 'gemini',
},
{
id: 'opencode',
@ -217,9 +225,9 @@ export const AGENT_DEFS = [
{ id: 'google/gemini-2.5-pro', label: 'google/gemini-2.5-pro' },
],
// Prompt delivered via stdin (`opencode run -`) to avoid Windows
// `spawn ENAMETOOLONG` for large composed prompts.
// `spawn ENAMETOOLONG` while preserving OpenCode's structured stream.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['run'];
const args = ['run', '--format', 'json', '--dangerously-skip-permissions'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
@ -227,7 +235,53 @@ export const AGENT_DEFS = [
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
streamFormat: 'json-event-stream',
eventParser: 'opencode',
},
{
id: 'hermes',
name: 'Hermes',
bin: 'hermes',
versionArgs: ['--version'],
fetchModels: async (resolvedBin) =>
detectAcpModels({
bin: resolvedBin,
args: ['acp', '--accept-hooks'],
timeoutMs: 15_000,
defaultModelOption: DEFAULT_MODEL_OPTION,
}),
fallbackModels: [
DEFAULT_MODEL_OPTION,
{ id: 'openai-codex:gpt-5.5', label: 'gpt-5.5 (openai-codex:gpt-5.5)' },
{ id: 'openai-codex:gpt-5.4', label: 'gpt-5.4 (openai-codex:gpt-5.4)' },
{
id: 'openai-codex:gpt-5.4-mini',
label: 'gpt-5.4-mini (openai-codex:gpt-5.4-mini)',
},
],
buildArgs: () => ['acp', '--accept-hooks'],
streamFormat: 'acp-json-rpc',
},
{
id: 'kimi',
name: 'Kimi CLI',
bin: 'kimi',
versionArgs: ['--version'],
fetchModels: async (resolvedBin) =>
detectAcpModels({
bin: resolvedBin,
args: ['acp'],
timeoutMs: 15_000,
defaultModelOption: DEFAULT_MODEL_OPTION,
}),
fallbackModels: [
DEFAULT_MODEL_OPTION,
{ id: 'kimi-k2-turbo-preview', label: 'kimi-k2-turbo-preview' },
{ id: 'moonshot-v1-8k', label: 'moonshot-v1-8k' },
{ id: 'moonshot-v1-32k', label: 'moonshot-v1-32k' },
],
buildArgs: () => ['acp'],
streamFormat: 'acp-json-rpc',
},
{
id: 'cursor-agent',
@ -254,10 +308,13 @@ export const AGENT_DEFS = [
{ id: 'gpt-5', label: 'gpt-5' },
],
// Prompt delivered via stdin (`cursor-agent -`) to avoid Windows
// `spawn ENAMETOOLONG` for large composed prompts. `--force` skips
// interactive approval prompts in the no-TTY web UI.
buildArgs: (_prompt, _imagePaths, _extra, options = {}) => {
const args = ['--force'];
// `spawn ENAMETOOLONG` while preserving Cursor Agent's structured stream.
buildArgs: (_prompt, _imagePaths, _extra, options = {}, runtimeContext = {}) => {
const args = [];
args.push('--print', '--output-format', 'stream-json', '--stream-partial-output', '--force', '--trust');
if (runtimeContext.cwd) {
args.push('--workspace', runtimeContext.cwd);
}
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
@ -265,7 +322,8 @@ export const AGENT_DEFS = [
return args;
},
promptViaStdin: true,
streamFormat: 'plain',
streamFormat: 'json-event-stream',
eventParser: 'cursor-agent',
},
{
id: 'qwen',
@ -356,6 +414,15 @@ export function resolveOnPath(bin) {
}
async function fetchModels(def, resolvedBin) {
if (typeof def.fetchModels === 'function') {
try {
const parsed = await def.fetchModels(resolvedBin);
if (!parsed || parsed.length === 0) return def.fallbackModels;
return parsed;
} catch {
return def.fallbackModels;
}
}
if (!def.listModels) return def.fallbackModels;
try {
const { stdout } = await execFileP(resolvedBin, def.listModels.args, {
@ -426,7 +493,15 @@ function stripFns(def) {
// populated separately by `fetchModels`, so we strip the static
// `fallbackModels` slot here too. `helpArgs` / `capabilityFlags` are
// probe-only metadata and shouldn't bleed into the API response either.
const { buildArgs, listModels, fallbackModels, helpArgs, capabilityFlags, ...rest } = def;
const {
buildArgs,
listModels,
fetchModels,
fallbackModels,
helpArgs,
capabilityFlags,
...rest
} = def;
return rest;
}

View file

@ -9,20 +9,30 @@ import path from 'node:path';
import fs from 'node:fs';
let dbInstance = null;
let dbFile = null;
export function openDatabase(projectRoot) {
if (dbInstance) return dbInstance;
const dir = path.join(projectRoot, '.od');
fs.mkdirSync(dir, { recursive: true });
export function openDatabase(projectRoot, { dataDir } = {}) {
const dir = dataDir ? path.resolve(dataDir) : path.join(projectRoot, '.od');
const file = path.join(dir, 'app.sqlite');
if (dbInstance && dbFile === file) return dbInstance;
if (dbInstance) closeDatabase();
fs.mkdirSync(dir, { recursive: true });
const db = new Database(file);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
migrate(db);
dbInstance = db;
dbFile = file;
return db;
}
export function closeDatabase() {
if (!dbInstance) return;
dbInstance.close();
dbInstance = null;
dbFile = null;
}
function migrate(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS projects (

287
daemon/json-event-stream.js Normal file
View file

@ -0,0 +1,287 @@
function safeParseJson(value) {
if (value == null) return null;
if (typeof value === 'object') return value;
if (typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
function stringifyContent(value) {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function formatOpenCodeUsage(tokens) {
if (!tokens || typeof tokens !== 'object') return null;
const usage = {};
if (typeof tokens.input === 'number') usage.input_tokens = tokens.input;
if (typeof tokens.output === 'number') usage.output_tokens = tokens.output;
if (typeof tokens.reasoning === 'number') usage.thought_tokens = tokens.reasoning;
if (tokens.cache && typeof tokens.cache === 'object') {
if (typeof tokens.cache.read === 'number') usage.cached_read_tokens = tokens.cache.read;
if (typeof tokens.cache.write === 'number') usage.cached_write_tokens = tokens.cache.write;
}
return Object.keys(usage).length > 0 ? usage : null;
}
function handleOpenCodeEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
const part = obj.part && typeof obj.part === 'object' ? obj.part : {};
if (obj.type === 'step_start') {
onEvent({ type: 'status', label: 'running' });
return true;
}
if (obj.type === 'text' && typeof part.text === 'string' && part.text.length > 0) {
onEvent({ type: 'text_delta', delta: part.text });
return true;
}
if (obj.type === 'tool_use' && typeof part.tool === 'string' && typeof part.callID === 'string') {
const statePart = part.state && typeof part.state === 'object' ? part.state : null;
const key = `${obj.sessionID || 'session'}:${part.callID}`;
if (!state.openCodeToolUses.has(key)) {
state.openCodeToolUses.add(key);
onEvent({
type: 'tool_use',
id: part.callID,
name: part.tool,
input: safeParseJson(statePart?.input) ?? statePart?.input ?? null,
});
}
if (statePart?.status === 'completed') {
onEvent({
type: 'tool_result',
toolUseId: part.callID,
content: stringifyContent(statePart.output),
isError: false,
});
}
return true;
}
if (obj.type === 'step_finish') {
const usage = formatOpenCodeUsage(part.tokens);
if (usage) {
onEvent({
type: 'usage',
usage,
costUsd: typeof part.cost === 'number' ? part.cost : undefined,
});
}
return true;
}
if (obj.type === 'error') {
const message =
(obj.error && typeof obj.error === 'object' && obj.error.data?.message) ||
(obj.error && typeof obj.error === 'object' && obj.error.name) ||
'OpenCode error';
onEvent({ type: 'raw', line: stringifyContent({ type: 'error', message }) });
return true;
}
return false;
}
function handleGeminiEvent(obj, onEvent) {
if (!obj || typeof obj !== 'object') return false;
if (obj.type === 'init') {
onEvent({
type: 'status',
label: 'initializing',
model: typeof obj.model === 'string' ? obj.model : undefined,
});
return true;
}
if (
obj.type === 'message' &&
obj.role === 'assistant' &&
typeof obj.content === 'string' &&
obj.content.length > 0
) {
onEvent({ type: 'text_delta', delta: obj.content });
return true;
}
if (obj.type === 'result' && obj.stats && typeof obj.stats === 'object') {
const usage = {};
if (typeof obj.stats.input_tokens === 'number') usage.input_tokens = obj.stats.input_tokens;
if (typeof obj.stats.output_tokens === 'number') usage.output_tokens = obj.stats.output_tokens;
if (typeof obj.stats.cached === 'number') usage.cached_read_tokens = obj.stats.cached;
onEvent({
type: 'usage',
usage,
durationMs: typeof obj.stats.duration_ms === 'number' ? obj.stats.duration_ms : undefined,
});
return true;
}
return false;
}
function extractCursorText(message) {
const blocks = Array.isArray(message?.content) ? message.content : [];
return blocks
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('');
}
function emitCursorTextDelta(text, onEvent, state) {
if (!state.cursorTextSoFar) {
state.cursorTextSoFar = text;
onEvent({ type: 'text_delta', delta: text });
return;
}
if (text === state.cursorTextSoFar) {
return;
}
if (text.startsWith(state.cursorTextSoFar)) {
const delta = text.slice(state.cursorTextSoFar.length);
if (delta) onEvent({ type: 'text_delta', delta });
state.cursorTextSoFar = text;
return;
}
state.cursorTextSoFar += text;
onEvent({ type: 'text_delta', delta: text });
}
function handleCursorEvent(obj, onEvent, state) {
if (!obj || typeof obj !== 'object') return false;
if (obj.type === 'system' && obj.subtype === 'init') {
onEvent({
type: 'status',
label: 'initializing',
model: typeof obj.model === 'string' ? obj.model : undefined,
});
return true;
}
if (obj.type === 'assistant' && obj.message) {
const text = extractCursorText(obj.message);
if (!text) return false;
if (typeof obj.timestamp_ms === 'number') {
emitCursorTextDelta(text, onEvent, state);
return true;
}
emitCursorTextDelta(text, onEvent, state);
return true;
}
if (obj.type === 'result' && obj.usage && typeof obj.usage === 'object') {
const usage = {};
if (typeof obj.usage.inputTokens === 'number') usage.input_tokens = obj.usage.inputTokens;
if (typeof obj.usage.outputTokens === 'number') usage.output_tokens = obj.usage.outputTokens;
if (typeof obj.usage.cacheReadTokens === 'number') {
usage.cached_read_tokens = obj.usage.cacheReadTokens;
}
if (typeof obj.usage.cacheWriteTokens === 'number') {
usage.cached_write_tokens = obj.usage.cacheWriteTokens;
}
onEvent({
type: 'usage',
usage,
durationMs: typeof obj.duration_ms === 'number' ? obj.duration_ms : undefined,
});
return true;
}
return false;
}
function handleCodexEvent(obj, onEvent) {
if (!obj || typeof obj !== 'object') return false;
if (obj.type === 'thread.started') {
onEvent({ type: 'status', label: 'initializing' });
return true;
}
if (obj.type === 'turn.started') {
onEvent({ type: 'status', label: 'running' });
return true;
}
if (
obj.type === 'item.completed' &&
obj.item &&
typeof obj.item === 'object' &&
obj.item.type === 'agent_message' &&
typeof obj.item.text === 'string' &&
obj.item.text.length > 0
) {
onEvent({ type: 'text_delta', delta: obj.item.text });
return true;
}
if (obj.type === 'turn.completed' && obj.usage && typeof obj.usage === 'object') {
const usage = {};
if (typeof obj.usage.input_tokens === 'number') usage.input_tokens = obj.usage.input_tokens;
if (typeof obj.usage.output_tokens === 'number') usage.output_tokens = obj.usage.output_tokens;
if (typeof obj.usage.cached_input_tokens === 'number') {
usage.cached_read_tokens = obj.usage.cached_input_tokens;
}
onEvent({ type: 'usage', usage });
return true;
}
return false;
}
export function createJsonEventStreamHandler(kind, onEvent) {
let buffer = '';
const state = {
cursorTextSoFar: '',
openCodeToolUses: new Set(),
};
function handleLine(line) {
let obj;
try {
obj = JSON.parse(line);
} catch {
onEvent({ type: 'raw', line });
return;
}
if (kind === 'opencode' && handleOpenCodeEvent(obj, onEvent, state)) return;
if (kind === 'gemini' && handleGeminiEvent(obj, onEvent)) return;
if (kind === 'cursor-agent' && handleCursorEvent(obj, onEvent, state)) return;
if (kind === 'codex' && handleCodexEvent(obj, onEvent)) return;
onEvent({ type: 'raw', line });
}
function feed(chunk) {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
handleLine(line);
}
}
function flush() {
const rem = buffer.trim();
buffer = '';
if (!rem) return;
handleLine(rem);
}
return { feed, flush };
}

View file

@ -0,0 +1,225 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { createJsonEventStreamHandler } from './json-event-stream.js';
test('opencode json stream emits text and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
handler.feed(
'{"type":"step_start","sessionID":"ses-1","part":{"type":"step-start"}}\n' +
'{"type":"text","sessionID":"ses-1","part":{"type":"text","text":"hello"}}\n' +
'{"type":"step_finish","sessionID":"ses-1","part":{"type":"step-finish","tokens":{"input":11,"output":7,"reasoning":3,"cache":{"read":5,"write":2}},"cost":0}}\n',
);
assert.deepEqual(events, [
{ type: 'status', label: 'running' },
{ type: 'text_delta', delta: 'hello' },
{
type: 'usage',
usage: {
input_tokens: 11,
output_tokens: 7,
thought_tokens: 3,
cached_read_tokens: 5,
cached_write_tokens: 2,
},
costUsd: 0,
},
]);
});
test('opencode json stream emits tool events', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
handler.feed(
JSON.stringify({
type: 'tool_use',
part: {
tool: 'read',
callID: 'call-1',
state: {
input: JSON.stringify({ file: 'foo.txt' }),
output: 'done',
status: 'completed',
},
},
}) + '\n',
);
assert.deepEqual(events, [
{ type: 'tool_use', id: 'call-1', name: 'read', input: { file: 'foo.txt' } },
{ type: 'tool_result', toolUseId: 'call-1', content: 'done', isError: false },
]);
});
test('unknown json stream lines become raw events', () => {
const events = [];
const handler = createJsonEventStreamHandler('opencode', (event) => events.push(event));
handler.feed('not-json\n');
handler.flush();
assert.deepEqual(events, [{ type: 'raw', line: 'not-json' }]);
});
test('gemini stream emits init text and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('gemini', (event) => events.push(event));
handler.feed(
JSON.stringify({ type: 'init', session_id: 'gm-1', model: 'gemini-3-flash-preview' }) + '\n' +
JSON.stringify({ type: 'message', role: 'assistant', content: 'hello', delta: true }) + '\n' +
JSON.stringify({
type: 'result',
status: 'success',
stats: { input_tokens: 9, output_tokens: 4, cached: 2, duration_ms: 321 },
}) +
'\n',
);
assert.deepEqual(events, [
{ type: 'status', label: 'initializing', model: 'gemini-3-flash-preview' },
{ type: 'text_delta', delta: 'hello' },
{
type: 'usage',
usage: { input_tokens: 9, output_tokens: 4, cached_read_tokens: 2 },
durationMs: 321,
},
]);
});
test('cursor stream emits partial text once and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('cursor-agent', (event) => events.push(event));
handler.feed(
JSON.stringify({ type: 'system', subtype: 'init', model: 'GPT-5 Mini' }) + '\n' +
JSON.stringify({
type: 'assistant',
timestamp_ms: 1,
message: { role: 'assistant', content: [{ type: 'text', text: 'OD' }] },
}) +
'\n' +
JSON.stringify({
type: 'assistant',
timestamp_ms: 2,
message: { role: 'assistant', content: [{ type: 'text', text: '_OK' }] },
}) +
'\n' +
JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'OD_OK' }] },
}) +
'\n' +
JSON.stringify({
type: 'result',
duration_ms: 120,
usage: { inputTokens: 5, outputTokens: 2, cacheReadTokens: 1, cacheWriteTokens: 0 },
}) +
'\n',
);
assert.deepEqual(events, [
{ type: 'status', label: 'initializing', model: 'GPT-5 Mini' },
{ type: 'text_delta', delta: 'OD' },
{ type: 'text_delta', delta: '_OK' },
{
type: 'usage',
usage: { input_tokens: 5, output_tokens: 2, cached_read_tokens: 1, cached_write_tokens: 0 },
durationMs: 120,
},
]);
});
test('cursor stream emits suffix when final assistant extends partial text', () => {
const events = [];
const handler = createJsonEventStreamHandler('cursor-agent', (event) => events.push(event));
handler.feed(
JSON.stringify({
type: 'assistant',
timestamp_ms: 1,
message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] },
}) +
'\n' +
JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'hello world' }] },
}) +
'\n',
);
assert.deepEqual(events, [
{ type: 'text_delta', delta: 'hello' },
{ type: 'text_delta', delta: ' world' },
]);
});
test('cursor stream de-duplicates cumulative timestamped assistant chunks', () => {
const events = [];
const handler = createJsonEventStreamHandler('cursor-agent', (event) => events.push(event));
handler.feed(
JSON.stringify({
type: 'assistant',
timestamp_ms: 1,
message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] },
}) +
'\n' +
JSON.stringify({
type: 'assistant',
timestamp_ms: 2,
message: { role: 'assistant', content: [{ type: 'text', text: 'hello world' }] },
}) +
'\n' +
JSON.stringify({
type: 'assistant',
timestamp_ms: 3,
message: { role: 'assistant', content: [{ type: 'text', text: 'hello world' }] },
}) +
'\n',
);
assert.deepEqual(events, [
{ type: 'text_delta', delta: 'hello' },
{ type: 'text_delta', delta: ' world' },
]);
});
test('codex json stream emits status text and usage events', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));
handler.feed(
JSON.stringify({ type: 'thread.started', thread_id: 'thr-1' }) + '\n' +
JSON.stringify({ type: 'turn.started' }) + '\n' +
JSON.stringify({
type: 'item.completed',
item: { id: 'item-1', type: 'agent_message', text: 'hello' },
}) +
'\n' +
JSON.stringify({
type: 'turn.completed',
usage: { input_tokens: 12, cached_input_tokens: 4, output_tokens: 3 },
}) +
'\n',
);
assert.deepEqual(events, [
{ type: 'status', label: 'initializing' },
{ type: 'status', label: 'running' },
{ type: 'text_delta', delta: 'hello' },
{ type: 'usage', usage: { input_tokens: 12, output_tokens: 3, cached_read_tokens: 4 } },
]);
});
test('unhandled structured events fall back to raw', () => {
const events = [];
const handler = createJsonEventStreamHandler('codex', (event) => events.push(event));
handler.feed(JSON.stringify({ type: 'unhandled.event', foo: 'bar' }) + '\n');
assert.deepEqual(events, [{ type: 'raw', line: '{"type":"unhandled.event","foo":"bar"}' }]);
});

View file

@ -15,8 +15,10 @@ import {
} from './agents.js';
import { listSkills } from './skills.js';
import { listDesignSystems, readDesignSystem } from './design-systems.js';
import { attachAcpSession } from './acp.js';
import { createClaudeStreamHandler } from './claude-stream.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { importClaudeDesignZip } from './claude-design-import.js';
@ -61,8 +63,11 @@ const PROJECT_ROOT = path.resolve(__dirname, '..');
const STATIC_DIR = path.join(PROJECT_ROOT, 'dist');
const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
const DESIGN_SYSTEMS_DIR = path.join(PROJECT_ROOT, 'design-systems');
const ARTIFACTS_DIR = path.join(PROJECT_ROOT, '.od', 'artifacts');
const PROJECTS_DIR = path.join(PROJECT_ROOT, '.od', 'projects');
const RUNTIME_DATA_DIR = process.env.OD_DATA_DIR
? path.resolve(process.env.OD_DATA_DIR)
: path.join(PROJECT_ROOT, '.od');
const ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'artifacts');
const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects');
fs.mkdirSync(PROJECTS_DIR, { recursive: true });
const UPLOAD_DIR = path.join(os.tmpdir(), 'od-uploads');
@ -158,10 +163,10 @@ function sendMulterError(res, err) {
return res.status(500).json({ code: 'UPLOAD_ERROR', error: 'upload failed' });
}
export async function startServer({ port = 7456 } = {}) {
export async function startServer({ port = 7456, returnServer = false } = {}) {
const app = express();
app.use(express.json({ limit: '4mb' }));
const db = openDatabase(PROJECT_ROOT);
const db = openDatabase(PROJECT_ROOT, { dataDir: RUNTIME_DATA_DIR });
// Warm agent-capability probes (e.g. whether the installed Claude Code
// build advertises --include-partial-messages) so the first /api/chat
@ -961,7 +966,7 @@ export async function startServer({ port = 7456 } = {}) {
? def.reasoningOptions.find((r) => r.id === reasoning)?.id ?? null
: null;
const agentOptions = { model: safeModel, reasoning: safeReasoning };
const args = def.buildArgs(composed, safeImages, extraAllowedDirs, agentOptions);
const args = def.buildArgs(composed, safeImages, extraAllowedDirs, agentOptions, { cwd });
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
@ -1030,12 +1035,13 @@ export async function startServer({ port = 7456 } = {}) {
});
let child;
let acpSession = null;
try {
// When the agent definition sets `promptViaStdin`, pipe the composed
// prompt through stdin instead of embedding it in argv. Bypasses the
// OS command-line length limit (Windows CreateProcess caps at ~32 KB)
// which causes `spawn ENAMETOOLONG` for any non-trivial prompt.
const stdinMode = def.promptViaStdin ? 'pipe' : 'ignore';
const stdinMode = def.promptViaStdin || def.streamFormat === 'acp-json-rpc' ? 'pipe' : 'ignore';
child = spawn(resolvedBin, args, {
env: { ...process.env },
stdio: [stdinMode, 'pipe', 'pipe'],
@ -1074,6 +1080,20 @@ export async function startServer({ port = 7456 } = {}) {
const copilot = createCopilotStreamHandler((ev) => send('agent', ev));
child.stdout.on('data', (chunk) => copilot.feed(chunk));
child.on('close', () => copilot.flush());
} else if (def.streamFormat === 'acp-json-rpc') {
acpSession = attachAcpSession({
child,
prompt: composed,
cwd: cwd || PROJECT_ROOT,
model: safeModel,
send,
});
} else if (def.streamFormat === 'json-event-stream') {
const handler = createJsonEventStreamHandler(def.eventParser || def.id, (ev) =>
send('agent', ev),
);
child.stdout.on('data', (chunk) => handler.feed(chunk));
child.on('close', () => handler.flush());
} else {
child.stdout.on('data', (chunk) => send('stdout', { chunk }));
}
@ -1091,6 +1111,9 @@ export async function startServer({ port = 7456 } = {}) {
res.end();
});
child.on('close', (code, signal) => {
if (acpSession?.hasFatalError()) {
return res.end();
}
send('end', { code, signal });
res.end();
});
@ -1105,7 +1128,12 @@ export async function startServer({ port = 7456 } = {}) {
}
return new Promise((resolve) => {
app.listen(port, '127.0.0.1', () => resolve(`http://localhost:${port}`));
const server = app.listen(port, '127.0.0.1', () => {
const address = server.address();
const actualPort = typeof address === 'object' && address ? address.port : port;
const url = `http://127.0.0.1:${actualPort}`;
resolve(returnServer ? { url, server } : url);
});
});
}

View file

@ -14,6 +14,7 @@
"dev": "vite",
"dev:all": "node scripts/dev-all.mjs",
"build": "tsc -b && vite build",
"test:e2e:live": "node --test scripts/runtime-adapter.e2e.live.test.mjs",
"preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc -b --noEmit",

View file

@ -17,6 +17,9 @@ importers:
express:
specifier: ^4.19.2
version: 4.22.1
jszip:
specifier: ^3.10.1
version: 3.10.1
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
@ -846,6 +849,9 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@ -876,6 +882,12 @@ packages:
engines: {node: '>=6'}
hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@ -990,6 +1002,9 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@ -1108,6 +1123,9 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@ -2146,6 +2164,8 @@ snapshots:
ieee754@1.2.1: {}
immediate@3.0.6: {}
inherits@2.0.4: {}
ini@1.3.8: {}
@ -2162,6 +2182,17 @@ snapshots:
json5@2.2.3: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
lie@3.3.0:
dependencies:
immediate: 3.0.6
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@ -2246,6 +2277,8 @@ snapshots:
dependencies:
wrappy: 1.0.2
pako@1.0.11: {}
parseurl@1.3.3: {}
path-to-regexp@0.1.13: {}
@ -2419,6 +2452,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {}
shell-quote@1.8.3: {}

View file

@ -0,0 +1,275 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const liveTimeoutMs = Number(process.env.OD_RUNTIME_LIVE_TIMEOUT_MS || 180_000);
const requestedRuntimeIds = parseRuntimeIds(process.env.OD_E2E_RUNTIMES);
const maxRuntimeCount = 8;
const marker = 'OD_RUNTIME_ADAPTER_LIVE_OK';
let baseUrl;
let server;
let startServer;
let closeDatabase;
let detectedAgents;
let dataDir;
test.before(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'od-runtime-adapter-live-'));
process.env.OD_DATA_DIR = dataDir;
({ startServer } = await import('../daemon/server.js'));
({ closeDatabase } = await import('../daemon/db.js'));
const started = await startServer({ port: 0, returnServer: true });
baseUrl = started.url;
server = started.server;
});
test.after(async () => {
if (server) {
await new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
closeDatabase?.();
if (dataDir) {
await fs.rm(dataDir, { recursive: true, force: true });
}
});
test('runtime adapter live detection flow exposes installed runtimes', async () => {
log('detect', 'starting runtime detection via /api/agents');
const res = await fetch(`${baseUrl}/api/agents`);
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(Array.isArray(body.agents));
assert.ok(body.agents.length > 0);
detectedAgents = body.agents;
const available = body.agents.filter((agent) => agent.available);
for (const agent of body.agents) {
const status = agent.available ? 'available' : 'unavailable';
const version = agent.version ? ` version=${agent.version}` : '';
const resolvedPath = agent.path ? ` path=${agent.path}` : '';
log(
'detect',
`${agent.id}: ${status}${version}${resolvedPath} models=${agent.models?.length ?? 0} stream=${agent.streamFormat}`,
);
}
assert.ok(
available.length > 0,
'Install at least one supported runtime CLI on PATH: claude, codex, gemini, opencode, hermes, kimi, cursor-agent, or qwen.',
);
for (const agent of body.agents) {
assert.equal(typeof agent.id, 'string');
assert.equal(typeof agent.name, 'string');
assert.equal(typeof agent.bin, 'string');
assert.equal(typeof agent.available, 'boolean');
assert.ok(Array.isArray(agent.models));
assert.ok(agent.models.some((model) => model.id === 'default'));
assert.equal(typeof agent.streamFormat, 'string');
if (agent.available) {
assert.equal(typeof agent.path, 'string');
assert.ok(agent.path.length > 0);
}
}
});
test('runtime adapter live run flow streams a successful response for every available runtime', { timeout: liveTimeoutMs * maxRuntimeCount + 30_000 }, async () => {
if (!detectedAgents) {
log('run', 'detection cache empty; fetching /api/agents before run flow');
const res = await fetch(`${baseUrl}/api/agents`);
detectedAgents = (await res.json()).agents;
}
const requestedSet = requestedRuntimeIds ? new Set(requestedRuntimeIds) : null;
const availableAgents = detectedAgents.filter(
(agent) => agent.available && (!requestedSet || requestedSet.has(agent.id)),
);
if (requestedSet) {
log('run', `runtime filter=${requestedRuntimeIds.join(',')}`);
for (const id of requestedSet) {
assert.ok(
detectedAgents.some((agent) => agent.id === id),
`Requested runtime ${id} was not returned by /api/agents.`,
);
}
}
for (const agent of detectedAgents) {
if (agent.available) {
if (!requestedSet || requestedSet.has(agent.id)) {
log('run', `${agent.id}: queued`);
} else {
log('run', `${agent.id}: skipped by runtime filter`);
}
} else {
log('run', `${agent.id}: skipped because runtime is unavailable`);
}
}
assert.ok(
availableAgents.length > 0,
requestedSet
? `No requested runtime is available: ${requestedRuntimeIds.join(',')}.`
: 'No available runtime returned by /api/agents.',
);
for (const agent of availableAgents) {
await runRuntime(agent);
}
});
async function runRuntime(agent) {
const startedAt = Date.now();
log('run', `${agent.id}: starting /api/chat live run`);
const projectId = `runtime-adapter-live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const events = [];
const abort = AbortSignal.timeout(liveTimeoutMs);
try {
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: abort,
body: JSON.stringify({
agentId: agent.id,
projectId,
model: 'default',
message: `Reply with exactly this token and nothing else: ${marker}`,
systemPrompt: [
'You are running a local runtime-adapter live smoke test.',
'Produce a minimal text-only response.',
'Do not create, edit, delete, or inspect files.',
].join('\n'),
}),
});
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type') || '', /text\/event-stream/);
await collectSseEvents(res, events, agent.id);
} finally {
await fs.rm(path.join(dataDir, 'projects', projectId), {
recursive: true,
force: true,
});
}
const start = events.find((event) => event.event === 'start');
assert.ok(start, 'SSE stream should include a start event.');
assert.equal(start.data.agentId, agent.id);
assert.equal(start.data.projectId, projectId);
log('run', `${agent.id}: start event cwd=${start.data.cwd}`);
const end = events.find((event) => event.event === 'end');
assert.ok(end, 'SSE stream should include an end event.');
assert.equal(end.data.code, 0, renderEvents(events));
log('run', `${agent.id}: end event code=${end.data.code} signal=${end.data.signal ?? 'none'}`);
const text = events
.map((event) => {
if (event.event === 'stdout') return event.data.chunk || '';
if (event.event === 'agent') return event.data.text || event.data.delta || '';
return '';
})
.join('');
assert.match(text, new RegExp(marker), renderEvents(events));
log('run', `${agent.id}: passed in ${Date.now() - startedAt}ms`);
}
async function collectSseEvents(res, events, agentId) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const seen = new Set();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() || '';
for (const chunk of chunks) {
const parsed = parseSseEvent(chunk);
if (parsed) {
events.push(parsed);
logSseProgress(agentId, parsed, seen);
}
}
}
buffer += decoder.decode();
if (buffer.trim()) {
const parsed = parseSseEvent(buffer);
if (parsed) {
events.push(parsed);
logSseProgress(agentId, parsed, seen);
}
}
}
function parseSseEvent(chunk) {
const eventLine = chunk.split('\n').find((line) => line.startsWith('event: '));
const dataLine = chunk.split('\n').find((line) => line.startsWith('data: '));
if (!eventLine || !dataLine) return null;
return {
event: eventLine.slice('event: '.length),
data: JSON.parse(dataLine.slice('data: '.length)),
};
}
function renderEvents(events) {
return JSON.stringify(events, null, 2).slice(0, 8000);
}
function parseRuntimeIds(value) {
if (!value) return null;
const ids = value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return ids.length > 0 ? ids : null;
}
function log(stage, message) {
console.log(`[runtime-adapter:e2e:${stage}] ${message}`);
}
function logSseProgress(agentId, event, seen) {
if (event.event === 'start' && !seen.has('start')) {
seen.add('start');
log('run', `${agentId}: received start event`);
return;
}
if (event.event === 'stdout' && !seen.has('stdout')) {
seen.add('stdout');
log('run', `${agentId}: received stdout stream`);
return;
}
if (event.event === 'agent' && !seen.has(`agent:${event.data.type || 'event'}`)) {
seen.add(`agent:${event.data.type || 'event'}`);
log('run', `${agentId}: received agent event type=${event.data.type || 'unknown'}`);
return;
}
if (event.event === 'stderr' && !seen.has('stderr')) {
seen.add('stderr');
log('run', `${agentId}: received stderr stream`);
return;
}
if (event.event === 'error') {
log('run', `${agentId}: received error event ${event.data.message || ''}`.trim());
return;
}
if (event.event === 'end' && !seen.has('end')) {
seen.add('end');
log('run', `${agentId}: received end event`);
}
}

View file

@ -0,0 +1,283 @@
# Runtime Adapter Current State
## Purpose
Runtime Adapter is the daemon layer responsible for adapting local AI agent CLIs. It converts Open Design's unified generation requests into the actual command-line invocations for each CLI, and converts CLI output into streaming events that the frontend can consume.
The current implementation is concentrated in:
- `daemon/agents.js`: agent definitions, detection, model lists, argument construction, model validation.
- `daemon/server.js`: `/api/chat` request orchestration, prompt composition, `spawn()` subprocesses, SSE forwarding.
- `daemon/claude-stream.js`: parsing Claude Code structured JSONL output.
- `daemon/json-event-stream.js`: parsing structured JSON/JSONL output from Codex, Gemini, OpenCode, and Cursor Agent.
- `daemon/acp.js`: model detection and streaming session orchestration for the ACP JSON-RPC runtime.
## Currently Supported Runtimes
`AGENT_DEFS` in `daemon/agents.js` defines 8 local runtimes:
| id | Name | CLI | Output format | Model list source |
|---|---|---|---|---|
| `claude` | Claude Code | `claude` | `claude-stream-json` | Static fallback |
| `codex` | Codex CLI | `codex` | `json-event-stream` | Static fallback |
| `gemini` | Gemini CLI | `gemini` | `json-event-stream` | Static fallback |
| `opencode` | OpenCode | `opencode` | `json-event-stream` | `opencode models` + fallback |
| `hermes` | Hermes | `hermes` | `acp-json-rpc` | `session/new` from `hermes acp` + fallback |
| `kimi` | Kimi CLI | `kimi` | `acp-json-rpc` | `session/new` from `kimi acp` + fallback |
| `cursor-agent` | Cursor Agent | `cursor-agent` | `json-event-stream` | `cursor-agent models` + fallback |
| `qwen` | Qwen Code | `qwen` | `plain` | Static fallback |
Each runtime definition contains:
- `id` / `name` / `bin`: used for frontend display and process startup.
- `versionArgs`: used to detect the version.
- `fallbackModels`: static fallback options for the model selector.
- `listModels`: optional model discovery command.
- `fetchModels`: optional custom model detection logic, suitable for runtimes such as ACP that require a handshake before the model list is available.
- `reasoningOptions`: optional reasoning effort options, currently used by Codex.
- `buildArgs()`: converts unified input into the CLI's argv; it can also read `runtimeContext` at runtime, currently used to explicitly pass execution context such as `cwd`.
- `streamFormat`: tells the daemon how to interpret stdout.
## Detection Flow
The detection entry point is `detectAgents()`.
Flow:
1. Iterate over `AGENT_DEFS`.
2. Use `resolveOnPath()` to locate the CLI binary in `PATH`.
3. After locating it, run `versionArgs` to get the version.
4. Generate the model list through `listModels`, `fetchModels`, or `fallbackModels`, depending on runtime capabilities.
5. Return the result to the frontend and refresh the runtime's model validation cache.
The detection result includes:
- `available`: whether the CLI is available.
- `path`: the actual binary path.
- `version`: version string.
- `models`: model list used by the frontend model menu.
- `reasoningOptions`: reasoning effort menu.
- `streamFormat`: output format hint.
## Runtime Flow
Actual execution happens in `POST /api/chat` in `daemon/server.js`.
Flow:
1. The frontend submits `agentId`, user message, system prompt, project ID, attachments, model, and reasoning options.
2. The daemon uses `getAgentDef(agentId)` to find the runtime definition.
3. The daemon creates or locates `.od/projects/<projectId>/` as the agent working directory.
4. The daemon validates uploaded image paths and project attachment paths.
5. The daemon combines the system prompt, working directory hint, existing file list, attachment list, and user request into one prompt.
6. The daemon prepares additional readable directories: `skills/` and `design-systems/`.
7. The daemon validates the model and reasoning option.
8. It calls `def.buildArgs(...)` to generate CLI arguments; currently it also passes `runtimeContext = { cwd }` for CLIs that need an explicit workspace argument.
9. It starts the local runtime with `spawn(def.bin, args, { cwd })`; plain / Claude use read-only stdin, and ACP runtimes use writable stdin.
10. The daemon forwards runtime output to the frontend through SSE.
## Output Stream Handling
There are currently four output formats:
### Claude Code: Structured JSONL
Claude Code uses:
```bash
claude -p <prompt> --output-format stream-json --verbose --include-partial-messages
```
The daemon parses stdout through `createClaudeStreamHandler()` and converts Claude Code JSONL events into UI events:
- `status`
- `text_delta`
- `thinking_delta`
- `thinking_start`
- `tool_use`
- `tool_result`
- `usage`
These events are sent to the frontend through the SSE `agent` event.
### Codex / Gemini / OpenCode / Cursor Agent: Structured JSON Event Stream
These four runtimes currently use the unified `json-event-stream` output format, with stdout parsed by `daemon/json-event-stream.js`.
#### Codex
Codex currently uses:
```bash
codex exec --json --skip-git-repo-check --full-auto -C <cwd> <prompt>
```
The current integration uses the lightweight structured path through `exec --json`. Compared with the original plain-text `codex exec`, this path adds:
- `--json`: structured event output
- `--skip-git-repo-check`: allows running in a temporary working directory
- `--full-auto`: non-interactive automatic execution
- `-C <cwd>`: explicit working directory
The daemon currently maps:
- `thread.started``status(initializing)`
- `turn.started``status(running)`
- `item.completed(agent_message)``text_delta`
- `turn.completed.usage``usage`
#### Gemini
Gemini currently uses:
```bash
gemini --output-format stream-json --skip-trust -p <prompt>
```
The daemon currently maps:
- `init``status(initializing)`
- `message(role=assistant)``text_delta`
- `result.stats``usage`
Gemini may still output some workspace scan warnings on stderr at runtime; the main flow remains unaffected.
#### OpenCode
OpenCode currently uses:
```bash
opencode run --format json --dangerously-skip-permissions <prompt>
```
When the user selects a model, `--model <id>` is appended.
The daemon currently maps:
- `step_start``status(running)`
- `text``text_delta`
- `tool_use``tool_use`
- Completed `tool_use.state``tool_result`
- `step_finish.part.tokens``usage`
#### Cursor Agent
Cursor Agent currently uses:
```bash
cursor-agent --print --output-format stream-json --stream-partial-output --force --trust --workspace <cwd> -p <prompt>
```
When the user selects a model, `--model <id>` is appended.
The daemon currently maps:
- `system(subtype=init)``status(initializing)`
- `assistant` partial chunks with `timestamp_ms``text_delta`
- `result.usage``usage`
Cursor outputs both partial assistant chunks and the final aggregated assistant message. The daemon currently prioritizes partial chunks and ignores the final aggregated text after partial chunks have appeared, avoiding duplicate rendering.
### Qwen: Plain Text Pass-through
Qwen currently still uses the `plain` output format.
The daemon directly forwards stdout chunks to the frontend through the SSE `stdout` event, and stderr chunks through the `stderr` event.
### Hermes / Kimi: ACP JSON-RPC
Hermes uses:
```bash
hermes acp --accept-hooks
```
Kimi uses:
```bash
kimi acp
```
The daemon starts an ACP session over stdio through `daemon/acp.js`:
1. `initialize`
2. `session/new`
3. Optional `session/set_model`
4. `session/prompt`
When an ACP runtime actively emits `session/request_permission`, the daemon prefers `approve_for_session`, which supports headless automatic approval for CLIs such as Kimi that require approval before tool calls.
The `session/new` response returns `sessionId`, `models.availableModels`, and `models.currentModelId`. The daemon reuses this information for model detection and runtime status reporting.
It then converts Hermes / Kimi `session/update` events into frontend-consumable `agent` events:
- `agent_thought_chunk``thinking_start` / `thinking_delta`
- `agent_message_chunk``text_delta`
- Final usage from `session/prompt``usage`
At runtime, two additional status events are added:
- Emit `status(model)` after `session/new` returns the default model.
- Emit `status(streaming)` when the first text token arrives, including `ttftMs`.
Model detection also reuses ACP: during detection, the daemon reads `models.availableModels` and `models.currentModelId` from the `session/new` response.
The current Kimi MVP integration directly reuses the Hermes ACP orchestrator. Automatic permission approval has been added to the shared ACP layer. `multica` also contains Kimi-specific tool title normalization and provider error sniffing; this repository currently keeps a lighter implementation.
## Prompt Injection Approach
Local CLIs currently use a unified approach of folding the system prompt into the user message.
The reason is that most local code-agent CLI command-line entry points lack an independent system channel. The daemon composes the following content into a single input:
- `systemPrompt`: base output contract + skill content + design system content.
- `cwdHint`: current working directory and file writing rules.
- `filesListBlock`: existing file list in the project directory.
- `attachmentHint`: attachments uploaded or selected by the user.
- `message`: original user request.
- `safeImages`: temporary uploaded image paths appended in `@path` form.
Claude Code additionally exposes `skills/` and `design-systems/` through `--add-dir`, making it easier for the agent to read skill seeds, templates, and design system files.
## Safety and Validation
Existing protections include:
- Process startup uses `spawn()` argument arrays, avoiding shell string concatenation.
- Model IDs are first compared with the model list exposed by the most recent `/api/agents` response.
- Custom model IDs are validated by `sanitizeCustomModel()`, limiting length, character set, and starting character.
- Reasoning options must exist in the runtime definition's `reasoningOptions`.
- Image paths must be located inside the daemon temporary upload directory.
- Attachment paths must be located inside the project working directory.
- Agent working directories are constrained to `.od/projects/<projectId>/`.
- ACP runtimes have timeout protection for the initialize, session/new, session/set_model, and session/prompt stages.
- ACP runtimes listen for `stdin` errors and proactively clean up detection processes after model detection completes.
- When the SSE connection closes, the daemon sends `SIGTERM` to the subprocess.
## Current Capability Boundaries
The current runtime adapter is a lightweight adaptation layer that already covers discovery, startup, argument construction, model selection, and streaming forwarding.
Main boundaries:
- The adapter is still a declarative object array and has not yet been split into independent adapter classes or directories.
- The capability model is thin and currently mainly exposes models, reasoning, and output format.
- Claude Code, Codex, Gemini, OpenCode, Cursor Agent, Hermes, and Kimi already have structured event parsing.
- Qwen currently still uses plain text pass-through.
- Skill injection mainly relies on prompt composition; only Claude Code uses `--add-dir` to support reading external directories.
- Hermes currently only integrates the core ACP text session path and has not mapped more `session/update` types into unified UI events.
- Cancellation is triggered by HTTP connection closure and `SIGTERM`; there is no explicit runId / cancel API yet.
- Resume, auth state, permission modes, and capability gating have not yet formed a unified interface.
- API fallback belongs to the frontend provider path and is currently outside the daemon runtime adapter layer.
## Gap from the Target Architecture
`docs/agent-adapters.md` describes a more complete target shape: each agent adapter has interfaces such as `detect()`, `capabilities()`, `run()`, `cancel()`, and `resume()`, and outputs unified `AgentEvent`s.
The current implementation already has the core outline of the target architecture:
- `detectAgents()` corresponds to `detect()`.
- `AGENT_DEFS` corresponds to the adapter registry.
- `buildArgs()` corresponds to runtime-specific invocation.
- `streamFormat` + `claude-stream.js` + `json-event-stream.js` + `acp.js` correspond to stream normalization.
- `/api/chat` corresponds to unified run orchestration.

7
vitest.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.{ts,js,mjs,cjs}', 'daemon/**/*.test.{ts,js,mjs,cjs}'],
},
});