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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Repo: https://github.com/openclaw/acpx
- Flows/replay: store flow runs as trace bundles with `manifest.json`, `flow.json`, `trace.ndjson`, projections, bundled session replay data, and per-attempt ACP/action receipts for later inspection. Thanks @osolmaz.
- Flows/replay viewer: add a React Flow-based replay viewer example that replays saved run bundles and shows the bundled ACP session beside the graph. Thanks @osolmaz.
- Flows/permissions: let flows declare explicit required permission modes, fail fast when a flow requires an explicit `--approve-all` grant, and preserve the granted mode through persistent ACP queue-owner paths. Thanks @osolmaz.
- Agents/qoder: add built-in Qoder CLI ACP support via `qoder -> qodercli --acp` and document Qoder-specific auth notes.
- Agents/qoder: forward `--allowed-tools` and `--max-turns` session options into Qoder CLI startup flags, including persisted session reuse, without requiring a raw `--agent` override.

### Breaking

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ Built-ins:
| `kimi` | native (`kimi acp`) | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) |
| `kiro` | native (`kiro-cli acp`) | [Kiro CLI](https://kiro.dev) |
| `opencode` | `npx -y opencode-ai acp` | [OpenCode](https://opencode.ai) |
| `qoder` | native (`qodercli --acp`) | [Qoder CLI](https://docs.qoder.com/cli/acp) |
| `qwen` | native (`qwen --acp`) | [Qwen Code](https://github.com/QwenLM/qwen-code) |
| `trae` | native (`traecli acp serve`) | [Trae CLI](https://docs.trae.cn/cli) |

Expand Down
9 changes: 9 additions & 0 deletions agents/Qoder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Qoder

- Built-in name: `qoder`
- Default command: `qodercli --acp`
- Upstream: https://docs.qoder.com/cli/acp

`acpx qoder` uses the same login state as Qoder CLI. For non-interactive runs, Qoder documents `QODER_PERSONAL_ACCESS_TOKEN` as the supported environment variable for authentication.

`acpx qoder` also forwards `--max-turns` and `--allowed-tools` into Qoder CLI startup flags when those session options are set. This makes those Qoder-native startup settings available without using a raw `--agent` override.
2 changes: 2 additions & 0 deletions agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Built-in agents:
- `kimi -> kimi acp`
- `kiro -> kiro-cli acp`
- `opencode -> npx -y opencode-ai acp`
- `qoder -> qodercli --acp`
- `qwen -> qwen --acp`
- `trae -> traecli acp serve`

Expand All @@ -30,5 +31,6 @@ Harness-specific docs in this directory:
- [Kimi](Kimi.md): built-in `kimi -> kimi acp`
- [Kiro](Kiro.md): built-in `kiro -> kiro-cli acp`
- [OpenCode](OpenCode.md): built-in `opencode -> npx -y opencode-ai acp`
- [Qoder](Qoder.md): built-in `qoder -> qodercli --acp`
- [Qwen](Qwen.md): built-in `qwen -> qwen --acp`
- [Trae](Trae.md): built-in `trae -> traecli acp serve`
2 changes: 2 additions & 0 deletions skills/acpx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Friendly agent names resolve to commands:
- `kimi` -> `kimi acp`
- `kiro` -> `kiro-cli acp`
- `opencode` -> `npx -y opencode-ai acp`
- `qoder` -> `qodercli --acp`
Forwards Qoder-native `--allowed-tools` and `--max-turns` startup flags from `acpx` session options.
- `qwen` -> `qwen --acp`
- `trae` -> `traecli acp serve`

Expand Down
1 change: 1 addition & 0 deletions src/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const AGENT_REGISTRY: Record<string, string> = {
kimi: "kimi acp",
kiro: "kiro-cli acp",
opencode: "npx -y opencode-ai acp",
qoder: "qodercli --acp",
qwen: "qwen --acp",
trae: "traecli acp serve",
};
Expand Down
142 changes: 137 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Readable, Writable } from "node:stream";
import {
ClientSideConnection,
PROTOCOL_VERSION,
ndJsonStream,
type AnyMessage,
type AuthMethod,
type CreateTerminalRequest,
Expand Down Expand Up @@ -65,7 +64,8 @@ type CommandParts = {
const REPLAY_IDLE_MS = 80;
const REPLAY_DRAIN_TIMEOUT_MS = 5_000;
const DRAIN_POLL_INTERVAL_MS = 20;
const AGENT_CLOSE_AFTER_STDIN_END_MS = 100;
const DEFAULT_AGENT_CLOSE_AFTER_STDIN_END_MS = 100;
const QODER_AGENT_CLOSE_AFTER_STDIN_END_MS = 750;
const AGENT_CLOSE_TERM_GRACE_MS = 1_500;
const AGENT_CLOSE_KILL_GRACE_MS = 1_000;
const GEMINI_ACP_STARTUP_TIMEOUT_MS = 15_000;
Expand Down Expand Up @@ -119,6 +119,10 @@ export type AgentLifecycleSnapshot = {
};

type ConsoleErrorMethod = typeof console.error;
const QODER_BENIGN_STDOUT_LINES = new Set([
"Received interrupt signal. Cleaning up resources...",
"Cleanup completed. Exiting...",
]);

function shouldSuppressSdkConsoleError(args: unknown[]): boolean {
if (args.length === 0) {
Expand Down Expand Up @@ -284,6 +288,83 @@ function basenameToken(value: string): string {
.replace(/\.(cmd|exe|bat)$/u, "");
}

export function resolveAgentCloseAfterStdinEndMs(agentCommand: string): number {
const { command } = splitCommandLine(agentCommand);
return basenameToken(command) === "qodercli"
? QODER_AGENT_CLOSE_AFTER_STDIN_END_MS
: DEFAULT_AGENT_CLOSE_AFTER_STDIN_END_MS;
}

export function shouldIgnoreNonJsonAgentOutputLine(
agentCommand: string,
trimmedLine: string,
): boolean {
const { command } = splitCommandLine(agentCommand);
return basenameToken(command) === "qodercli" && QODER_BENIGN_STDOUT_LINES.has(trimmedLine);
}

function createNdJsonMessageStream(
agentCommand: string,
output: WritableStream<Uint8Array>,
input: ReadableStream<Uint8Array>,
): {
readable: ReadableStream<AnyMessage>;
writable: WritableStream<AnyMessage>;
} {
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

const readable = new ReadableStream<AnyMessage>({
async start(controller) {
let content = "";
const reader = input.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
content += textDecoder.decode(value, { stream: true });
const lines = content.split("\n");
content = lines.pop() || "";
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || shouldIgnoreNonJsonAgentOutputLine(agentCommand, trimmedLine)) {
continue;
}
try {
const message = JSON.parse(trimmedLine) as AnyMessage;
controller.enqueue(message);
} catch (err) {
console.error("Failed to parse JSON message:", trimmedLine, err);
}
}
}
} finally {
reader.releaseLock();
controller.close();
}
},
});

const writable = new WritableStream<AnyMessage>({
async write(message) {
const content = JSON.stringify(message) + "\n";
const writer = output.getWriter();
try {
await writer.write(textEncoder.encode(content));
} finally {
writer.releaseLock();
}
},
});

return { readable, writable };
}

function isGeminiAcpCommand(command: string, args: readonly string[]): boolean {
return (
basenameToken(command) === "gemini" &&
Expand All @@ -303,6 +384,50 @@ function isCopilotAcpCommand(command: string, args: readonly string[]): boolean
return basenameToken(command) === "copilot" && args.includes("--acp");
}

function isQoderAcpCommand(command: string, args: readonly string[]): boolean {
return basenameToken(command) === "qodercli" && args.includes("--acp");
}

function hasCommandFlag(args: readonly string[], flagName: string): boolean {
return args.some((arg) => arg === flagName || arg.startsWith(`${flagName}=`));
}

function normalizeQoderAllowedToolName(tool: string): string {
switch (tool.trim().toLowerCase()) {
case "bash":
case "glob":
case "grep":
case "ls":
case "read":
case "write":
return tool.trim().toUpperCase();
default:
return tool.trim();
}
}

export function buildQoderAcpCommandArgs(
initialArgs: readonly string[],
options: Pick<AcpClientOptions, "sessionOptions">,
): string[] {
const args = [...initialArgs];
const sessionOptions = options.sessionOptions;

if (typeof sessionOptions?.maxTurns === "number" && !hasCommandFlag(args, "--max-turns")) {
args.push(`--max-turns=${sessionOptions.maxTurns}`);
}

if (
Array.isArray(sessionOptions?.allowedTools) &&
!hasCommandFlag(args, "--allowed-tools") &&
!hasCommandFlag(args, "--disallowed-tools")
) {
const encodedTools = sessionOptions.allowedTools.map(normalizeQoderAllowedToolName).join(",");
args.push(`--allowed-tools=${encodedTools}`);
}

return args;
}
function resolveGeminiAcpStartupTimeoutMs(): number {
const raw = process.env.ACPX_GEMINI_ACP_STARTUP_TIMEOUT_MS;
if (typeof raw === "string" && raw.trim().length > 0) {
Expand Down Expand Up @@ -857,7 +982,10 @@ export class AcpClient {
}

const { command, args: initialArgs } = splitCommandLine(this.options.agentCommand);
const args = await resolveGeminiCommandArgs(command, initialArgs);
let args = await resolveGeminiCommandArgs(command, initialArgs);
if (isQoderAcpCommand(command, args)) {
args = buildQoderAcpCommandArgs(args, this.options);
}
this.log(`spawning agent: ${command} ${args.join(" ")}`);
const geminiAcp = isGeminiAcpCommand(command, args);
const copilotAcp = isCopilotAcpCommand(command, args);
Expand Down Expand Up @@ -896,7 +1024,9 @@ export class AcpClient {

const input = Writable.toWeb(child.stdin);
const output = Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>;
const stream = this.createTappedStream(ndJsonStream(input, output));
const stream = this.createTappedStream(
createNdJsonMessageStream(this.options.agentCommand, input, output),
);

const connection = new ClientSideConnection(
() => ({
Expand Down Expand Up @@ -1268,6 +1398,8 @@ export class AcpClient {
private async terminateAgentProcess(
child: ChildProcessByStdio<Writable, Readable, Readable>,
): Promise<void> {
const stdinCloseGraceMs = resolveAgentCloseAfterStdinEndMs(this.options.agentCommand);

// Closing stdin is the most graceful shutdown signal for stdio-based ACP agents.
if (!child.stdin.destroyed) {
try {
Expand All @@ -1277,7 +1409,7 @@ export class AcpClient {
}
}

let exited = await waitForChildExit(child, AGENT_CLOSE_AFTER_STDIN_END_MS);
let exited = await waitForChildExit(child, stdinCloseGraceMs);
if (!exited && isChildProcessRunning(child)) {
try {
child.kill("SIGTERM");
Expand Down
9 changes: 9 additions & 0 deletions src/session-conversation-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,15 @@ export function cloneSessionAcpxState(
desired_mode_id: state.desired_mode_id,
available_commands: state.available_commands ? [...state.available_commands] : undefined,
config_options: state.config_options ? deepClone(state.config_options) : undefined,
session_options: state.session_options
? {
model: state.session_options.model,
allowed_tools: state.session_options.allowed_tools
? [...state.session_options.allowed_tools]
: undefined,
max_turns: state.session_options.max_turns,
}
: undefined,
};
}

Expand Down
25 changes: 25 additions & 0 deletions src/session-persistence/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,31 @@ function parseAcpxState(raw: unknown): SessionAcpxState | undefined {
state.config_options = record.config_options as SessionAcpxState["config_options"];
}

const sessionOptions = asRecord(record.session_options);
if (sessionOptions) {
const parsedSessionOptions: NonNullable<SessionAcpxState["session_options"]> = {};

if (typeof sessionOptions.model === "string") {
parsedSessionOptions.model = sessionOptions.model;
}

if (isStringArray(sessionOptions.allowed_tools)) {
parsedSessionOptions.allowed_tools = [...sessionOptions.allowed_tools];
}

if (
typeof sessionOptions.max_turns === "number" &&
Number.isInteger(sessionOptions.max_turns) &&
sessionOptions.max_turns > 0
) {
parsedSessionOptions.max_turns = sessionOptions.max_turns;
}

if (Object.keys(parsedSessionOptions).length > 0) {
state.session_options = parsedSessionOptions;
}
}

return state;
}

Expand Down
59 changes: 59 additions & 0 deletions src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,61 @@ export type SessionAgentOptions = {
maxTurns?: number;
};

function sessionOptionsFromRecord(record: SessionRecord): SessionAgentOptions | undefined {
const stored = record.acpx?.session_options;
if (!stored) {
return undefined;
}

const sessionOptions: SessionAgentOptions = {};

if (typeof stored.model === "string" && stored.model.trim().length > 0) {
sessionOptions.model = stored.model;
}
if (Array.isArray(stored.allowed_tools)) {
sessionOptions.allowedTools = [...stored.allowed_tools];
}
if (typeof stored.max_turns === "number") {
sessionOptions.maxTurns = stored.max_turns;
}

return Object.keys(sessionOptions).length > 0 ? sessionOptions : undefined;
}

function persistSessionOptions(
record: SessionRecord,
options: SessionAgentOptions | undefined,
): void {
const next =
options &&
({
model: typeof options.model === "string" ? options.model : undefined,
allowed_tools: Array.isArray(options.allowedTools) ? [...options.allowedTools] : undefined,
max_turns: typeof options.maxTurns === "number" ? options.maxTurns : undefined,
} satisfies NonNullable<NonNullable<SessionRecord["acpx"]>["session_options"]>);

const hasValues = Boolean(
next &&
((typeof next.model === "string" && next.model.trim().length > 0) ||
(Array.isArray(next.allowed_tools) && next.allowed_tools.length > 0) ||
typeof next.max_turns === "number"),
);

if (hasValues && next) {
record.acpx = {
...record.acpx,
session_options: next,
};
return;
}

if (!record.acpx) {
return;
}

delete record.acpx.session_options;
}

export type RunOnceOptions = {
agentCommand: string;
cwd: string;
Expand Down Expand Up @@ -531,6 +586,7 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise<Sessi
authPolicy: options.authPolicy,
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
verbose: options.verbose,
sessionOptions: sessionOptionsFromRecord(record),
});
client.updateRuntimeOptions({
permissionMode: options.permissionMode,
Expand Down Expand Up @@ -882,6 +938,8 @@ export async function createSession(options: SessionCreateOptions): Promise<Sess
acpx: {},
};

persistSessionOptions(record, options.sessionOptions);

await writeSessionRecord(record);
return record;
},
Expand Down Expand Up @@ -970,6 +1028,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
authPolicy: options.authPolicy,
suppressSdkConsoleErrors: options.suppressSdkConsoleErrors,
verbose: options.verbose,
sessionOptions: sessionOptionsFromRecord(sessionRecord),
});
const ttlMs = normalizeQueueOwnerTtlMs(options.ttlMs);
const maxQueueDepth = Math.max(1, Math.round(options.maxQueueDepth ?? 16));
Expand Down
Loading
Loading