From 9d07d220b672bf47f24f210acc23a76d4f288e69 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:30:14 +0100 Subject: [PATCH 1/6] chore(deps): bump cc-peer to 1.4.1 for the alias-pool subpath Reply aliases (agent-comms#158) need cc-peer's AliasPool, published under the cc-peer/alias-pool subpath in 1.4.1 (agent-comms#40). --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 56a965dd..e9b4ea42 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0aab82f0..3b17eb32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,8 +189,8 @@ importers: specifier: 2.3.0 version: 2.3.0 cc-peer: - specifier: 1.3.4 - version: 1.3.4 + specifier: 1.4.1 + version: 1.4.1 preact: specifier: 10.29.7 version: 10.29.7 @@ -1993,8 +1993,8 @@ packages: resolution: {integrity: sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==} engines: {node: '>=20'} - cc-peer@1.3.4: - resolution: {integrity: sha512-0+XHghOvUBYqwrXgA7y041ebKvk2Ml7Gucr6SfyMHnGGytMojEGOHrpWHVxuWz2yuUCnvXXzIccFawr8S395Bg==} + cc-peer@1.4.1: + resolution: {integrity: sha512-TETcA2MOexkQ+xlBXAV/ftOfK9t2k6C73JJY3CHCkKue7ddh7vZyRU+TWK3lJ1gVbCTDcas8q7ousdvc8sad1w==} engines: {node: '>=20'} hasBin: true @@ -6442,7 +6442,7 @@ snapshots: dependencies: '@cto.af/wtf8': 0.0.5 - cc-peer@1.3.4: + cc-peer@1.4.1: dependencies: zod: 4.5.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 711754d0..6d7f7e0d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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" From ebd0a4d3151b79698750661ee3d43a79ad20d250 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:32:07 +0100 Subject: [PATCH 2/6] feat(bridge): add reply-alias name derivation and directory Derives a deterministic cc-peer peer name from a mesh correspondent's device-id and tracks the correspondent-to-alias mapping in a bounded, in-memory directory, bijective in both directions. Also identifies which DeliveryEvent types carry a single originating correspondent worth aliasing (dm, room_message) versus ones that describe something happening rather than a message from someone (room membership/status/ capability events). The directory is deliberately unpersisted: reply aliases are ephemeral by design, lost on restart and re-materialised by the next inbound message from that correspondent. --- src/bridges/cc-peer/reply-aliases.ts | 65 ++++++++++++++ src/test/cc-peer-reply-aliases.test.ts | 112 +++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/bridges/cc-peer/reply-aliases.ts create mode 100644 src/test/cc-peer-reply-aliases.test.ts diff --git a/src/bridges/cc-peer/reply-aliases.ts b/src/bridges/cc-peer/reply-aliases.ts new file mode 100644 index 00000000..1b7bd87e --- /dev/null +++ b/src/bridges/cc-peer/reply-aliases.ts @@ -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, +): 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(); + private readonly correspondentToName = new Map(); + + /** 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); + } +} diff --git a/src/test/cc-peer-reply-aliases.test.ts b/src/test/cc-peer-reply-aliases.test.ts new file mode 100644 index 00000000..3d1effa1 --- /dev/null +++ b/src/test/cc-peer-reply-aliases.test.ts @@ -0,0 +1,112 @@ +/** + * Unit tests for the cc-peer front's reply-alias bookkeeping (bridges/cc-peer/reply-aliases.ts) -- alias-name derivation, the correspondent↔alias directory, and which DeliveryEvent types carry a correspondent worth aliasing, all tested purely over data with no real cc-peer/AliasPool involved. + */ +import { describe, expect, it } from "vitest"; +import { + correspondentForEvent, + deriveAliasName, + ReplyAliasDirectory, +} from "../bridges/cc-peer/reply-aliases.js"; +import type { DeliveryEvent, DmMessage, RoomMessage } from "../core/types.js"; + +function dmMessage(overrides: Partial = {}): DmMessage { + return { + id: "msg-1", + from: "a1b2c3d4e5f60000000000000000000000000000000000000000000000000000", + to: "agent-1", + content: "hi", + timestamp: "2026-01-01T00:00:00.000Z", + readBy: [], + ...overrides, + }; +} + +function roomMessage(overrides: Partial = {}): RoomMessage { + return { + id: "msg-1", + from: "f1e2d3c4b5a60000000000000000000000000000000000000000000000000000", + room: "owner/project", + content: "hi", + timestamp: "2026-01-01T00:00:00.000Z", + readBy: [], + ...overrides, + }; +} + +describe("deriveAliasName", () => { + it("derives a stable, non-empty peer name from a correspondent id", () => { + const name = deriveAliasName("a1b2c3d4e5f6789012345678"); + expect(name.length).toBeGreaterThan(0); + expect(name).toBe(deriveAliasName("a1b2c3d4e5f6789012345678")); + }); + + it("derives distinct names for distinct correspondent ids", () => { + expect(deriveAliasName("aaaaaaaaaaaaaaaaaaaaaaaa")).not.toBe( + deriveAliasName("bbbbbbbbbbbbbbbbbbbbbbbb"), + ); + }); + + it("is stable even for a very short correspondent id", () => { + expect(deriveAliasName("ab").length).toBeGreaterThan(0); + }); +}); + +describe("correspondentForEvent", () => { + it("returns the sender's agent id for a dm event", () => { + const event: DeliveryEvent = { + type: "dm", + message: dmMessage({ from: "sender-id" }), + }; + expect(correspondentForEvent(event)).toBe("sender-id"); + }); + + it("returns the sender's agent id for a room_message event", () => { + const event: DeliveryEvent = { + type: "room_message", + message: roomMessage({ from: "sender-id" }), + }; + expect(correspondentForEvent(event)).toBe("sender-id"); + }); + + it("returns undefined for an event with no single originating correspondent", () => { + const event: DeliveryEvent = { + type: "member_joined", + room: "owner/project", + agent: "agent-1", + }; + expect(correspondentForEvent(event)).toBeUndefined(); + }); +}); + +describe("ReplyAliasDirectory", () => { + it("mints a fresh alias name the first time a correspondent is seen", () => { + const directory = new ReplyAliasDirectory(); + const name = directory.ensure("correspondent-1"); + expect(name.length).toBeGreaterThan(0); + }); + + it("is idempotent: the same correspondent always maps to the same alias", () => { + const directory = new ReplyAliasDirectory(); + const first = directory.ensure("correspondent-1"); + const second = directory.ensure("correspondent-1"); + expect(second).toBe(first); + }); + + it("gives distinct correspondents distinct alias names", () => { + const directory = new ReplyAliasDirectory(); + const a = directory.ensure("aaaaaaaaaaaaaaaaaaaaaaaa"); + const b = directory.ensure("bbbbbbbbbbbbbbbbbbbbbbbb"); + expect(a).not.toBe(b); + }); + + it("resolves a minted alias name back to its correspondent id", () => { + const directory = new ReplyAliasDirectory(); + const name = directory.ensure("correspondent-1"); + expect(directory.correspondentFor(name)).toBe("correspondent-1"); + }); + + it("returns undefined for an alias name it never minted", () => { + const directory = new ReplyAliasDirectory(); + expect(directory.correspondentFor("never-seen")).toBeUndefined(); + }); +}); From 5e2291d54eb5accb34372417821f4f75ae1f04c0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:38:44 +0100 Subject: [PATCH 3/6] feat(bridge): route alias replies through the front controller Adds CcPeerFront.handleAliasMessage, matching an inbound reply-alias message to the fronted session that sent it the same way handleInboundMessage already does, then resolving the alias to its correspondent via an injected directory. A resolved correspondent routes to the session's own handleAliasReply; an alias the directory no longer knows about (ephemeral aliases don't survive a front restart) routes to notifyStaleAlias instead of being silently dropped. A message matching no fronted session is dropped, same as handleInboundMessage, since there is nowhere to safely deliver an error for a session this front does not control. --- src/bridges/cc-peer/front-controller.ts | 30 +++++++++ src/test/cc-peer-front-controller.test.ts | 79 +++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/bridges/cc-peer/front-controller.ts b/src/bridges/cc-peer/front-controller.ts index fda0ac70..861b7101 100644 --- a/src/bridges/cc-peer/front-controller.ts +++ b/src/bridges/cc-peer/front-controller.ts @@ -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 { @@ -34,6 +46,8 @@ export interface CcPeerFrontDeps { attach: (entry: Readonly) => Promise; /** Tears a fronted-session record down (marks its agent offline, shuts its mesh store down). Rejects propagate to onError. */ detach: (record: TRecord) => Promise; + /** 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; } @@ -77,6 +91,22 @@ export class CcPeerFront { 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 { let roster: readonly CcPeerRosterEntryLike[]; try { diff --git a/src/test/cc-peer-front-controller.test.ts b/src/test/cc-peer-front-controller.test.ts index 0c432206..643e9d2b 100644 --- a/src/test/cc-peer-front-controller.test.ts +++ b/src/test/cc-peer-front-controller.test.ts @@ -49,6 +49,8 @@ function stubRecord(entry: Readonly): StubRecord { cwd: entry.cwd, messagingSocketPath: entry.messagingSocketPath, handleInbound: vi.fn(), + handleAliasReply: vi.fn(), + notifyStaleAlias: vi.fn(), detached: false, }; } @@ -65,6 +67,11 @@ interface Harness { onError: ReturnType< typeof vi.fn["onError"]>> >; + correspondentFor: ReturnType< + typeof vi.fn< + CcPeerFrontDeps["aliasDirectory"]["correspondentFor"] + > + >; } function harness(initialRoster: readonly CcPeerRosterEntryLike[]): Harness { @@ -82,11 +89,15 @@ function harness(initialRoster: readonly CcPeerRosterEntryLike[]): Harness { () => undefined, ); const onError = vi.fn["onError"]>>(); + const correspondentFor = vi.fn< + CcPeerFrontDeps["aliasDirectory"]["correspondentFor"] + >(() => undefined); const deps: CcPeerFrontDeps = { listRoster: async () => Promise.resolve([...roster]), probeSlotOwner, attach, detach, + aliasDirectory: { correspondentFor }, pollIntervalMs: POLL_INTERVAL_MS, onError, }; @@ -98,6 +109,7 @@ function harness(initialRoster: readonly CcPeerRosterEntryLike[]): Harness { detach, probeSlotOwner, onError, + correspondentFor, }; } @@ -273,3 +285,70 @@ describe("CcPeerFront — attach/detach diffing across ticks", () => { await h.front.stop(); }); }); + +describe("CcPeerFront — handleAliasMessage", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("routes an alias reply to the fronted session it came from, resolving the correspondent via the alias directory", async () => { + const h = harness([ + rosterEntry({ pid: 1, messagingSocketPath: "/tmp/sock-1" }), + ]); + h.correspondentFor.mockReturnValue("correspondent-1"); + h.front.start(); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + const record = await attachResult(h.attach); + + h.front.handleAliasMessage({ + alias: "mesh-correspond", + from: "uds:/tmp/sock-1", + body: "reply body", + }); + + expect(h.correspondentFor).toHaveBeenCalledWith("mesh-correspond"); + expect(record.handleAliasReply).toHaveBeenCalledWith("correspondent-1", { + alias: "mesh-correspond", + from: "uds:/tmp/sock-1", + body: "reply body", + }); + expect(record.notifyStaleAlias).not.toHaveBeenCalled(); + await h.front.stop(); + }); + + it("notifies the fronted session of a stale alias when the directory no longer knows the correspondent", async () => { + const h = harness([ + rosterEntry({ pid: 1, messagingSocketPath: "/tmp/sock-1" }), + ]); + h.correspondentFor.mockReturnValue(undefined); + h.front.start(); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + const record = await attachResult(h.attach); + + h.front.handleAliasMessage({ + alias: "mesh-stale", + from: "uds:/tmp/sock-1", + body: "reply body", + }); + + expect(record.notifyStaleAlias).toHaveBeenCalledWith("mesh-stale"); + expect(record.handleAliasReply).not.toHaveBeenCalled(); + await h.front.stop(); + }); + + it("silently ignores an alias message matching no fronted session", async () => { + const h = harness([]); + expect(() => + h.front.handleAliasMessage({ + alias: "mesh-unknown", + from: "uds:/tmp/unknown", + body: "hi", + }), + ).not.toThrow(); + expect(h.correspondentFor).not.toHaveBeenCalled(); + }); +}); From 89ceb7495792b3a765eb8e76e185a28bb8d8fb7b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:41:09 +0100 Subject: [PATCH 4/6] feat(bridge): wire reply aliases into the fronted-session relay Extends buildFrontedSessionRecord so a mesh delivery with a single originating correspondent (a dm or room_message) materialises that correspondent's reply alias before delivery and mentions it in the message body, so the fronted session can address a reply to that specific correspondent the way it addresses any other local peer. An event with no single correspondent (room membership/status/ capability events) still delivers via the shared peer unchanged; a failure to materialise the alias still delivers the message, just without a reply hint the session couldn't have used anyway. Adds handleAliasReply (translates a reply arriving on an alias into a mesh DM to that correspondent, sent as the session's own agentId) and notifyStaleAlias (a clear error back into the session for a reply on an alias the directory no longer recognises) to the record CcPeerFront tracks each fronted session under. --- src/bridges/cc-peer/front-relay.ts | 71 +++++++++- src/test/cc-peer-front-relay.test.ts | 192 ++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 7 deletions(-) diff --git a/src/bridges/cc-peer/front-relay.ts b/src/bridges/cc-peer/front-relay.ts index 169a1c68..827cb20c 100644 --- a/src/bridges/cc-peer/front-relay.ts +++ b/src/bridges/cc-peer/front-relay.ts @@ -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 { @@ -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; +} + +/** 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; @@ -41,18 +52,45 @@ export interface BuildFrontedSessionRecordDeps { store: FrontRelayStore; tool: Pick; 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, ): 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 { @@ -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.`, + ); + }, }; } diff --git a/src/test/cc-peer-front-relay.test.ts b/src/test/cc-peer-front-relay.test.ts index 8a581821..2ee303e3 100644 --- a/src/test/cc-peer-front-relay.test.ts +++ b/src/test/cc-peer-front-relay.test.ts @@ -7,12 +7,14 @@ import { detachFrontedSession, } from "../bridges/cc-peer/front-relay.js"; import type { + FrontRelayAliasDirectory, + FrontRelayAliasPool, FrontRelayPeer, FrontRelayStore, } from "../bridges/cc-peer/front-relay.js"; import type { CcPeerRosterEntryLike } from "../bridges/cc-peer/front.js"; import type { CommsTool } from "../core/tool.js"; -import type { DeliveryEvent, RoomMessage } from "../core/types.js"; +import type { DeliveryEvent, DmMessage, RoomMessage } from "../core/types.js"; function rosterEntry( overrides: Readonly> = {}, @@ -85,8 +87,39 @@ function roomMessage(overrides: Partial = {}): RoomMessage { }; } +function dmMessage(overrides: Partial = {}): DmMessage { + return { + id: "msg-1", + from: "peer-a", + to: "agent-1", + content: "hi from the mesh", + timestamp: "2026-01-01T00:00:00.000Z", + readBy: [], + ...overrides, + }; +} + +function fakeAliasPool(): FrontRelayAliasPool & { ensureCalls: string[] } { + const ensureCalls: string[] = []; + return { + ensureCalls, + ensure: vi.fn(async (name: string) => { + ensureCalls.push(name); + return Promise.resolve(); + }), + }; +} + +function fakeAliasDirectory( + nameFor: (correspondentId: string) => string = (id) => `mesh-${id}`, +): FrontRelayAliasDirectory { + return { + ensure: vi.fn(nameFor), + }; +} + describe("buildFrontedSessionRecord — outbound (mesh -> cc-peer)", () => { - it("wires store.onDelivery to send the formatted event to the session's own pid", () => { + it("wires store.onDelivery to send the formatted event to the session's own pid", async () => { const store = fakeStore(); const peer = fakePeer(); const entry = rosterEntry({ pid: 333 }); @@ -98,6 +131,8 @@ describe("buildFrontedSessionRecord — outbound (mesh -> cc-peer)", () => { store, tool: fakeTool(), peer, + aliasPool: fakeAliasPool(), + aliasDirectory: fakeAliasDirectory(), }); expect(store.onDelivery).toBeDefined(); @@ -105,11 +140,156 @@ describe("buildFrontedSessionRecord — outbound (mesh -> cc-peer)", () => { type: "room_message", message: roomMessage(), }; - void store.onDelivery?.("agent-1", event); + await store.onDelivery?.("agent-1", event); expect(peer.sendCalls).toHaveLength(1); expect(peer.sendCalls[0]?.target).toEqual({ pid: 333 }); }); + + it("materialises a reply alias for the event's own correspondent and mentions it in the delivered body", async () => { + const store = fakeStore(); + const peer = fakePeer(); + const aliasPool = fakeAliasPool(); + const aliasDirectory = fakeAliasDirectory((id) => `mesh-${id}`); + + buildFrontedSessionRecord({ + entry: rosterEntry(), + agentId: "agent-1", + roomId: "owner/project", + store, + tool: fakeTool(), + peer, + aliasPool, + aliasDirectory, + }); + + const event: DeliveryEvent = { + type: "dm", + message: dmMessage({ from: "correspondent-1" }), + }; + await store.onDelivery?.("agent-1", event); + + expect(aliasDirectory.ensure).toHaveBeenCalledWith("correspondent-1"); + expect(aliasPool.ensureCalls).toEqual(["mesh-correspondent-1"]); + expect(peer.sendCalls[0]?.body).toContain( + 'reply via peer "mesh-correspondent-1"', + ); + }); + + it("delivers an event with no single correspondent (e.g. member_joined) without touching the alias pool", async () => { + const store = fakeStore(); + const peer = fakePeer(); + const aliasPool = fakeAliasPool(); + + buildFrontedSessionRecord({ + entry: rosterEntry(), + agentId: "agent-1", + roomId: "owner/project", + store, + tool: fakeTool(), + peer, + aliasPool, + aliasDirectory: fakeAliasDirectory(), + }); + + const event: DeliveryEvent = { + type: "member_joined", + room: "owner/project", + agent: "agent-2", + }; + await store.onDelivery?.("agent-1", event); + + expect(aliasPool.ensureCalls).toEqual([]); + expect(peer.sendCalls).toHaveLength(1); + }); + + it("still delivers the event, without a reply hint, if materialising the alias fails", async () => { + const store = fakeStore(); + const peer = fakePeer(); + const failure = new Error("alias worker failed to start"); + const aliasPool: FrontRelayAliasPool = { + ensure: vi.fn(async () => Promise.reject(failure)), + }; + + buildFrontedSessionRecord({ + entry: rosterEntry(), + agentId: "agent-1", + roomId: "owner/project", + store, + tool: fakeTool(), + peer, + aliasPool, + aliasDirectory: fakeAliasDirectory(), + }); + + const event: DeliveryEvent = { + type: "dm", + message: dmMessage({ from: "correspondent-1" }), + }; + await store.onDelivery?.("agent-1", event); + + expect(peer.sendCalls).toHaveLength(1); + expect(peer.sendCalls[0]?.body).not.toContain("reply via peer"); + }); +}); + +describe("buildFrontedSessionRecord — alias reply (cc-peer -> mesh DM)", () => { + it("sends a mesh DM to the correspondent, as this session's own agent id, when a reply arrives on its alias", () => { + const tool = fakeTool(); + const record = buildFrontedSessionRecord({ + entry: rosterEntry({ cwd: "/tmp/my-project" }), + agentId: "agent-1", + roomId: "owner/my-project", + store: fakeStore(), + tool, + peer: fakePeer(), + aliasPool: fakeAliasPool(), + aliasDirectory: fakeAliasDirectory(), + }); + + record.handleAliasReply("correspondent-1", { + from: "local-session", + fromName: "my-local-session", + body: "reply body", + }); + + expect(tool.handleCalls).toHaveLength(1); + expect(tool.handleCalls[0]).toEqual({ + ctx: { + agentId: "agent-1", + harness: "claude-code", + cwd: "/tmp/my-project", + pid: process.pid, + }, + action: { + action: "dm", + target: "correspondent-1", + content: "reply body", + }, + }); + }); +}); + +describe("buildFrontedSessionRecord — stale alias notification", () => { + it("sends a clear error back to the session's own pid rather than silently dropping the reply", () => { + const peer = fakePeer(); + const record = buildFrontedSessionRecord({ + entry: rosterEntry({ pid: 555 }), + agentId: "agent-1", + roomId: "owner/project", + store: fakeStore(), + tool: fakeTool(), + peer, + aliasPool: fakeAliasPool(), + aliasDirectory: fakeAliasDirectory(), + }); + + record.notifyStaleAlias("mesh-stale"); + + expect(peer.sendCalls).toHaveLength(1); + expect(peer.sendCalls[0]?.target).toEqual({ pid: 555 }); + expect(peer.sendCalls[0]?.body).toContain("mesh-stale"); + }); }); describe("buildFrontedSessionRecord — inbound (cc-peer -> mesh)", () => { @@ -124,6 +304,8 @@ describe("buildFrontedSessionRecord — inbound (cc-peer -> mesh)", () => { store: fakeStore(), tool, peer: fakePeer(), + aliasPool: fakeAliasPool(), + aliasDirectory: fakeAliasDirectory(), }); record.handleInbound({ @@ -157,6 +339,8 @@ describe("buildFrontedSessionRecord — inbound (cc-peer -> mesh)", () => { store: fakeStore(), tool, peer: fakePeer(), + aliasPool: fakeAliasPool(), + aliasDirectory: fakeAliasDirectory(), }); record.handleInbound({ from: "local-session", body: "hi" }); @@ -180,6 +364,8 @@ describe("buildFrontedSessionRecord — record shape", () => { store: fakeStore(), tool: fakeTool(), peer: fakePeer(), + aliasPool: fakeAliasPool(), + aliasDirectory: fakeAliasDirectory(), }); expect(record.pid).toBe(entry.pid); From fd807bccf877ef5df34fe45e3fbf57e295c9af43 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:43:34 +0100 Subject: [PATCH 5/6] feat(bridge): construct the shared AliasPool for the default front Wires cc-peer 1.4.1's AliasPool (cc-peer/alias-pool) and a shared ReplyAliasDirectory into the real cc-peer front construction: the pool is materialised lazily on first use, mirroring the shared CcPeer peer's own lazy-construction convention, and its "message" event (a reply arriving on any fronted session's own correspondent alias) routes straight into CcPeerFront.handleAliasMessage. Both the pool and the directory are shared across every fronted session on this machine, so two sessions corresponding with the same mesh agent share one alias rather than minting a duplicate. The pool is stopped alongside the shared peer when the front itself stops. --- src/bridges/cc-peer/front-runtime.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/bridges/cc-peer/front-runtime.ts b/src/bridges/cc-peer/front-runtime.ts index c5cfb974..4c1e3a3c 100644 --- a/src/bridges/cc-peer/front-runtime.ts +++ b/src/bridges/cc-peer/front-runtime.ts @@ -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, @@ -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"; @@ -46,6 +49,8 @@ export function createDefaultCcPeerFront( options: Readonly = {}, ): Pick, "start" | "stop"> { let sharedPeerPromise: Promise | undefined; + let aliasPool: AliasPool | undefined; + const aliasDirectory = new ReplyAliasDirectory(); const front = new CcPeerFront({ listRoster: async () => { @@ -55,6 +60,7 @@ export function createDefaultCcPeerFront( probeSlotOwner, attach: async (entry) => attachSession(entry, await ensureSharedPeer()), detach: detachFrontedSession, + aliasDirectory, pollIntervalMs: options.pollIntervalMs, onError: options.onError, }); @@ -68,6 +74,8 @@ export function createDefaultCcPeerFront( const peer = await sharedPeerPromise?.catch(() => undefined); sharedPeerPromise = undefined; await peer?.stop(); + await aliasPool?.stopAll(); + aliasPool = undefined; }, }; @@ -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) => { + front.handleAliasMessage(message); + }); + aliasPool = pool; + return pool; + } + async function attachSession( entry: Readonly, peer: CcPeer, @@ -112,6 +131,8 @@ export function createDefaultCcPeerFront( store, tool, peer, + aliasPool: ensureAliasPool(), + aliasDirectory, }); } } From fc688b2271976f1b143650d93194285ac624b4db Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:44:05 +0100 Subject: [PATCH 6/6] docs: document the reply half of the default cc-peer front Extends the existing "Default cc-peer front" section with the reply path: lazy per-correspondent aliases via cc-peer's AliasPool, created only on inbound contact, ephemeral across restarts, and a clear error for a reply landing on a stale alias. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b37b0108..9273b09b 100644 --- a/README.md +++ b/README.md @@ -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: