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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ One bridge process relays for exactly one local Claude Code session, the same "o

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.

The fronted session can reply, not just receive. A message with a single originating mesh agent (a DM or a room message) materialises a lazy, per-correspondent reply alias via `cc-peer`'s own `AliasPool` (the `cc-peer/alias-pool` subpath) — a real, natively-discoverable local peer the session can address the way it addresses any other local `cc-peer` peer. Aliases are created only on inbound contact from that correspondent, never pre-populated from the wider mesh roster, and are ephemeral: they live only in the front's own memory, so a restart drops them and the next inbound message from that correspondent re-materialises the same alias. A reply landing on an alias the front no longer recognises (e.g. after a restart) is reported back into the session as a clear error rather than silently dropped.

## Adding a new harness

A bridge is two things:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"cbor2": "2.3.0",
"cc-peer": "1.3.4",
"cc-peer": "1.4.1",
"preact": "10.29.7",
"typebox": "1.3.6",
"wire-mesh-core": "1.30.1",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ allowBuilds:
minimumReleaseAgeExclude:
- '@exadev/eslint-config'
- wire-mesh-core
- cc-peer@1.4.1
overrides:
"@anthropic-ai/sdk": ">=0.91.1"
basic-ftp: ">=6.0.1"
Expand Down
30 changes: 30 additions & 0 deletions src/bridges/cc-peer/front-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export interface FrontedSessionRecord {
readonly handleInbound: (
message: Readonly<{ from?: string; fromName?: string; body: string }>,
) => void;
/** Routes a reply arriving on one of this session's own reply aliases into a mesh DM addressed to the correspondent that alias stands for, sent as this session's own device-id (agent-comms#158) -- the session-to-mesh direction for lazy per-correspondent aliases. Only called once handleAliasMessage has already resolved the alias to a known correspondent; a stale alias goes to notifyStaleAlias instead. */
readonly handleAliasReply: (
correspondentId: string,
message: Readonly<{ from?: string; fromName?: string; body: string }>,
) => void;
/** Delivers a clear error back into this session when a reply arrives on an alias the directory no longer maps to any correspondent -- e.g. after a front restart, since reply aliases are ephemeral by design -- rather than silently dropping the reply. */
readonly notifyStaleAlias: (aliasName: string) => void;
}

/** The narrow slice of ReplyAliasDirectory (reply-aliases.ts) CcPeerFront needs to resolve an inbound alias message's own correspondent -- narrowed rather than importing the class directly so front-controller.ts stays free of any construction concern, matching probeSlotOwner's own injected-function convention. */
export interface CcPeerFrontAliasDirectory {
correspondentFor: (aliasName: string) => string | undefined;
}

export interface CcPeerFrontDeps<TRecord extends FrontedSessionRecord> {
Expand All @@ -34,6 +46,8 @@ export interface CcPeerFrontDeps<TRecord extends FrontedSessionRecord> {
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>;
/** Resolves an inbound alias message's own alias name back to the correspondent it stands for -- reply-aliases.ts's ReplyAliasDirectory in production. */
aliasDirectory: CcPeerFrontAliasDirectory;
pollIntervalMs?: number | undefined;
onError?: ((error: Error) => void) | undefined;
}
Expand Down Expand Up @@ -77,6 +91,22 @@ export class CcPeerFront<TRecord extends FrontedSessionRecord> {
record?.handleInbound(message);
}

/** Routes a reply arriving on a shared reply alias to whichever fronted session actually sent it -- matched the same way as handleInboundMessage, by the envelope's own socket-path convention. A message matching no fronted session is silently dropped, same as handleInboundMessage: there is nowhere for it to go, and we cannot safely address an error back to a session we do not control. Once the sending session is identified, the alias directory resolves which correspondent this reply is for; an alias the directory no longer knows about is reported back into the session as a stale-alias error rather than silently dropped, per agent-comms#158. */
handleAliasMessage(
message: Readonly<{ alias: string; from?: string; body: string }>,
): void {
const record = matchInboundMessageSession(this.fronted.values(), message);
if (!record) return;
const correspondentId = this.deps.aliasDirectory.correspondentFor(
message.alias,
);
if (correspondentId === undefined) {
record.notifyStaleAlias(message.alias);
return;
}
record.handleAliasReply(correspondentId, message);
}

private async tick(): Promise<void> {
let roster: readonly CcPeerRosterEntryLike[];
try {
Expand Down
71 changes: 67 additions & 4 deletions src/bridges/cc-peer/front-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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";
import { correspondentForEvent } from "./reply-aliases.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 {
Expand All @@ -28,6 +29,16 @@ export interface FrontRelayPeer {
) => Promise<{ msgId: string }>;
}

/** The narrow slice of AliasPool (cc-peer's own cc-peer/alias-pool subpath) this relay needs -- materialising the real OS-backed reply alias a correspondent's name was already minted for by FrontRelayAliasDirectory. Narrowed so this module has no compile-time dependency on the cc-peer package itself -- front-runtime.ts supplies the real AliasPool. */
export interface FrontRelayAliasPool {
ensure: (name: string) => Promise<void>;
}

/** The narrow slice of ReplyAliasDirectory (reply-aliases.ts) this relay needs to mint/recall the alias name for one correspondent on the mesh-to-session direction. */
export interface FrontRelayAliasDirectory {
ensure: (correspondentId: string) => string;
}

export interface FrontedRelayRecord extends FrontedSessionRecord {
agentId: string;
roomId: string;
Expand All @@ -41,18 +52,45 @@ export interface BuildFrontedSessionRecordDeps {
store: FrontRelayStore;
tool: Pick<CommsTool, "handle">;
peer: FrontRelayPeer;
aliasPool: FrontRelayAliasPool;
aliasDirectory: FrontRelayAliasDirectory;
}

/**
* 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.
* 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 -- for an event with a single originating correspondent (a dm or room_message, per correspondentForEvent), it first materialises a reply alias for that correspondent and mentions it in the delivered body, so the session can address a reply to that specific correspondent the way it addresses any other local peer (agent-comms#158). 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; handleAliasReply (called once CcPeerFront has resolved an inbound alias message to its correspondent) instead sends a mesh DM to that correspondent, as this session's own agentId; notifyStaleAlias delivers a clear error back into the session for a reply on an alias the directory no longer recognises.
*/
export function buildFrontedSessionRecord(
deps: Readonly<BuildFrontedSessionRecordDeps>,
): FrontedRelayRecord {
const { entry, agentId, roomId, store, tool, peer } = deps;
const {
entry,
agentId,
roomId,
store,
tool,
peer,
aliasPool,
aliasDirectory,
} = deps;

store.onDelivery = (_targetId, event) => {
void peer.send({ pid: entry.pid }, formatDeliveryEvent(event));
store.onDelivery = async (_targetId, event) => {
const body = formatDeliveryEvent(event);
const correspondentId = correspondentForEvent(event);
if (correspondentId === undefined) {
await peer.send({ pid: entry.pid }, body);
return;
}
const aliasName = aliasDirectory.ensure(correspondentId);
try {
await aliasPool.ensure(aliasName);
await peer.send(
{ pid: entry.pid },
`${body} (reply via peer "${aliasName}")`,
);
} catch {
// The alias failed to materialise (e.g. the worker process failed to start) -- deliver the message anyway, just without a reply hint the session couldn't actually use.
await peer.send({ pid: entry.pid }, body);
}
};

return {
Expand All @@ -79,6 +117,31 @@ export function buildFrontedSessionRecord(
action,
);
},
handleAliasReply: (
correspondentId: string,
message: Readonly<{ body: string }>,
) => {
const action = buildAction({
action: "dm",
target: correspondentId,
content: message.body,
});
void tool.handle(
{
agentId,
harness: "claude-code",
cwd: entry.cwd,
pid: process.pid,
},
action,
);
},
notifyStaleAlias: (aliasName: string) => {
void peer.send(
{ pid: entry.pid },
`Reply not delivered: peer "${aliasName}" is no longer a known correspondent (reply aliases don't survive a front restart). Wait for a new message from them and reply to that instead.`,
);
},
};
}

Expand Down
21 changes: 21 additions & 0 deletions src/bridges/cc-peer/front-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import { CcPeer } from "cc-peer";
import type { InboundMessage as CcPeerInboundMessage } from "cc-peer";
import { AliasPool } from "cc-peer/alias-pool";
import type { AliasMessage } from "cc-peer/alias-pool";
import {
createBridgeMeshFromIdentity,
ensureRegistered,
Expand All @@ -23,6 +25,7 @@ import {
detachFrontedSession,
type FrontedRelayRecord,
} from "./front-relay.js";
import { ReplyAliasDirectory } from "./reply-aliases.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";
Expand All @@ -46,6 +49,8 @@ export function createDefaultCcPeerFront(
options: Readonly<CreateDefaultCcPeerFrontOptions> = {},
): Pick<CcPeerFront<FrontedRelayRecord>, "start" | "stop"> {
let sharedPeerPromise: Promise<CcPeer> | undefined;
let aliasPool: AliasPool | undefined;
const aliasDirectory = new ReplyAliasDirectory();

const front = new CcPeerFront<FrontedRelayRecord>({
listRoster: async () => {
Expand All @@ -55,6 +60,7 @@ export function createDefaultCcPeerFront(
probeSlotOwner,
attach: async (entry) => attachSession(entry, await ensureSharedPeer()),
detach: detachFrontedSession,
aliasDirectory,
pollIntervalMs: options.pollIntervalMs,
onError: options.onError,
});
Expand All @@ -68,6 +74,8 @@ export function createDefaultCcPeerFront(
const peer = await sharedPeerPromise?.catch(() => undefined);
sharedPeerPromise = undefined;
await peer?.stop();
await aliasPool?.stopAll();
aliasPool = undefined;
},
};

Expand All @@ -83,6 +91,17 @@ export function createDefaultCcPeerFront(
return sharedPeerPromise;
}

/** Materialises the front's own shared AliasPool on first use (mirroring ensureSharedPeer's own lazy-construction convention) and wires its "message" event -- every reply arriving on any fronted session's own correspondent aliases -- straight into the controller's handleAliasMessage, which resolves the sending session and the alias's own correspondent before routing it on. */
function ensureAliasPool(): AliasPool {
if (aliasPool) return aliasPool;
const pool = AliasPool.create();
pool.on("message", (message: Readonly<AliasMessage>) => {
front.handleAliasMessage(message);
});
aliasPool = pool;
return pool;
}

async function attachSession(
entry: Readonly<CcPeerRosterEntryLike>,
peer: CcPeer,
Expand Down Expand Up @@ -112,6 +131,8 @@ export function createDefaultCcPeerFront(
store,
tool,
peer,
aliasPool: ensureAliasPool(),
aliasDirectory,
});
}
}
65 changes: 65 additions & 0 deletions src/bridges/cc-peer/reply-aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Lazy per-correspondent reply aliases for the default cc-peer front (agent-comms#158) -- pure alias-name derivation and the in-memory correspondent↔alias directory the front consults on both the mesh-to-session direction (materialise an alias so the session can address a reply to it) and the session-to-mesh direction (translate a reply arriving on an alias back into the correspondent it stands for).
*
* Deliberately not persisted: aliases are ephemeral by design, lost on restart and re-materialised by the next inbound message from that correspondent (see the issue body's "Rejected alternatives" for why a directory of every mesh agent is not minted upfront). Kept free of any real cc-peer/AliasPool construction, exactly like front.ts's pure decision logic -- front-relay.ts and front-controller.ts consult this directory, and front-runtime.ts supplies the real AliasPool the alias names get materialised against.
*/

import type { DeliveryEvent } from "../../core/types.js";

/** How many leading hex characters of a correspondent's device-id go into its alias name -- 48 bits of a SHA-256 hash, chosen purely for a short, readable cc-peer peer name; cc-peer's own registry name field has no length or format restriction beyond non-empty (see AliasStartCommandSchema in cc-peer's alias-ipc schema), so this is a readability choice, not a protocol requirement. */
const ALIAS_ID_LENGTH = 12;

/** cc-peer peer names materialised for reply aliases are prefixed so they read unambiguously as a mesh correspondent, not a real local Claude Code session, in `roster()`/UI listings that show every locally known peer name side by side. */
const ALIAS_NAME_PREFIX = "mesh-";

/** Derives the cc-peer peer name a correspondent's reply alias registers under. Deterministic and side-effect-free: the same correspondent id always derives the same name, so ReplyAliasDirectory only needs to remember the mapping, not invent a fresh name per call. */
export function deriveAliasName(correspondentId: string): string {
return `${ALIAS_NAME_PREFIX}${correspondentId.slice(0, ALIAS_ID_LENGTH)}`;
}

/** The mesh agent id a DeliveryEvent originated from, for the event types that carry a single, repliable-to sender -- undefined for every other event type (room membership/status/capability events describe something happening, not a message from one correspondent worth aliasing). */
export function correspondentForEvent(
event: Readonly<DeliveryEvent>,
): string | undefined {
switch (event.type) {
case "dm":
case "room_message":
return event.message.from;
case "room_invite":
case "member_joined":
case "member_left":
case "room_members":
case "member_status":
case "delivery_status":
case "invite_declined":
case "name_changed":
case "connection_request":
case "capability_request":
return undefined;
default:
return event satisfies never;
}
}

/**
* Bounded, in-memory, bijective map between the cc-peer peer names materialised for a front's reply aliases and the mesh correspondent id each one stands for. Bounded by real correspondents, per the issue's own "Rejected alternatives": a name is only ever minted by ensure(), which the front calls solely on genuine inbound contact from that correspondent -- nothing pre-populates this directory with the wider mesh roster.
*/
export class ReplyAliasDirectory {
private readonly nameToCorrespondent = new Map<string, string>();
private readonly correspondentToName = new Map<string, string>();

/** Returns the alias name for a correspondent, deriving and recording it the first time this correspondent is seen. Idempotent: a correspondent already known always gets back its existing name, so a session's reply target stays stable across repeated inbound contact within the same front lifetime. */
ensure(correspondentId: string): string {
const existing = this.correspondentToName.get(correspondentId);
if (existing !== undefined) return existing;
const name = deriveAliasName(correspondentId);
this.correspondentToName.set(correspondentId, name);
this.nameToCorrespondent.set(name, correspondentId);
return name;
}

/** The correspondent a previously-minted alias name stands for, or undefined for a name this directory never minted (or no longer remembers, e.g. after a front restart cleared it) -- the front's own signal to treat an inbound reply as addressed to a stale alias rather than a known correspondent. */
correspondentFor(aliasName: string): string | undefined {
return this.nameToCorrespondent.get(aliasName);
}
}
Loading
Loading