@modelprofile.com/crossharness-mcp
MCP server for cross-harness chat interop: list, read, and send messages into Claude Code, OpenCode, and Codex sessions scoped to a project directory. Register it in any MCP-capable coding agent and that agent can see and talk to the chats of all other harnesses on the same machine.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
What It Does
Every AI coding harness keeps its own chat history in its own format:
| Harness | Session store | Headless resume |
|---|---|---|
| Claude Code | ~/.claude/projects/<slug>/<uuid>.jsonl |
claude -p --resume <id> |
| OpenCode | ~/.local/share/opencode/opencode.db (SQLite) |
opencode run --session <id> |
| Codex | ~/.codex/sessions/**/rollout-*.jsonl |
codex exec resume <id> |
crossharness-mcp reads all three stores directly (read-only) and exposes eight MCP tools over stdio:
harness_status— per-harness availability plus real live-server health: handshake result, holder pid, uptime, and stale/wedged endpointslist_chats— list chats of all (or one) harness for a project directory, newest firstread_chat— read a chat transcript by id, with per-message truncation and context-noise collapsingsend_message— send a message into an existing chat and wait for the agent's replysend_message_async— dispatch a message and return immediately with adispatchId; delivery continues in the backgroundcheck_reply— poll (or briefly wait for) an async dispatch; returnsworking/stalled/completed/failedplus the reply and transportensure_live_server— make sure a healthy shared server exists (starts one only if none answers; idempotent)restart_live_server— stop the process holding the control socket and start a fresh server (drops in-memory sessions, never touches transcripts)
Sending a message reaches the real agent with its full session context, and the exchange is persisted in that harness's own history. Each send runs under the target harness's own permission/sandbox configuration and consumes its usage.
Server Attach
send_message uses a shared live server whenever one can be had, and treats spawning a per-chat child session as a last resort:
- Codex: liveness is verified with a real WebSocket handshake against the app-server control socket (
$CODEX_HOME/app-server-control/app-server-control.sock); the adapter then drives the running server directly (initialize→thread/resume→turn/start, streaming the reply). If nothing answers, it starts a shared app-server (codex -c features.code_mode_host=true app-server --listen unix://) and uses that. Only if starting one fails does it fall back tocodex exec resume, which is reported as a last resort in the result. - OpenCode: when a running server is found — via the
serverUrloption, theOPENCODE_SERVER_URLenvironment variable, or a probe ofhttp://127.0.0.1:4096(validated through/global/health) — the adapter usesopencode run --attach <url>, so the send goes through the live server and its event bus. Fallback: plainopencode runspawn. - Claude Code has no daemon; sends always use
claude -p --resume.
The ISendResult.via field reports which path was used ('app-server', 'attach:<url>', 'cli', 'spawn') and ISendResult.notes explains transport decisions such as "a server was started" or "a child session was created". Passing serverUrl: false to OpencodeAdapter disables attaching entirely; autoStartServer: false on CodexAdapter disables starting one. For Codex, once a turn has been dispatched to the app-server, failures are surfaced as errors instead of retried via CLI — this prevents the same message from being delivered twice.
The presence of a socket file is never treated as health. A dead app-server leaves its socket behind; dispatching into that file is accepted and then silently swallowed. Every live-vs-fallback decision therefore runs a connect probe plus a protocol handshake, and harness_status distinguishes healthy, STALE endpoint (file present, nothing listening) and WEDGED listener (something listens but will not complete the handshake).
Server Lifecycle
ensure_live_server and restart_live_server make the shared Codex app-server manageable instead of guessed at:
ensure_live_serveris idempotent: a healthy server is left running; otherwise one is started and awaited (boundedtimeoutSeconds, default 30). A leftover socket file is removed only afterconnectproves nothing listens on it, and an abandonedapp-server-startup.lockis cleared only when it is older than 60 seconds. If a live process holds the socket without answering the handshake, the call is reported asblocked— no socket is deleted and no competing server is started. Startups are serialized in-process and across processes (crossharness-app-server-start.lock), so concurrent tool calls cannot race into two servers. Failures quote the tail ofapp-server-control/app-server.log, where a failed bind (Error: app-server control socket is already in use) would otherwise go unnoticed.restart_live_serverSIGTERMs the specific pid holding the socket (plus its codex launcher parent), escalates to SIGKILL after a grace period, then starts a fresh server. In-memory server state is dropped — loaded threads are unloaded and in-flight turns are lost — while on-disk transcripts under~/.codex/sessionsare never deleted or modified, so every chat id stays resumable and the next send re-resumes the thread in the new server.
Wedged Session Detection
A session can accept a dispatch and then simply stop progressing. send_message_async records the target session's transcript state (rollout mtime for Codex, session jsonl for Claude Code, session.time_updated for OpenCode) before handing the message over, and warns immediately when that transcript was already implausibly idle (> 30 minutes). check_reply compares against that baseline: an in-flight dispatch whose transcript has seen no write for more than 2 minutes is reported as stalled — with the transcript path and last-write age — instead of working forever. Nothing is retried and no reply is ever fabricated; the tool points at read_chat, harness_status and restart_live_server instead.
Install
# global CLI (recommended for MCP registration)
pnpm add -g @modelprofile.com/crossharness-mcp
# or as a library
pnpm install @modelprofile.com/crossharness-mcp
Requires Node.js >= 24 (uses the built-in node:sqlite module).
Register in Your Harness
The server speaks MCP over stdio via the crossharness-mcp binary, so any MCP client can consume it.
Claude Code
claude mcp add --scope user crossharness -- crossharness-mcp
OpenCode
In ~/.config/opencode/opencode.json (or opencode.jsonc):
{
"mcp": {
"crossharness": {
"type": "local",
"command": ["crossharness-mcp"],
"enabled": true,
"environment": {
"OPENCODE_SERVER_URL": "http://127.0.0.1:4096"
}
}
}
}
OPENCODE_SERVER_URL is optional: set it to your long-running opencode serve/web URL so sends attach to that server instead of spawning one (see Server Attach above).
Codex
In ~/.codex/config.toml:
[mcp_servers.crossharness]
command = "crossharness-mcp"
Tool Usage
Once registered, the agent can use the tools naturally. Typical calls:
harness_status {}
list_chats { directory?: "/path/to/project", harness?: "claudecode" | "opencode" | "codex", limit?: 10 }
read_chat { harness: "codex", chatId: "019f...", limit?: 20, maxChars?: 2000 }
send_message { harness: "opencode", chatId: "ses_...", message: "...", timeoutSeconds?: 300 }
send_message_async { harness: "codex", chatId: "019f...", message: "...", timeoutSeconds?: 900 }
check_reply { dispatchId: "d-ab12c-1", waitSeconds?: 0 }
ensure_live_server { harness?: "codex", timeoutSeconds?: 30 }
restart_live_server { harness?: "codex", timeoutSeconds?: 30 }
send_message_async returns a dispatchId immediately while delivery continues inside the long-lived MCP server process; poll with check_reply (instant, or bounded wait via waitSeconds) or just read_chat the target session — the exchange is persisted there either way. On the Codex app-server path the turn runs inside the live server itself. Note that dispatches live in server memory: they do not survive an MCP server restart, and spawned-CLI deliveries (Claude, headless OpenCode/Codex fallback) need the MCP server process to stay alive until the turn finishes — when in doubt, read_chat is the source of truth.
harness_status reports, per harness, whether it is available and the genuine health of its live server — handshake result, holder pid, uptime, and whether the endpoint is stale or wedged — plus the server log tail when nothing answers. Every send_message reply ends with a [crossharness transport: ...] footer stating which path was actually used, followed by [note] lines when a server had to be started or a child session was created.
list_chats sorts by last activity (newest write to the session, not session start time) and includes an activity column: active means the session was written to within the last 2 minutes, loaded-in-live-server means the running Codex app-server currently has the thread loaded (queried via thread/loaded/list), and idle means neither; both markers combine as active+loaded-in-live-server.
Example prompts to an agent with this server registered:
- "What did the Codex chat in this directory conclude about the disk cleanup?"
- "Ask the OpenCode session that set up the tmux config why it chose that layout."
- "List all AI chats that touched this repo this week, across harnesses."
Library API
All building blocks are exported for programmatic use:
import {
CrossHarness,
ClaudeCodeAdapter,
OpencodeAdapter,
CodexAdapter,
CrossHarnessMcpServer,
} from '@modelprofile.com/crossharness-mcp';
// aggregate view over all harnesses
const crossHarness = new CrossHarness();
const chats = await crossHarness.listChats('/path/to/project', 10);
const transcript = await crossHarness.readChat('codex', chats[0].chatId, 20, 2000);
const result = await crossHarness.sendMessage('codex', chats[0].chatId, 'Summarize this session.', 300);
// adapters are individually configurable (e.g. for tests or custom homes)
const codexAdapter = new CodexAdapter({ codexHome: '/custom/.codex', binPath: 'codex' });
// live server lifecycle (codex)
const health = await codexAdapter.getHealth(); // handshake, pid, uptime, staleness
const ensured = await codexAdapter.ensureLiveServer(); // idempotent start
await codexAdapter.restartLiveServer(); // targeted stop + fresh start
const progress = await codexAdapter.getSessionProgress(chats[0].chatId); // transcript last write
// run the MCP stdio server yourself
const mcpServer = new CrossHarnessMcpServer();
await mcpServer.start();
Every adapter implements the IHarnessAdapter interface (listChats, readChat, sendMessage, isAvailable, getLiveServer, getHealth, and optionally getSessionProgress / ensureLiveServer / restartLiveServer), so additional harnesses can be added by implementing the same interface and passing adapters into new CrossHarness([...]).
Behavior Notes
- All session-store access is read-only; writing happens exclusively through each harness's own CLI or its running server. Server lifecycle operations touch only
$CODEX_HOME/app-server-control/— never anything under$CODEX_HOME/sessions/. - Codex subagent threads (guardian/judge sessions) are filtered out of listings.
- Injected context blocks (
AGENTS.mdinstructions, environment context) are collapsed in Codex transcripts so the actual conversation stays readable. - OpenCode child sessions (subagents) are excluded; only top-level chats are listed.
send_messageblocks until the target agent finishes its turn — allow generous timeouts for complex questions.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.