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
42 changes: 42 additions & 0 deletions docs/provider-gemini-acp-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,45 @@ expression of the meta-harness bet.
3. Does Antigravity itself ever expose an official agent protocol/SDK? Re-check
before GA; if yes, it likely also speaks ACP (it's Zed-adjacent tooling) —
the AcpProvider would absorb it.

## Antigravity agentapi investigation — VERDICT: do not build now (2026-07-10)

Traced the full path on this machine (Antigravity IDE 2.0.6 running).

**How it works (all verified live):**
- The Antigravity Electron app spawns a `language_server` (Codeium/"exa" lineage)
that listens on **ephemeral localhost ports** (this run: 40133 web, 38405
HTTPS-gRPC) with a **per-launch CSRF token** (from the LS cmdline).
- `~/.gemini/antigravity/bin/agentapi` is a shell wrapper → `language_server
agentapi <cmd>`, reading `ANTIGRAVITY_LS_ADDRESS` + `ANTIGRAVITY_CSRF_TOKEN` +
`ANTIGRAVITY_PROJECT_ID`.
- Got a real conversation created end-to-end: `initialize`→CSRF auth passes→
`ANTIGRAVITY_PROJECT_ID` must be a REAL project uuid from
`~/.gemini/config/projects/<id>.json` (each maps to a workspace git folder)→
`new-conversation` returns a conversationId. **Auth + subscription work.**

**Why it is NOT a viable codeoid backend right now:**
1. **No response-streaming surface in the CLI.** agentapi has exactly three
commands: `new-conversation`, `get-conversation-metadata` (returns workspace
metadata only — NOT messages), `send-message`. The agent's reply streams into
the IDE's own trajectory store; there is no `get-messages`/`stream` command.
Fire-and-forget prompt injection, not a conversation loop.
2. **The real streaming RPCs are internal LS gRPC** (`GetCascadeTrajectorySteps`,
`StreamCascadePanelReactiveUpdates`, `HandleStreamingCommand` on
`exa.remoting.RemotingService`) — no public .proto, TLS + rotating CSRF,
reverse-engineering required.
3. **Requires a running GUI IDE.** The LS is a child of the Electron app: it dies
when the IDE closes, and the port + CSRF regenerate every launch. codeoid's
other harnesses are standalone CLIs codeoid spawns and owns; Antigravity's
agent is embedded in a desktop app.
4. ToS/stability: consuming undocumented internal gRPC of a GUI product is
fragile and gray-area.

**Recommendation:** park it. The subscription/auth path is proven, but the
integration cost (proto RE + IDE-lifecycle coupling + rotating creds) is out of
proportion to the payoff, and the surface is unstable. Revisit only if Google
ships a standalone/headless Antigravity agent binary or a documented protocol —
at which point it slots into the existing provider template (and likely speaks
ACP, absorbing into the AcpProvider). For Google-subscription coverage today,
gemini-cli over ACP already works for API-key/Vertex users; personal-OAuth is
blocked by Google's own server policy, not by us.
33 changes: 29 additions & 4 deletions src/daemon/providers/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ import { renderHistorySeed, type CanonicalTurn } from "../canonical.js";
import { buildCodexEnv } from "../env.js";
import { CodexRpcProcess } from "./rpc.js";

/** codex `tokenUsage.last` / `.total` shape (thread/tokenUsage/updated). */
interface CodexTokenUsage {
totalTokens?: number;
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
reasoningOutputTokens?: number;
}

export interface CodexProviderInit {
sessionId: string;
/** codex thread id from a previous run, or the codeoid session id on first run. */
Expand Down Expand Up @@ -88,6 +97,13 @@ export class CodexProvider implements SessionProvider {
#turnStartedAt = 0;
#turnModel = "codex";
#currentTurnId: string | null = null;
/**
* Latest per-turn token usage from `thread/tokenUsage/updated`. codex's
* `turn/completed` carries NO usage (verified live) — it arrives on the
* separate token-usage notification, `tokenUsage.last` being this turn's
* counts. Captured here and folded into turn_done.
*/
#lastTokenUsage: CodexTokenUsage | null = null;
/** item id → {name, input} for items already announced via tool_start. */
#announcedItems = new Map<string, { name: string; input: Record<string, unknown> }>();

Expand Down Expand Up @@ -132,6 +148,7 @@ export class CodexProvider implements SessionProvider {
this.#turnStartedAt = Date.now();
this.#turnModel = opts.model ?? "codex";
this.#announcedItems.clear();
this.#lastTokenUsage = null;
this.#hasQueried = true;

void this.#startTurn(opts).catch((err: unknown) => {
Expand Down Expand Up @@ -328,13 +345,16 @@ export class CodexProvider implements SessionProvider {
}
case "turn/completed": {
const turn = params.turn as Record<string, unknown> | undefined;
const usage = (turn?.usage ?? params.usage ?? {}) as Record<string, number>;
const u = this.#lastTokenUsage;
// Match the claude convention: inputTokens = NEW tokens (cache reads
// excluded); codex's inputTokens INCLUDES its cachedInputTokens.
const cacheRead = u?.cachedInputTokens ?? 0;
const result: NormalizedTurnResult = {
providerId: this.id,
model: this.#turnModel,
inputTokens: usage.inputTokens ?? usage.input_tokens ?? 0,
outputTokens: usage.outputTokens ?? usage.output_tokens ?? 0,
cacheReadTokens: usage.cachedInputTokens ?? usage.cached_input_tokens ?? 0,
inputTokens: Math.max(0, (u?.inputTokens ?? 0) - cacheRead),
outputTokens: u?.outputTokens ?? 0,
cacheReadTokens: cacheRead,
cacheCreationTokens: 0,
totalCostUsd: 0,
durationMs: Date.now() - this.#turnStartedAt,
Expand All @@ -344,6 +364,11 @@ export class CodexProvider implements SessionProvider {
this.#turnQueue?.close();
break;
}
case "thread/tokenUsage/updated": {
const usage = (params.tokenUsage as { last?: CodexTokenUsage } | undefined)?.last;
if (usage) this.#lastTokenUsage = usage;
break;
}
case "error": {
const message = (params.message ?? params.error ?? "codex error") as string;
this.#push({ type: "error", message: String(message) });
Expand Down
84 changes: 73 additions & 11 deletions src/daemon/providers/codex/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
/**
* codex binary resolution — config override → system PATH.
* codex binary resolution — config override → system PATH → common Node
* install dirs.
*
* No bundled fallback (yet): @openai/codex ships a per-platform native
* Rust binary, so bundling needs a size/platform audit first — see
* docs/provider-codex-design.md. Until then, the registry's
* supported-but-unavailable path (#141) surfaces the install hint in the
* catalog, the startup log, and session.set_provider errors.
* No BUNDLED fallback (yet): @openai/codex ships a per-platform native Rust
* binary, so bundling needs a size/platform audit first — see
* docs/provider-codex-design.md. But codex is almost always installed via a
* Node package manager, and the daemon frequently runs with a PATH that
* omits the user's nvm / npm-global bin (a launchd/systemd service, or a
* shell that never sourced nvm). So after PATH we scan the standard
* per-user Node bin locations before giving up — otherwise a codex that is
* clearly installed shows as "not installed". A missing binary still lands
* on the registry's supported-but-unavailable hint (#141).
*/

import { existsSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

export const CODEX_INSTALL_HINT =
"no codex binary found — install the Codex CLI (npm i -g @openai/codex) " +
Expand All @@ -17,15 +24,15 @@ export const CODEX_INSTALL_HINT =
export interface CodexCommandResolution {
command: string;
argsPrefix: string[];
source: "config" | "path";
source: "config" | "path" | "node-bin";
}

export function resolveCodexCommand(
configured: string | undefined,
env: Record<string, string | undefined> = process.env,
): CodexCommandResolution | null {
// Explicit config override — verified so a typo is loud at startup, not
// a first-turn spawn failure.
// 1. Explicit config override — verified so a typo is loud at startup,
// not a first-turn spawn failure.
if (configured !== undefined && configured !== "codex") {
if (configured.includes("/")) {
return existsSync(configured)
Expand All @@ -35,6 +42,61 @@ export function resolveCodexCommand(
const found = Bun.which(configured, { PATH: env.PATH ?? "" });
return found ? { command: found, argsPrefix: [], source: "config" } : null;
}

// 2. System codex on PATH.
const onPath = Bun.which("codex", { PATH: env.PATH ?? "" });
return onPath ? { command: onPath, argsPrefix: [], source: "path" } : null;
if (onPath) return { command: onPath, argsPrefix: [], source: "path" };

// 3. Common per-user Node bin dirs the daemon's PATH may not include.
for (const dir of nodeBinDirs(env)) {
const candidate = join(dir, "codex");
if (existsSync(candidate)) {
return { command: candidate, argsPrefix: [], source: "node-bin" };
}
}
return null;
}

/**
* Candidate per-user Node bin directories, newest-nvm-first. Covers nvm
* (every installed node version), the active nvm/Volta/fnm bin, an
* npm-global prefix, and ~/.local/bin. Best-effort — unreadable dirs are
* skipped.
*/
function nodeBinDirs(env: Record<string, string | undefined>): string[] {
const home = env.HOME ?? homedir();
const dirs: string[] = [];

// nvm: ~/.nvm/versions/node/<version>/bin — prefer the highest version.
const nvmVersions = join(home, ".nvm", "versions", "node");
try {
const versions = readdirSync(nvmVersions)
.filter((v) => v.startsWith("v"))
.sort(compareNodeVersionsDesc);
for (const v of versions) dirs.push(join(nvmVersions, v, "bin"));
} catch {
/* no nvm */
}

// Active version managers + npm global + user-local.
if (env.NVM_BIN) dirs.push(env.NVM_BIN);
if (env.VOLTA_HOME) dirs.push(join(env.VOLTA_HOME, "bin"));
if (env.npm_config_prefix) dirs.push(join(env.npm_config_prefix, "bin"));
dirs.push(join(home, ".npm-global", "bin"));
dirs.push(join(home, ".local", "bin"));
dirs.push("/usr/local/bin");

// De-dup while preserving order.
return [...new Set(dirs)];
}

/** Sort "v21.5.0" > "v20.11.1" numerically, descending. Exported for tests. */
export function compareNodeVersionsDesc(a: string, b: string): number {
const pa = a.slice(1).split(".").map(Number);
const pb = b.slice(1).split(".").map(Number);
for (let i = 0; i < 3; i++) {
const d = (pb[i] ?? 0) - (pa[i] ?? 0);
if (d !== 0) return d;
}
return 0;
}
16 changes: 12 additions & 4 deletions src/tests/fixtures/fake-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ function serverRequest(method: string, params: Record<string, unknown>): Promise
return new Promise((resolve) => pendingServerReqs.set(id, resolve));
}

function usage() {
return { inputTokens: 120, cachedInputTokens: 20, outputTokens: 45 };
/** Matches the real codex tokenUsage.last shape (thread/tokenUsage/updated). */
function tokenUsage() {
return { totalTokens: 165, inputTokens: 120, cachedInputTokens: 20, outputTokens: 45, reasoningOutputTokens: 0 };
}
function emitUsage(threadId: string): void {
send({
method: "thread/tokenUsage/updated",
params: { threadId, turnId: "turn-1", tokenUsage: { total: tokenUsage(), last: tokenUsage() } },
});
}

async function runTurn(threadId: string, prompt: string): Promise<void> {
Expand Down Expand Up @@ -93,7 +100,8 @@ async function runTurn(threadId: string, prompt: string): Promise<void> {
send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: "Hello world" } } });
}

send({ method: "turn/completed", params: { turn: { id: "turn-1", status: "completed", usage: usage() } } });
emitUsage(threadId);
send({ method: "turn/completed", params: { turn: { id: "turn-1", status: "completed" } } });
}

let buf = "";
Expand Down Expand Up @@ -168,7 +176,7 @@ async function handle(frame: Record<string, unknown>): Promise<void> {
}
case "turn/interrupt":
send({ id, result: {} });
send({ method: "turn/completed", params: { turn: { id: "turn-1", status: "interrupted", usage: usage() } } });
send({ method: "turn/completed", params: { turn: { id: "turn-1", status: "interrupted" } } });
break;
case "test/noReply":
break; // deliberately never answered — rpc timeout test
Expand Down
73 changes: 67 additions & 6 deletions src/tests/provider-codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
*/

import { describe, it, expect } from "bun:test";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CodexRpcProcess } from "../daemon/providers/codex/rpc.js";
import { CodexProvider } from "../daemon/providers/codex/index.js";
import { resolveCodexCommand } from "../daemon/providers/codex/resolve.js";
import { compareNodeVersionsDesc, resolveCodexCommand } from "../daemon/providers/codex/resolve.js";
import { createDefaultProviderRegistry } from "../daemon/providers/registry.js";
import type { ProviderEvent, TurnOpts, TurnRun } from "../daemon/providers/interface.js";
import type { CodeoidConfig } from "../config.js";
Expand Down Expand Up @@ -75,7 +75,9 @@ describe("CodexProvider over fake-codex", () => {
expect(turnDone).toBeDefined();
if (turnDone?.type === "turn_done") {
expect(turnDone.result.providerId).toBe("codex");
expect(turnDone.result.inputTokens).toBe(120);
// Usage comes from thread/tokenUsage/updated (turn/completed has none);
// inputTokens excludes cache reads (claude convention): 120 - 20.
expect(turnDone.result.inputTokens).toBe(100);
expect(turnDone.result.outputTokens).toBe(45);
expect(turnDone.result.cacheReadTokens).toBe(20);
}
Expand Down Expand Up @@ -321,11 +323,15 @@ describe("codex resolution + registry", () => {
expect(registry.has("codex")).toBe(false);
expect(registry.unavailableHint("codex")).toContain("providers.codex.command");

// No codex anywhere → null + generic install hint.
expect(resolveCodexCommand(undefined, { PATH: "" })).toBeNull();

// Bare-name override resolves via PATH; system codex resolves as "path".
const tmp = mkdtempSync(join(tmpdir(), "codeoid-codex-resolve-"));
// Isolated env: empty HOME + no version-manager vars, so the node-bin
// fallback has nothing to find (the real machine's nvm codex can't leak in).
const isolated = { PATH: "", HOME: join(tmp, "empty-home") };

// No codex anywhere → null + generic install hint.
expect(resolveCodexCommand(undefined, isolated)).toBeNull();

try {
const bin = join(tmp, "my-codex");
writeFileSync(bin, "#!/bin/sh\necho fake-codex\n");
Expand All @@ -344,6 +350,50 @@ describe("codex resolution + registry", () => {
argsPrefix: [],
source: "path",
});

// node-bin fallback: codex NOT on PATH but in ~/.npm-global/bin — the
// "daemon PATH omits the user's node bin" case this fix exists for.
const nodeBin = join(tmp, "home", ".npm-global", "bin");
mkdirSync(nodeBin, { recursive: true });
const codexInNodeBin = join(nodeBin, "codex");
writeFileSync(codexInNodeBin, "#!/bin/sh\necho fake\n");
chmodSync(codexInNodeBin, 0o755);
expect(resolveCodexCommand(undefined, { PATH: "", HOME: join(tmp, "home") })).toEqual({
command: codexInNodeBin,
argsPrefix: [],
source: "node-bin",
});

// nvm: codex under ~/.nvm/versions/node/<version>/bin, resolved
// NEWEST-version-first (v21.5.0 preferred over v9.0.0 lexical order).
const nvmHome = join(tmp, "nvmhome");
for (const v of ["v9.0.0", "v21.5.0"]) {
const vbin = join(nvmHome, ".nvm", "versions", "node", v, "bin");
mkdirSync(vbin, { recursive: true });
writeFileSync(join(vbin, "codex"), "#!/bin/sh\necho fake\n");
chmodSync(join(vbin, "codex"), 0o755);
}
const nvmResolved = resolveCodexCommand(undefined, { PATH: "", HOME: nvmHome });
expect(nvmResolved?.source).toBe("node-bin");
// Highest version wins the sort, even though "v21" < "v9" lexically.
expect(nvmResolved?.command).toBe(join(nvmHome, ".nvm", "versions", "node", "v21.5.0", "bin", "codex"));

// Active version-manager env vars are searched too (NVM_BIN /
// VOLTA_HOME / npm_config_prefix), before the home defaults.
for (const key of ["NVM_BIN", "VOLTA_HOME", "npm_config_prefix"] as const) {
const base = join(tmp, key.toLowerCase());
const bin = key === "NVM_BIN" ? base : join(base, "bin");
mkdirSync(bin, { recursive: true });
writeFileSync(join(bin, "codex"), "#!/bin/sh\necho fake\n");
chmodSync(join(bin, "codex"), 0o755);
const r = resolveCodexCommand(undefined, {
PATH: "",
HOME: join(tmp, "no-home"),
[key]: base,
});
expect(r?.source).toBe("node-bin");
expect(r?.command).toBe(join(bin, "codex"));
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
Expand All @@ -358,4 +408,15 @@ describe("codex resolution + registry", () => {
expect(disabled.has("codex")).toBe(false);
expect(disabled.unavailableHint("codex")).toBeUndefined();
});

it("compareNodeVersionsDesc orders newest-first and ties equal versions", () => {
expect(["v9.0.0", "v21.5.0", "v20.11.1"].sort(compareNodeVersionsDesc)).toEqual([
"v21.5.0",
"v20.11.1",
"v9.0.0",
]);
// Equal versions (incl. a missing patch component) compare as 0.
expect(compareNodeVersionsDesc("v18.0.0", "v18.0")).toBe(0);
expect(compareNodeVersionsDesc("v18.1.0", "v18.1.0")).toBe(0);
});
});
Loading