SLOPSHOPPER

connect

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

guardstatusprocesstimer
★ 1v0.8.0MITupdated 2026-10-19Gary0302/connect
A shopper browsing a rack in a slop shop
README

Connect

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

Why

  • A second, independent model. When a design is contested, a concurrency bug is subtle, or a diff is security-sensitive, a different model's disagreement is the useful signal.
  • Memory that survives. Codex threads live on the local daemon, not in Claude's context. They survive Claude Code restarts, daemon restarts and reboots, and you can resume any of them.
  • It does not burn tokens by accident. Codex's thread default effort can be 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.

Requirements

  • Claude Code with plugin support.
  • macOS or Linux — Connect talks to the daemon over a Unix-domain socket.
  • Node.js 18+. No packages are installed.
  • The standalone Codex install. The app-server daemon needs it; a brew or npm codex is not enough.
  • A signed-in Codex / ChatGPT account.

Install

# 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.

Troubleshooting

symptomwhat to check
daemon will not startthe 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 mismatchthe 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 somethingConnect declines every approval and input request from Codex automatically; only what the workspace-write sandbox already allows can happen

Uninstall

claude plugin uninstall connect@connect-local
claude plugin marketplace remove connect-local   # optional
rm -rf ~/.config/connect                         # optional: your preferences

Developing on the plugin

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

Usage

Slash commands

commandwhat it does
/connect:ask <question>ask Codex in a new persistent thread
/connect:threadslist 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:doctorhealth 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

Check usage without a model

/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.

Status line

/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.

Letting Claude decide

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:

toolpurpose
codex_askone routed turn: prompt, intent, optional threadId and cwd
codex_threadslist recent threads on the daemon
codex_historya recent, truncated transcript of a thread (default 20 turns, 400 chars per item)
codex_configread or change preferences
codex_doctordiagnose the daemon and versions
agy_askone Antigravity CLI turn, read-only unless implement: prompt, intent, optional conversationId, model, cwd
agy_threadslist the agy conversations Connect started
agy_doctorwhich 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.

Intents and routing

Every codex_ask carries an intent. It pins three things: reasoning effort, the sandbox, and a per-turn token ceiling.

intenteffortsandboxtoken ceilingfor
quick_answerlowread-only20kfacts, versions, one-liners
second_opinionmediumread-only80k"is this approach sound?" (the default)
code_reviewhighread-only200kreading a diff or file for defects
deep_reasoningxhighread-only400karchitecture, protocols, races
implementhighworkspace-write300kasking Codex to write code
  • If no intent is given, a cheap keyword heuristic guesses one (src/router.mjs). Claude is instructed to always pass it explicitly.
  • The sandbox is pinned on purpose. 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.
  • The ceiling is measured per turn, not against the thread's running total. It is enforced by Connect, not the daemon: once reported usage passes it, Connect sends 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.
  • Codex never waits on a human. Every approval or input request Codex raises is declined automatically, so a turn cannot hang on a prompt nobody will see.

Threads and resuming

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.

Antigravity CLI (agy) as a second peer

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.

  • Read-only except 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.
  • A hard link inside the workspace writes through to its target outside (Codex's sandbox has the same gap). Connect does not guard this.
  • agy denies every write when started from a path containing a symlink (/tmp on macOS), so Connect resolves the working directory first. --dangerously-skip-permissions is never used: --sandbox does not keep it in the workspace.
  • Its own ceilings. agy reads several files per question: one "which function starts the daemon?" cost 36k–130k tokens across runs. Ceilings are 150k / 250k / 400k / 800k / 600k for quick / second opinion / review / deep / implement. Effort follows your Connect config where agy supports the level.
  • One recovery. If agy calls a denied command and stops with no answer, Connect asks once more in the same conversation and says so in the result.
  • Conversations. agy has no command that lists them, so Connect keeps a ledger of the ones it started at ~/.config/connect/agy-conversations.jsonl; agy_threads reads it.
  • The turn time cap (CONNECT_TURN_TIMEOUT_MS, default 60 min) applies to both peers.

Comparing 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.

Configuration

/connect:config with no arguments opens forms in the terminal, drawn by the MCP server itself through MCP elicitation:

  1. Model (from the daemon's live model/list), effort mode (auto by intent / one level everywhere / per intent) and token ceilings (keep / built-in / 2× / 4×).
  2. If you chose a level: Codex's own effort levels for that model, worded as Codex's /model picker words them.
  3. Only if the model changed: what to do with existing threads — switch directly (default), compact first then switch, or keep old threads on their model. Switching a thread's model discards its prompt cache, so the next turn re-reads the whole history at full price.

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 variabledefaultpurpose
CONNECT_CONFIG~/.config/connect/config.jsonpreferences file
CONNECT_CODEX_BINresolved automaticallywhich codex binary to use
CONNECT_AGY_CREDENTIALSOS keyringAntigravity token profile file for usage checks
CONNECT_HUDunset0 turns off the status line refresh and the running-peer line
CONNECT_TEST_DISRUPTIVEunset1 enables the daemon-restart test

Standalone CLI

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>

How it works

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.
  • Reasoning notifications only arrive when summary is set on turn/start.

Testing

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.

  • Sections 11–13 run four live Codex turns and spend a small number of tokens — two of them full codex_ask calls through the MCP server, the second continuing the first's thread after a model change (compact, then switch).
  • Sections 1, 2 and 4 need the running daemon but spend no model tokens.
  • Everything else runs offline.

CONNECT_TEST_DISRUPTIVE=1 adds a reconnect test. It restarts the daemon, which disconnects every attached Codex session, so it is off by default.

⚠️ Do not restart the daemon casually

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.

Known limitations

  • A daemon restart mid-turn loses that turn's answer. The thread survives, but the in-flight turn fails fast with a reset error rather than being replayed.
  • Codex Remote Control requires MFA on the ChatGPT account (403 Multi-factor authentication required otherwise). Connect only reads Remote Control status for /connect:doctor.
  • Leases are local to Connect. Remote Control and TUI clients are not fed into the lease registry; the contention test uses a simulated second writer.
  • Two Codex installs can coexist (standalone in ~/.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.
  • No status-line HUD yet. 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.

Project layout

.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)

License

MIT

Source 1 files
hooks/hud.mjs 88 lines
1/**
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