diff --git a/README.md b/README.md index 4ac0ca44..0e9c3f3c 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,18 @@ npx agent-comms # auto-detect and configure The CLI detects which harnesses are installed (pi, Claude Code, Codex, OpenCode) and writes the appropriate config files automatically. +### cc-peer (cross-machine Claude Code relay) + +[`cc-peer`](https://github.com/ExaDev/cc-peer) speaks Claude Code's own local cross-session peer protocol directly — a per-session Unix socket, no cross-machine leg of its own. The `cc-peer` bridge relays one local Claude Code session into this mesh, so it becomes visible and messageable from any other agent-comms bridge, including one on a different machine, riding on the mesh's own transport: + +```bash +npx agent-comms bridge cc-peer +# or address the local session by pid instead of its registered name: +npx agent-comms bridge cc-peer --pid=12345 +``` + +One bridge process relays for exactly one local Claude Code session, the same "one bridge process is one agent is one device" model every other bridge here follows. Inbound messages from that session are posted into this bridge's own project room; mesh deliveries addressed to this bridge's agent are relayed back to that same session via `cc-peer`'s own `send()`. + ## Adding a new harness A bridge is two things: diff --git a/package.json b/package.json index 24b85662..f51d315c 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "cbor2": "2.3.0", + "cc-peer": "1.3.4", "preact": "10.29.7", "typebox": "1.3.6", "wire-mesh-core": "1.30.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad5d7bba..0aab82f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: cbor2: specifier: 2.3.0 version: 2.3.0 + cc-peer: + specifier: 1.3.4 + version: 1.3.4 preact: specifier: 10.29.7 version: 10.29.7 @@ -1990,6 +1993,11 @@ packages: resolution: {integrity: sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==} engines: {node: '>=20'} + cc-peer@1.3.4: + resolution: {integrity: sha512-0+XHghOvUBYqwrXgA7y041ebKvk2Ml7Gucr6SfyMHnGGytMojEGOHrpWHVxuWz2yuUCnvXXzIccFawr8S395Bg==} + engines: {node: '>=20'} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6434,6 +6442,10 @@ snapshots: dependencies: '@cto.af/wtf8': 0.0.5 + cc-peer@1.3.4: + dependencies: + zod: 4.5.4 + ccount@2.0.1: {} cddl.js@1.0.1: diff --git a/src/bridges/cc-peer/bridge.ts b/src/bridges/cc-peer/bridge.ts new file mode 100644 index 00000000..c9dda201 --- /dev/null +++ b/src/bridges/cc-peer/bridge.ts @@ -0,0 +1,77 @@ +/** + * cc-peer bridge — relays between one local Claude Code peer (reached via cc-peer's own native cross-session protocol) and this side's agent-comms mesh, giving that local session cross-machine reach through the mesh's own transport (agent-comms#87). + * + * One bridge process relays for exactly one target local Claude Code peer, matching the "one bridge process is one agent is one device" model every other bridge in this codebase already follows -- not a many-to-one fan-out. Inbound cc-peer messages from that target are posted into this agent's own project room (the same auto-created room ensureRegistered/ensureProjectRoom already gives every bridge); mesh deliveries for this agent are relayed back to that same target via CcPeer.send(). + * + * The wiring itself (wireCcPeerBridge) is dependency-injected and deliberately separate from the real CcPeer/MeshStore construction in run.ts, so it can be tested against a fake CcPeer without a real local Claude Code session. + */ + +import type { CommsTool } from "../../core/tool.js"; +import { buildAction } from "../../core/bridge.js"; +import { formatDeliveryEvent } from "../../core/bridge.js"; +import type { DeliveryEvent } from "../../core/types.js"; + +/** How cc-peer addresses a target peer -- mirrors cc-peer's own PeerRef type without importing it, so this file has no direct dependency on the cc-peer package (only run.ts, which does the real construction, needs that). */ +export type CcPeerRef = + { pid: number } | { name: string } | { address: string }; + +/** The one inbound-message shape this bridge reads from CcPeer's own "message" event -- narrowed to the fields it actually uses. */ +export interface CcPeerInboundMessage { + from?: string; + fromName?: string; + body: string; +} + +/** The narrow slice of CcPeer's own real API this bridge needs -- satisfied by the real class in run.ts, and by a fake in tests. */ +export interface CcPeerLike { + on: ( + event: "message", + listener: (m: Readonly) => void, + ) => void; + send: ( + target: Readonly, + body: string, + ) => Promise<{ msgId: string }>; +} + +/** The narrow slice of MeshStore this bridge needs -- onDelivery is MeshStore-only (not part of the generic CommsStore interface every bridge otherwise depends on), since only a mesh-backed store can push. */ +export interface CcPeerBridgeStore { + onDelivery: + | ((agentId: string, event: DeliveryEvent) => void | Promise) + | undefined; +} + +export interface CcPeerBridgeDeps { + store: CcPeerBridgeStore; + tool: Pick; + peer: CcPeerLike; + agentId: string; + roomId: string; + target: Readonly; + cwd: string; +} + +/** Wires the two directions of the relay. Never awaited by the caller -- both directions are genuinely fire-and-forget from this function's own point of view (a send failure surfaces through cc-peer's own receipt events / agent-comms' own delivery-status events, not a thrown error here). */ +export function wireCcPeerBridge(deps: Readonly): void { + deps.peer.on("message", (m) => { + const sender = m.fromName ?? m.from ?? "unknown"; + const action = buildAction({ + action: "send", + room: deps.roomId, + content: `${sender}: ${m.body}`, + }); + void deps.tool.handle( + { + agentId: deps.agentId, + harness: "cc-peer", + cwd: deps.cwd, + pid: process.pid, + }, + action, + ); + }); + + deps.store.onDelivery = (_targetId, event) => { + void deps.peer.send(deps.target, formatDeliveryEvent(event)); + }; +} diff --git a/src/bridges/cc-peer/run.ts b/src/bridges/cc-peer/run.ts new file mode 100644 index 00000000..737aa463 --- /dev/null +++ b/src/bridges/cc-peer/run.ts @@ -0,0 +1,84 @@ +/** + * cc-peer bridge — CLI entry point. + * + * Run via: npx agent-comms bridge cc-peer npx agent-comms bridge cc-peer --pid= + * + * Constructs the real CcPeer and MeshStore, then hands both to wireCcPeerBridge for the actual relay wiring (see bridge.ts). + */ + +import { CcPeer } from "cc-peer"; +import { + createBridgeMesh, + ensureRegistered, + ensureProjectRoom, +} from "../../core/index.js"; +import type { IdentitySlot } from "../../core/identity-store.js"; +import { releaseIdentityLock } from "../../core/identity-store.js"; +import { wireCcPeerBridge, type CcPeerRef } from "./bridge.js"; + +const PID_FLAG_PREFIX = "--pid="; +/** argv layout for `node cli.js bridge cc-peer `: index 0/1 are the node binary and script path, 2 is "bridge", 3 is the bridge id ("cc-peer") itself -- this bridge's own args start one past that. */ +const BRIDGE_ARGS_START_INDEX = 4; + +function parseTarget(argv: readonly string[]): CcPeerRef { + const arg = argv[0]; + if (arg === undefined || arg === "") { + console.error( + "Usage: agent-comms bridge cc-peer | --pid=", + ); + process.exit(1); + } + if (arg.startsWith(PID_FLAG_PREFIX)) { + const pid = Number(arg.slice(PID_FLAG_PREFIX.length)); + if (!Number.isInteger(pid) || pid <= 0) { + console.error(`Invalid --pid value: ${arg}`); + process.exit(1); + } + return { pid }; + } + return { name: arg }; +} + +export async function run(): Promise { + const target = parseTarget(process.argv.slice(BRIDGE_ARGS_START_INDEX)); + + const identitySlot: IdentitySlot = { harness: "cc-peer", cwd: process.cwd() }; + const { store, tool } = await createBridgeMesh(identitySlot); + + const reg = await ensureRegistered({ + store, + cwd: process.cwd(), + harness: "cc-peer", + defaultName: "cc-peer-bridge", + }); + const roomId = await ensureProjectRoom(store, reg.agentId, process.cwd()); + + const peer = await CcPeer.create({ name: "agent-comms-bridge" }); + + wireCcPeerBridge({ + store, + tool, + peer, + agentId: reg.agentId, + roomId, + target, + cwd: process.cwd(), + }); + + await store.init(); + + process.on("SIGINT", () => { + void shutdown(); + }); + process.on("SIGTERM", () => { + void shutdown(); + }); + + async function shutdown(): Promise { + await peer.stop(); + await store.setAgentOffline(reg.agentId); + releaseIdentityLock(identitySlot); + await store.shutdown(); + process.exit(0); + } +} diff --git a/src/bridges/registry.ts b/src/bridges/registry.ts index 141141b5..8072a30e 100644 --- a/src/bridges/registry.ts +++ b/src/bridges/registry.ts @@ -1,6 +1,7 @@ import * as claudeCode from "./claude-code/channel.js"; import * as codex from "./codex/tool.js"; import * as mcp from "./mcp/server.js"; +import * as ccPeer from "./cc-peer/run.js"; export interface Bridge { run: () => void | Promise; @@ -10,4 +11,5 @@ export const bridges: Record = { "claude-code": claudeCode, codex, mcp, + "cc-peer": ccPeer, }; diff --git a/src/test/cc-peer-bridge.test.ts b/src/test/cc-peer-bridge.test.ts new file mode 100644 index 00000000..047c9fc7 --- /dev/null +++ b/src/test/cc-peer-bridge.test.ts @@ -0,0 +1,174 @@ +/** + * Direct, DI-based unit tests for wireCcPeerBridge -- the pure relay wiring cc-peer's own bridge.ts exposes, tested against a fake CcPeer/tool/store rather than a real local Claude Code session (no such session exists in a test environment; run.ts's own real CcPeer.create() construction is exercised only by actually running the bridge). + */ +import { describe, expect, it, vi } from "vitest"; +import { wireCcPeerBridge } from "../bridges/cc-peer/bridge.js"; +import type { + CcPeerBridgeStore, + CcPeerInboundMessage, + CcPeerLike, +} from "../bridges/cc-peer/bridge.js"; +import type { CommsTool } from "../core/tool.js"; +import type { DeliveryEvent, RoomMessage } from "../core/types.js"; + +function fakePeer(): CcPeerLike & { + messageListener: ((m: Readonly) => void) | undefined; + sendCalls: { target: unknown; body: string }[]; +} { + const peer = { + messageListener: undefined as + ((m: Readonly) => void) | undefined, + sendCalls: [] as { target: unknown; body: string }[], + on: ( + event: "message", + listener: (m: Readonly) => void, + ): void => { + if (event === "message") peer.messageListener = listener; + }, + send: vi.fn(async (target: unknown, body: string) => { + peer.sendCalls.push({ target, body }); + return Promise.resolve({ msgId: "msg-1" }); + }), + }; + return peer; +} + +function fakeTool(): Pick & { + handleCalls: unknown[]; +} { + const handleCalls: unknown[] = []; + return { + handleCalls, + handle: vi.fn(async (ctx: unknown, action: unknown) => { + handleCalls.push({ ctx, action }); + return Promise.resolve({ content: "ok", isError: false }); + }), + }; +} + +function roomMessage(overrides: Partial = {}): RoomMessage { + return { + id: "msg-1", + from: "peer-a", + room: "owner/project", + content: "hi from the mesh", + timestamp: "2026-01-01T00:00:00.000Z", + readBy: [], + ...overrides, + }; +} + +describe("wireCcPeerBridge — inbound (cc-peer -> mesh)", () => { + it("posts an inbound cc-peer message into this agent's own project room, prefixed with the sender's name", () => { + const peer = fakePeer(); + const tool = fakeTool(); + const store: CcPeerBridgeStore = { onDelivery: undefined }; + + wireCcPeerBridge({ + store, + tool, + peer, + agentId: "agent-1", + roomId: "owner/project", + target: { name: "local-session" }, + cwd: "/tmp/project", + }); + + peer.messageListener?.({ + from: "local-session", + fromName: "my-local-session", + body: "hello from cc-peer", + }); + + expect(tool.handleCalls).toHaveLength(1); + expect(tool.handleCalls[0]).toEqual({ + ctx: { + agentId: "agent-1", + harness: "cc-peer", + cwd: "/tmp/project", + pid: process.pid, + }, + action: { + action: "send", + target: "owner/project", + content: "my-local-session: hello from cc-peer", + }, + }); + }); + + it("falls back to the raw from id when the local peer has no registered display name", () => { + const peer = fakePeer(); + const tool = fakeTool(); + const store: CcPeerBridgeStore = { onDelivery: undefined }; + + wireCcPeerBridge({ + store, + tool, + peer, + agentId: "agent-1", + roomId: "owner/project", + target: { name: "local-session" }, + cwd: "/tmp/project", + }); + + peer.messageListener?.({ from: "local-session", body: "hi" }); + + const call = tool.handleCalls[0] as { action: { content: string } }; + expect(call.action.content).toBe("local-session: hi"); + }); +}); + +describe("wireCcPeerBridge — outbound (mesh -> cc-peer)", () => { + it("relays a mesh delivery to the configured cc-peer target", () => { + const peer = fakePeer(); + const tool = fakeTool(); + const store: CcPeerBridgeStore = { onDelivery: undefined }; + + wireCcPeerBridge({ + store, + tool, + peer, + agentId: "agent-1", + roomId: "owner/project", + target: { name: "local-session" }, + cwd: "/tmp/project", + }); + + expect(store.onDelivery).toBeDefined(); + const event: DeliveryEvent = { + type: "room_message", + message: roomMessage(), + }; + void store.onDelivery?.("agent-1", event); + + expect(peer.sendCalls).toEqual([ + { + target: { name: "local-session" }, + body: "[owner/project] peer-a: hi from the mesh", + }, + ]); + }); + + it("relays to a pid-addressed target exactly as configured", () => { + const peer = fakePeer(); + const tool = fakeTool(); + const store: CcPeerBridgeStore = { onDelivery: undefined }; + + wireCcPeerBridge({ + store, + tool, + peer, + agentId: "agent-1", + roomId: "owner/project", + target: { pid: 4242 }, + cwd: "/tmp/project", + }); + + void store.onDelivery?.("agent-1", { + type: "room_message", + message: roomMessage(), + }); + + expect(peer.sendCalls[0]?.target).toEqual({ pid: 4242 }); + }); +});