Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ jobs:
- { suffix: "-codex", dockerfile: "Dockerfile.codex", artifact: "codex" }
- { suffix: "-claude", dockerfile: "Dockerfile.claude", artifact: "claude" }
- { suffix: "-gemini", dockerfile: "Dockerfile.gemini", artifact: "gemini" }
- { suffix: "-ggcoder", dockerfile: "Dockerfile.ggcoder", artifact: "ggcoder" }
- { suffix: "-copilot", dockerfile: "Dockerfile.copilot", artifact: "copilot" }
- { suffix: "-opencode", dockerfile: "Dockerfile.opencode", artifact: "opencode" }
- { suffix: "-cursor", dockerfile: "Dockerfile.cursor", artifact: "cursor" }
Expand Down Expand Up @@ -132,6 +133,7 @@ jobs:
- { suffix: "-codex", artifact: "codex" }
- { suffix: "-claude", artifact: "claude" }
- { suffix: "-gemini", artifact: "gemini" }
- { suffix: "-ggcoder", artifact: "ggcoder" }
- { suffix: "-copilot", artifact: "copilot" }
- { suffix: "-opencode", artifact: "opencode" }
- { suffix: "-cursor", artifact: "cursor" }
Expand Down Expand Up @@ -182,6 +184,7 @@ jobs:
- { suffix: "-codex" }
- { suffix: "-claude" }
- { suffix: "-gemini" }
- { suffix: "-ggcoder" }
- { suffix: "-copilot" }
- { suffix: "-opencode" }
- { suffix: "-cursor" }
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docker-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
paths:
- 'Dockerfile*'
- 'bin/**'
- 'src/**'
- 'Cargo.*'

Expand All @@ -17,6 +18,7 @@ jobs:
- { dockerfile: Dockerfile.claude, suffix: "-claude", agent: "claude-agent-acp", agent_args: "" }
- { dockerfile: Dockerfile.codex, suffix: "-codex", agent: "codex-acp", agent_args: "" }
- { dockerfile: Dockerfile.gemini, suffix: "-gemini", agent: "gemini", agent_args: "--acp" }
- { dockerfile: Dockerfile.ggcoder, suffix: "-ggcoder", agent: "ggcoder-acp", agent_args: "" }
- { dockerfile: Dockerfile.copilot, suffix: "-copilot", agent: "copilot", agent_args: "--acp" }
- { dockerfile: Dockerfile.opencode, suffix: "-opencode", agent: "opencode", agent_args: "acp" }
- { dockerfile: Dockerfile.cursor, suffix: "-cursor", agent: "cursor-agent", agent_args: "acp" }
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ on:
- copilot
- cursor
- gemini
- ggcoder
- opencode
default: 'default'

Expand Down
38 changes: 38 additions & 0 deletions Dockerfile.ggcoder
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# --- Build stage ---
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src
COPY src/ src/
RUN touch src/main.rs && cargo build --release

