Skip to content
Merged
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ Every release is also mirrored to the GitHub Packages registry as `@exadev/cc-pe

The REST facade (`npx cc-peer`) serves `GET /sessions`, `POST /messages`, `POST /idle-subscriptions`, `GET /events` (SSE), and a self-describing `GET /openapi.json` on loopback with a bearer token.

### Session discovery and reply aliases

`CcPeer.roster()` already lists every live local Claude Code session, not just ones `cc-peer` itself registered — the registry it reads (`~/.claude/sessions/*.json`) is written by every interactive session on startup. A relay/front application that wants to discover every session to attach to needs nothing beyond `roster()`.

Giving a relayed session a name it can reply to natively for each of several correspondents is a different problem: the registry is one file per real OS pid with a single name each, so one process can only ever publish one discoverable name at a time (see [docs/PROTOCOL.md](docs/PROTOCOL.md#session-enumeration-and-reply-aliases-for-a-relayfront-building-on-this-sdk) for the empirical detail). `AliasPool`, exported from `cc-peer/alias-pool`, is the mechanism for this: it lazily forks one lightweight `CcPeer`-backed child process per correspondent name, and relays whatever that alias receives back to the parent.

```ts
import { AliasPool } from "cc-peer/alias-pool";

const aliases = AliasPool.create();

aliases.on("message", (m) => {
// m.alias is the correspondent name the relayed session replied to;
// forward m.body to that correspondent's own channel.
console.log(`reply for ${m.alias}: ${m.body}`);
});

// Whenever a new correspondent messages the relayed session for the first
// time, give it a reply-able name (idempotent; a no-op if already active).
await aliases.ensure("alice");

// …later, once a correspondent is no longer relevant:
await aliases.retire("alice");
await aliases.stopAll();
```

## Limitations

- **Same-process constraint**: receipts and idle notices only reach the process that owns the peer's listening socket (the protocol verifies return addresses via kernel peer-pids). Do not split `CcPeer` listening and sending across processes or differently-owned workers.
Expand Down
9 changes: 9 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ The roster builder (`listLivePeerSessions`) reads all `~/.claude/sessions/<pid>.

Verified chain: a Python peer registered this way appears in `ListAgents` within seconds and receives native `SendMessage` by bare name (`from-name` resolves from the sender's own registry entry).

### Session enumeration and reply aliases (for a relay/front building on this SDK)

Two capabilities a message relay ("front") needs from this protocol, gated on what it actually supports rather than assumed:

- **Session enumeration** — listing every live local Claude Code session, not just ones the relay itself registered — is already fully native. The roster builder above reads every `~/.claude/sessions/<pid>.json` file on disk, regardless of who wrote it; `cc-peer`'s own `CcPeer.roster()` (and the REST facade's `GET /sessions`) is exactly this roster builder, so a relay gets full session discovery for free, with no separate mechanism needed.
- **Reply aliases** — giving each correspondent that messages a relayed session its own natively-`SendMessage`-reachable name, so the session can reply to it directly by name — is **not** natively supported for more than one name per process. The registry is one file per real OS pid (`registryFilePath`: `<pid>.json`) and each entry carries a single optional `name` field; a process publishing a second name overwrites, rather than adds to, its own entry. This is directly observable in this SDK's own test suite: two `CcPeer` instances sharing one pid (unavoidable — both are the same OS process) leave only the last-registered name visible in the roster, because both wrote to the identical `<pid>.json` file. Native name resolution (`ListAgents`/`SendMessage(name=X)`) walks the registry directory exactly as it is on disk — it has no concept of "this one process answers to several names."

The practical consequence: a relay that wants N correspondents to each get their own reply-able name needs N distinct, genuinely live OS processes — one real pid, one registry file, one name, per correspondent — not a lighter-weight in-process mapping. `cc-peer`'s own `AliasPool` (see the root README) implements exactly this: it lazily forks one lightweight child process per correspondent name, each running an ordinary `CcPeer` instance under that name, and relays whatever the relayed session replies with back to the parent process for translation into whatever channel the correspondent actually lives on.

## Receipts and status

`peer_message_status` is pushed from receiver to sender over a fresh connection to the sender's socket, authenticated with the sender's own peerToken:
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@
"default": "./dist/cc-peer.cjs"
}
},
"./alias-pool": {
"import": {
"types": "./dist/alias-pool.d.mts",
"default": "./dist/alias-pool.mjs"
},
"require": {
"types": "./dist/alias-pool.d.cts",
"default": "./dist/alias-pool.cjs"
}
},
"./schemas/*.schema.json": "./schemas/*.schema.json",
"./package.json": "./package.json"
},
Expand Down
39 changes: 39 additions & 0 deletions src/adapters/node/alias-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Entry point for one reply alias's real OS process, forked by ForkedAliasProcess. Excluded from the coverage gate the same way src/bin/** is (see vitest.config.ts): pure process-lifecycle glue, exercised end to end by a real fork in forked-alias-process.integration.test.ts rather than in-process unit coverage.
*/
import process from "node:process";

