cc-peer 1.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/dist/bin/cc-peer.cjs +348 -2
- package/dist/bin/cc-peer.mjs +325 -2
- package/dist/cc-peer-BpNw-MoF.mjs +945 -0
- package/dist/cc-peer-CH38-mD2.cjs +974 -0
- package/dist/cc-peer.cjs +3 -4
- package/dist/cc-peer.d.cts +301 -0
- package/dist/cc-peer.d.mts +301 -0
- package/dist/cc-peer.mjs +2 -4
- package/package.json +1 -1
package/dist/bin/cc-peer.cjs
CHANGED
|
@@ -1,5 +1,351 @@
|
|
|
1
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
|
|
2
294
|
//#region src/bin/cc-peer.ts
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
});
|
|
5
351
|
//#endregion
|
package/dist/bin/cc-peer.mjs
CHANGED
|
@@ -1,6 +1,329 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { a as defineSchema, i as RegistryEntrySchema, n as CcPeer, r as PrioritySchema } from "../cc-peer-BpNw-MoF.mjs";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
//#region src/api/schemas.ts
|
|
8
|
+
/** Address a REST target by session pid, roster name, or raw address. */
|
|
9
|
+
const PeerTargetSchema = defineSchema(z.object({
|
|
10
|
+
pid: z.number().int().positive().optional(),
|
|
11
|
+
name: z.string().min(1).optional(),
|
|
12
|
+
address: z.string().min(1).optional()
|
|
13
|
+
}).refine((t) => t.pid !== void 0 || t.name !== void 0 || t.address !== void 0, { message: "target must specify pid, name, or address" }).meta({
|
|
14
|
+
id: "PeerTarget",
|
|
15
|
+
title: "Peer target"
|
|
16
|
+
}));
|
|
17
|
+
const SendMessageRequestSchema = defineSchema(z.object({
|
|
18
|
+
to: PeerTargetSchema,
|
|
19
|
+
body: z.string().min(1),
|
|
20
|
+
priority: PrioritySchema.optional(),
|
|
21
|
+
/** false deliberately sends unattested (the recipient holds it). */
|
|
22
|
+
fromMode: z.union([z.enum(["bypass", "prompting"]), z.literal(false)]).optional()
|
|
23
|
+
}).meta({
|
|
24
|
+
id: "SendMessageRequest",
|
|
25
|
+
title: "Send message request"
|
|
26
|
+
}));
|
|
27
|
+
const IdleSubscriptionRequestSchema = defineSchema(z.object({ to: PeerTargetSchema }).meta({
|
|
28
|
+
id: "IdleSubscriptionRequest",
|
|
29
|
+
title: "Idle subscription request"
|
|
30
|
+
}));
|
|
31
|
+
/**
|
|
32
|
+
* 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.
|
|
33
|
+
*/
|
|
34
|
+
const API_COMPONENT_SCHEMAS = {
|
|
35
|
+
PeerTarget: PeerTargetSchema,
|
|
36
|
+
SendMessageRequest: SendMessageRequestSchema,
|
|
37
|
+
IdleSubscriptionRequest: IdleSubscriptionRequestSchema,
|
|
38
|
+
SendAccepted: defineSchema(z.object({ msgId: z.string() }).meta({
|
|
39
|
+
id: "SendAccepted",
|
|
40
|
+
title: "Send accepted"
|
|
41
|
+
})),
|
|
42
|
+
RosterResponse: defineSchema(z.object({ sessions: z.array(RegistryEntrySchema) }).meta({
|
|
43
|
+
id: "RosterResponse",
|
|
44
|
+
title: "Roster response"
|
|
45
|
+
})),
|
|
46
|
+
ErrorResponse: defineSchema(z.object({ error: z.string() }).meta({
|
|
47
|
+
id: "ErrorResponse",
|
|
48
|
+
title: "Error response"
|
|
49
|
+
}))
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/api/server.ts
|
|
53
|
+
/** Loopback only; this bridges onto a same-user IPC trust boundary. */
|
|
54
|
+
const BIND_HOST = "127.0.0.1";
|
|
55
|
+
const ALLOWED_HOSTS = /* @__PURE__ */ new Set([
|
|
56
|
+
"localhost",
|
|
57
|
+
"127.0.0.1",
|
|
58
|
+
`[${BIND_HOST}]`
|
|
59
|
+
]);
|
|
60
|
+
/** API tokens are 24 random bytes in hex. */
|
|
61
|
+
const API_TOKEN_BYTES = 24;
|
|
62
|
+
const HTTP_OK = 200;
|
|
63
|
+
const HTTP_ACCEPTED = 202;
|
|
64
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
65
|
+
const HTTP_FORBIDDEN = 403;
|
|
66
|
+
const HTTP_NOT_FOUND = 404;
|
|
67
|
+
const HTTP_INTERNAL = 500;
|
|
68
|
+
async function createApiServer(peer, options = {}) {
|
|
69
|
+
const token = options.noToken === true ? void 0 : options.token ?? randomBytes(API_TOKEN_BYTES).toString("hex");
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
handle(peer, req, res, token);
|
|
72
|
+
});
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
server.listen(options.port ?? 0, BIND_HOST, () => {
|
|
75
|
+
const address = server.address();
|
|
76
|
+
resolve({
|
|
77
|
+
port: typeof address === "object" && address !== null ? address.port : 0,
|
|
78
|
+
token,
|
|
79
|
+
close: async () => {
|
|
80
|
+
await new Promise((resolveClose) => {
|
|
81
|
+
server.close(() => {
|
|
82
|
+
resolveClose();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function handle(peer, req, res, token) {
|
|
91
|
+
const finish = (status, body) => {
|
|
92
|
+
res.writeHead(status, {
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
"cache-control": "no-store"
|
|
95
|
+
});
|
|
96
|
+
res.end(body);
|
|
97
|
+
};
|
|
98
|
+
const host = (req.headers.host ?? "").split(":")[0] ?? "";
|
|
99
|
+
if (!ALLOWED_HOSTS.has(host)) {
|
|
100
|
+
finish(HTTP_FORBIDDEN, errorBody("host not allowed"));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (token !== void 0) {
|
|
104
|
+
if ((req.headers.authorization ?? "") !== `Bearer ${token}`) {
|
|
105
|
+
finish(HTTP_UNAUTHORIZED, errorBody("unauthorized"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
110
|
+
try {
|
|
111
|
+
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
112
|
+
finish(HTTP_OK, JSON.stringify({ ok: true }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (req.method === "GET" && url.pathname === "/openapi.json") {
|
|
116
|
+
finish(HTTP_OK, JSON.stringify(openApiDocument()));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (req.method === "GET" && url.pathname === "/sessions") {
|
|
120
|
+
const roster = await peer.roster();
|
|
121
|
+
finish(HTTP_OK, JSON.stringify({ sessions: roster }));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (req.method === "POST" && url.pathname === "/messages") {
|
|
125
|
+
const request = SendMessageRequestSchema.parse(JSON.parse(await readBody(req)));
|
|
126
|
+
const sent = await peer.send(toPeerRef(request.to), request.body, {
|
|
127
|
+
...request.priority !== void 0 ? { priority: request.priority } : {},
|
|
128
|
+
...request.fromMode !== void 0 ? { fromMode: request.fromMode } : {}
|
|
129
|
+
});
|
|
130
|
+
finish(HTTP_ACCEPTED, JSON.stringify(sent));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (req.method === "POST" && url.pathname === "/idle-subscriptions") {
|
|
134
|
+
const request = IdleSubscriptionRequestSchema.parse(JSON.parse(await readBody(req)));
|
|
135
|
+
const sent = await peer.subscribeIdle(toPeerRef(request.to));
|
|
136
|
+
finish(HTTP_ACCEPTED, JSON.stringify(sent));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
140
|
+
streamEvents(peer, req, res);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
finish(HTTP_NOT_FOUND, errorBody("not found"));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
finish(HTTP_INTERNAL, errorBody(error instanceof Error ? error.message : "internal"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function toPeerRef(to) {
|
|
149
|
+
if (to.pid !== void 0) return { pid: to.pid };
|
|
150
|
+
if (to.name !== void 0) return { name: to.name };
|
|
151
|
+
if (to.address !== void 0) return { address: to.address };
|
|
152
|
+
throw new Error("target must specify pid, name, or address");
|
|
153
|
+
}
|
|
154
|
+
function errorBody(message) {
|
|
155
|
+
return JSON.stringify(API_COMPONENT_SCHEMAS.ErrorResponse.parse({ error: message }));
|
|
156
|
+
}
|
|
157
|
+
function streamEvents(peer, req, res) {
|
|
158
|
+
res.writeHead(HTTP_OK, {
|
|
159
|
+
"content-type": "text/event-stream",
|
|
160
|
+
"cache-control": "no-store",
|
|
161
|
+
connection: "keep-alive"
|
|
162
|
+
});
|
|
163
|
+
const forward = (event, data) => {
|
|
164
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
165
|
+
};
|
|
166
|
+
const onMessage = (m) => {
|
|
167
|
+
forward("message", m);
|
|
168
|
+
};
|
|
169
|
+
const onReceipt = (r) => {
|
|
170
|
+
forward("receipt", r);
|
|
171
|
+
};
|
|
172
|
+
const onIdle = (n) => {
|
|
173
|
+
forward("idle", n);
|
|
174
|
+
};
|
|
175
|
+
peer.on("message", onMessage);
|
|
176
|
+
peer.on("receipt", onReceipt);
|
|
177
|
+
peer.on("idle", onIdle);
|
|
178
|
+
req.on("close", () => {
|
|
179
|
+
peer.off("message", onMessage);
|
|
180
|
+
peer.off("receipt", onReceipt);
|
|
181
|
+
peer.off("idle", onIdle);
|
|
182
|
+
});
|
|
183
|
+
res.write(": connected\n\n");
|
|
184
|
+
}
|
|
185
|
+
async function readBody(req) {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
let data = "";
|
|
188
|
+
req.on("data", (chunk) => {
|
|
189
|
+
data += chunk.toString("utf8");
|
|
190
|
+
});
|
|
191
|
+
req.on("end", () => {
|
|
192
|
+
resolve(data);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function ref(name) {
|
|
197
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 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.
|
|
201
|
+
*/
|
|
202
|
+
function openApiDocument() {
|
|
203
|
+
const components = {};
|
|
204
|
+
for (const [name, schema] of Object.entries(API_COMPONENT_SCHEMAS)) components[name] = z.toJSONSchema(schema, { target: "draft-2020-12" });
|
|
205
|
+
const jsonBody = (name) => ({
|
|
206
|
+
required: true,
|
|
207
|
+
content: { "application/json": { schema: ref(name) } }
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
openapi: "3.1.0",
|
|
211
|
+
info: {
|
|
212
|
+
title: "cc-peer",
|
|
213
|
+
version: "1.0.0",
|
|
214
|
+
description: "REST facade over Claude Code's local cross-session peer messaging. Loopback only; bearer-token auth by default."
|
|
215
|
+
},
|
|
216
|
+
servers: [{ url: `http://${BIND_HOST}` }],
|
|
217
|
+
paths: {
|
|
218
|
+
"/healthz": { get: {
|
|
219
|
+
summary: "Liveness probe",
|
|
220
|
+
responses: { "200": { description: "OK" } }
|
|
221
|
+
} },
|
|
222
|
+
"/openapi.json": { get: {
|
|
223
|
+
summary: "This document",
|
|
224
|
+
responses: { "200": { description: "OpenAPI document" } }
|
|
225
|
+
} },
|
|
226
|
+
"/sessions": { get: {
|
|
227
|
+
summary: "Live peer roster with admission checks applied",
|
|
228
|
+
responses: { "200": {
|
|
229
|
+
description: "Roster entries",
|
|
230
|
+
content: { "application/json": { schema: ref("RosterResponse") } }
|
|
231
|
+
} }
|
|
232
|
+
} },
|
|
233
|
+
"/messages": { post: {
|
|
234
|
+
summary: "Send a message to a peer by pid, name, or address",
|
|
235
|
+
requestBody: jsonBody("SendMessageRequest"),
|
|
236
|
+
responses: {
|
|
237
|
+
"202": {
|
|
238
|
+
description: "Queued; delivery receipts arrive on /events",
|
|
239
|
+
content: { "application/json": { schema: ref("SendAccepted") } }
|
|
240
|
+
},
|
|
241
|
+
"400": {
|
|
242
|
+
description: "Invalid request body",
|
|
243
|
+
content: { "application/json": { schema: ref("ErrorResponse") } }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} },
|
|
247
|
+
"/idle-subscriptions": { post: {
|
|
248
|
+
summary: "Subscribe for a peer's next idle (or exit) notice",
|
|
249
|
+
requestBody: jsonBody("IdleSubscriptionRequest"),
|
|
250
|
+
responses: { "202": {
|
|
251
|
+
description: "Subscribed; notices arrive on /events",
|
|
252
|
+
content: { "application/json": { schema: ref("SendAccepted") } }
|
|
253
|
+
} }
|
|
254
|
+
} },
|
|
255
|
+
"/events": { get: {
|
|
256
|
+
summary: "SSE stream of inbound messages, receipts, and idle notices",
|
|
257
|
+
responses: { "200": { description: "text/event-stream" } }
|
|
258
|
+
} }
|
|
259
|
+
},
|
|
260
|
+
components: {
|
|
261
|
+
schemas: components,
|
|
262
|
+
securitySchemes: { bearer: {
|
|
263
|
+
type: "http",
|
|
264
|
+
scheme: "bearer"
|
|
265
|
+
} }
|
|
266
|
+
},
|
|
267
|
+
security: [{ bearer: [] }]
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
2
271
|
//#region src/bin/cc-peer.ts
|
|
3
|
-
|
|
4
|
-
|
|
272
|
+
function parseArgs(argv) {
|
|
273
|
+
const args = { noToken: false };
|
|
274
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
275
|
+
const arg = argv[i];
|
|
276
|
+
const next = argv[i + 1];
|
|
277
|
+
if (arg === "--port" && next !== void 0) {
|
|
278
|
+
args.port = Number.parseInt(next, 10);
|
|
279
|
+
i += 1;
|
|
280
|
+
} else if (arg === "--token" && next !== void 0) {
|
|
281
|
+
args.token = next;
|
|
282
|
+
i += 1;
|
|
283
|
+
} else if (arg === "--name" && next !== void 0) {
|
|
284
|
+
args.name = next;
|
|
285
|
+
i += 1;
|
|
286
|
+
} else if (arg === "--home" && next !== void 0) {
|
|
287
|
+
args.home = next;
|
|
288
|
+
i += 1;
|
|
289
|
+
} else if (arg === "--no-token") args.noToken = true;
|
|
290
|
+
}
|
|
291
|
+
return args;
|
|
292
|
+
}
|
|
293
|
+
async function main() {
|
|
294
|
+
const args = parseArgs(process.argv.slice(2));
|
|
295
|
+
const peer = await CcPeer.create({
|
|
296
|
+
...args.name !== void 0 ? { name: args.name } : {},
|
|
297
|
+
...args.home !== void 0 ? { homeDir: args.home } : {},
|
|
298
|
+
logger: (message) => {
|
|
299
|
+
process.stderr.write(`[cc-peer] ${message}\n`);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
const server = await createApiServer(peer, {
|
|
303
|
+
...args.port !== void 0 ? { port: args.port } : {},
|
|
304
|
+
...args.token !== void 0 ? { token: args.token } : {},
|
|
305
|
+
noToken: args.noToken
|
|
306
|
+
});
|
|
307
|
+
process.stderr.write(`[cc-peer] REST facade listening on http://127.0.0.1:${server.port.toString()}\n`);
|
|
308
|
+
if (server.token !== void 0) {
|
|
309
|
+
process.stderr.write(`[cc-peer] bearer token: ${server.token}\n`);
|
|
310
|
+
process.stderr.write("[cc-peer] example: curl -H 'Authorization: Bearer <token>' http://127.0.0.1:" + server.port.toString() + "/sessions\n");
|
|
311
|
+
}
|
|
312
|
+
const shutdown = async () => {
|
|
313
|
+
await server.close();
|
|
314
|
+
await peer.stop();
|
|
315
|
+
process.exit(0);
|
|
316
|
+
};
|
|
317
|
+
process.on("SIGINT", () => {
|
|
318
|
+
shutdown();
|
|
319
|
+
});
|
|
320
|
+
process.on("SIGTERM", () => {
|
|
321
|
+
shutdown();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
main().catch((error) => {
|
|
325
|
+
process.stderr.write(`[cc-peer] fatal: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
});
|
|
5
328
|
//#endregion
|
|
6
329
|
export {};
|