# --- Runtime stage ---
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/*

# Install GG Coder CLI. OpenAB talks ACP to the ggcoder-acp wrapper, which
# translates to GG Coder's JSON-RPC mode.
ARG GGCODER_VERSION=4.3.155
RUN npm install -g @kenkaiiii/ggcoder@${GGCODER_VERSION} --retry 3

# Install gh CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list && \
apt-get update && apt-get install -y --no-install-recommends gh && \
rm -rf /var/lib/apt/lists/*

ENV HOME=/home/node
WORKDIR /home/node

COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab
COPY --chown=node:node bin/ggcoder-acp.mjs /usr/local/bin/ggcoder-acp

RUN mkdir -p /home/node/.config/ggcoder && chown -R node:node /home/node/.config

USER node
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD pgrep -x openab || exit 1
ENTRYPOINT ["tini", "--"]
CMD ["openab", "run", "-c", "/etc/openab/config.toml"]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ The bot creates a thread. After that, just type in the thread — no @mention ne
| Claude Code | `claude-agent-acp` | [@agentclientprotocol/claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp) | [docs/claude-code.md](docs/claude-code.md) |
| Codex | `codex-acp` | [@zed-industries/codex-acp](https://github.com/zed-industries/codex-acp) | [docs/codex.md](docs/codex.md) |
| Gemini | `gemini --acp` | Native | [docs/gemini.md](docs/gemini.md) |
| GG Coder | `ggcoder-acp` | OpenAB wrapper around `ggcoder --rpc` | [docs/ggcoder.md](docs/ggcoder.md) |
| OpenCode | `opencode acp` | Native | [docs/opencode.md](docs/opencode.md) |
| Copilot CLI ⚠️ | `copilot --acp --stdio` | Native | [docs/copilot.md](docs/copilot.md) |
| Cursor | `cursor-agent acp` | Native | [docs/cursor.md](docs/cursor.md) |
Expand Down
281 changes: 281 additions & 0 deletions bin/ggcoder-acp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";

const passthroughArgs = process.argv.slice(2).filter((arg) => arg !== "--rpc");
let sessionId = null;
let rpc = null;
let nextRpcId = 1;
const pending = new Map();
const toolNames = new Map();

function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}

function result(id, value) {
send({ jsonrpc: "2.0", id, result: value });
}

function error(id, code, message) {
send({ jsonrpc: "2.0", id, error: { code, message } });
}

function notify(update) {
if (!sessionId) return;
send({
jsonrpc: "2.0",
method: "session/update",
params: { sessionId, update },
});
}

function flattenPrompt(prompt) {
if (!Array.isArray(prompt)) return "";
return prompt
.map((block) => {
if (block?.type === "text") return block.text ?? "";
if (block?.type === "image") return "[Image attachment omitted: GG Coder RPC accepts text only.]";
return `[Unsupported content block: ${JSON.stringify(block)}]`;
})
.filter(Boolean)
.join("\n\n");
}

function toolTitle(name, args) {
if (!args || Object.keys(args).length === 0) return name;
const detail = args.command ?? args.file_path ?? args.path ?? args.pattern ?? "";
return detail ? `${name}: ${String(detail).slice(0, 120)}` : name;
}

function handleRpcEvent(event) {
if (event.type === "ready") {
rpc.ready = true;
for (const resolve of rpc.readyWaiters) resolve();
rpc.readyWaiters = [];
return;
}

if (event.type === "result" || event.type === "error") {
const acpId = pending.get(event.id);
if (acpId === undefined) return;
pending.delete(event.id);
if (event.type === "error") {
error(acpId, -32000, event.message ?? "GG Coder RPC error");
} else {
result(acpId, event.data ?? {});
}
return;
}

if (event.type === "text_delta" && event.text) {
notify({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: event.text },
});
return;
}

if (event.type === "thinking_delta") {
notify({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: event.text ?? "" },
});
return;
}

if (event.type === "tool_call_start") {
toolNames.set(event.toolCallId, event.name);
notify({
sessionUpdate: "tool_call",
toolCallId: event.toolCallId,
title: toolTitle(event.name, event.args),
});
return;
}

if (event.type === "tool_call_update") {
notify({
sessionUpdate: "tool_call_update",
toolCallId: event.toolCallId,
title: toolNames.get(event.toolCallId) ?? "Tool",
status: "running",
});
return;
}

if (event.type === "tool_call_end") {
notify({
sessionUpdate: "tool_call_update",
toolCallId: event.toolCallId,
title: toolNames.get(event.toolCallId) ?? "Tool",
status: event.isError ? "failed" : "completed",
});
toolNames.delete(event.toolCallId);
return;
}

if (event.type === "error" && event.message) {
notify({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: `GG Coder error: ${event.message}` },
});
}
}

function startRpc(cwd) {
if (rpc) return rpc;

const child = spawn("ggcoder", [...passthroughArgs, "--rpc"], {
cwd,
env: process.env,
stdio: ["pipe", "pipe", "inherit"],
});

rpc = {
child,
ready: false,
exitError: null,
readyWaiters: [],
};

child.on("error", (err) => {
if (rpc) rpc.exitError = err;
const message = `failed to start GG Coder RPC: ${err.message}`;
for (const acpId of pending.values()) error(acpId, -32000, message);
pending.clear();
for (const resolve of rpc?.readyWaiters ?? []) resolve();
});

child.on("exit", (code, signal) => {
const message = `GG Coder RPC exited${signal ? ` by ${signal}` : ` with code ${code}`}`;
if (rpc) rpc.exitError = new Error(message);
for (const acpId of pending.values()) error(acpId, -32000, message);
pending.clear();
if (!rpc.ready) {
for (const resolve of rpc.readyWaiters) resolve();
rpc.readyWaiters = [];
}
rpc = null;
});

const lines = createInterface({ input: child.stdout, terminal: false });
lines.on("line", (line) => {
if (!line.trim()) return;
try {
handleRpcEvent(JSON.parse(line));
} catch (err) {
process.stderr.write(`ggcoder-acp: ignored non-JSON RPC output: ${line}\n`);
}
});

return rpc;
}

function waitReady(state, timeoutMs = 120_000) {
if (state.ready) return Promise.resolve();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("timeout waiting for GG Coder RPC ready")), timeoutMs);
state.readyWaiters.push(() => {
clearTimeout(timeout);
if (state.ready) {
resolve();
} else {
reject(state.exitError ?? new Error("GG Coder RPC exited before ready"));
}
});
});
}

function sendRpc(command) {
if (!rpc?.child?.stdin?.writable) {
throw new Error("GG Coder RPC is not running");
}
const id = nextRpcId++;
rpc.child.stdin.write(`${JSON.stringify({ id, ...command })}\n`);
return id;
}

function waitForPending(timeoutMs = 5_000) {
if (pending.size === 0) return Promise.resolve();
return new Promise((resolve) => {
const started = Date.now();
const interval = setInterval(() => {
if (pending.size === 0 || Date.now() - started >= timeoutMs) {
clearInterval(interval);
resolve();
}
}, 25);
});
}

async function handleAcp(message) {
const { id, method, params } = message;

if (method === "initialize") {
result(id, {
protocolVersion: 1,
agentInfo: { name: "ggcoder", version: "ggcoder-acp" },
agentCapabilities: { loadSession: false },
});
return;
}

if (method === "session/new") {
sessionId = `ggcoder-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const cwd = params?.cwd ?? process.cwd();
const state = startRpc(cwd);
await waitReady(state);
result(id, { sessionId });
return;
}

if (method === "session/prompt") {
if (!sessionId) throw new Error("session/new must be called before session/prompt");
const text = flattenPrompt(params?.prompt);
const rpcId = sendRpc({ command: "prompt", text });
pending.set(rpcId, id);
return;
}

if (method === "session/cancel") {
if (rpc?.child?.stdin?.writable) {
sendRpc({ command: "abort" });
}
return;
}

if (method === "session/load") {
error(id, -32601, "session/load is not supported by ggcoder-acp");
return;
}

if (method === "session/set_config_option") {
error(id, -32601, "session/set_config_option is not supported by ggcoder-acp");
return;
}

error(id, -32601, `Unsupported method: ${method}`);
}

const acpLines = createInterface({ input: process.stdin, terminal: false });
for await (const line of acpLines) {
if (!line.trim()) continue;
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}

try {
await handleAcp(message);
} catch (err) {
if (message.id !== undefined) {
error(message.id, -32000, err instanceof Error ? err.message : String(err));
}
}
}

await waitForPending();
if (rpc?.child) rpc.child.kill("SIGTERM");
3 changes: 3 additions & 0 deletions charts/openab/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Agents deployed:
{{- else if eq (toString $cfg.command) "gemini" }}
Authenticate:
kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- gemini
{{- else if eq (toString $cfg.command) "ggcoder-acp" }}
Authenticate:
kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- ggcoder login
{{- else if eq (toString $cfg.command) "opencode" }}
Authenticate:
kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- opencode auth login
Expand Down
Loading
Loading