import { CcPeer, type InboundMessage } from "../../cc-peer.js";
import { AliasCommandSchema } from "../../schemas/alias-ipc.js";

let peer: CcPeer | undefined;

process.on("message", (raw: unknown) => {
void handleCommand(raw);
});

async function handleCommand(raw: unknown): Promise<void> {
if (!AliasCommandSchema.is(raw)) return;
if (raw.type === "stop") {
await peer?.stop();
process.exit(0);
return;
}
let created: CcPeer;
try {
created = await CcPeer.create({
name: raw.name,
...(raw.homeDir !== undefined ? { homeDir: raw.homeDir } : {}),
...(raw.socketDir !== undefined ? { socketDir: raw.socketDir } : {}),
...(raw.sessionId !== undefined ? { sessionId: raw.sessionId } : {}),
});
} catch {
process.exit(1);
return;
}
peer = created;
peer.on("message", (message: InboundMessage) => {
process.send?.({ type: "message", ...message });
});
process.send?.({ type: "started" });
}
189 changes: 189 additions & 0 deletions src/adapters/node/forked-alias-process.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { describe, expect, test } from "vitest";
import { fork, type ChildProcess } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtemp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { connect } from "node:net";
import { once } from "node:events";
import { randomUUID } from "node:crypto";

import { ForkedAliasProcess } from "./forked-alias-process.js";
import { AliasStartError } from "../../errors.js";
import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "../../test/timeouts.js";
import type { RegistryEntry } from "../../schemas/registry.js";
import type { PeerKeyFile } from "../../schemas/keyfile.js";

/**
* Runs the real alias-worker.ts source under tsx's Node loader hook (registered in-process via --import, never a nested subprocess of its own, so fork()'s IPC channel is unaffected) rather than the built .js sibling ForkedAliasProcess uses by default. That built file only exists once this package has actually been run through tsdown; forking the real TypeScript source directly here proves the worker's own behaviour without requiring a prior `pnpm build`, matching how the SEA binary's own smoke test is instead deferred to a separate, build-gated e2e tier.
*/
const REAL_WORKER_PATH = fileURLToPath(
new URL("./alias-worker.ts", import.meta.url),
);

function forkViaTsx(
modulePath: string,
args: readonly string[] | undefined,
options: Readonly<Record<string, unknown>> | undefined,
): ChildProcess {
return fork(modulePath, args ?? [], {
...options,
execArgv: ["--import", "tsx"],
});
}

function makeAliasProcess(): ForkedAliasProcess {
return new ForkedAliasProcess({
fork: forkViaTsx as typeof fork,
workerPath: REAL_WORKER_PATH,
});
}

async function tempHome(): Promise<string> {
return mkdtemp(join(tmpdir(), "cc-peer-alias-it-"));
}

async function readAliasRegistryEntry(
homeDir: string,
name: string,
): Promise<RegistryEntry> {
const sessionsDir = join(homeDir, ".claude", "sessions");
const files = await readdir(sessionsDir);
for (const file of files) {
if (!/^\d+\.json$/.test(file)) continue;
const raw = await readFile(join(sessionsDir, file), "utf8");
const entry = JSON.parse(raw) as RegistryEntry;
if (entry.name === name) return entry;
}
throw new Error(`no registry entry found for alias ${name}`);
}

async function readAliasKey(
homeDir: string,
socketPath: string,
): Promise<PeerKeyFile> {
const sessionsDir = join(homeDir, ".claude", "sessions");
const files = await readdir(sessionsDir);
const { createHash } = await import("node:crypto");
const hash = createHash("sha256").update(socketPath).digest("hex");
const match = files.find((f) => f.endsWith(`.${hash}.key`));
if (match === undefined) {
throw new Error(`no key file found for socket ${socketPath}`);
}
const raw = await readFile(join(sessionsDir, match), "utf8");
return JSON.parse(raw) as PeerKeyFile;
}

