Talk to OpenAI Codex, and the Antigravity CLI, from inside Claude Code. Persistent threads on a shared Codex daemon, with cost-aware routing.

Talk to OpenAI Codex from inside Claude Code. Persistent threads, cost-aware routing, one shared Codex daemon.
Website: gary0302.github.io/connect · Source: github.com/Gary0302/connect · License: MIT
Connect is a Claude Code plugin: an MCP server, a skill, and slash commands. It lets Claude ask Codex for a second opinion, a code review, or a hard design call — and keep that conversation going across sessions. There is nothing to npm install: zero dependencies, plain Node.js.
/connect:ask is this lock-free queue actually safe under ABA? see src/queue.ts
Codex's view: the CAS on `head` is ABA-prone because … (thread: 019a…)
routed: code_review · gpt-5.x · effort high
xhigh. One measured one-sentence factual question at that default cost 273,840 tokens in 48 seconds; the same question routed as quick_answer cost about 16,000 tokens in 3.5 seconds. Connect pins effort, sandbox and a token ceiling on every turn.codex is not enough.# 1. standalone Codex
curl -fsSL https://chatgpt.com/codex/install.sh | sh
# 2. the plugin
claude plugin marketplace add Gary0302/connect
claude plugin install connect@connect-local
Restart Claude Code, then run /connect:doctor to confirm the daemon is reachable.
| symptom | what to check |
|---|---|
| daemon will not start | the standalone Codex install is missing, or an older brew/npm codex wins in PATH — set CONNECT_CODEX_BIN=~/.local/bin/codex |
| doctor reports a version mismatch | the CLI was upgraded but the daemon is still the old one. The fix is codex app-server daemon restart — read the warning below first |
| errors about the config file | ~/.config/connect/config.json is malformed — fix or delete it |
implement cannot do something | Connect declines every approval and input request from Codex automatically; only what the workspace-write sandbox already allows can happen |
claude plugin uninstall connect@connect-local
claude plugin marketplace remove connect-local # optional
rm -rf ~/.config/connect # optional: your preferences
Installing copies the plugin into ~/.claude/plugins/cache/, so edits in this checkout do not take effect until you resync. claude plugin update is a no-op while the version in .claude-plugin/plugin.json is unchanged, so either bump the version or reinstall:
claude plugin uninstall connect@connect-local && claude plugin install connect@connect-local
| command | what it does | |||
|---|---|---|---|---|
/connect:ask <question> | ask Codex in a new persistent thread | |||
/connect:threads | list recent Codex threads | |||
/connect:resume <threadId> <question> | read an earlier thread back, then continue it | |||
/connect:config [args] | choose model, effort and token ceilings (interactive with no args) | |||
/connect:doctor | health check: daemon, versions, remote control | |||
| `/connect:usage [on\ | off\ | show\ | --json]` | switch the quota line in your status line on or off (no argument toggles), or show quotas; script only, no model call |
/connect:compare <question> | ask Codex and agy the same question at once, read-only, and compare |
/connect:usage show shows four compact rows. Percentages are remaining; the bracketed values are countdowns to reset in hours and minutes, in 5h|7d order. Missing windows are omitted:
claude 5h:22% 7d:80% [1:33|100:00]
codex 5h:67% 7d:95% [2:00|150:00]
agy-gemini 7d:99.6% [165:20]
agy-other 7d:100% [168:00]
The 5h value is green above 50%, yellow above 20% through 50%, and red at 20% or less. Terminal output detects color support; the command hook enables colors explicitly. NO_COLOR disables colors. Antigravity uses its reported shared quota when available, otherwise the least remaining model quota in each group. Its Starter tier has weekly limits, as described in Google's quota documentation; an unreported 5h limit is omitted. Unknown reset times display —. A failed provider displays unavailable.
These are account quotas across the providers' apps, including activity outside Connect. --json retains the full quota details, credit balances, and errors. Percentages from different models or providers are never added. The report does not include historical token totals or bills.
The plugin's command hooks intercept the command and display the script output as a hook message before Claude processes a prompt. Claude Code may label this a blocked prompt: that is how Connect prevents inference, not a failure of the usage check. This uses Claude Code's documented UserPromptSubmit and UserPromptExpansion decision controls. Keep plugin hooks enabled and restart Claude Code after updating the plugin. A normal custom command alone cannot guarantee a check without inference. For a direct script invocation, use Claude Code's Bash mode (!) or your terminal:
node /path/to/connect/bin/connect.mjs usage
node /path/to/connect/bin/connect.mjs usage --json
The script reads Codex's account/rateLimits/read and the other providers' quota endpoints using your existing sign-ins. It starts the Codex daemon if needed, but never starts a thread or turn. Claude credentials come from macOS Keychain or CLAUDE_CONFIG_DIR/.credentials.json (~/.claude by default); Antigravity uses Keychain on macOS or Secret Service's secret-tool on Linux. For Antigravity file-backed sign-ins, set CONNECT_AGY_CREDENTIALS to the CLI's token profile (an object with token.access_token). It never prints or rewrites credentials. An expired sign-in is reported with instructions to refresh it in the provider CLI. Antigravity enterprise and API-key accounts are currently unsupported; subscription quotas are not API billing totals.
/connect:usage with no argument switches a quota line in Claude Code's status line on or off; /connect:usage on and /connect:usage off say which. Turning it on also shows the four rows above. The line goes after whatever your status line already prints:
[Opus 5.5 | ctx: 18%] claude [78%|67%] codex [96%|99%] agy [n/a|100%]
Each bracket is [5h|7d] remaining, the 5h value colored as in the rows above; n/a means that window is not reported. agy is one row: the least remaining across its model families, never a sum. A provider Connect cannot find or read (not installed, signed out) has no row at all, so a machine without agy shows only claude and codex. Use /connect:usage show to see why one is missing.
Claude Code runs your own status line command, so the plugin cannot add to it by itself. Connect keeps the line in ~/.config/connect/usage-line.txt while it is on and deletes the file when it is off; add this to the end of your status line script once (/connect:usage on reminds you until the script reads the file):
f=~/.config/connect/usage-line.txt; if [ -s "$f" ]; then printf ' %s' "$(cat "$f")"; fi
A function-hooks module (hooks/hud.mjs) that Claude Code loads with the plugin keeps the file fresh. It runs bin/connect.mjs usage refresh when a session starts, every five minutes, and after a Connect call or a turn ends (at most once a minute), so it costs no model tokens. Several sessions share the file, and a refresh skips a line written in the last minute. While a Connect call runs, the same module pins the peer at work under the prompt, as codex ▸ second_opinion 0:42. Set CONNECT_HUD=0 to turn the module off; it never runs in claude -p.
The bundled connect skill tells Claude when Codex is worth asking. It loads when you say "ask Codex", "what does Codex think", "get a second opinion", or when Claude judges that a risky diff or a contested design call deserves an independent model. It is user-invocable: false, so it stays out of the / menu.
Claude also gets the MCP tools directly:
| tool | purpose |
|---|---|
codex_ask | one routed turn: prompt, intent, optional threadId and cwd |
codex_threads | list recent threads on the daemon |
codex_history | a recent, truncated transcript of a thread (default 20 turns, 400 chars per item) |
codex_config | read or change preferences |
codex_doctor | diagnose the daemon and versions |
agy_ask | one Antigravity CLI turn, read-only unless implement: prompt, intent, optional conversationId, model, cwd |
agy_threads | list the agy conversations Connect started |
agy_doctor | which agy binary, its version, and its models |
Claude is told to report Codex's answer as Codex's view, not as fact, and to say plainly where it disagrees.
Every codex_ask carries an intent. It pins three things: reasoning effort, the sandbox, and a per-turn token ceiling.
| intent | effort | sandbox | token ceiling | for |
|---|---|---|---|---|
quick_answer | low | read-only | 20k | facts, versions, one-liners |
second_opinion | medium | read-only | 80k | "is this approach sound?" (the default) |
code_review | high | read-only | 200k | reading a diff or file for defects |
deep_reasoning | xhigh | read-only | 400k | architecture, protocols, races |
implement | high | workspace-write | 300k | asking Codex to write code |
src/router.mjs). Claude is instructed to always pass it explicitly.approvalPolicy: "never" decides who is asked, not what is possible — with nobody to ask, whatever the sandbox allows just happens. Asking for an opinion must not be able to edit your tree, so only implement can write. Codex can still read the working directory, so point it at real paths instead of pasting files.turn/interrupt and the result says INTERRUPTED: exceeded the budget for this intent — the answer is partial. If the interrupt itself fails, the result says that instead.Every codex_ask result ends with its thread id. Pass it back as threadId to continue with the thread's memory intact. codex_threads returns conversations from days ago, and codex_history shows what one contains — so /connect:resume can pick up a conversation from a previous session.
Verified: resume a thread, ask "what did I ask you earlier in this thread?", and Codex answers from the thread's own history.
If Google's Antigravity CLI is installed (agy on PATH or in ~/.local/bin; override with CONNECT_AGY_BIN), Connect can ask it too — /connect:agy, or Claude calls agy_ask when you ask for Antigravity, agy or Gemini. Each turn is one headless agy -p --output-format stream-json process; --conversation <id> continues an earlier one.
implement. Headless agy cannot ask for approval, so file writes and shell commands are denied (reported as denied (read-only): …); its own read tools still work inside the working directory. If your agy settings put it in any permission mode other than request-review, Connect refuses the turn. Allow rules in your own ~/.gemini/antigravity-cli/settings.json still apply.implement writes inside the workspace only. Connect adds --mode accept-edits (agy 1.3+) for that turn alone. Measured on agy 1.3.2: edits inside the working directory succeed; writes outside it are denied whether by absolute path, ../, or a symlink in the workspace; shell commands stay denied, so agy cannot build or test what it wrote. A continued conversation is read-only again unless that turn is implement too. Where agy's own check falls short:.git/ is writable, and a hook or core.hooksPath would later run as you. Connect snapshots .git hooks, config and info/ before the turn and puts them back after it, reporting REVERTED … if agy changed any./tmp on macOS), so Connect resolves the working directory first. --dangerously-skip-permissions is never used: --sandbox does not keep it in the workspace.~/.config/connect/agy-conversations.jsonl; agy_threads reads it.CONNECT_TURN_TIMEOUT_MS, default 60 min) applies to both peers./connect:compare <question>, or Claude calling peers_compare, sends one question to Codex and agy together and returns both answers, each with its own thread or conversation id. It only takes read-only intents (implement is refused) and costs both peers' tokens. If one peer fails, the other's answer still comes back.
/connect:config with no arguments opens forms in the terminal, drawn by the MCP server itself through MCP elicitation:
model/list), effort mode (auto by intent / one level everywhere / per intent) and token ceilings (keep / built-in / 2× / 4×)./model picker words them.Clients without elicitation get a clear refusal, and the slash command falls back to asking the same questions with AskUserQuestion. Quick changes take arguments:
/connect:config show
/connect:config model gpt-5.6-terra
/connect:config model default
/connect:config code_review xhigh
/connect:config old-threads compact
/connect:config reset
Preferences are validated against the models and efforts the daemon actually offers, stored outside the plugin (so a reinstall does not wipe them), and re-read on every call (so changes apply to the next ask). Sandboxes are deliberately not configurable.
| environment variable | default | purpose |
|---|---|---|
CONNECT_CONFIG | ~/.config/connect/config.json | preferences file |
CONNECT_CODEX_BIN | resolved automatically | which codex binary to use |
CONNECT_AGY_CREDENTIALS | OS keyring | Antigravity token profile file for usage checks |
CONNECT_HUD | unset | 0 turns off the status line refresh and the running-peer line |
CONNECT_TEST_DISRUPTIVE | unset | 1 enables the daemon-restart test |
Codex without Claude in the loop. It reads the same config file; --model overrides it.
node bin/connect.mjs doctor
node bin/connect.mjs usage
node bin/connect.mjs usage --json
node bin/connect.mjs usage --line # one line, as the status line shows it
node bin/connect.mjs usage on # or off: the status line's quota line
node bin/connect.mjs threads 10
node bin/connect.mjs ask --intent quick_answer "what version of node is this"
node bin/connect.mjs ask --thread <id> "and what about the previous point?"
node bin/connect.mjs history <threadId>
Claude Code ──stdio / MCP──▶ mcp/connect-mcp.mjs
│
src/session.mjs ← router (intent → effort, sandbox, ceiling)
│ config (user overrides) · lease (fencing)
src/codex-client.mjs JSON-RPC, reconnect with backoff
│
src/ws-unix.mjs RFC 6455 WebSocket over a Unix socket
│
~/.codex/app-server-control/app-server-control.sock
│
codex app-server daemon (also reachable from `codex --remote`)
Connect starts the daemon if needed (codex app-server daemon start) and talks to it over its control socket. A Codex TUI started with codex --remote unix://<socket> drives the same app-server, so threads are shared between it and Connect.
Within one Connect MCP process, thread leases with fencing tokens stop two concurrent codex_ask calls from starting turns on the same thread. The registry is in-memory: it does not fence writers outside Connect, such as a Codex TUI or Remote Control session on the same thread.
Protocol details that drove the design are written up in spike/FINDINGS.md — read it before changing src/. Two examples:
thread/resume ignores model for a thread the daemon already has loaded; a model switch has to ride on turn/start, and then sticks for later turns.summary is set on turn/start.Usage checks have an offline suite that needs no sign-in and spends no model tokens:
node --test test/usage.mjs
The status line's module has its own tests, run by Claude Code's plugin test kit:
claude plugin test .
Both run in CI on every push. For the existing Codex integration suite:
export PATH="$HOME/.local/bin:$PATH" # make sure the standalone codex comes first
node test/verify.mjs
About 130 checks: daemon lifecycle, WebSocket handshake and frame validation, concurrent clients, routing policy, user config, lease fencing, notification correlation on a shared daemon, per-turn budget accounting, session lifecycle and refusal shapes, the MCP protocol surface over stdio, and the config forms.
codex_ask calls through the MCP server, the second continuing the first's thread after a model change (compact, then switch).CONNECT_TEST_DISRUPTIVE=1 adds a reconnect test. It restarts the daemon, which disconnects every attached Codex session, so it is off by default.
codex app-server daemon restart, enable-remote-control and disable-remote-control each tear down the app-server and drop every attached client — including a Codex TUI you have open, mid turn. That is why /connect:doctor reports a version mismatch but never fixes it for you: you pick the moment.
Connect itself survives a restart: the client reconnects with backoff, re-runs initialize, and continues on the same thread.
403 Multi-factor authentication required otherwise). Connect only reads Remote Control status for /connect:doctor.~/.local/bin and brew earlier in PATH). Connect resolves the binary in one place so the CLI and MCP server always agree; override with CONNECT_CODEX_BIN.session.hudLine() produces one, but it is not wired into Claude Code's statusLine, since that slot usually already holds the user's own script..claude-plugin/ plugin + marketplace manifests
.mcp.json MCP server registration
mcp/ the MCP server
skills/connect/ when Claude should reach for Codex
commands/ slash commands
hooks/hooks.json intercept /connect:usage before model processing; load hud.mjs
hooks/hud.mjs the status line (a function-hooks module, no Node)
hooks/hud.test.ts its tests, for claude plugin test
scripts/usage-hook.mjs display usage directly from a command hook
bin/connect.mjs standalone CLI
src/
ws-unix.mjs minimal RFC 6455 client over a Unix socket
codex-client.mjs JSON-RPC over that socket, with reconnect
router.mjs per-intent effort / sandbox / ceiling policy
config.mjs user preferences layered over it
lease.mjs fencing-token thread leases
session.mjs ties them together, plus HUD state
usage.mjs account quota reads and formatting (no inference)
test/usage.mjs offline usage and interception checks
test/verify.mjs end-to-end suite
spike/ protocol experiments and FINDINGS.md
docs/ the product website (GitHub Pages)
hooks/hud.mjs 88 lines1/**
2 * Connect's HUD, a function-hooks module Claude Code loads with the plugin.
3 *
4 * Two jobs. While a Connect call runs, a line pinned under the prompt names
5 * the peer at work and how long it has run. And it keeps the quota line that
6 * `/connect:usage` switches on fresh: `bin/connect.mjs usage refresh` rewrites
7 * ~/.config/connect/usage-line.txt, which the user's status line prints. That
8 * reads quotas only and never starts a model turn; while the line is off it
9 * exits at once. This module has no Node, so the work runs on the host.
10 * CONNECT_HUD=0 turns the module off.
11 */
12
13const PEERS = {
14 mcp__plugin_connect_connect__codex_ask: "codex",
15 mcp__plugin_connect_connect__agy_ask: "agy",
16 mcp__plugin_connect_connect__peers_compare: "codex+agy",
17};
18const REFRESH_MS = 5 * 60_000;
19// Turns end often; the script also skips a line another session wrote this minute.
20const MIN_GAP_MS = 60_000;
21
22let enabled = true;
23let lastRefresh = -Infinity;
24let refreshing = null;
25const running = new Map();
26
27const clock = (ms) => `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, "0")}`;
28
29async function draw($) {
30 const now = await $.clock.now();
31 const busy = [...running.values()].map((r) => `${r.who} ▸ ${r.intent} ${clock(now - r.startedAt)}`);
32 $.ui.status(busy.length ? busy.join(" · ") : undefined);
33}
34
35async function refresh($, { force = false } = {}) {
36 const now = await $.clock.now();
37 if (refreshing || (!force && now - lastRefresh < MIN_GAP_MS)) return refreshing;
38 lastRefresh = now;
39 refreshing = $.process.run(["node", `${$.plugin.root}/bin/connect.mjs`, "usage", "refresh"], { timeoutMs: 30_000 })
40 .catch(() => {})
41 .finally(() => { refreshing = null; });
42 return refreshing;
43}
44
45/** @type {import('claude-code').Register} */
46export const register = (on) => {
47 on("session.start", async ($, e, next) => {
48 const result = await next(e);
49 enabled = (await $.env.get("CONNECT_HUD")) !== "0" && e.isInteractive;
50 if (enabled) {
51 $.clock.after(0, () => { refresh($, { force: true }); });
52 $.clock.every(REFRESH_MS, () => { refresh($, { force: true }); });
53 }
54 return result;
55 });
56
57 on("tool.call", { tool: [
58 "mcp__plugin_connect_connect__codex_ask",
59 "mcp__plugin_connect_connect__agy_ask",
60 "mcp__plugin_connect_connect__peers_compare",
61 ] }, async ($, e, next) => {
62 if (!enabled) return next(e);
63 running.set(e.tool_use_id, {
64 who: PEERS[e.tool],
65 intent: typeof e.intent === "string" ? e.intent : "ask",
66 startedAt: await $.clock.now(),
67 });
68 await draw($);
69 const tick = $.clock.every(1000, () => { draw($); });
70 try {
71 return await next(e);
72 } finally {
73 tick.cancel();
74 running.delete(e.tool_use_id);
75 await draw($);
76 // Work that outlives this dispatch rides a timer, as the hooks API asks.
77 $.clock.after(0, () => { refresh($); });
78 }
79 }).catch(($, e, next) => next(e));
80
81 on("turn.complete", async ($, e, next) => {
82 const result = await next(e);
83 // A subagent's turns end here too; the main loop's are the ones worth a check.
84 if (enabled && !e.agentId) $.clock.after(0, () => { refresh($); });
85 return result;
86 });
87};
88