cc-peer 0.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph Mearman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # cc-peer
2
+
3
+ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/cc-peer) [![npm](https://img.shields.io/npm/v/cc-peer)](https://www.npmjs.com/package/cc-peer) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/cc-peer/ci.yml?branch=main)](https://github.com/ExaDev/cc-peer/actions)
4
+
5
+ Talk to the Claude Code instances running on your machine, from any Node application: send messages, register as a named peer other sessions can discover and message, receive replies and delivery receipts, and subscribe to idle notifications. Ships a REST facade you can run with `npx cc-peer`.
6
+
7
+ > **Unofficial.** This SDK speaks Claude Code's local cross-session peer protocol, which was reverse-engineered and verified against Claude Code 2.1.269. It is not affiliated with or endorsed by Anthropic, and the protocol may change without notice between Claude Code releases.
8
+
9
+ ## Why
10
+
11
+ Claude Code sessions are isolated: each interactive session binds a private Unix socket, and the only first-party way in is another Claude Code session's `SendMessage`. `cc-peer` opens that door to everything else — build tooling, agents in other harnesses, dashboards, shell scripts — with the protocol's own consent model intact (permission-mode attestation, hold-for-review, delivery receipts).
12
+
13
+ ## How it works
14
+
15
+ - **Discovery**: live sessions publish a registry at `~/.claude/sessions/<pid>.json`; `cc-peer` reads it, verifies each entry's socket and process, and can register itself there so real Claude sessions see it by name in `ListAgents`.
16
+ - **Transport**: per-exchange Unix-socket connections carrying two newline-delimited JSON lines (a bearer-token auth line, then the frame), authenticated with per-session key files.
17
+ - **Consent**: unattested messages land in the recipient's hold-for-review dialog; attested ones deliver directly. Delivery status comes back as receipts (`held`, `delivered`, `denied`, `expired`, `dropped` with reasons).
18
+ - **Idle subscriptions**: ask any session to notify you when it next goes idle (or exits).
19
+
20
+ The full wire reference for implementing the protocol yourself lives in [docs/PROTOCOL.md](docs/PROTOCOL.md), with machine-readable JSON Schemas published alongside the package (`cc-peer/schemas/*.schema.json`).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install cc-peer
26
+ ```
27
+
28
+ Or run the REST facade with no install:
29
+
30
+ ```bash
31
+ npx cc-peer
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```ts
37
+ import { CcPeer } from "cc-peer";
38
+
39
+ const peer = await CcPeer.create({ name: "my-app" });
40
+
41
+ peer.on("message", (m) => console.log(`${m.fromName ?? m.from}: ${m.body}`));
42
+ peer.on("receipt", (r) => console.log(`status: ${r.status}`));
43
+
44
+ const sessions = await peer.roster();
45
+ const { msgId } = await peer.send(sessions[0]!.pid, "hello from my app");
46
+
47
+ await peer.subscribeIdle(sessions[0]!.pid);
48
+ peer.on("idle", (n) => console.log(`session ${n.state}`));
49
+
50
+ // …later
51
+ await peer.stop();
52
+ ```
53
+
54
+ 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.
55
+
56
+ ## Limitations
57
+
58
+ - **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.
59
+ - **Single machine**: the local protocol is Unix-socket only. Writing to cloud sessions directly is blocked by design (device-attestation-signed events); bridged sessions reachable locally still work via their local mirror.
60
+ - **File transfers to Claude sessions** wait on an upstream feature flag (`tengu_send_file`) before Claude-side materialisation activates; peer-to-peer transfers work today.
61
+ - Verified against Claude Code 2.1.269; treat every Claude Code upgrade as a potential protocol change.
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ const require_cc_peer = require("../cc-peer-CH38-mD2.cjs");
25
+ let node_crypto = require("node:crypto");
26
+ let zod = require("zod");
27
+ let node_process = require("node:process");
28
+ node_process = __toESM(node_process, 1);
29
+ let node_http = require("node:http");
30
+ //#region src/api/schemas.ts
31
+ /** Address a REST target by session pid, roster name, or raw address. */
32
+ const PeerTargetSchema = require_cc_peer.defineSchema(zod.z.object({
33
+ pid: zod.z.number().int().positive().optional(),
34
+ name: zod.z.string().min(1).optional(),
35
+ address: zod.z.string().min(1).optional()
36
+ }).refine((t) => t.pid !== void 0 || t.name !== void 0 || t.address !== void 0, { message: "target must specify pid, name, or address" }).meta({
37
+ id: "PeerTarget",
38
+ title: "Peer target"
39
+ }));
40
+ const SendMessageRequestSchema = require_cc_peer.defineSchema(zod.z.object({
41
+ to: PeerTargetSchema,
42
+ body: zod.z.string().min(1),
43
+ priority: require_cc_peer.PrioritySchema.optional(),
44
+ /** false deliberately sends unattested (the recipient holds it). */
45
+ fromMode: zod.z.union([zod.z.enum(["bypass", "prompting"]), zod.z.literal(false)]).optional()
46
+ }).meta({
47
+ id: "SendMessageRequest",
48
+ title: "Send message request"
49
+ }));
50
+ const IdleSubscriptionRequestSchema = require_cc_peer.defineSchema(zod.z.object({ to: PeerTargetSchema }).meta({
51
+ id: "IdleSubscriptionRequest",
52
+ title: "Idle subscription request"
53
+ }));
54
+ /**
55
+ * The schemas that become OpenAPI 3.1 components. OpenAPI 3.1 component schemas are JSON Schema 2020-12, so z.toJSONSchema with that target converts each definition losslessly; ids come from .meta() so components are named and reusable rather than inlined per-operation.
56
+ */
57
+ const API_COMPONENT_SCHEMAS = {
58
+ PeerTarget: PeerTargetSchema,
59
+ SendMessageRequest: SendMessageRequestSchema,
60
+ IdleSubscriptionRequest: IdleSubscriptionRequestSchema,
61
+ SendAccepted: require_cc_peer.defineSchema(zod.z.object({ msgId: zod.z.string() }).meta({
62
+ id: "SendAccepted",
63
+ title: "Send accepted"
64
+ })),
65
+ RosterResponse: require_cc_peer.defineSchema(zod.z.object({ sessions: zod.z.array(require_cc_peer.RegistryEntrySchema) }).meta({
66
+ id: "RosterResponse",
67
+ title: "Roster response"
68
+ })),
69
+ ErrorResponse: require_cc_peer.defineSchema(zod.z.object({ error: zod.z.string() }).meta({
70
+ id: "ErrorResponse",
71
+ title: "Error response"
72
+ }))
73
+ };
74
+ //#endregion
75
+ //#region src/api/server.ts
76
+ /** Loopback only; this bridges onto a same-user IPC trust boundary. */
77
+ const BIND_HOST = "127.0.0.1";
78
+ const ALLOWED_HOSTS = /* @__PURE__ */ new Set([
79
+ "localhost",
80
+ "127.0.0.1",
81
+ `[${BIND_HOST}]`
82
+ ]);
83
+ /** API tokens are 24 random bytes in hex. */
84
+ const API_TOKEN_BYTES = 24;
85
+ const HTTP_OK = 200;
86
+ const HTTP_ACCEPTED = 202;
87
+ const HTTP_UNAUTHORIZED = 401;
88
+ const HTTP_FORBIDDEN = 403;
89
+ const HTTP_NOT_FOUND = 404;
90
+ const HTTP_INTERNAL = 500;
91
+ async function createApiServer(peer, options = {}) {
92
+ const token = options.noToken === true ? void 0 : options.token ?? (0, node_crypto.randomBytes)(API_TOKEN_BYTES).toString("hex");
93
+ const server = (0, node_http.createServer)((req, res) => {
94
+ handle(peer, req, res, token);
95
+ });
96
+ return new Promise((resolve) => {
97
+ server.listen(options.port ?? 0, BIND_HOST, () => {
98
+ const address = server.address();
99
+ resolve({
100
+ port: typeof address === "object" && address !== null ? address.port : 0,
101
+ token,
102
+ close: async () => {
103
+ await new Promise((resolveClose) => {
104
+ server.close(() => {
105
+ resolveClose();
106
+ });
107
+ });
108
+ }
109
+ });
110
+ });
111
+ });
112
+ }
113
+ async function handle(peer, req, res, token) {
114
+ const finish = (status, body) => {
115
+ res.writeHead(status, {
116
+ "content-type": "application/json",
117
+ "cache-control": "no-store"
118
+ });
119
+ res.end(body);
120
+ };
121
+ const host = (req.headers.host ?? "").split(":")[0] ?? "";
122
+ if (!ALLOWED_HOSTS.has(host)) {
123
+ finish(HTTP_FORBIDDEN, errorBody("host not allowed"));
124
+ return;
125
+ }
126
+ if (token !== void 0) {
127
+ if ((req.headers.authorization ?? "") !== `Bearer ${token}`) {
128
+ finish(HTTP_UNAUTHORIZED, errorBody("unauthorized"));
129
+ return;
130
+ }
131
+ }
132
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
133
+ try {
134
+ if (req.method === "GET" && url.pathname === "/healthz") {
135
+ finish(HTTP_OK, JSON.stringify({ ok: true }));
136
+ return;
137
+ }
138
+ if (req.method === "GET" && url.pathname === "/openapi.json") {
139
+ finish(HTTP_OK, JSON.stringify(openApiDocument()));
140
+ return;
141
+ }
142
+ if (req.method === "GET" && url.pathname === "/sessions") {
143
+ const roster = await peer.roster();
144
+ finish(HTTP_OK, JSON.stringify({ sessions: roster }));
145
+ return;
146
+ }
147
+ if (req.method === "POST" && url.pathname === "/messages") {
148
+ const request = SendMessageRequestSchema.parse(JSON.parse(await readBody(req)));
149
+ const sent = await peer.send(toPeerRef(request.to), request.body, {
150
+ ...request.priority !== void 0 ? { priority: request.priority } : {},
151
+ ...request.fromMode !== void 0 ? { fromMode: request.fromMode } : {}
152
+ });
153
+ finish(HTTP_ACCEPTED, JSON.stringify(sent));
154
+ return;
155
+ }
156
+ if (req.method === "POST" && url.pathname === "/idle-subscriptions") {
157
+ const request = IdleSubscriptionRequestSchema.parse(JSON.parse(await readBody(req)));
158
+ const sent = await peer.subscribeIdle(toPeerRef(request.to));
159
+ finish(HTTP_ACCEPTED, JSON.stringify(sent));
160
+ return;
161
+ }
162
+ if (req.method === "GET" && url.pathname === "/events") {
163
+ streamEvents(peer, req, res);
164
+ return;
165
+ }
166
+ finish(HTTP_NOT_FOUND, errorBody("not found"));
167
+ } catch (error) {
168
+ finish(HTTP_INTERNAL, errorBody(error instanceof Error ? error.message : "internal"));
169
+ }
170
+ }
171
+ function toPeerRef(to) {
172
+ if (to.pid !== void 0) return { pid: to.pid };
173
+ if (to.name !== void 0) return { name: to.name };
174
+ if (to.address !== void 0) return { address: to.address };
175
+ throw new Error("target must specify pid, name, or address");
176
+ }
177
+ function errorBody(message) {
178
+ return JSON.stringify(API_COMPONENT_SCHEMAS.ErrorResponse.parse({ error: message }));
179
+ }
180
+ function streamEvents(peer, req, res) {
181
+ res.writeHead(HTTP_OK, {
182
+ "content-type": "text/event-stream",
183
+ "cache-control": "no-store",
184
+ connection: "keep-alive"
185
+ });
186
+ const forward = (event, data) => {
187
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
188
+ };
189
+ const onMessage = (m) => {
190
+ forward("message", m);
191
+ };
192
+ const onReceipt = (r) => {
193
+ forward("receipt", r);
194
+ };
195
+ const onIdle = (n) => {
196
+ forward("idle", n);
197
+ };
198
+ peer.on("message", onMessage);
199
+ peer.on("receipt", onReceipt);
200
+ peer.on("idle", onIdle);
201
+ req.on("close", () => {
202
+ peer.off("message", onMessage);
203
+ peer.off("receipt", onReceipt);
204
+ peer.off("idle", onIdle);
205
+ });
206
+ res.write(": connected\n\n");
207
+ }
208
+ async function readBody(req) {
209
+ return new Promise((resolve) => {
210
+ let data = "";
211
+ req.on("data", (chunk) => {
212
+ data += chunk.toString("utf8");
213
+ });
214
+ req.on("end", () => {
215
+ resolve(data);
216
+ });
217
+ });
218
+ }
219
+ function ref(name) {
220
+ return { $ref: `#/components/schemas/${name}` };
221
+ }
222
+ /**
223
+ * OpenAPI 3.1 document: hand-authored paths/operations (which Zod cannot express) with every component schema converted losslessly from the same Zod definitions the server parses request bodies with — 3.1 components are JSON Schema 2020-12, so z.toJSONSchema output embeds directly.
224
+ */
225
+ function openApiDocument() {
226
+ const components = {};
227
+ for (const [name, schema] of Object.entries(API_COMPONENT_SCHEMAS)) components[name] = zod.z.toJSONSchema(schema, { target: "draft-2020-12" });
228
+ const jsonBody = (name) => ({
229
+ required: true,
230
+ content: { "application/json": { schema: ref(name) } }
231
+ });
232
+ return {
233
+ openapi: "3.1.0",
234
+ info: {
235
+ title: "cc-peer",
236
+ version: "1.0.0",
237
+ description: "REST facade over Claude Code's local cross-session peer messaging. Loopback only; bearer-token auth by default."
238
+ },
239
+ servers: [{ url: `http://${BIND_HOST}` }],
240
+ paths: {
241
+ "/healthz": { get: {
242
+ summary: "Liveness probe",
243
+ responses: { "200": { description: "OK" } }
244
+ } },
245
+ "/openapi.json": { get: {
246
+ summary: "This document",
247
+ responses: { "200": { description: "OpenAPI document" } }
248
+ } },
249
+ "/sessions": { get: {
250
+ summary: "Live peer roster with admission checks applied",
251
+ responses: { "200": {
252
+ description: "Roster entries",
253
+ content: { "application/json": { schema: ref("RosterResponse") } }
254
+ } }
255
+ } },
256
+ "/messages": { post: {
257
+ summary: "Send a message to a peer by pid, name, or address",
258
+ requestBody: jsonBody("SendMessageRequest"),
259
+ responses: {
260
+ "202": {
261
+ description: "Queued; delivery receipts arrive on /events",
262
+ content: { "application/json": { schema: ref("SendAccepted") } }
263
+ },
264
+ "400": {
265
+ description: "Invalid request body",
266
+ content: { "application/json": { schema: ref("ErrorResponse") } }
267
+ }
268
+ }
269
+ } },
270
+ "/idle-subscriptions": { post: {
271
+ summary: "Subscribe for a peer's next idle (or exit) notice",
272
+ requestBody: jsonBody("IdleSubscriptionRequest"),
273
+ responses: { "202": {
274
+ description: "Subscribed; notices arrive on /events",
275
+ content: { "application/json": { schema: ref("SendAccepted") } }
276
+ } }
277
+ } },
278
+ "/events": { get: {
279
+ summary: "SSE stream of inbound messages, receipts, and idle notices",
280
+ responses: { "200": { description: "text/event-stream" } }
281
+ } }
282
+ },
283
+ components: {
284
+ schemas: components,
285
+ securitySchemes: { bearer: {
286
+ type: "http",
287
+ scheme: "bearer"
288
+ } }
289
+ },
290
+ security: [{ bearer: [] }]
291
+ };
292
+ }
293
+ //#endregion
294
+ //#region src/bin/cc-peer.ts
295
+ function parseArgs(argv) {
296
+ const args = { noToken: false };
297
+ for (let i = 0; i < argv.length; i += 1) {
298
+ const arg = argv[i];
299
+ const next = argv[i + 1];
300
+ if (arg === "--port" && next !== void 0) {
301
+ args.port = Number.parseInt(next, 10);
302
+ i += 1;
303
+ } else if (arg === "--token" && next !== void 0) {
304
+ args.token = next;
305
+ i += 1;
306
+ } else if (arg === "--name" && next !== void 0) {
307
+ args.name = next;
308
+ i += 1;
309
+ } else if (arg === "--home" && next !== void 0) {
310
+ args.home = next;
311
+ i += 1;
312
+ } else if (arg === "--no-token") args.noToken = true;
313
+ }
314
+ return args;
315
+ }
316
+ async function main() {
317
+ const args = parseArgs(node_process.default.argv.slice(2));
318
+ const peer = await require_cc_peer.CcPeer.create({
319
+ ...args.name !== void 0 ? { name: args.name } : {},
320
+ ...args.home !== void 0 ? { homeDir: args.home } : {},
321
+ logger: (message) => {
322
+ node_process.default.stderr.write(`[cc-peer] ${message}\n`);
323
+ }
324
+ });
325
+ const server = await createApiServer(peer, {
326
+ ...args.port !== void 0 ? { port: args.port } : {},
327
+ ...args.token !== void 0 ? { token: args.token } : {},
328
+ noToken: args.noToken
329
+ });
330
+ node_process.default.stderr.write(`[cc-peer] REST facade listening on http://127.0.0.1:${server.port.toString()}\n`);
331
+ if (server.token !== void 0) {
332
+ node_process.default.stderr.write(`[cc-peer] bearer token: ${server.token}\n`);
333
+ node_process.default.stderr.write("[cc-peer] example: curl -H 'Authorization: Bearer <token>' http://127.0.0.1:" + server.port.toString() + "/sessions\n");
334
+ }
335
+ const shutdown = async () => {
336
+ await server.close();
337
+ await peer.stop();
338
+ node_process.default.exit(0);
339
+ };
340
+ node_process.default.on("SIGINT", () => {
341
+ shutdown();
342
+ });
343
+ node_process.default.on("SIGTERM", () => {
344
+ shutdown();
345
+ });
346
+ }
347
+ main().catch((error) => {
348
+ node_process.default.stderr.write(`[cc-peer] fatal: ${error instanceof Error ? error.message : String(error)}\n`);
349
+ node_process.default.exit(1);
350
+ });
351
+ //#endregion
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1 @@
1
+ export {}