/** Sends one raw wire frame to the alias's socket, mirroring the reference reproduction in docs/PROTOCOL.md. */
async function sendReplyFrame(
socketPath: string,
token: string,
body: string,
): Promise<void> {
const socket = connect(socketPath);
await once(socket, "connect");
const envelope = `<cross-session-message from="uds:/tmp/cc-socks/9.sock">\n${body}\n</cross-session-message>`;
const frame = {
msgV: 1,
msg_id: randomUUID(),
type: "user",
message: { role: "user", content: envelope },
priority: "next",
from: "uds:/tmp/cc-socks/9.sock",
};
socket.write(`${JSON.stringify({ type: "auth", token })}\n`);
socket.write(`${JSON.stringify(frame)}\n`);
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 200);
timer.unref();
});
socket.destroy();
}

describe("ForkedAliasProcess default fork() fallback", () => {
test(
"with no injected fork function, uses the real node:child_process.fork and fails fast against a nonexistent path",
async () => {
// Proves the `deps.fork ?? fork` fallback (used whenever no fork is injected, i.e. every real production call) computes a real, invokable fork() call rather than merely typechecking. workerPath itself is always required now (see ForkedAliasProcessDeps's own doc comment) — AliasPool.create() is what computes the real default for that (proved by alias-pool.integration.test.ts's own "default wiring" case).
const proc = new ForkedAliasProcess({
workerPath: "/nonexistent/alias-worker-path.js",
});
await expect(
proc.start({ name: "unbuilt-default-test" }),
).rejects.toThrow(AliasStartError);
},
REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS,
);
});

describe("ForkedAliasProcess against the real alias-worker source", () => {
test(
"registers a discoverable peer and relays an inbound reply",
async () => {
const homeDir = await tempHome();
const socketDir = join(homeDir, "socks");
const proc = makeAliasProcess();
await proc.start({ name: "alice-relay-test", homeDir, socketDir });
const entry = await readAliasRegistryEntry(homeDir, "alice-relay-test");
const key = await readAliasKey(homeDir, entry.messagingSocketPath);
const received: unknown[] = [];
proc.events.on("message", (m: unknown) => {
received.push(m);
});
await sendReplyFrame(
entry.messagingSocketPath,
key.peerToken,
"reply from the relayed session",
);
const deadline = Date.now() + 5_000;
while (received.length === 0 && Date.now() < deadline) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 50);
timer.unref();
});
}
expect(received).toEqual([
expect.objectContaining({ body: "reply from the relayed session" }),
]);
await proc.stop();
},
REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS,
);

test(
"stop() removes the registry entry and the process exits",
async () => {
const homeDir = await tempHome();
const socketDir = join(homeDir, "socks");
const proc = makeAliasProcess();
await proc.start({ name: "bob-stop-test", homeDir, socketDir });
await readAliasRegistryEntry(homeDir, "bob-stop-test");
await proc.stop();
await expect(
readAliasRegistryEntry(homeDir, "bob-stop-test"),
).rejects.toThrow();
},
REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS,
);

test(
"rejects with AliasStartError when the worker fails to start",
async () => {
const root = await tempHome();
// homeDir is the trigger, not socketDir: CcPeer.start() skips its socketDir mkdir entirely on Windows (a named pipe has no filesystem directory of its own — see cc-peer.ts's own isWindows() guard), so corrupting socketDir can never fail there regardless of nesting (confirmed: this test failed on Windows CI with exactly that approach). keys.writeForSocket() and registry.write() both mkdir into sessionsDir(homeDir) unconditionally on every platform, so corrupting homeDir instead reaches a real, unconditional mkdir() everywhere. Nesting one level inside the broken file (not pointing homeDir directly at it) is still load-bearing for the same reason established earlier: creating a genuinely new directory entry inside a file has no valid resolution on any platform, whereas recursive mkdir() against an already-existing path does not appear to verify it is actually a directory on Windows.
const brokenFile = join(root, "not-a-directory");
const brokenHomeDir = join(brokenFile, "home");
await mkdir(root, { recursive: true });
await writeFile(brokenFile, "not a directory");
const proc = makeAliasProcess();
await expect(
proc.start({
name: "carol-fail-test",
homeDir: brokenHomeDir,
socketDir: join(brokenHomeDir, "socks"),
}),
).rejects.toThrow(AliasStartError);
},
REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS,
);
});
Loading
Loading