
TL;DR for implementers.
probe, listModels, startTurn, capabilities) that returns a stream of your event type. Map each CLI's events into it, and route everything through a registry keyed by provider.ask, auto, no-shell, readonlycanUseTool\n yourself. Don't use readline: it also splits on U+2028/U+2029, which appear inside JSON strings. Skip non-JSON lines instead of crashing.| CLI | Transport Onesist uses | Session handle | Permission gate |
|---|---|---|---|
| Opencode | One shared opencode serve on 127.0.0.1 with a random password (HTTP + SSE) | Opencode session id | Permission events -> questions |
| Claude Code |
If those differences leak into the chat route, every feature (Stop, the Test button, the model picker, "continue in terminal") grows an if per CLI. So they don't leak.
src/server/agent/runtime/type.ts:
export interface CliTurnInput {
runId: string;
thread: ThreadRow;
root: string; // the thread's workspace (project root or a worktree)
provider: ProviderRow;
/** The new user message; the CLI keeps the earlier conversation in its own session. */
text: string;
/** The provider's session for this thread; null = start a new one. */
sessionId: string | null;
/** Called once when the runtime creates a new session. */
onSessionCreated: (sessionId: string) => void;
modelOptions: ModelOptions;
/** Onesist's MCP endpoint for this project, or null when it cannot be offered safely. */
mcp: { url: string; token: string } | null;
}
export interface CliTurn {
stream: ReadableStream<TextStreamPart<ToolSet>>; // AI SDK stream parts
usage: Promise<{ inputTokens: number | null; outputTokens: number | null }>;
}
export interface CliRuntime {
id: "opencode" | "antigravity" | "codex" | "claude" | "pi";
capabilities: RuntimeCapabilities;
probe(provider: ProviderRow): Promise<RuntimeStatus>; // installed / logged in / reachable
listModels(provider: ProviderRow, opts?: { force?: boolean }): Promise<RuntimeModel[]>;
startTurn(input: CliTurnInput): Promise<CliTurn>;
/** A copy of a session with its whole history, when the runtime can fork natively. */
forkSession?(provider: ProviderRow, sessionId: string): Promise<string>;
}Three design choices carry most of the weight:
TextStreamPart. The native (BYOK) agent already produced those, so the chat route stores a CLI turn exactly like a native one: same message rows, same tool cards, same transcript. Each runtime has a stream-mapper.ts that converts its CLI's events into text deltas, reasoning, tool calls and tool results.onSessionCreated is a callback, not a return value. The session must be recorded before the turn finishes. Otherwise a turn that fails halfway loses its session and the next turn starts from zero. That was a real bug, fixed in v0.4.0.forkSession is optional. Only Opencode can fork natively. Everything else falls back to a portable handoffThe registry is the only place that knows which runtime is which (src/server/agent/runtime/registry.ts):
const CLI_RUNTIMES: Record<string, CliRuntime> = {
opencode: opencodeRuntime, antigravity: antigravityRuntime,
codex: codexRuntime, claude: claudeRuntime, pi: piRuntime,
};
export function cliRuntimeFor(provider: Pick<ProviderRow, "apiStyle" | "cliAgent">): CliRuntime | null {
if (provider.apiStyle !== "cli" || !provider.cliAgent) return null;
return CLI_RUNTIMES[provider.cliAgent] ?? null;
}The project rule in AGENTS.md is blunt: add a CLI by adding a runtime there, not by branching on cliAgent elsewhere.
Onesist has its own permission modes: ask (approval card per action), auto, no-shell, and readonly. Chat threads also have a mode (answer / plan / agent), and anything that isn't agent mode is treated as read-only. Each runtime has a small, pure policy function that translates these, which makes the policy trivially unit-testable.
src/server/agent/cli/claude/permission.ts. The SDK's own permissionMode always stays "default". Read-only modes remove tools outright with dissallowedTools, and everything Claude would prompt for goes through canUseTool:
export function makeCanUseTool(opts: { runId: string; policy: ClaudePolicy }): CanUseTool {
return async (toolName, input, options) => {
if (toolName.startsWith("mcp__onesist__")) return allow(input); // our own MCP is read-only
if (options.signal.aborted) return deny("Dihentikan.");
if (opts.policy === "auto") return allow(input);
if (opts.policy === "readonly") return READ_ONLY_TOOLS.includes(toolName) ? allow(input) : deny("…");
if (opts.policy === "no-shell" && SHELL_TOOLS.includes(toolName)) return deny("…");
const answers = await awaitQuestion(opts.runId, [{ question: question(toolName, input), options: [...CLAUDE_APPROVAL_OPTIONS] }]);
if (answers?.[0] === CLAUDE_APPROVAL_OPTIONS[0]) return allow(input);
// "Always" lasts for this session only: the SDK's suggestions may target project settings,
// which would write a rule into the user's workspace.
if (answers?.[0] === CLAUDE_APPROVAL_OPTIONS[1]) {
const updatedPermissions = (options.suggestions ?? []).map((s) => ({ ...s, destination: "session" as const }));
return { behavior: "allow", updatedInput: input, updatedPermissions };
}
return deny("Ditolak oleh pengguna.");
};
}The destination: "session" rewrite is the subtle part. The permission suggestions the SDK hands you can target projectSettings, so passing them through unchanged makes "Always allow" write a rule into the user's .claude/settings file. Rewriting the destination keeps it in memory. This was a v0.4.0 bug fix.
awaitQuestion is Onesist's generic "pause the run and ask the user" primitive. It surfaces as a card in the chat over SSE and resolves when the user clicks. Every runtime uses it.
The rest of the Claude options (src/server/agent/cli/claude/turn.ts):
return {
cwd: input.root,
pathToClaudeCodeExecutable: input.executable, // always the user's own `claude`
includePartialMessages: true,
permissionMode: "default",
...(resume ? { resume } : {}),
...(newSessionId ? { sessionId: newSessionId } : {}),
...(input.mcp ? { mcpServers: { onesist: { type: "http", url: input.mcp.url,
headers: { Authorization: `Bearer ${input.mcp.token}` } } } } : {}),
...(disallowed.length ? { disallowedTools: disallowed } : {}),
abortController,
canUseTool,
};Two production lessons from this block:
pathToClaudeCodeExecutable is mandatory in a compiled app. A bun build --compile sidecar can't resolve the SDK's bundled binary, and that binary is around 250 MB per platform anyway. Using the user's own claude also means using their login. On Windows .cmd/.bat/.ps1 shims are refused, because they can't be spawned without a shell.sessionId: crypto.randomUUID(). A known one is resumed. If Claude says the session no longer exists, the turn retires once with a fresh UUID, but only if nothing has streamed yet:if (input.sessionId) {
try {
await runQuery(input.sessionId, null);
} catch (err) {
if (run.abort.signal.aborted || mapper.started || !isMissingSession(err)) throw err;
await runQuery(null, crypto.randomUUID());
}
} else {
await runQuery(null, crypto.randomUUID());
}src/server/agent/cli/codex/policy.ts. Codex already has two separate dials, so the mapping is one function:
export function codexPolicy(mode: PermissionMode, threadMode: ThreadMode) {
if (threadMode !== "agent" || mode === "readonly" || mode === "no-shell")
return { approvalPolicy: "never", sandbox: "read-only", ask: false };
if (mode === "auto") return { approvalPolicy: "never", sandbox: "workspace-write", ask: false };
return { approvalPolicy: "on-request", sandbox: "workspace-write", ask: true };
}Codex sends item/commandExecution/requestApproval and item/fileChange/requestApproval as JSON-RPC requests from server to client. Onesist answers with { decision: "accept" | "acceptForSession" | "decline" }, and an unanswered request is declined. One codex app-server process is shared per binary, and notifications are routed to the right chat thread by threadId.
Headless agy -p (version 1.2.2 at the time) has exactly two settings: refuse every action that needs permission, or allow all of them with --dangerously-skip-permissions. It can't pause mid-turn. So in ask mode Onesist does this (src/server/agent/cli/antigravity/turn.ts):
result.denied_actions.let outcome = await runOnce(agyArgs({ text: input.text, sessionId: conversation, model, skipPermissions: input.policy === "skip" }));
const denied = mapper.lastResult?.denied_actions ?? [];
if (!outcome.aborted && outcome.sawResult && input.policy === "ask" && denied.length && conversation) {
const answers = await awaitQuestion(input.runId, [{ question: deniedQuestion(denied), options: [...ESCALATE_OPTIONS] }]);
if (answers?.[0] === ESCALATE_OPTIONS[0] && !run.abort.signal.aborted) {
outcome = await runOnce(agyArgs({ text: continueText(denied), sessionId: conversation, model, skipPermissions: true }));
}
}
It's coarser than per-action approval, and the UI says so honestly. Two more details from args.ts that bit me:
// A prompt that starts with "-" could be read as a flag; a leading space changes nothing for the model.
const prompt = o.text.startsWith("-") ? ` ${o.text}` : o.text;
// Windows caps a whole command line at 32,767 characters; this leaves room for the rest.
export const WINDOWS_ARG_LIMIT = 30_000;
agy's default --print-timeout (5 minutes) is also shorter than many agent turns, so Onesist passes 60m.
Pi has no permission system. It does have extensions, though, so Onesist wrtes one into its own data folder (never ~/.pi) and loads it with --extension (src/server/agent/cli/pi/approval-extension.ts):
const WRITE_TOOLS = ["write", "edit", "bash"];
export default function (pi: ExtensionAPI) {
const policy = process.env.ONESIST_PI_POLICY ?? "ask";
pi.on("tool_call", async (event, ctx) => {
if (!WRITE_TOOLS.includes(event.toolName)) return;
if (policy === "auto") return;
if (policy === "readonly") return { block: true, reason: "Mode hanya-baca: perubahan tidak diizinkan." };
const ok = await ctx.ui.confirm("Izinkan Pi memakai " + event.toolName + "?", JSON.stringify(event.input).slice(0, 500));
if (!ok) return { block: true, reason: "Ditolak oleh pengguna." };
});
}
The mode reaches the extension through an environment variable (ONESIST_PI_POLICY). In RPC mode, ctx.ui.confirm arrives on stdout as an extension_ui_request record with method: "confirm". Onesist turns it into an approval card (Allow once / Deny) and answers with { type: "extension_ui_response", id, confirmed }. The file is rewritten only when its contents differ, so it stays stable across launches.
Codex and Pi both speak line-delimited JSON over stdio. The obvious tool is Node's readline, and it's wrong here: readline treats U+2028 and U+2029 as line breaks, and those characters can legally appear inside JSON strings (and do, in model output). A message containing one gets split in half and both halves fail to parse.
Both transports split on \n only, strip a trailing \r, and skip lines that aren't JSON instead of tearing down the session, because extensions and CLIs sometimes print logs to stdout. From src/server/agent/cli/pi/jsonl.ts:
feed(chunk: string): void {
this.buffer += chunk;
const lines = this.buffer.split("\n");
this.buffer = lines.pop() ?? "";
for (const line of lines) this.handleLine(line);
}
private handleLine(raw: string): void {
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (!line.trim()) return;
let record: any;
try { record = JSON.parse(line); } catch { return; } // a stray log line, not a protocol error
if (record.type === "response" && record.id !== undefined) { /* resolve the pending command */ return; }
for (const h of this.eventHandlers) h(record);
}
Every request also has timeout (30 seconds by default), and closing the transport rejects everything still pending, so a crashed CLI can never leave a chat turn spinning forever.
Per-turn CLIs (agy, Pi) spawn child processes, so the server tracks them in a Set and kills them on exit. One Bun-specific trap:
process.once("exit", stopAgyProcesses);
// Bun does not run "exit" listeners when a signal ends the process.
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, () => { stopAgyProcesses(); process.exit(signal === "SIGINT" ? 130 : 143); });
}
The sets live on globalThis (globalThis.__onesistAgyChildren ??= new Set()) so that Vite's hot reload in dev doesn't orphan processes started by the previous module instance.
Stop is per runtime too. Claude interrupts the query and closes the stream after a grace period. Pi sends abort and waits for the acknowledgement (at most one second) before killing the process. agy simply gets killed.
Every integration point is per process:
| What | How it's passed |
|---|---|
| Onesist's MCP server, Claude | mcpServers option on each query() |
| Onesist's MCP server, Opencode | PUT /api/experimental/mcp/onesist?location[directory]=... on Onesist's own opencode serve, codemode: false |
| Pi approval gate | --extension <appData>/pi/onesist-approval.ts plus --sesion-dir <appData>/pi/sessions |
| Claude "Always allow" | rewritten to destination: "session" |
That last row is deliberate gap. A missing feature is easier to forgive than a modified ~/.codex/config.toml.
None of the runtime tests call a real CLI. The Antigravity tests replay events recorded during a spike through a fake agy script (test-fixtures.ts). Codex and Pi are tested at the transport level by feeding chunks into RpcClient / PiJsonl, including chunks that split a line in half, non-JSON noise and timeouts. The policy functions are pure, so they get table tests.
@anthropic-ai/claude-agent-sdkquery()UUID chosen by Onesist (sessionId / resume) |
canUseTool callback |
| Codex | JSON-RPC 2.0 over stdio to one shared codex app-server | Codex thread id ( thread/start / thread/resume) | Server -> client approval requests |
Antigravity (agy) | One agy -p ... -- output-format stream-json process per turn | --conversation <id> | None while headless: actions are refused |
| Pi | One pi --mode rpc process per turn, LF-delimited JSON | A .jsonl session file | None: Onesist injects an extension |
| agy, Codex |
| no Onesist MCP yet, because the only simple way would write to the user's config |