Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ 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()`.

### Default cc-peer front

A Claude Code session with no agent-comms bridge of its own is still reachable from the mesh: whichever bridge on the machine currently holds the mesh coordinator role fronts every local session it discovers via `cc-peer`'s own roster, using the same `(harness, cwd)` identity slot that session's own `claude-code` bridge would use if it started. Identity belongs to the slot, not to whichever process is currently serving it — the session's own bridge holds the slot when it's live; the front holds it otherwise, and yields the moment a real bridge for that slot appears, so addressing, room membership, and queued deliveries all carry over unchanged across the transition. No configuration is needed: every bridge in this repo wires the front to its own coordinator-role transitions automatically, and a machine with no local Claude Code sessions (or no `cc-peer` sockets at all) runs it as a clean no-op.

## Adding a new harness

A bridge is two things:
Expand Down
49 changes: 49 additions & 0 deletions src/bridges/cc-peer/default-front.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Wires the default cc-peer front (agent-comms#157) onto a real bridge's own store, right after construction -- the piece that actually makes "a machine's coordinator bridge fronts local Claude Code sessions by default" true, rather than a capability every bridge builds but nothing ever switches on. Every real bridge entry point (pi, claude-code, mcp, codex, opencode, user, cc-peer's own one-shot command) calls this once, immediately after createBridgeMesh/createBridgeMeshSync.
*
* Deliberately the only file outside bridges/cc-peer/ that ever needs to know cc-peer exists: core/mesh-store.ts's onCoordinatorRoleChanged hook is generic (a bare boolean callback), and core/bridge-mesh.ts's factories return a plain MeshStore with no cc-peer awareness at all -- keeping this Node/filesystem/cc-peer-specific capability out of the transport-agnostic core, per this repo's own portable-runtime-boundary convention. A bridge that never becomes this machine's coordinator never starts a front at all; one that does gets it started and stopped automatically as that role comes and goes.
*/

import type { MeshStore } from "../../core/mesh-store.js";
import {
createDefaultCcPeerFront,
type CreateDefaultCcPeerFrontOptions,
} from "./front-runtime.js";
import type { CcPeerFront } from "./front-controller.js";
import type { FrontedSessionRecord } from "./front-controller.js";

export interface WireDefaultCcPeerFrontOptions {
coordinatorPort?: number | undefined;
hubUrl?: string | undefined;
/** Builds the front itself -- defaults to the real createDefaultCcPeerFront. Overridable so this function's own start/stop wiring against store.onCoordinatorRoleChanged is testable without a real CcPeer/local Claude Code session. */
createFront?:
| ((
options: Readonly<CreateDefaultCcPeerFrontOptions>,
) => Pick<CcPeerFront<FrontedSessionRecord>, "start" | "stop">)
| undefined;
}

/**
* Builds the store.onCoordinatorRoleChanged callback for a real bridge's own store. Returns the callback rather than assigning it directly, so the caller does the actual store.onCoordinatorRoleChanged = ... assignment itself -- this keeps the store parameter here read-only, matching every other pure-construction function in this file's neighbourhood, instead of this function reaching into the store to mutate it.
*/
export function wireDefaultCcPeerFront(
store: Readonly<Pick<MeshStore, "onError">>,
options: Readonly<WireDefaultCcPeerFrontOptions> = {},
): (isCoordinator: boolean) => Promise<void> {
const createFront = options.createFront ?? createDefaultCcPeerFront;
const front = createFront({
coordinatorPort: options.coordinatorPort,
hubUrl: options.hubUrl,
onError: (error) => {
store.onError?.(error);
},
});

return async (isCoordinator: boolean) => {
if (isCoordinator) {
front.start();
} else {
await front.stop();
}
};
}
118 changes: 118 additions & 0 deletions src/bridges/cc-peer/front-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* CcPeerFront — coordinator-only periodic controller for the default cc-peer front (agent-comms#157), attaching and detaching fronted sessions on each poll tick. Mirrors StaleAgentChecker's own "coordinator-only periodic probe" shape (owns its own interval timer exclusively, start/stop, no immediate first tick) since both are the same kind of thing: a background job that only ever runs on this machine's current mesh coordinator.
*
* Kept free of any real cc-peer/MeshStore construction, exactly like front.ts's pure decision logic this class is built on -- every real I/O (listRoster, probeSlotOwner, attach, detach) is injected, so the attach/detach/yield diffing across ticks is testable with fake timers and no real local Claude Code session. front-runtime.ts supplies the real dependencies.
*/

import {
selectSessionsToFront,
matchInboundMessageSession,
type CcPeerRosterEntryLike,
} from "./front.js";
import type { IdentitySlot } from "../../core/identity-store.js";

/** How often the front re-enumerates the local cc-peer roster and re-probes every currently-fronted session's own slot. */
const DEFAULT_POLL_INTERVAL_MS = 5000;

/** The minimum shape a fronted-session record must carry so CcPeerFront can track and route to it, regardless of whatever real MeshStore/CommsTool state a concrete implementation (front-runtime.ts) attaches alongside these fields. */
export interface FrontedSessionRecord {
readonly pid: number;
readonly cwd: string;
readonly messagingSocketPath: string;
/** Delivers an inbound cc-peer message addressed to this fronted session into the mesh (the session-to-mesh direction) -- wired by attach() itself, exactly as wireCcPeerBridge's own peer.on("message") handler does for the one-shot bridge command. */
readonly handleInbound: (
message: Readonly<{ from?: string; fromName?: string; body: string }>,
) => void;
}

export interface CcPeerFrontDeps<TRecord extends FrontedSessionRecord> {
/** Enumerates the current local cc-peer roster. Rejects propagate to onError rather than throwing out of the poll timer. */
listRoster: () => Promise<readonly CcPeerRosterEntryLike[]>;
/** Read-only probe of a slot's current lock holder -- identity-store.ts's probeSlotOwner in production. */
probeSlotOwner: (slot: Readonly<IdentitySlot>) => number | undefined;
/** Builds a fronted-session record for a newly-selected roster entry (mints/loads its identity, wires the mesh store and the inbound relay). Rejects propagate to onError; the entry is retried on the next tick. */
attach: (entry: Readonly<CcPeerRosterEntryLike>) => Promise<TRecord>;
/** Tears a fronted-session record down (marks its agent offline, shuts its mesh store down). Rejects propagate to onError. */
detach: (record: TRecord) => Promise<void>;
pollIntervalMs?: number | undefined;
onError?: ((error: Error) => void) | undefined;
}

function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}

export class CcPeerFront<TRecord extends FrontedSessionRecord> {
private readonly fronted = new Map<number, TRecord>();
private timer: ReturnType<typeof setInterval> | undefined;

constructor(private readonly deps: Readonly<CcPeerFrontDeps<TRecord>>) {}

/** Starts the periodic poll (coordinator-only). No-op if already running. Matches StaleAgentChecker's own convention of no immediate first tick -- the first attach happens on the first elapsed interval, not synchronously on start(). */
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
}

/** Stops the periodic poll and detaches every currently-fronted session -- this side is no longer the coordinator, so it has no business still relaying on any session's behalf. */
async stop(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
const records = [...this.fronted.values()];
this.fronted.clear();
for (const record of records) {
await this.detachOne(record);
}
}

/** Routes an inbound cc-peer message to whichever fronted session it came from, if any -- the shared front-wide CcPeer instance's own "message" listener calls this directly (front-runtime.ts), since only this class knows the current fronted set. A message matching no fronted session (already detached, or genuinely foreign) is silently dropped -- there's nowhere for it to go. */
handleInboundMessage(
message: Readonly<{ from?: string; fromName?: string; body: string }>,
): void {
const record = matchInboundMessageSession(this.fronted.values(), message);
record?.handleInbound(message);
}

private async tick(): Promise<void> {
let roster: readonly CcPeerRosterEntryLike[];
try {
roster = await this.deps.listRoster();
} catch (err) {
this.deps.onError?.(toError(err));
return;
}

const selected = selectSessionsToFront(roster, this.deps.probeSlotOwner);
const selectedPids = new Set(selected.map((entry) => entry.pid));
const rosterPids = new Set(roster.map((entry) => entry.pid));

// Detach anything that either exited (gone from the roster entirely) or yielded (its own slot is now held by a live bridge, so it's no longer in the selected set even though the session process itself is still running).
for (const [pid, record] of [...this.fronted]) {
if (rosterPids.has(pid) && selectedPids.has(pid)) continue;
this.fronted.delete(pid);
await this.detachOne(record);
}

for (const entry of selected) {
if (this.fronted.has(entry.pid)) continue;
try {
const record = await this.deps.attach(entry);
this.fronted.set(entry.pid, record);
} catch (err) {
this.deps.onError?.(toError(err));
}
}
}

private async detachOne(record: TRecord): Promise<void> {
try {
await this.deps.detach(record);
} catch (err) {
this.deps.onError?.(toError(err));
}
}
}
91 changes: 91 additions & 0 deletions src/bridges/cc-peer/front-relay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Pure, DI-testable wiring for one fronted session's own cc-peer-to-mesh relay (agent-comms#157) -- the "attach"/"detach" half of the default front, building a FrontedSessionRecord (front-controller.ts) from an already-constructed mesh and the front's shared cc-peer peer. Mirrors bridges/cc-peer/bridge.ts's own wireCcPeerBridge split: kept free of any real CcPeer.create()/createBridgeMeshFromIdentity construction so it's testable against fakes, with front-runtime.ts supplying the real ones.
*
* Reuses CcPeerRef/CcPeerInboundMessage from bridge.ts rather than redeclaring them -- the wire shape a shared front-wide peer speaks is identical to the one-shot bridge command's own peer, just addressed by pid instead of a fixed target.
*/

import { buildAction, formatDeliveryEvent } from "../../core/bridge.js";
import type { CommsTool } from "../../core/tool.js";
import type { DeliveryEvent } from "../../core/types.js";
import type { FrontedSessionRecord } from "./front-controller.js";
import type { CcPeerRosterEntryLike } from "./front.js";
import type { CcPeerInboundMessage, CcPeerRef } from "./bridge.js";

/** The narrow slice of MeshStore a fronted session's own record needs -- onDelivery to wire the mesh-to-session direction, setAgentOffline/shutdown for detachFrontedSession's own teardown. */
export interface FrontRelayStore {
onDelivery:
| ((agentId: string, event: DeliveryEvent) => void | Promise<void>)
| undefined;
setAgentOffline: (id: string) => Promise<void>;
shutdown: () => Promise<void>;
}

/** The narrow slice of the front's shared CcPeer instance this relay needs -- just send(), since the front-wide "message" listener (front-runtime.ts) is what routes an inbound message to this record's own handleInbound in the first place, not this module. */
export interface FrontRelayPeer {
send: (
target: Readonly<CcPeerRef>,
body: string,
) => Promise<{ msgId: string }>;
}

export interface FrontedRelayRecord extends FrontedSessionRecord {
agentId: string;
roomId: string;
store: FrontRelayStore;
}

export interface BuildFrontedSessionRecordDeps {
entry: Readonly<CcPeerRosterEntryLike>;
agentId: string;
roomId: string;
store: FrontRelayStore;
tool: Pick<CommsTool, "handle">;
peer: FrontRelayPeer;
}

/**
* Wires both relay directions for one fronted session and returns the record CcPeerFront tracks it under. Mesh-to-session: store.onDelivery sends the formatted event to this session's own pid via the shared peer. Session-to-mesh: the returned handleInbound (called by the front's shared "message" listener once it's matched this record by socket path) posts the message into this session's own project room, exactly as wireCcPeerBridge's own peer.on("message") handler does for the one-shot bridge command.
*/
export function buildFrontedSessionRecord(
deps: Readonly<BuildFrontedSessionRecordDeps>,
): FrontedRelayRecord {
const { entry, agentId, roomId, store, tool, peer } = deps;

store.onDelivery = (_targetId, event) => {
void peer.send({ pid: entry.pid }, formatDeliveryEvent(event));
};

return {
pid: entry.pid,
cwd: entry.cwd,
messagingSocketPath: entry.messagingSocketPath,
agentId,
roomId,
store,
handleInbound: (message: Readonly<CcPeerInboundMessage>) => {
const sender = message.fromName ?? message.from ?? "unknown";
const action = buildAction({
action: "send",
room: roomId,
content: `${sender}: ${message.body}`,
});
void tool.handle(
{
agentId,
harness: "claude-code",
cwd: entry.cwd,
pid: process.pid,
},
action,
);
},
};
}

/** Tears a fronted session's record down: marks its agent offline (a courtesy to peers watching its presence), then shuts its mesh store down. There is no identity lock to release -- attach() never took one (loadIdentityForFront's whole point), so a real bridge for this slot can already have claimed it by the time this runs. */
export async function detachFrontedSession(
record: Readonly<Pick<FrontedRelayRecord, "agentId" | "store">>,
): Promise<void> {
await record.store.setAgentOffline(record.agentId);
await record.store.shutdown();
}
117 changes: 117 additions & 0 deletions src/bridges/cc-peer/front-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Real cc-peer/MeshStore construction for the default coordinator-run front (agent-comms#157) -- the counterpart to bridges/cc-peer/run.ts's own real CcPeer.create()/createBridgeMesh construction for the one-shot `bridge cc-peer` command. Wires front-controller.ts's CcPeerFront (attach/detach diffing across ticks) to front-relay.ts's buildFrontedSessionRecord/detachFrontedSession (one session's own relay) against one shared, front-wide CcPeer instance and a fresh per-session MeshStore built from loadIdentityForFront's lock-free identity.
*
* Untested directly, exactly like run.ts's own real construction (see cc-peer-bridge.test.ts's header comment): front.ts, front-controller.ts, and front-relay.ts already carry the front's entire decision/diffing/relay logic under direct DI-based unit tests, so this file's only remaining job -- calling the real cc-peer/core APIs in the right order -- is exercised by actually running a bridge as this machine's coordinator.
*/

import { CcPeer } from "cc-peer";
import type { InboundMessage as CcPeerInboundMessage } from "cc-peer";
import {
createBridgeMeshFromIdentity,
ensureRegistered,
ensureProjectRoom,
} from "../../core/index.js";
import {
loadIdentityForFront,
probeSlotOwner,
} from "../../core/identity-store.js";
import { CcPeerFront } from "./front-controller.js";
import { computeFrontSlot } from "./front.js";
import type { CcPeerRosterEntryLike } from "./front.js";
import {
buildFrontedSessionRecord,
detachFrontedSession,
type FrontedRelayRecord,
} from "./front-relay.js";

/** cc-peer's own registered display name for the front's shared peer -- distinct from any individual fronted session's own agent-comms display name (front-relay.ts's ensureRegistered call uses the session's own cc-peer name/pid for that). */
const FRONT_PEER_NAME = "agent-comms-front";

export interface CreateDefaultCcPeerFrontOptions {
coordinatorPort?: number | undefined;
hubUrl?: string | undefined;
pollIntervalMs?: number | undefined;
onError?: ((error: Error) => void) | undefined;
}

/** Falls back to "claude-code-<pid>" when a session has never picked its own cc-peer display name -- ensureRegistered requires a defaultName, and an unnamed session is still worth fronting under something stable and identifiable. */
function defaultSessionName(entry: Readonly<CcPeerRosterEntryLike>): string {
return entry.name ?? `claude-code-${String(entry.pid)}`;
}

/**
* Builds the default cc-peer front. start()/stop() are safe to call from bridge-mesh.ts's onCoordinatorRoleChanged hook regardless of whether cc-peer itself is usable on this machine: the shared CcPeer instance is created lazily, on the first poll tick that actually needs it (listRoster), not at start() itself, and any construction or attach failure is reported via onError rather than thrown -- "absent sockets" (no local Claude Code sessions at all) is then a clean no-op front rather than a crash of the coordinator that owns it.
*/
export function createDefaultCcPeerFront(
options: Readonly<CreateDefaultCcPeerFrontOptions> = {},
): Pick<CcPeerFront<FrontedRelayRecord>, "start" | "stop"> {
let sharedPeerPromise: Promise<CcPeer> | undefined;

const front = new CcPeerFront<FrontedRelayRecord>({
listRoster: async () => {
const peer = await ensureSharedPeer();
return peer.roster();
},
probeSlotOwner,
attach: async (entry) => attachSession(entry, await ensureSharedPeer()),
detach: detachFrontedSession,
pollIntervalMs: options.pollIntervalMs,
onError: options.onError,
});

return {
start: () => {
front.start();
},
stop: async () => {
await front.stop();
const peer = await sharedPeerPromise?.catch(() => undefined);
sharedPeerPromise = undefined;
await peer?.stop();
},
};

async function ensureSharedPeer(): Promise<CcPeer> {
sharedPeerPromise ??= CcPeer.create({ name: FRONT_PEER_NAME }).then(
(peer) => {
peer.on("message", (message: Readonly<CcPeerInboundMessage>) => {
front.handleInboundMessage(message);
});
return peer;
},
);
return sharedPeerPromise;
}

async function attachSession(
entry: Readonly<CcPeerRosterEntryLike>,
peer: CcPeer,
): Promise<FrontedRelayRecord> {
const slot = computeFrontSlot(entry.cwd);
const identity = loadIdentityForFront(slot);
const { store, tool } = await createBridgeMeshFromIdentity(
identity,
slot,
options.coordinatorPort,
options.hubUrl,
);

const reg = await ensureRegistered({
store,
harness: "claude-code",
cwd: entry.cwd,
defaultName: defaultSessionName(entry),
visibility: "visible",
});
const roomId = await ensureProjectRoom(store, reg.agentId, entry.cwd);

return buildFrontedSessionRecord({
entry,
agentId: reg.agentId,
roomId,
store,
tool,
peer,
});
}
}
Loading
Loading