Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a0c8658
feat: add claude-code and codex-cli provider definitions
csy1204 Jul 12, 2026
fe999fd
feat: add cli-runner types and session store
csy1204 Jul 12, 2026
1c5fe3f
feat: add claude headless engine adapter
csy1204 Jul 12, 2026
c331975
feat: add codex headless engine adapter
csy1204 Jul 12, 2026
aa68eaf
refactor: extract shared prompt sections and add cli prompt builder
csy1204 Jul 12, 2026
d19f961
fix: override remaining virtual-path fields in cli prompt config
csy1204 Jul 12, 2026
cdae2b8
feat: add cli runner core with streaming, timeout and session resume
csy1204 Jul 12, 2026
f2567ab
fix: swallow cli stdin EPIPE so early exits reject instead of crashing
csy1204 Jul 12, 2026
4eb45a9
feat: route cli providers to the headless cli runner
csy1204 Jul 12, 2026
d080dbb
feat: support cli auth providers in the onboarding wizard
csy1204 Jul 12, 2026
3f5e7a2
fix: exempt cli-auth providers from non-interactive api-key gate
csy1204 Jul 12, 2026
803d022
fix: scope cli-auth exemption to the api-key gate only
csy1204 Jul 12, 2026
86372a7
feat: warn on out-of-wiki writes after repository cli runs
csy1204 Jul 12, 2026
756c718
fix: render the local-wiki synthesis block in the cli system prompt
csy1204 Jul 12, 2026
d3e6b29
feat: add cli auth login hints and clear the sigkill timer on settle
csy1204 Jul 12, 2026
f983234
fix: harden session file mode and fix cli-provider wizard article
csy1204 Jul 12, 2026
c42d07a
fix: baseline the out-of-wiki write guard against pre-run dirty trees
csy1204 Jul 12, 2026
1d6899e
Merge branch 'main' into feat/cli-headless-providers
csy1204 Jul 14, 2026
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
184 changes: 184 additions & 0 deletions src/agent/cli-runner/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { CliEngineAdapter, CliParsedEvent, CliRunSpec } from "./types.js";

const CLAUDE_ALLOWED_TOOLS = [
"Read",
"Glob",
"Grep",
"LS",
"Write",
"Edit",
"MultiEdit",
"TodoWrite",
"Task",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git diff:*)",
"Bash(git status:*)",
"Bash(git blame:*)",
"Bash(rg:*)",
"Bash(ls:*)",
// Exact plan-file cleanup commands referenced by the CLI prompt.
"Bash(rm -f openwiki/_plan.md)",
"Bash(rm -f _plan.md)",
].join(",");

export const claudeAdapter: CliEngineAdapter = {
cliCommand: "claude",
engine: "claude-code",

buildArgs(spec: CliRunSpec): string[] {
const args = [
"-p",
"--output-format",
"stream-json",
"--verbose",
"--model",
spec.modelId,
"--permission-mode",
"acceptEdits",
"--append-system-prompt",
spec.systemPrompt,
"--allowedTools",
CLAUDE_ALLOWED_TOOLS,
];

if (spec.resumeSessionId) {
args.push("--resume", spec.resumeSessionId);
}

return args;
},

stdinPayload(spec: CliRunSpec): string {
return spec.userPrompt;
},

parseLine(line: string): CliParsedEvent[] {
const trimmed = line.trim();

if (trimmed.length === 0) {
return [];
}

let payload: unknown;

try {
payload = JSON.parse(trimmed);
} catch {
return [debugEvent(`claude.unparsed ${trimmed.slice(0, 200)}`)];
}

if (!isRecord(payload) || typeof payload.type !== "string") {
return [];
}

switch (payload.type) {
case "system":
return payload.subtype === "init" &&
typeof payload.session_id === "string"
? [{ kind: "session", sessionId: payload.session_id }]
: [];
case "assistant":
return parseAssistantMessage(payload);
case "user":
return parseToolResults(payload);
case "result": {
const isError =
payload.is_error === true || payload.subtype !== "success";

return [
{
kind: "result",
isError,
message: typeof payload.result === "string" ? payload.result : "",
},
debugEvent(
`claude.result subtype=${String(payload.subtype)} cost=${String(
payload.total_cost_usd,
)} turns=${String(payload.num_turns)}`,
),
];
}
default:
return [];
}
},
};

