Skip to content
Open
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 @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx

- Conformance/ACP: add a data-driven ACP core v1 conformance suite with CI smoke coverage, nightly coverage, and a hardened runner that reports startup failures cleanly and scopes filesystem checks to the session cwd. (#130) Thanks @lynnzc.
- Agents/droid: add `factory-droid` and `factorydroid` aliases for the built-in Factory Droid adapter and sync the built-in docs. Thanks @vincentkoc.
- 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 @@ -294,6 +294,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) |

`factory-droid` and `factorydroid` also resolve to the built-in `droid` adapter.
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`

Harness-specific docs in this directory:
Expand All @@ -29,4 +30,5 @@ 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`
7 changes: 5 additions & 2 deletions skills/acpx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,13 @@ Friendly agent names resolve to commands:
- `cursor` -> `cursor-agent acp`
- `copilot` -> `copilot --acp --stdio`
- `droid` -> `droid exec --output-format acp` (`factory-droid` and `factorydroid` also resolve to `droid`)
- `iflow` -> `iflow --experimental-acp`
- `kilocode` -> `npx -y @kilocode/cli acp`
- `kimi` -> `kimi acp`
- `opencode` -> `npx -y opencode-ai acp`
- `kiro` -> `kiro-cli acp`
- `kilocode` -> `npx -y @kilocode/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`

Rules:
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",
};

Expand Down
143 changes: 138 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Readable, Writable } from "node:stream";
import {
ClientSideConnection,
PROTOCOL_VERSION,
ndJsonStream,
type AnyMessage,
type AuthMethod,
type CreateTerminalRequest,
Expand Down Expand Up @@ -63,7 +62,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 @@ -117,6 +117,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 @@ -282,6 +286,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 @@ -301,6 +382,51 @@ 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 readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key);
return matchedKey ? env[matchedKey] : undefined;
Expand Down Expand Up @@ -928,7 +1054,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 @@ -967,7 +1096,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 @@ -1339,6 +1470,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 @@ -1348,7 +1481,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
Loading