cc-peer 1.1.5 → 1.1.7
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 +48 -11
- package/dist/bin/cc-peer.mjs +48 -11
- package/dist/{cc-peer-BpNw-MoF.mjs → cc-peer-CWGY6H7F.mjs} +66 -79
- package/dist/{cc-peer-CH38-mD2.cjs → cc-peer-IGtoK_0q.cjs} +66 -79
- package/dist/cc-peer.cjs +1 -1
- package/dist/cc-peer.d.cts +6 -1
- package/dist/cc-peer.d.mts +6 -1
- package/dist/cc-peer.mjs +1 -1
- package/docs/PROTOCOL.md +1 -1
- package/package.json +6 -5
package/dist/bin/cc-peer.cjs
CHANGED
|
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
21
|
enumerable: true
|
|
22
22
|
}) : target, mod));
|
|
23
23
|
//#endregion
|
|
24
|
-
const require_cc_peer = require("../cc-peer-
|
|
24
|
+
const require_cc_peer = require("../cc-peer-IGtoK_0q.cjs");
|
|
25
25
|
let node_crypto = require("node:crypto");
|
|
26
26
|
let zod = require("zod");
|
|
27
27
|
let node_process = require("node:process");
|
|
@@ -92,17 +92,25 @@ API_REGISTRY.add(ErrorResponseSchema, {
|
|
|
92
92
|
function isJsonObject(value) {
|
|
93
93
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Pure extraction of the conversion shape so malformed registry output fails loudly instead of silently producing empty components. Exported for direct unit coverage of every malformed-input path.
|
|
97
|
+
*/
|
|
98
|
+
function componentSchemasFrom(converted) {
|
|
99
|
+
if (!isJsonObject(converted)) throw new Error("zod registry conversion did not produce an object");
|
|
100
|
+
const schemas = converted.schemas;
|
|
101
|
+
if (!isJsonObject(schemas)) throw new Error("zod registry conversion produced no schemas object");
|
|
98
102
|
const stripped = {};
|
|
99
|
-
for (const [name, schema] of Object.entries(schemas))
|
|
103
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
104
|
+
if (!isJsonObject(schema)) throw new Error(`component ${name} is not a JSON object`);
|
|
100
105
|
const rest = { ...schema };
|
|
101
106
|
delete rest.$schema;
|
|
102
107
|
stripped[name] = rest;
|
|
103
|
-
}
|
|
108
|
+
}
|
|
104
109
|
return stripped;
|
|
105
110
|
}
|
|
111
|
+
function apiComponentSchemas() {
|
|
112
|
+
return componentSchemasFrom(zod.z.toJSONSchema(API_REGISTRY, { uri: (id) => `#/components/schemas/${id}` }));
|
|
113
|
+
}
|
|
106
114
|
//#endregion
|
|
107
115
|
//#region src/api/server.ts
|
|
108
116
|
/** Loopback only; this bridges onto a same-user IPC trust boundary. */
|
|
@@ -127,9 +135,8 @@ async function createApiServer(peer, options = {}) {
|
|
|
127
135
|
});
|
|
128
136
|
return new Promise((resolve) => {
|
|
129
137
|
server.listen(options.port ?? 0, BIND_HOST, () => {
|
|
130
|
-
const address = server.address();
|
|
131
138
|
resolve({
|
|
132
|
-
port:
|
|
139
|
+
port: listeningPort(server.address()),
|
|
133
140
|
token,
|
|
134
141
|
close: async () => {
|
|
135
142
|
await new Promise((resolveClose) => {
|
|
@@ -150,7 +157,7 @@ async function handle(peer, req, res, token) {
|
|
|
150
157
|
});
|
|
151
158
|
res.end(body);
|
|
152
159
|
};
|
|
153
|
-
const host = (req.headers.host
|
|
160
|
+
const host = hostnameOf(req.headers.host);
|
|
154
161
|
if (!ALLOWED_HOSTS.has(host)) {
|
|
155
162
|
finish(HTTP_FORBIDDEN, errorBody("host not allowed"));
|
|
156
163
|
return;
|
|
@@ -161,7 +168,7 @@ async function handle(peer, req, res, token) {
|
|
|
161
168
|
return;
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
|
-
const url =
|
|
171
|
+
const url = requestUrl(req.url, req.headers.host);
|
|
165
172
|
try {
|
|
166
173
|
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
167
174
|
finish(HTTP_OK, JSON.stringify({ ok: true }));
|
|
@@ -197,9 +204,39 @@ async function handle(peer, req, res, token) {
|
|
|
197
204
|
}
|
|
198
205
|
finish(HTTP_NOT_FOUND, errorBody("not found"));
|
|
199
206
|
} catch (error) {
|
|
200
|
-
finish(HTTP_INTERNAL, errorBody(error
|
|
207
|
+
finish(HTTP_INTERNAL, errorBody(httpErrorMessage(error)));
|
|
201
208
|
}
|
|
202
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Extract the numeric port `createApiServer` reports after `listen()`. The server always listens on a TCP host:port pair (never a named pipe), so `address()` returning anything but an AddressInfo object is a Node behaviour our own call site cannot trigger; a thrown error surfaces that violated assumption loudly rather than silently reporting port 0. Exported so the impossible-input side is directly unit-coverable without mocking node:net.
|
|
212
|
+
*/
|
|
213
|
+
function listeningPort(address) {
|
|
214
|
+
if (address === null || typeof address !== "object") throw new Error("expected the server to report an AddressInfo after listen()");
|
|
215
|
+
return address.port;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Extract the hostname portion of a Host header, ignoring any port suffix. Uses indexOf/slice rather than split()[0] so every branch is genuinely reachable: an absent header is a legitimate "reject as disallowed" case, and a header with no colon is the common case, both real inputs a test can construct directly, unlike split()[0]'s type-only undefined case.
|
|
219
|
+
*/
|
|
220
|
+
function hostnameOf(hostHeader) {
|
|
221
|
+
if (hostHeader === void 0) return "";
|
|
222
|
+
const colonIndex = hostHeader.indexOf(":");
|
|
223
|
+
return colonIndex === -1 ? hostHeader : hostHeader.slice(0, colonIndex);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Build the request URL from possibly-absent raw parts. Exported as a pure function so both undefined sides of the fallbacks are directly coverable (Node always populates these for well-formed requests, but the types allow absence and malformed raw requests exercise it).
|
|
227
|
+
*/
|
|
228
|
+
function requestUrl(rawUrl, host) {
|
|
229
|
+
return new URL(rawUrl ?? "/", `http://${host ?? "localhost"}`);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Map a caught throwable to an HTTP error body message. Exported for direct unit coverage of the non-Error side, which live handlers cannot produce (every throw site raises Error subclasses).
|
|
233
|
+
*/
|
|
234
|
+
function httpErrorMessage(error) {
|
|
235
|
+
return error instanceof Error ? error.message : JSON.stringify(error);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Narrow an already schema-refined target to exactly one PeerRef shape. Exported for direct unit coverage of the no-field throw, which the PeerTargetSchema refine makes unreachable through the HTTP surface.
|
|
239
|
+
*/
|
|
203
240
|
function toPeerRef(to) {
|
|
204
241
|
if (to.pid !== void 0) return { pid: to.pid };
|
|
205
242
|
if (to.name !== void 0) return { name: to.name };
|
package/dist/bin/cc-peer.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as defineSchema, i as RegistryEntrySchema, n as CcPeer, r as PrioritySchema } from "../cc-peer-
|
|
2
|
+
import { a as defineSchema, i as RegistryEntrySchema, n as CcPeer, r as PrioritySchema } from "../cc-peer-CWGY6H7F.mjs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import process from "node:process";
|
|
@@ -69,17 +69,25 @@ API_REGISTRY.add(ErrorResponseSchema, {
|
|
|
69
69
|
function isJsonObject(value) {
|
|
70
70
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Pure extraction of the conversion shape so malformed registry output fails loudly instead of silently producing empty components. Exported for direct unit coverage of every malformed-input path.
|
|
74
|
+
*/
|
|
75
|
+
function componentSchemasFrom(converted) {
|
|
76
|
+
if (!isJsonObject(converted)) throw new Error("zod registry conversion did not produce an object");
|
|
77
|
+
const schemas = converted.schemas;
|
|
78
|
+
if (!isJsonObject(schemas)) throw new Error("zod registry conversion produced no schemas object");
|
|
75
79
|
const stripped = {};
|
|
76
|
-
for (const [name, schema] of Object.entries(schemas))
|
|
80
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
81
|
+
if (!isJsonObject(schema)) throw new Error(`component ${name} is not a JSON object`);
|
|
77
82
|
const rest = { ...schema };
|
|
78
83
|
delete rest.$schema;
|
|
79
84
|
stripped[name] = rest;
|
|
80
|
-
}
|
|
85
|
+
}
|
|
81
86
|
return stripped;
|
|
82
87
|
}
|
|
88
|
+
function apiComponentSchemas() {
|
|
89
|
+
return componentSchemasFrom(z.toJSONSchema(API_REGISTRY, { uri: (id) => `#/components/schemas/${id}` }));
|
|
90
|
+
}
|
|
83
91
|
//#endregion
|
|
84
92
|
//#region src/api/server.ts
|
|
85
93
|
/** Loopback only; this bridges onto a same-user IPC trust boundary. */
|
|
@@ -104,9 +112,8 @@ async function createApiServer(peer, options = {}) {
|
|
|
104
112
|
});
|
|
105
113
|
return new Promise((resolve) => {
|
|
106
114
|
server.listen(options.port ?? 0, BIND_HOST, () => {
|
|
107
|
-
const address = server.address();
|
|
108
115
|
resolve({
|
|
109
|
-
port:
|
|
116
|
+
port: listeningPort(server.address()),
|
|
110
117
|
token,
|
|
111
118
|
close: async () => {
|
|
112
119
|
await new Promise((resolveClose) => {
|
|
@@ -127,7 +134,7 @@ async function handle(peer, req, res, token) {
|
|
|
127
134
|
});
|
|
128
135
|
res.end(body);
|
|
129
136
|
};
|
|
130
|
-
const host = (req.headers.host
|
|
137
|
+
const host = hostnameOf(req.headers.host);
|
|
131
138
|
if (!ALLOWED_HOSTS.has(host)) {
|
|
132
139
|
finish(HTTP_FORBIDDEN, errorBody("host not allowed"));
|
|
133
140
|
return;
|
|
@@ -138,7 +145,7 @@ async function handle(peer, req, res, token) {
|
|
|
138
145
|
return;
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
|
-
const url =
|
|
148
|
+
const url = requestUrl(req.url, req.headers.host);
|
|
142
149
|
try {
|
|
143
150
|
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
144
151
|
finish(HTTP_OK, JSON.stringify({ ok: true }));
|
|
@@ -174,9 +181,39 @@ async function handle(peer, req, res, token) {
|
|
|
174
181
|
}
|
|
175
182
|
finish(HTTP_NOT_FOUND, errorBody("not found"));
|
|
176
183
|
} catch (error) {
|
|
177
|
-
finish(HTTP_INTERNAL, errorBody(error
|
|
184
|
+
finish(HTTP_INTERNAL, errorBody(httpErrorMessage(error)));
|
|
178
185
|
}
|
|
179
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Extract the numeric port `createApiServer` reports after `listen()`. The server always listens on a TCP host:port pair (never a named pipe), so `address()` returning anything but an AddressInfo object is a Node behaviour our own call site cannot trigger; a thrown error surfaces that violated assumption loudly rather than silently reporting port 0. Exported so the impossible-input side is directly unit-coverable without mocking node:net.
|
|
189
|
+
*/
|
|
190
|
+
function listeningPort(address) {
|
|
191
|
+
if (address === null || typeof address !== "object") throw new Error("expected the server to report an AddressInfo after listen()");
|
|
192
|
+
return address.port;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Extract the hostname portion of a Host header, ignoring any port suffix. Uses indexOf/slice rather than split()[0] so every branch is genuinely reachable: an absent header is a legitimate "reject as disallowed" case, and a header with no colon is the common case, both real inputs a test can construct directly, unlike split()[0]'s type-only undefined case.
|
|
196
|
+
*/
|
|
197
|
+
function hostnameOf(hostHeader) {
|
|
198
|
+
if (hostHeader === void 0) return "";
|
|
199
|
+
const colonIndex = hostHeader.indexOf(":");
|
|
200
|
+
return colonIndex === -1 ? hostHeader : hostHeader.slice(0, colonIndex);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Build the request URL from possibly-absent raw parts. Exported as a pure function so both undefined sides of the fallbacks are directly coverable (Node always populates these for well-formed requests, but the types allow absence and malformed raw requests exercise it).
|
|
204
|
+
*/
|
|
205
|
+
function requestUrl(rawUrl, host) {
|
|
206
|
+
return new URL(rawUrl ?? "/", `http://${host ?? "localhost"}`);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Map a caught throwable to an HTTP error body message. Exported for direct unit coverage of the non-Error side, which live handlers cannot produce (every throw site raises Error subclasses).
|
|
210
|
+
*/
|
|
211
|
+
function httpErrorMessage(error) {
|
|
212
|
+
return error instanceof Error ? error.message : JSON.stringify(error);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Narrow an already schema-refined target to exactly one PeerRef shape. Exported for direct unit coverage of the no-field throw, which the PeerTargetSchema refine makes unreachable through the HTTP surface.
|
|
216
|
+
*/
|
|
180
217
|
function toPeerRef(to) {
|
|
181
218
|
if (to.pid !== void 0) return { pid: to.pid };
|
|
182
219
|
if (to.name !== void 0) return { name: to.name };
|
|
@@ -9,8 +9,6 @@ import { spawn } from "node:child_process";
|
|
|
9
9
|
//#region src/adapters/node/uds-transport.ts
|
|
10
10
|
/** macOS linger before close, matching the reference client's ~150ms. */
|
|
11
11
|
const DEFAULT_LINGER_MS = 150;
|
|
12
|
-
const CONNECT_TIMEOUT_MS = 5e3;
|
|
13
|
-
const PROBE_TIMEOUT_MS = 2e3;
|
|
14
12
|
var NodeInboundConnection = class {
|
|
15
13
|
socket;
|
|
16
14
|
buffer = "";
|
|
@@ -34,14 +32,12 @@ var NodeInboundConnection = class {
|
|
|
34
32
|
index = this.buffer.indexOf("\n");
|
|
35
33
|
}
|
|
36
34
|
});
|
|
37
|
-
|
|
35
|
+
const finish = () => {
|
|
38
36
|
this.ended = true;
|
|
39
37
|
for (const waiter of this.waiters.splice(0)) waiter(void 0);
|
|
40
|
-
}
|
|
41
|
-
socket.on("
|
|
42
|
-
|
|
43
|
-
for (const waiter of this.waiters.splice(0)) waiter(void 0);
|
|
44
|
-
});
|
|
38
|
+
};
|
|
39
|
+
socket.on("close", finish);
|
|
40
|
+
socket.on("error", finish);
|
|
45
41
|
}
|
|
46
42
|
peerPid() {
|
|
47
43
|
return this.cachedPid;
|
|
@@ -69,15 +65,9 @@ var UdsTransport = class {
|
|
|
69
65
|
socket.destroy();
|
|
70
66
|
reject(error);
|
|
71
67
|
};
|
|
72
|
-
socket.setTimeout(CONNECT_TIMEOUT_MS, () => {
|
|
73
|
-
fail(/* @__PURE__ */ new Error(`timeout connecting ${socketPath}`));
|
|
74
|
-
});
|
|
75
68
|
socket.once("error", fail);
|
|
76
69
|
socket.once("connect", () => {
|
|
77
|
-
socket.
|
|
78
|
-
socket.write(payload, (error) => {
|
|
79
|
-
if (error !== null && error !== void 0) fail(error);
|
|
80
|
-
});
|
|
70
|
+
socket.write(payload);
|
|
81
71
|
setTimeout(() => {
|
|
82
72
|
socket.end();
|
|
83
73
|
}, lingerMs).unref();
|
|
@@ -97,9 +87,6 @@ var UdsTransport = class {
|
|
|
97
87
|
socket.destroy();
|
|
98
88
|
resolve(value);
|
|
99
89
|
};
|
|
100
|
-
socket.setTimeout(PROBE_TIMEOUT_MS, () => {
|
|
101
|
-
done(false);
|
|
102
|
-
});
|
|
103
90
|
socket.once("error", (error) => {
|
|
104
91
|
done(error.code === "EBUSY");
|
|
105
92
|
});
|
|
@@ -127,12 +114,12 @@ var UdsTransport = class {
|
|
|
127
114
|
return {
|
|
128
115
|
socketPath,
|
|
129
116
|
close: async () => {
|
|
117
|
+
for (const socket of accepted.splice(0)) socket.destroy();
|
|
130
118
|
await new Promise((resolve) => {
|
|
131
119
|
server.close(() => {
|
|
132
120
|
resolve();
|
|
133
121
|
});
|
|
134
122
|
});
|
|
135
|
-
for (const socket of accepted) socket.destroy();
|
|
136
123
|
}
|
|
137
124
|
};
|
|
138
125
|
}
|
|
@@ -170,7 +157,11 @@ const PeerKeyFileSchema = defineSchema(z.object({
|
|
|
170
157
|
}));
|
|
171
158
|
//#endregion
|
|
172
159
|
//#region src/adapters/node/paths.ts
|
|
173
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Candidate socket directories, in the order the reference client accepts
|
|
162
|
+
* them. The tuple return type guarantees at least one candidate exists, so
|
|
163
|
+
* callers can index [0] without a fallback branch.
|
|
164
|
+
*/
|
|
174
165
|
function socketDirCandidates(config = {}) {
|
|
175
166
|
if (config.socketDir !== void 0) return [config.socketDir];
|
|
176
167
|
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
@@ -184,7 +175,7 @@ function sessionsDir(config = {}) {
|
|
|
184
175
|
return join(config.homeDir ?? homedir(), ".claude", "sessions");
|
|
185
176
|
}
|
|
186
177
|
function socketPathForPid(pid, config = {}) {
|
|
187
|
-
return `${socketDirCandidates(config)[0]
|
|
178
|
+
return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`;
|
|
188
179
|
}
|
|
189
180
|
function registryFilePath(pid, config = {}) {
|
|
190
181
|
return join(sessionsDir(config), `${pid.toString()}.json`);
|
|
@@ -195,7 +186,7 @@ function keyFilePath(socketPath, config = {}) {
|
|
|
195
186
|
return join(sessionsDir(config), `${pidFromSocketPath(socketPath).toString()}.${hash}.key`);
|
|
196
187
|
}
|
|
197
188
|
function pidFromSocketPath(socketPath) {
|
|
198
|
-
const base = socketPath.
|
|
189
|
+
const base = socketPath.substring(socketPath.lastIndexOf("/") + 1);
|
|
199
190
|
const pid = Number.parseInt(base.replace(/\.sock$/, ""), 10);
|
|
200
191
|
return Number.isNaN(pid) ? 0 : pid;
|
|
201
192
|
}
|
|
@@ -353,18 +344,21 @@ var FsRegistryStore = class {
|
|
|
353
344
|
};
|
|
354
345
|
//#endregion
|
|
355
346
|
//#region src/adapters/node/ps-proc-info.ts
|
|
347
|
+
/**
|
|
348
|
+
* The errno code of an unknown throwable: Node's process.kill throws a SystemError carrying a string code, but a defensive caller may hand us anything, so the narrowing is explicit rather than assumed. Exported for direct unit coverage of every narrowing side.
|
|
349
|
+
*/
|
|
350
|
+
function errnoOf(error) {
|
|
351
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
352
|
+
return "";
|
|
353
|
+
}
|
|
356
354
|
var PsProcInfo = class PsProcInfo {
|
|
357
355
|
async alive(pid) {
|
|
358
|
-
const errno = (error) => {
|
|
359
|
-
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
360
|
-
return "";
|
|
361
|
-
};
|
|
362
356
|
return new Promise((resolve) => {
|
|
363
357
|
try {
|
|
364
358
|
process.kill(pid, 0);
|
|
365
359
|
resolve(true);
|
|
366
360
|
} catch (error) {
|
|
367
|
-
resolve(
|
|
361
|
+
resolve(errnoOf(error) === "EPERM");
|
|
368
362
|
}
|
|
369
363
|
});
|
|
370
364
|
}
|
|
@@ -383,31 +377,25 @@ var PsProcInfo = class PsProcInfo {
|
|
|
383
377
|
const existing = this.inFlight.get(pid);
|
|
384
378
|
if (existing !== void 0) return existing;
|
|
385
379
|
const promise = new Promise((resolve) => {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
]
|
|
404
|
-
});
|
|
405
|
-
} catch {
|
|
406
|
-
resolve(void 0);
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
380
|
+
const child = spawn("ps", [
|
|
381
|
+
"-o",
|
|
382
|
+
"lstart=",
|
|
383
|
+
"-p",
|
|
384
|
+
String(pid)
|
|
385
|
+
], {
|
|
386
|
+
env: {
|
|
387
|
+
...process.env,
|
|
388
|
+
LC_ALL: "C",
|
|
389
|
+
TZ: "UTC"
|
|
390
|
+
},
|
|
391
|
+
stdio: [
|
|
392
|
+
"ignore",
|
|
393
|
+
"pipe",
|
|
394
|
+
"ignore"
|
|
395
|
+
]
|
|
396
|
+
});
|
|
409
397
|
let out = "";
|
|
410
|
-
child.stdout
|
|
398
|
+
child.stdout.on("data", (chunk) => {
|
|
411
399
|
out += chunk.toString("utf8");
|
|
412
400
|
});
|
|
413
401
|
child.on("error", () => {
|
|
@@ -471,7 +459,9 @@ function buildEnvelope(attrs, body) {
|
|
|
471
459
|
function parseEnvelope(content) {
|
|
472
460
|
const match = ENVELOPE_RE.exec(content);
|
|
473
461
|
if (match === null) return void 0;
|
|
474
|
-
const
|
|
462
|
+
const openingEnd = content.indexOf(">\n") + 2;
|
|
463
|
+
const closingStart = content.length - `\n</${TAG}>`.length;
|
|
464
|
+
const parsed = { body: content.substring(openingEnd, closingStart) };
|
|
475
465
|
if (match[1] !== void 0) parsed.from = match[1];
|
|
476
466
|
if (match[2] !== void 0) parsed.fromSession = match[2];
|
|
477
467
|
if (match[3] !== void 0) parsed.hopChain = match[3].split(",");
|
|
@@ -500,8 +490,7 @@ var Pacer = class {
|
|
|
500
490
|
/** Milliseconds to wait before one token is available (0 = now). */
|
|
501
491
|
msUntilNextToken() {
|
|
502
492
|
this.refill();
|
|
503
|
-
|
|
504
|
-
const deficit = 1 - this.tokens;
|
|
493
|
+
const deficit = Math.max(0, 1 - this.tokens);
|
|
505
494
|
return Math.ceil(deficit / this.refillPerSecond * MS_PER_SECOND);
|
|
506
495
|
}
|
|
507
496
|
/** Consume one token if available. */
|
|
@@ -511,10 +500,10 @@ var Pacer = class {
|
|
|
511
500
|
this.tokens -= 1;
|
|
512
501
|
return true;
|
|
513
502
|
}
|
|
503
|
+
/** Clamps negative elapsed time (a clock moving backward) to zero rather than draining tokens, folding the guard into the arithmetic instead of a separate branch. */
|
|
514
504
|
refill() {
|
|
515
505
|
const now = this.clock.nowMs();
|
|
516
|
-
const elapsedSeconds = (now - this.lastRefillMs) / MS_PER_SECOND;
|
|
517
|
-
if (elapsedSeconds <= 0) return;
|
|
506
|
+
const elapsedSeconds = Math.max(0, (now - this.lastRefillMs) / MS_PER_SECOND);
|
|
518
507
|
this.tokens = Math.min(this.capacity, this.tokens + elapsedSeconds * this.refillPerSecond);
|
|
519
508
|
this.lastRefillMs = now;
|
|
520
509
|
}
|
|
@@ -531,20 +520,16 @@ function newMsgId() {
|
|
|
531
520
|
* The receiver's own admission rules, verified live: an entry is listed when it has a socket, is not the caller's own, is not spare/parked, its socket accepts a live connect probe, and its pid is alive with a procStart that byte-matches the ps output. Mismatches classify as recycled and are silently skipped by the reference roster builder.
|
|
532
521
|
*/
|
|
533
522
|
async function filterRoster(entries, probes) {
|
|
534
|
-
|
|
535
|
-
for (const entry of entries) {
|
|
536
|
-
const verdict = await checkEntry(entry, probes);
|
|
537
|
-
verdicts.push(verdict);
|
|
538
|
-
}
|
|
539
|
-
return verdicts.filter((v) => v.admitted).map((v) => v.entry);
|
|
523
|
+
return (await Promise.all(entries.map(async (entry) => checkEntry(entry, probes)))).filter((v) => v.admitted).map((v) => v.entry);
|
|
540
524
|
}
|
|
525
|
+
/** Exported for direct unit coverage of each rejection reason, which filterRoster's own filtered-entries return value cannot distinguish. */
|
|
541
526
|
async function checkEntry(entry, probes) {
|
|
542
527
|
if (entry.messagingSocketPath.length === 0) return {
|
|
543
528
|
entry,
|
|
544
529
|
admitted: false,
|
|
545
530
|
reason: "no-socket"
|
|
546
531
|
};
|
|
547
|
-
if (
|
|
532
|
+
if (entry.messagingSocketPath === probes.ownSocketPath) return {
|
|
548
533
|
entry,
|
|
549
534
|
admitted: false,
|
|
550
535
|
reason: "own-socket"
|
|
@@ -554,8 +539,7 @@ async function checkEntry(entry, probes) {
|
|
|
554
539
|
admitted: false,
|
|
555
540
|
reason: "pid-dead"
|
|
556
541
|
};
|
|
557
|
-
|
|
558
|
-
if (lstart === void 0 || lstart !== entry.procStart) return {
|
|
542
|
+
if (await probes.procInfo.lstart(entry.pid) !== entry.procStart) return {
|
|
559
543
|
entry,
|
|
560
544
|
admitted: false,
|
|
561
545
|
reason: "proc-start-mismatch"
|
|
@@ -646,6 +630,8 @@ const PeerIdleNoticeSchema = defineSchema(z.object({
|
|
|
646
630
|
msgV: z.number().int(),
|
|
647
631
|
msg_id: z.string().min(1)
|
|
648
632
|
}));
|
|
633
|
+
/** Bare enum, exported separately so a test can assert its own membership directly rather than through the object field's .catch("claim") fallback, which would otherwise mask a corrupted "claim" member by coincidentally recovering the same value. */
|
|
634
|
+
const YieldReasonSchema = z.enum(["resume", "claim"]);
|
|
649
635
|
const YieldArtifactRepliesSchema = defineSchema(z.object({
|
|
650
636
|
type: z.literal("control"),
|
|
651
637
|
action: z.literal("yield_artifact_replies"),
|
|
@@ -653,7 +639,7 @@ const YieldArtifactRepliesSchema = defineSchema(z.object({
|
|
|
653
639
|
msg_id: z.string().min(1).max(128),
|
|
654
640
|
session_id: z.string().max(512),
|
|
655
641
|
slugs: z.array(z.string().max(128)).max(16),
|
|
656
|
-
reason:
|
|
642
|
+
reason: YieldReasonSchema.catch("claim"),
|
|
657
643
|
sent_at: z.number(),
|
|
658
644
|
claimed_at: z.number().optional(),
|
|
659
645
|
requester: z.object({
|
|
@@ -768,6 +754,9 @@ var CcPeer = class CcPeer extends EventEmitter {
|
|
|
768
754
|
await peer.start();
|
|
769
755
|
return peer;
|
|
770
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* Bind, publish key and registry, and begin listening. Public rather than private because dependency-injected construction (this constructor takes the full Deps) needs to trigger it explicitly; everyday callers use the static create(), which wires the real node adapters.
|
|
759
|
+
*/
|
|
771
760
|
async start() {
|
|
772
761
|
const socketPath = socketPathForPid(process.pid, this.options);
|
|
773
762
|
this.ownKey = {
|
|
@@ -781,10 +770,11 @@ var CcPeer = class CcPeer extends EventEmitter {
|
|
|
781
770
|
recursive: true,
|
|
782
771
|
mode: 448
|
|
783
772
|
});
|
|
784
|
-
const entry = this.buildRegistryEntry();
|
|
773
|
+
const entry = this.buildRegistryEntry(this.ownKey.procStart);
|
|
785
774
|
await this.deps.registry.write(entry);
|
|
775
|
+
const ownToken = this.ownKey.peerToken;
|
|
786
776
|
this.listening = await this.deps.transport.listen(socketPath, (conn) => {
|
|
787
|
-
this.handleConnection(conn);
|
|
777
|
+
this.handleConnection(conn, ownToken);
|
|
788
778
|
});
|
|
789
779
|
this.heartbeatTimer = setInterval(() => {
|
|
790
780
|
this.deps.registry.touch(process.pid).catch(() => void 0);
|
|
@@ -792,14 +782,15 @@ var CcPeer = class CcPeer extends EventEmitter {
|
|
|
792
782
|
this.heartbeatTimer.unref();
|
|
793
783
|
this.log(`listening as ${this.options.name ?? "unnamed"} at ${socketPath}`);
|
|
794
784
|
}
|
|
795
|
-
|
|
785
|
+
/** procStart is a required parameter, not read from this.ownKey: start() already guarantees a non-empty value before this is called, so the type checker enforces it rather than a runtime fallback that can never actually fire. */
|
|
786
|
+
buildRegistryEntry(procStart) {
|
|
796
787
|
const now = this.deps.clock.nowMs();
|
|
797
788
|
return {
|
|
798
789
|
pid: process.pid,
|
|
799
790
|
sessionId: this.options.sessionId ?? newMsgId(),
|
|
800
791
|
cwd: process.cwd(),
|
|
801
792
|
startedAt: now,
|
|
802
|
-
procStart
|
|
793
|
+
procStart,
|
|
803
794
|
version: "cc-peer",
|
|
804
795
|
peerProtocol: 1,
|
|
805
796
|
peerFeatures: ["notify_idle", "reply_across_default_dirs"],
|
|
@@ -864,10 +855,7 @@ var CcPeer = class CcPeer extends EventEmitter {
|
|
|
864
855
|
resolve();
|
|
865
856
|
}, waitMs).unref();
|
|
866
857
|
});
|
|
867
|
-
|
|
868
|
-
await this.pacedSend(socketPath, lines);
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
858
|
+
this.pacer.tryReserve();
|
|
871
859
|
await this.deps.transport.connectWrite(socketPath, lines);
|
|
872
860
|
}
|
|
873
861
|
async subscribeIdle(target) {
|
|
@@ -898,14 +886,13 @@ var CcPeer = class CcPeer extends EventEmitter {
|
|
|
898
886
|
if (match === void 0) throw new UnknownPeerError(`no roster entry named ${target.name}`);
|
|
899
887
|
return match.messagingSocketPath;
|
|
900
888
|
}
|
|
901
|
-
|
|
889
|
+
/** ownToken is passed explicitly rather than read from this.ownKey: the listener callback that invokes this is only ever registered after start() has set ownKey, so the parameter records that guarantee at the type level instead of a runtime guard that can never actually be false. */
|
|
890
|
+
async handleConnection(conn, ownToken) {
|
|
902
891
|
const lines = conn.readLines();
|
|
903
892
|
const first = await lines[Symbol.asyncIterator]().next();
|
|
904
893
|
if (first.done === true) return;
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (AuthLineSchema.is(parsed) && parsed.token !== this.ownKey.peerToken) this.log("inbound auth token mismatch (foreign token tolerated)");
|
|
908
|
-
}
|
|
894
|
+
const parsed = JSON.parse(first.value);
|
|
895
|
+
if (AuthLineSchema.is(parsed) && parsed.token !== ownToken) this.log("inbound auth token mismatch (foreign token tolerated)");
|
|
909
896
|
for await (const line of lines) {
|
|
910
897
|
let frame;
|
|
911
898
|
try {
|
|
@@ -9,8 +9,6 @@ let node_child_process = require("node:child_process");
|
|
|
9
9
|
//#region src/adapters/node/uds-transport.ts
|
|
10
10
|
/** macOS linger before close, matching the reference client's ~150ms. */
|
|
11
11
|
const DEFAULT_LINGER_MS = 150;
|
|
12
|
-
const CONNECT_TIMEOUT_MS = 5e3;
|
|
13
|
-
const PROBE_TIMEOUT_MS = 2e3;
|
|
14
12
|
var NodeInboundConnection = class {
|
|
15
13
|
socket;
|
|
16
14
|
buffer = "";
|
|
@@ -34,14 +32,12 @@ var NodeInboundConnection = class {
|
|
|
34
32
|
index = this.buffer.indexOf("\n");
|
|
35
33
|
}
|
|
36
34
|
});
|
|
37
|
-
|
|
35
|
+
const finish = () => {
|
|
38
36
|
this.ended = true;
|
|
39
37
|
for (const waiter of this.waiters.splice(0)) waiter(void 0);
|
|
40
|
-
}
|
|
41
|
-
socket.on("
|
|
42
|
-
|
|
43
|
-
for (const waiter of this.waiters.splice(0)) waiter(void 0);
|
|
44
|
-
});
|
|
38
|
+
};
|
|
39
|
+
socket.on("close", finish);
|
|
40
|
+
socket.on("error", finish);
|
|
45
41
|
}
|
|
46
42
|
peerPid() {
|
|
47
43
|
return this.cachedPid;
|
|
@@ -69,15 +65,9 @@ var UdsTransport = class {
|
|
|
69
65
|
socket.destroy();
|
|
70
66
|
reject(error);
|
|
71
67
|
};
|
|
72
|
-
socket.setTimeout(CONNECT_TIMEOUT_MS, () => {
|
|
73
|
-
fail(/* @__PURE__ */ new Error(`timeout connecting ${socketPath}`));
|
|
74
|
-
});
|
|
75
68
|
socket.once("error", fail);
|
|
76
69
|
socket.once("connect", () => {
|
|
77
|
-
socket.
|
|
78
|
-
socket.write(payload, (error) => {
|
|
79
|
-
if (error !== null && error !== void 0) fail(error);
|
|
80
|
-
});
|
|
70
|
+
socket.write(payload);
|
|
81
71
|
setTimeout(() => {
|
|
82
72
|
socket.end();
|
|
83
73
|
}, lingerMs).unref();
|
|
@@ -97,9 +87,6 @@ var UdsTransport = class {
|
|
|
97
87
|
socket.destroy();
|
|
98
88
|
resolve(value);
|
|
99
89
|
};
|
|
100
|
-
socket.setTimeout(PROBE_TIMEOUT_MS, () => {
|
|
101
|
-
done(false);
|
|
102
|
-
});
|
|
103
90
|
socket.once("error", (error) => {
|
|
104
91
|
done(error.code === "EBUSY");
|
|
105
92
|
});
|
|
@@ -127,12 +114,12 @@ var UdsTransport = class {
|
|
|
127
114
|
return {
|
|
128
115
|
socketPath,
|
|
129
116
|
close: async () => {
|
|
117
|
+
for (const socket of accepted.splice(0)) socket.destroy();
|
|
130
118
|
await new Promise((resolve) => {
|
|
131
119
|
server.close(() => {
|
|
132
120
|
resolve();
|
|
133
121
|
});
|
|
134
122
|
});
|
|
135
|
-
for (const socket of accepted) socket.destroy();
|
|
136
123
|
}
|
|
137
124
|
};
|
|
138
125
|
}
|
|
@@ -170,7 +157,11 @@ const PeerKeyFileSchema = defineSchema(zod.z.object({
|
|
|
170
157
|
}));
|
|
171
158
|
//#endregion
|
|
172
159
|
//#region src/adapters/node/paths.ts
|
|
173
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Candidate socket directories, in the order the reference client accepts
|
|
162
|
+
* them. The tuple return type guarantees at least one candidate exists, so
|
|
163
|
+
* callers can index [0] without a fallback branch.
|
|
164
|
+
*/
|
|
174
165
|
function socketDirCandidates(config = {}) {
|
|
175
166
|
if (config.socketDir !== void 0) return [config.socketDir];
|
|
176
167
|
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
@@ -184,7 +175,7 @@ function sessionsDir(config = {}) {
|
|
|
184
175
|
return (0, node_path.join)(config.homeDir ?? (0, node_os.homedir)(), ".claude", "sessions");
|
|
185
176
|
}
|
|
186
177
|
function socketPathForPid(pid, config = {}) {
|
|
187
|
-
return `${socketDirCandidates(config)[0]
|
|
178
|
+
return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`;
|
|
188
179
|
}
|
|
189
180
|
function registryFilePath(pid, config = {}) {
|
|
190
181
|
return (0, node_path.join)(sessionsDir(config), `${pid.toString()}.json`);
|
|
@@ -195,7 +186,7 @@ function keyFilePath(socketPath, config = {}) {
|
|
|
195
186
|
return (0, node_path.join)(sessionsDir(config), `${pidFromSocketPath(socketPath).toString()}.${hash}.key`);
|
|
196
187
|
}
|
|
197
188
|
function pidFromSocketPath(socketPath) {
|
|
198
|
-
const base = socketPath.
|
|
189
|
+
const base = socketPath.substring(socketPath.lastIndexOf("/") + 1);
|
|
199
190
|
const pid = Number.parseInt(base.replace(/\.sock$/, ""), 10);
|
|
200
191
|
return Number.isNaN(pid) ? 0 : pid;
|
|
201
192
|
}
|
|
@@ -353,18 +344,21 @@ var FsRegistryStore = class {
|
|
|
353
344
|
};
|
|
354
345
|
//#endregion
|
|
355
346
|
//#region src/adapters/node/ps-proc-info.ts
|
|
347
|
+
/**
|
|
348
|
+
* The errno code of an unknown throwable: Node's process.kill throws a SystemError carrying a string code, but a defensive caller may hand us anything, so the narrowing is explicit rather than assumed. Exported for direct unit coverage of every narrowing side.
|
|
349
|
+
*/
|
|
350
|
+
function errnoOf(error) {
|
|
351
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
352
|
+
return "";
|
|
353
|
+
}
|
|
356
354
|
var PsProcInfo = class PsProcInfo {
|
|
357
355
|
async alive(pid) {
|
|
358
|
-
const errno = (error) => {
|
|
359
|
-
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
360
|
-
return "";
|
|
361
|
-
};
|
|
362
356
|
return new Promise((resolve) => {
|
|
363
357
|
try {
|
|
364
358
|
process.kill(pid, 0);
|
|
365
359
|
resolve(true);
|
|
366
360
|
} catch (error) {
|
|
367
|
-
resolve(
|
|
361
|
+
resolve(errnoOf(error) === "EPERM");
|
|
368
362
|
}
|
|
369
363
|
});
|
|
370
364
|
}
|
|
@@ -383,31 +377,25 @@ var PsProcInfo = class PsProcInfo {
|
|
|
383
377
|
const existing = this.inFlight.get(pid);
|
|
384
378
|
if (existing !== void 0) return existing;
|
|
385
379
|
const promise = new Promise((resolve) => {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
]
|
|
404
|
-
});
|
|
405
|
-
} catch {
|
|
406
|
-
resolve(void 0);
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
380
|
+
const child = (0, node_child_process.spawn)("ps", [
|
|
381
|
+
"-o",
|
|
382
|
+
"lstart=",
|
|
383
|
+
"-p",
|
|
384
|
+
String(pid)
|
|
385
|
+
], {
|
|
386
|
+
env: {
|
|
387
|
+
...process.env,
|
|
388
|
+
LC_ALL: "C",
|
|
389
|
+
TZ: "UTC"
|
|
390
|
+
},
|
|
391
|
+
stdio: [
|
|
392
|
+
"ignore",
|
|
393
|
+
"pipe",
|
|
394
|
+
"ignore"
|
|
395
|
+
]
|
|
396
|
+
});
|
|
409
397
|
let out = "";
|
|
410
|
-
child.stdout
|
|
398
|
+
child.stdout.on("data", (chunk) => {
|
|
411
399
|
out += chunk.toString("utf8");
|
|
412
400
|
});
|
|
413
401
|
child.on("error", () => {
|
|
@@ -471,7 +459,9 @@ function buildEnvelope(attrs, body) {
|
|
|
471
459
|
function parseEnvelope(content) {
|
|
472
460
|
const match = ENVELOPE_RE.exec(content);
|
|
473
461
|
if (match === null) return void 0;
|
|
474
|
-
const
|
|
462
|
+
const openingEnd = content.indexOf(">\n") + 2;
|
|
463
|
+
const closingStart = content.length - `\n</${TAG}>`.length;
|
|
464
|
+
const parsed = { body: content.substring(openingEnd, closingStart) };
|
|
475
465
|
if (match[1] !== void 0) parsed.from = match[1];
|
|
476
466
|
if (match[2] !== void 0) parsed.fromSession = match[2];
|
|
477
467
|
if (match[3] !== void 0) parsed.hopChain = match[3].split(",");
|
|
@@ -500,8 +490,7 @@ var Pacer = class {
|
|
|
500
490
|
/** Milliseconds to wait before one token is available (0 = now). */
|
|
501
491
|
msUntilNextToken() {
|
|
502
492
|
this.refill();
|
|
503
|
-
|
|
504
|
-
const deficit = 1 - this.tokens;
|
|
493
|
+
const deficit = Math.max(0, 1 - this.tokens);
|
|
505
494
|
return Math.ceil(deficit / this.refillPerSecond * MS_PER_SECOND);
|
|
506
495
|
}
|
|
507
496
|
/** Consume one token if available. */
|
|
@@ -511,10 +500,10 @@ var Pacer = class {
|
|
|
511
500
|
this.tokens -= 1;
|
|
512
501
|
return true;
|
|
513
502
|
}
|
|
503
|
+
/** Clamps negative elapsed time (a clock moving backward) to zero rather than draining tokens, folding the guard into the arithmetic instead of a separate branch. */
|
|
514
504
|
refill() {
|
|
515
505
|
const now = this.clock.nowMs();
|
|
516
|
-
const elapsedSeconds = (now - this.lastRefillMs) / MS_PER_SECOND;
|
|
517
|
-
if (elapsedSeconds <= 0) return;
|
|
506
|
+
const elapsedSeconds = Math.max(0, (now - this.lastRefillMs) / MS_PER_SECOND);
|
|
518
507
|
this.tokens = Math.min(this.capacity, this.tokens + elapsedSeconds * this.refillPerSecond);
|
|
519
508
|
this.lastRefillMs = now;
|
|
520
509
|
}
|
|
@@ -531,20 +520,16 @@ function newMsgId() {
|
|
|
531
520
|
* The receiver's own admission rules, verified live: an entry is listed when it has a socket, is not the caller's own, is not spare/parked, its socket accepts a live connect probe, and its pid is alive with a procStart that byte-matches the ps output. Mismatches classify as recycled and are silently skipped by the reference roster builder.
|
|
532
521
|
*/
|
|
533
522
|
async function filterRoster(entries, probes) {
|
|
534
|
-
|
|
535
|
-
for (const entry of entries) {
|
|
536
|
-
const verdict = await checkEntry(entry, probes);
|
|
537
|
-
verdicts.push(verdict);
|
|
538
|
-
}
|
|
539
|
-
return verdicts.filter((v) => v.admitted).map((v) => v.entry);
|
|
523
|
+
return (await Promise.all(entries.map(async (entry) => checkEntry(entry, probes)))).filter((v) => v.admitted).map((v) => v.entry);
|
|
540
524
|
}
|
|
525
|
+
/** Exported for direct unit coverage of each rejection reason, which filterRoster's own filtered-entries return value cannot distinguish. */
|
|
541
526
|
async function checkEntry(entry, probes) {
|
|
542
527
|
if (entry.messagingSocketPath.length === 0) return {
|
|
543
528
|
entry,
|
|
544
529
|
admitted: false,
|
|
545
530
|
reason: "no-socket"
|
|
546
531
|
};
|
|
547
|
-
if (
|
|
532
|
+
if (entry.messagingSocketPath === probes.ownSocketPath) return {
|
|
548
533
|
entry,
|
|
549
534
|
admitted: false,
|
|
550
535
|
reason: "own-socket"
|
|
@@ -554,8 +539,7 @@ async function checkEntry(entry, probes) {
|
|
|
554
539
|
admitted: false,
|
|
555
540
|
reason: "pid-dead"
|
|
556
541
|
};
|
|
557
|
-
|
|
558
|
-
if (lstart === void 0 || lstart !== entry.procStart) return {
|
|
542
|
+
if (await probes.procInfo.lstart(entry.pid) !== entry.procStart) return {
|
|
559
543
|
entry,
|
|
560
544
|
admitted: false,
|
|
561
545
|
reason: "proc-start-mismatch"
|
|
@@ -646,6 +630,8 @@ const PeerIdleNoticeSchema = defineSchema(zod.z.object({
|
|
|
646
630
|
msgV: zod.z.number().int(),
|
|
647
631
|
msg_id: zod.z.string().min(1)
|
|
648
632
|
}));
|
|
633
|
+
/** Bare enum, exported separately so a test can assert its own membership directly rather than through the object field's .catch("claim") fallback, which would otherwise mask a corrupted "claim" member by coincidentally recovering the same value. */
|
|
634
|
+
const YieldReasonSchema = zod.z.enum(["resume", "claim"]);
|
|
649
635
|
const YieldArtifactRepliesSchema = defineSchema(zod.z.object({
|
|
650
636
|
type: zod.z.literal("control"),
|
|
651
637
|
action: zod.z.literal("yield_artifact_replies"),
|
|
@@ -653,7 +639,7 @@ const YieldArtifactRepliesSchema = defineSchema(zod.z.object({
|
|
|
653
639
|
msg_id: zod.z.string().min(1).max(128),
|
|
654
640
|
session_id: zod.z.string().max(512),
|
|
655
641
|
slugs: zod.z.array(zod.z.string().max(128)).max(16),
|
|
656
|
-
reason:
|
|
642
|
+
reason: YieldReasonSchema.catch("claim"),
|
|
657
643
|
sent_at: zod.z.number(),
|
|
658
644
|
claimed_at: zod.z.number().optional(),
|
|
659
645
|
requester: zod.z.object({
|
|
@@ -768,6 +754,9 @@ var CcPeer = class CcPeer extends node_events.EventEmitter {
|
|
|
768
754
|
await peer.start();
|
|
769
755
|
return peer;
|
|
770
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* Bind, publish key and registry, and begin listening. Public rather than private because dependency-injected construction (this constructor takes the full Deps) needs to trigger it explicitly; everyday callers use the static create(), which wires the real node adapters.
|
|
759
|
+
*/
|
|
771
760
|
async start() {
|
|
772
761
|
const socketPath = socketPathForPid(process.pid, this.options);
|
|
773
762
|
this.ownKey = {
|
|
@@ -781,10 +770,11 @@ var CcPeer = class CcPeer extends node_events.EventEmitter {
|
|
|
781
770
|
recursive: true,
|
|
782
771
|
mode: 448
|
|
783
772
|
});
|
|
784
|
-
const entry = this.buildRegistryEntry();
|
|
773
|
+
const entry = this.buildRegistryEntry(this.ownKey.procStart);
|
|
785
774
|
await this.deps.registry.write(entry);
|
|
775
|
+
const ownToken = this.ownKey.peerToken;
|
|
786
776
|
this.listening = await this.deps.transport.listen(socketPath, (conn) => {
|
|
787
|
-
this.handleConnection(conn);
|
|
777
|
+
this.handleConnection(conn, ownToken);
|
|
788
778
|
});
|
|
789
779
|
this.heartbeatTimer = setInterval(() => {
|
|
790
780
|
this.deps.registry.touch(process.pid).catch(() => void 0);
|
|
@@ -792,14 +782,15 @@ var CcPeer = class CcPeer extends node_events.EventEmitter {
|
|
|
792
782
|
this.heartbeatTimer.unref();
|
|
793
783
|
this.log(`listening as ${this.options.name ?? "unnamed"} at ${socketPath}`);
|
|
794
784
|
}
|
|
795
|
-
|
|
785
|
+
/** procStart is a required parameter, not read from this.ownKey: start() already guarantees a non-empty value before this is called, so the type checker enforces it rather than a runtime fallback that can never actually fire. */
|
|
786
|
+
buildRegistryEntry(procStart) {
|
|
796
787
|
const now = this.deps.clock.nowMs();
|
|
797
788
|
return {
|
|
798
789
|
pid: process.pid,
|
|
799
790
|
sessionId: this.options.sessionId ?? newMsgId(),
|
|
800
791
|
cwd: process.cwd(),
|
|
801
792
|
startedAt: now,
|
|
802
|
-
procStart
|
|
793
|
+
procStart,
|
|
803
794
|
version: "cc-peer",
|
|
804
795
|
peerProtocol: 1,
|
|
805
796
|
peerFeatures: ["notify_idle", "reply_across_default_dirs"],
|
|
@@ -864,10 +855,7 @@ var CcPeer = class CcPeer extends node_events.EventEmitter {
|
|
|
864
855
|
resolve();
|
|
865
856
|
}, waitMs).unref();
|
|
866
857
|
});
|
|
867
|
-
|
|
868
|
-
await this.pacedSend(socketPath, lines);
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
858
|
+
this.pacer.tryReserve();
|
|
871
859
|
await this.deps.transport.connectWrite(socketPath, lines);
|
|
872
860
|
}
|
|
873
861
|
async subscribeIdle(target) {
|
|
@@ -898,14 +886,13 @@ var CcPeer = class CcPeer extends node_events.EventEmitter {
|
|
|
898
886
|
if (match === void 0) throw new UnknownPeerError(`no roster entry named ${target.name}`);
|
|
899
887
|
return match.messagingSocketPath;
|
|
900
888
|
}
|
|
901
|
-
|
|
889
|
+
/** ownToken is passed explicitly rather than read from this.ownKey: the listener callback that invokes this is only ever registered after start() has set ownKey, so the parameter records that guarantee at the type level instead of a runtime guard that can never actually be false. */
|
|
890
|
+
async handleConnection(conn, ownToken) {
|
|
902
891
|
const lines = conn.readLines();
|
|
903
892
|
const first = await lines[Symbol.asyncIterator]().next();
|
|
904
893
|
if (first.done === true) return;
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (AuthLineSchema.is(parsed) && parsed.token !== this.ownKey.peerToken) this.log("inbound auth token mismatch (foreign token tolerated)");
|
|
908
|
-
}
|
|
894
|
+
const parsed = JSON.parse(first.value);
|
|
895
|
+
if (AuthLineSchema.is(parsed) && parsed.token !== ownToken) this.log("inbound auth token mismatch (foreign token tolerated)");
|
|
909
896
|
for await (const line of lines) {
|
|
910
897
|
let frame;
|
|
911
898
|
try {
|
package/dist/cc-peer.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_cc_peer = require("./cc-peer-
|
|
2
|
+
const require_cc_peer = require("./cc-peer-IGtoK_0q.cjs");
|
|
3
3
|
exports.CC_PEER_VERSION = require_cc_peer.CC_PEER_VERSION;
|
|
4
4
|
exports.CcPeer = require_cc_peer.CcPeer;
|
package/dist/cc-peer.d.cts
CHANGED
|
@@ -287,7 +287,11 @@ export declare class CcPeer extends EventEmitter {
|
|
|
287
287
|
private stopped;
|
|
288
288
|
constructor(options: Readonly<CcPeerOptions>, deps: Deps);
|
|
289
289
|
static create(options?: Readonly<CcPeerOptions>): Promise<CcPeer>;
|
|
290
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Bind, publish key and registry, and begin listening. Public rather than private because dependency-injected construction (this constructor takes the full Deps) needs to trigger it explicitly; everyday callers use the static create(), which wires the real node adapters.
|
|
292
|
+
*/
|
|
293
|
+
start(): Promise<void>;
|
|
294
|
+
/** procStart is a required parameter, not read from this.ownKey: start() already guarantees a non-empty value before this is called, so the type checker enforces it rather than a runtime fallback that can never actually fire. */
|
|
291
295
|
private buildRegistryEntry;
|
|
292
296
|
roster(): Promise<RegistryEntry[]>;
|
|
293
297
|
send(target: PeerRef, body: string, options?: Readonly<SendOptions>): Promise<{
|
|
@@ -298,6 +302,7 @@ export declare class CcPeer extends EventEmitter {
|
|
|
298
302
|
msgId: string;
|
|
299
303
|
}>;
|
|
300
304
|
private resolveTarget;
|
|
305
|
+
/** ownToken is passed explicitly rather than read from this.ownKey: the listener callback that invokes this is only ever registered after start() has set ownKey, so the parameter records that guarantee at the type level instead of a runtime guard that can never actually be false. */
|
|
301
306
|
private handleConnection;
|
|
302
307
|
stop(): Promise<void>;
|
|
303
308
|
}
|
package/dist/cc-peer.d.mts
CHANGED
|
@@ -287,7 +287,11 @@ export declare class CcPeer extends EventEmitter {
|
|
|
287
287
|
private stopped;
|
|
288
288
|
constructor(options: Readonly<CcPeerOptions>, deps: Deps);
|
|
289
289
|
static create(options?: Readonly<CcPeerOptions>): Promise<CcPeer>;
|
|
290
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Bind, publish key and registry, and begin listening. Public rather than private because dependency-injected construction (this constructor takes the full Deps) needs to trigger it explicitly; everyday callers use the static create(), which wires the real node adapters.
|
|
292
|
+
*/
|
|
293
|
+
start(): Promise<void>;
|
|
294
|
+
/** procStart is a required parameter, not read from this.ownKey: start() already guarantees a non-empty value before this is called, so the type checker enforces it rather than a runtime fallback that can never actually fire. */
|
|
291
295
|
private buildRegistryEntry;
|
|
292
296
|
roster(): Promise<RegistryEntry[]>;
|
|
293
297
|
send(target: PeerRef, body: string, options?: Readonly<SendOptions>): Promise<{
|
|
@@ -298,6 +302,7 @@ export declare class CcPeer extends EventEmitter {
|
|
|
298
302
|
msgId: string;
|
|
299
303
|
}>;
|
|
300
304
|
private resolveTarget;
|
|
305
|
+
/** ownToken is passed explicitly rather than read from this.ownKey: the listener callback that invokes this is only ever registered after start() has set ownKey, so the parameter records that guarantee at the type level instead of a runtime guard that can never actually be false. */
|
|
301
306
|
private handleConnection;
|
|
302
307
|
stop(): Promise<void>;
|
|
303
308
|
}
|
package/dist/cc-peer.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as CcPeer, t as CC_PEER_VERSION } from "./cc-peer-
|
|
1
|
+
import { n as CcPeer, t as CC_PEER_VERSION } from "./cc-peer-CWGY6H7F.mjs";
|
|
2
2
|
export { CC_PEER_VERSION, CcPeer };
|
package/docs/PROTOCOL.md
CHANGED
|
@@ -180,7 +180,7 @@ A receiving peer is the mirror: bind the socket, write key file and registry (pr
|
|
|
180
180
|
| Holds, attestation, self-sent verdict, `crossSessionInbound` parity | verified live |
|
|
181
181
|
| Peer registration, roster admission, name discovery | verified live (standalone peer in `ListAgents`, named `SendMessage`) |
|
|
182
182
|
| Receipts: held / delivered / denied / expired / dropped{duplicate, rate-limited, hop-loop, hop-runaway} | verified live |
|
|
183
|
-
| `queue-full` | code-verified; trigger attempted (55 queued), effective cap
|
|
183
|
+
| `queue-full` | code-verified; trigger attempted (55 queued behind a hold dialog, no drop). The `tengu_harbor_kite_limits` dynamic override has never been served to this account (absent from the Statsig evaluations cache), so the effective cap is the code default of 50 — the non-firing therefore reflects queue accounting (messages parked behind the approval dialog do not count toward the undelivered-peer-message queue), not a raised cap |
|
|
184
184
|
| Idle subscriptions (`idle`, `exited`) | verified live |
|
|
185
185
|
| artifact_yield admission + answer | verified live (refused and admitted paths); populated handover not exercised |
|
|
186
186
|
| File transfer | staging + wire replicated; receive path behind a never-served server flag (evidenced) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-peer",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Communicate with local Claude Code instances over their native cross-session peer messaging: send and receive messages, register as a named discoverable peer, receipts, idle subscriptions, and a REST facade via `npx cc-peer`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"_typecheck": "tsc --noEmit",
|
|
56
56
|
"test": "vitest run",
|
|
57
57
|
"test:watch": "vitest",
|
|
58
|
-
"test:mutation": "stryker run",
|
|
58
|
+
"test:mutation": "stryker run stryker.config.ts",
|
|
59
59
|
"prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && publint && attw --pack",
|
|
60
60
|
"prepare": "husky",
|
|
61
61
|
"test:coverage": "vitest run --coverage"
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
],
|
|
73
73
|
"overrides": {
|
|
74
74
|
"eslint-plugin-jsdoc": "64.3.6",
|
|
75
|
-
"conventional-changelog-writer": "9.2.1"
|
|
75
|
+
"conventional-changelog-writer": "9.2.1",
|
|
76
|
+
"qs": "6.15.2"
|
|
76
77
|
}
|
|
77
78
|
},
|
|
78
79
|
"devDependencies": {
|
|
@@ -91,7 +92,7 @@
|
|
|
91
92
|
"@stryker-mutator/core": "10.0.0",
|
|
92
93
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
93
94
|
"@types/node": "26.4.1",
|
|
94
|
-
"@vitest/coverage-v8": "
|
|
95
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
95
96
|
"commitlint": "21.2.2",
|
|
96
97
|
"eslint": "10.10.0",
|
|
97
98
|
"eslint-config-prettier": "10.1.8",
|
|
@@ -107,6 +108,6 @@
|
|
|
107
108
|
"tsx": "4.23.13",
|
|
108
109
|
"turbo": "2.10.12",
|
|
109
110
|
"typescript": "6.0.3",
|
|
110
|
-
"vitest": "
|
|
111
|
+
"vitest": "4.1.11"
|
|
111
112
|
}
|
|
112
113
|
}
|