function parseAssistantMessage(
payload: Record<string, unknown>,
): CliParsedEvent[] {
const events: CliParsedEvent[] = [];

for (const block of getContentBlocks(payload)) {
if (block.type === "text" && typeof block.text === "string") {
if (block.text.length > 0) {
events.push({
kind: "event",
event: { type: "text", text: block.text },
});
}
} else if (
block.type === "tool_use" &&
typeof block.id === "string" &&
typeof block.name === "string"
) {
events.push({
kind: "event",
event: {
type: "tool_start",
call: `${block.name}(${formatToolInput(block.input)})`,
id: block.id,
input: block.input,
name: block.name,
},
});
}
}

return events;
}

function parseToolResults(payload: Record<string, unknown>): CliParsedEvent[] {
const events: CliParsedEvent[] = [];

for (const block of getContentBlocks(payload)) {
if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
events.push({
kind: "event",
event: {
type: "tool_end",
id: block.tool_use_id,
name: "tool",
status: block.is_error === true ? "error" : "finished",
},
});
}
}

return events;
}

function getContentBlocks(
payload: Record<string, unknown>,
): Record<string, unknown>[] {
if (!isRecord(payload.message) || !Array.isArray(payload.message.content)) {
return [];
}

return payload.message.content.filter(isRecord);
}

function formatToolInput(input: unknown): string {
const formatted = JSON.stringify(input) ?? "";

return formatted.length > 200 ? `${formatted.slice(0, 197)}...` : formatted;
}

function debugEvent(message: string): CliParsedEvent {
return { kind: "event", event: { type: "debug", message } };
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
144 changes: 144 additions & 0 deletions src/agent/cli-runner/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { CliEngineAdapter, CliParsedEvent, CliRunSpec } from "./types.js";

export const codexAdapter: CliEngineAdapter = {
cliCommand: "codex",
engine: "codex-cli",

buildArgs(spec: CliRunSpec): string[] {
const flags = [
"--json",
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--model",
spec.modelId,
];

return spec.resumeSessionId
? ["exec", "resume", spec.resumeSessionId, ...flags, "-"]
: ["exec", ...flags, "-"];
},

stdinPayload(spec: CliRunSpec): string {
return `${spec.systemPrompt}\n\n${spec.userPrompt}`;
},

parseLine(line: string): CliParsedEvent[] {
const trimmed = line.trim();

if (trimmed.length === 0) {
return [];
}

let payload: unknown;

try {
payload = JSON.parse(trimmed);
} catch {
return [debugEvent(`codex.unparsed ${trimmed.slice(0, 200)}`)];
}

if (!isRecord(payload) || typeof payload.type !== "string") {
return [];
}

switch (payload.type) {
case "thread.started":
return typeof payload.thread_id === "string"
? [{ kind: "session", sessionId: payload.thread_id }]
: [];
case "item.started":
return parseItem(payload.item, "start");
case "item.completed":
return parseItem(payload.item, "end");
case "turn.completed":
return [debugEvent(`codex.usage ${JSON.stringify(payload.usage)}`)];
case "turn.failed":
case "error":
return [
{
kind: "result",
isError: true,
message: extractErrorMessage(payload),
},
];
default:
return [];
}
},
};

function parseItem(item: unknown, phase: "start" | "end"): CliParsedEvent[] {
if (!isRecord(item) || typeof item.type !== "string") {
return [];
}

if (item.type === "reasoning") {
return [];
}

if (item.type === "agent_message") {
return phase === "end" && typeof item.text === "string" && item.text
? [{ kind: "event", event: { type: "text", text: item.text } }]
: [];
}

const id = typeof item.id === "string" ? item.id : `codex:${item.type}`;

if (phase === "start") {
return [
{
kind: "event",
event: {
type: "tool_start",
call: formatItemCall(item),
id,
input: item,
name: item.type,
},
},
];
}

const failed =
item.status === "failed" ||
(typeof item.exit_code === "number" && item.exit_code !== 0);

return [
{
kind: "event",
event: {
type: "tool_end",
id,
name: item.type,
status: failed ? "error" : "finished",
},
},
];
}

function formatItemCall(item: Record<string, unknown>): string {
if (typeof item.command === "string") {
return `Execute(${JSON.stringify(item.command)})`;
}

return `${String(item.type)}(${JSON.stringify(item).slice(0, 160)})`;
}

function extractErrorMessage(payload: Record<string, unknown>): string {
if (isRecord(payload.error) && typeof payload.error.message === "string") {
return payload.error.message;
}

return typeof payload.message === "string"
? payload.message
: "codex exec failed";
}

function debugEvent(message: string): CliParsedEvent {
return { kind: "event", event: { type: "debug", message } };
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
Loading