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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ with no silent fallback.
(native in-process plugin injected via `OPENCODE_CONFIG_CONTENT`).
- **`@cotal-ai/connector-hermes`** (`extensions/connector-hermes`): the Hermes (Nous Research)
adapter; includes a Python sidecar.
- **`@cotal-ai/connector-codex`** (`extensions/connector-codex`): the OpenAI Codex adapter —
a headless host-mode peer that drives a `codex app-server` thread over JSON-RPC (wake /
steer / interrupt); resolved by agent type `codex-app-server`.
- **`@cotal-ai/cmux`** (`extensions/cmux`): the cmux integration: a driver over the cmux CLI
plus a self-registering `cmux` Runtime and `TerminalLayout` provider.
- **`@cotal-ai/cli`** (`implementations/cli`): the mesh CLI: `up`, `join`, `watch`, `console`,
Expand Down
1 change: 1 addition & 0 deletions bin/cotal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import "@cotal-ai/manager"; // self-registers supervise / cmux / start / stop /
import "@cotal-ai/connector-claude-code"; // registers the `claude` connector that spawn / start resolve
import "@cotal-ai/connector-opencode"; // registers the `opencode` connector (native in-process plugin)
import "@cotal-ai/connector-hermes"; // registers the `hermes` connector (Nous Research gateway as a mesh peer)
import "@cotal-ai/connector-codex"; // registers the `codex-app-server` connector (headless host-mode peer driving codex app-server)
import "@cotal-ai/cmux"; // opt into the cmux integration — registers the `cmux` runtime + TerminalLayout providers
import { claudeConnector } from "@cotal-ai/connector-claude-code";
import { registry } from "@cotal-ai/core";
Expand Down
1 change: 1 addition & 0 deletions bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@cotal-ai/cli": "workspace:*",
"@cotal-ai/cmux": "workspace:*",
"@cotal-ai/connector-claude-code": "workspace:*",
"@cotal-ai/connector-codex": "workspace:*",
"@cotal-ai/connector-hermes": "workspace:*",
"@cotal-ai/connector-opencode": "workspace:*",
"@cotal-ai/core": "workspace:*",
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,12 @@ examples ──→ one-or-more implementations ──→ core ←(peer)── ex
```

The migration is done: `demos/` use-cases are now `examples/`. The connector is split into
`@cotal-ai/connector-core` (shared mesh runtime) plus two thin adapters,
`@cotal-ai/connector-claude-code` (`claudeConnector`) and `@cotal-ai/connector-opencode`
(`opencodeConnector`). Those are `extensions/` packages that **peer-depend** on core and export
a `Connector`. `@cotal-ai/cli` and `@cotal-ai/manager` are `implementations/` packages.
`@cotal-ai/connector-core` (shared mesh runtime) plus thin adapters —
`@cotal-ai/connector-claude-code` (`claudeConnector`), `@cotal-ai/connector-opencode`
(`opencodeConnector`), `@cotal-ai/connector-hermes`, and `@cotal-ai/connector-codex`
(`codexAppServerConnector`, a headless host-mode peer driving `codex app-server` — see
[codex-integration.md](codex-integration.md)). Those are `extensions/` packages that
**peer-depend** on core and export a `Connector`. `@cotal-ai/cli` and `@cotal-ai/manager` are `implementations/` packages.
Assembly lives at the **composition root**: an example (`examples/01/src/manager.ts`) imports
the manager plus the connectors it wants and hands them to the manager (`new Manager({
connectors: […] })`), which resolves one by agent type when spawning (unknown throws).
Expand Down
55 changes: 55 additions & 0 deletions docs/codex-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Codex connector

> App Server: [developers.openai.com/codex/app-server](https://developers.openai.com/codex/app-server)
> (built + verified against codex-cli 0.137.0).

The connector turns OpenAI Codex into a Cotal mesh peer over the shared runtime in
[`@cotal-ai/connector-core`](../extensions/connector-core). It runs **host-mode** (headless):
a peer that drives a `codex app-server` over JSON-RPC for true wake / steer / interrupt of a
*live* session — no native TUI. The human view comes via the manager's attach / WS.

Identity-gated (`hasIdentity()`): launched with no `COTAL_*` env it stays off the mesh. The
manager resolves it by agent type **`codex-app-server`**.

## How it works

[`host.ts`](../extensions/connector-codex/src/host.ts) embeds a `MeshAgent` in the same process
as an `AppServerDriver` ([`app-server.ts`](../extensions/connector-codex/src/app-server.ts)),
which owns a `codex app-server` child and speaks the app-server **v2** JSON-RPC (JSONL over
stdio — the same protocol the TUI / VS Code extension use; the wire omits the `jsonrpc` field).
A mesh message becomes a real user turn:

- idle → `turn/start` (wake); a turn already running → `turn/steer` (true mid-turn inject, no
abort — Codex exceeds Claude here); shutdown → `turn/interrupt`.
- Presence is read off the event stream: `turn/started`→working, `turn/completed`→idle, an
approval request→waiting (auto-accepted to stay autonomous). Each turn's final `agentMessage`
is routed back to whoever prompted it (channel→`send`, dm/anycast→`dm`).

`approval_policy=never` + `sandbox_mode=workspace-write` make a spawned session autonomous (it
would otherwise hang on the first approval the host can't surface). The operator's `~/.codex`
(auth, model, their own servers) is never written — all config rides `-c` overrides. Codex auth
is the operator's ChatGPT login in `~/.codex/auth.json` (reachable via `HOME`) or `OPENAI_API_KEY`
(the only provider key forwarded into the child — no other operator env leaks, per `launchEnv`).

The host is **directed-only**: it acts on DMs, anycasts, and @-mentions; ambient channel chatter
is dropped, not surfaced — keeping a headless peer focused and its inbox bounded. It drives off
the mesh inbox with **ack-on-completion**: a turn's surfaced messages are `drainInbox()`-acked
only when the turn ends un-interrupted (a `failed` turn acks to avoid a retry-loop; only
`interrupted`/crash redelivers), and a message is steered into the live turn only when it shares
that turn's scope (`channel:<ch>` vs `dm:<id>`), so a DM never rides a channel broadcast.

## Verified

Built + typechecked against codex-cli 0.137.0 — the app-server protocol bindings were confirmed
via `codex app-server generate-ts` (the v2 methods `turn/start` / `turn/steer` / `turn/interrupt`
and notifications `turn/started` / `item/completed` / `turn/completed` match). A live turn (DM →
`PONG` round-trip) is exercised by `smoke:codex` against the real binary (needs an authenticated
`codex`; gated by `COTAL_E2E_CODEX=1`).

## Not yet

- **Agent-initiated `cotal_*` tools.** The host owns all mesh I/O so there is one mesh identity;
letting the Codex agent proactively `cotal_send`/`cotal_spawn` needs those tool calls routed
back through the host's single `MeshAgent` (else two presences). Today the peer is reply-driven.
- **Attach surface + remote-TUI** (a human watching the real `codex` TUI while the mesh drives it)
— rides Codex's newer `--remote` path; tracked upstream (codex#18203, codex#21551).
41 changes: 41 additions & 0 deletions extensions/connector-codex/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@cotal-ai/connector-codex",
"description": "Cotal connector for OpenAI Codex: a headless host-mode peer that drives a live `codex app-server` thread over JSON-RPC.",
"version": "0.1.0",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/Cotal-AI/Cotal.git",
"directory": "extensions/connector-codex"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "tsc -p tsconfig.json",
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"@cotal-ai/connector-core": "workspace:*",
"tsx": "^4.22.4"
},
"peerDependencies": {
"@cotal-ai/core": ">=0.1.0"
},
"devDependencies": {
"@cotal-ai/core": "workspace:*"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
}
}
113 changes: 113 additions & 0 deletions extensions/connector-codex/smoke/host-turn.smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Live E2E for the Codex host-mode connector — drives the REAL `codex` binary end to end.
* Spins up its own nats-server + a real mesh, launches the host peer (tsx host-main.ts) as a
* child, then DMs it and asserts a model reply routes back over the mesh. Unlike the opencode
* turn-wedge smoke (fake client), this needs an AUTHENTICATED codex, so it is gated behind
* `COTAL_E2E_CODEX=1` — without the flag it skips (keeps `pnpm smoke`/CI green).
*
* 1. handshake — AppServerDriver.start() against the real binary returns a thread id (no model);
* 2. full turn — operator DMs the peer "reply PONG"; the codex turn's final message comes back
* as a DM. Proves wake (turn/start) + presence + reply routing over a real mesh.
* Run: COTAL_E2E_CODEX=1 pnpm smoke:codex
*/
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { CotalEndpoint, seedChannelRegistry, isReachable } from "@cotal-ai/core";
import { AppServerDriver } from "../src/app-server.js";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

if (!/^(1|true|yes|on)$/i.test(process.env.COTAL_E2E_CODEX ?? "")) {
console.log("SKIP codex host E2E — set COTAL_E2E_CODEX=1 (needs an authenticated `codex` CLI) to run it");
process.exit(0);
}

const PORT = 14271;
const servers = `nats://127.0.0.1:${PORT}`;
const space = "codexe2e";
const PEER = "codexpeer";
let pass = 0;
const check = (name: string, cond: boolean, extra?: unknown) => {
assert.ok(cond, `${name}${extra !== undefined ? ` — ${JSON.stringify(extra)}` : ""}`);
pass++;
console.log(` ✓ ${name}`);
};

const HOST_ENTRY = fileURLToPath(new URL("../src/host-main.ts", import.meta.url));
const TSX = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url));

const dir = mkdtempSync(join(tmpdir(), "cotal-codexe2e-"));
const nats = spawn("nats-server", ["-js", "-p", String(PORT), "-sd", join(dir, "js")], { stdio: "ignore" });

const operator = new CotalEndpoint({
space,
servers,
card: { name: "operator", kind: "agent", id: "operator" },
channels: ["team"],
});
operator.on("error", () => {});

let reply = "";
operator.on("message", (msg: { parts?: { kind: string; text?: string }[] }, _d: unknown, meta: { kind: string; historical: boolean }) => {
if (meta.historical || meta.kind !== "dm") return;
reply += (msg.parts ?? []).filter((p) => p.kind === "text").map((p) => p.text ?? "").join("");
});

let online = false;
operator.on("presence", (e: { type: string; presence: { card: { id: string; name: string } } }) => {
const c = e.presence.card;
if ((c.id === PEER || c.name === PEER) && e.type !== "offline") online = true;
});

let host: ReturnType<typeof spawn> | undefined;
let handshakeDriver: AppServerDriver | undefined;
try {
// (1) handshake against the real binary — spawn → initialize → thread/start returns a thread id.
handshakeDriver = new AppServerDriver({ cwd: dir, log: () => {} });
const threadId = await handshakeDriver.start();
check("app-server handshake returns a thread id", typeof threadId === "string" && threadId.length > 0, threadId);
await handshakeDriver.stop();

// (2) full turn over a real mesh.
for (let i = 0; i < 50; i++) { if (await isReachable(servers)) break; await sleep(200); }
await seedChannelRegistry({ servers, space, file: { defaults: { replay: false }, channels: { team: { replay: false } } } });
await operator.start();

host = spawn(TSX, [HOST_ENTRY], {
env: {
...process.env,
COTAL_SPACE: space,
COTAL_NAME: PEER,
COTAL_ID: PEER,
COTAL_SERVERS: servers,
COTAL_SUBSCRIBE: "team",
COTAL_ROLE: "coder",
},
stdio: ["ignore", "inherit", "inherit"],
});

// Wait for the peer to come online (mesh connected AND app-server thread up — the host only
// reports idle after driver.start()).
for (let i = 0; i < 600 && !online; i++) await sleep(100); // up to 60s
check("codex host peer comes online", online);

await operator.unicast(PEER, "Reply with exactly the single word: PONG. Do not run any tools or commands.");

// A model turn can take a while; poll up to 120s for the reply DM.
for (let i = 0; i < 1200 && !/PONG/i.test(reply); i++) await sleep(100);
check("codex peer replies to the DM with PONG", /PONG/i.test(reply), reply.trim());

console.log(`\nCODEX HOST E2E PASSED ✅ (${pass} checks) reply=${JSON.stringify(reply.trim())}`);
} finally {
host?.kill("SIGTERM");
await handshakeDriver?.stop().catch(() => {});
await operator.stop().catch(() => {});
nats.kill("SIGKILL");
await sleep(200);
rmSync(dir, { recursive: true, force: true });
}
process.exit(0);
Loading