@ccmsg/cli 0.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 +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { type InboxMessage, renderDirectDelivery, type Sid } from "@ccmsg/protocol";
|
|
6
|
+
|
|
7
|
+
/** What route (a) answered (§4.1).
|
|
8
|
+
*
|
|
9
|
+
* `unavailable` is every way the route does not apply — the flag is off, the
|
|
10
|
+
* harness names no messaging socket, the generation is one we do not speak, the
|
|
11
|
+
* key cannot be read, the acknowledgement did not come. §4.1 gives them one
|
|
12
|
+
* outcome on purpose: the route either carried the message or it did not, and
|
|
13
|
+
* route (b) is tried the same way in each case.
|
|
14
|
+
*
|
|
15
|
+
* `refused` is separate because it is not "the route does not apply": the
|
|
16
|
+
* session is there and turned this message away for now, which is the one
|
|
17
|
+
* outcome that reaches the sender as its own reason (§4.4). */
|
|
18
|
+
export type DirectOutcome = "delivered" | "unavailable" | "refused";
|
|
19
|
+
|
|
20
|
+
/** Route (a): the harness's own messaging socket. */
|
|
21
|
+
export interface DirectRoute {
|
|
22
|
+
send(sid: Sid, message: InboxMessage): Promise<DirectOutcome>;
|
|
23
|
+
/** Let go of what the route holds open. The status inbox below is a bound
|
|
24
|
+
* socket with a name on disk, and it leaves when the instance does (§8.5). */
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Route (a) turned off by config (§4.1 condition 0). Delivery is unchanged by
|
|
29
|
+
* this: route (b) is the fallback, and a fallback that always runs is still the
|
|
30
|
+
* same semantics (§4.1). */
|
|
31
|
+
export class DisabledDirectRoute implements DirectRoute {
|
|
32
|
+
send(): Promise<DirectOutcome> {
|
|
33
|
+
return Promise.resolve("unavailable");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
close(): void {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `peerProtocol` generation this speaks. One value, because one is what
|
|
40
|
+
* has been read off a running harness (2.1.263); any other generation is a
|
|
41
|
+
* protocol nobody here has seen, which is condition 1 of §4.1. */
|
|
42
|
+
export const PEER_PROTOCOL = 1;
|
|
43
|
+
|
|
44
|
+
/** How long one attempt has to reach the point where the harness holds our
|
|
45
|
+
* bytes: connect, then flush both frames.
|
|
46
|
+
*
|
|
47
|
+
* Provisional. No primary source states a budget for the sending side — the
|
|
48
|
+
* only stated deadline is the receiver's own 30 s wait for a first complete
|
|
49
|
+
* line, which is its tolerance and not ours. This is short enough that a
|
|
50
|
+
* message falls to route (b) well inside the turn that sent it. */
|
|
51
|
+
export const DIRECT_ACK_MS = 2_000;
|
|
52
|
+
|
|
53
|
+
/** How long the status inbox is watched for word about this message before the
|
|
54
|
+
* send is taken to have landed (§4.1 condition 3).
|
|
55
|
+
*
|
|
56
|
+
* Provisional. What is known from the harness (2.1.263) is where the receipt
|
|
57
|
+
* is raised, not how long it takes to arrive: the receiving session decides a
|
|
58
|
+
* peer message at its inbound gate and reports the outcome from that same
|
|
59
|
+
* decision, so a receipt for a message we have finished writing is one connect
|
|
60
|
+
* and one line away on a socket of this same host. A quarter second is far
|
|
61
|
+
* more than that costs and far less than a person waits for `message_send` to
|
|
62
|
+
* answer. Nothing measured stands behind the number itself. */
|
|
63
|
+
export const DIRECT_STATUS_MS = 250;
|
|
64
|
+
|
|
65
|
+
/** What the receiving session says about a message it did not simply take
|
|
66
|
+
* (harness 2.1.263, `peer_message_status`).
|
|
67
|
+
*
|
|
68
|
+
* There is no word for the ordinary case. The receipt is raised where a peer
|
|
69
|
+
* message is turned away, parked or lost, and a message the session accepts
|
|
70
|
+
* passes its gate without anything being written back — so these are the whole
|
|
71
|
+
* of what route (a) can hear, and hearing none of them within the window is
|
|
72
|
+
* what "it arrived" looks like on this route.
|
|
73
|
+
*
|
|
74
|
+
* `held` is among them because a parked message is not delivered yet: it waits
|
|
75
|
+
* on somebody's approval there, which is the same "there, and not taking it
|
|
76
|
+
* now" that §4.4 keeps in our inbox and offers again. */
|
|
77
|
+
const REFUSING = new Set(["refused", "denied", "dropped", "expired", "held"]);
|
|
78
|
+
|
|
79
|
+
/** The socket this daemon offers so the receiving session can say what became
|
|
80
|
+
* of a message (§4.1 condition 3).
|
|
81
|
+
*
|
|
82
|
+
* It lives in the directory the target's own socket is in, and not in this
|
|
83
|
+
* instance's state directory, because the receiving harness vets the address it
|
|
84
|
+
* would answer before it answers: a reply target outside its socket namespace
|
|
85
|
+
* is dropped with `reply address unshaped or outside our socket namespace`
|
|
86
|
+
* (2.1.263). A socket beside the one we are writing to is inside it, so this is
|
|
87
|
+
* the one place a status can be heard from at all. The name is this process's
|
|
88
|
+
* pid and eight random hex digits, which is a shape that namespace admits and
|
|
89
|
+
* that no harness will ever bind for a session of its own.
|
|
90
|
+
*
|
|
91
|
+
* Bound once per directory and held for the life of the instance: binding per
|
|
92
|
+
* send would race a receipt against its own socket going away. */
|
|
93
|
+
class StatusInbox {
|
|
94
|
+
readonly #waiting = new Map<string, (status: string) => void>();
|
|
95
|
+
readonly #buffers = new Map<object, string>();
|
|
96
|
+
#server: ReturnType<typeof Bun.listen> | undefined;
|
|
97
|
+
readonly #path: string;
|
|
98
|
+
|
|
99
|
+
constructor(directory: string) {
|
|
100
|
+
this.#path = join(directory, `${process.pid}-${randomBytes(4).toString("hex")}.sock`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The address to put in `from`, or nothing if the socket could not be
|
|
104
|
+
* bound. Binding fails on a directory we cannot write, which costs the
|
|
105
|
+
* route its status channel and nothing else: the message still goes, and
|
|
106
|
+
* what the session says about it is simply not heard. */
|
|
107
|
+
address(): string | undefined {
|
|
108
|
+
if (this.#server !== undefined) return `uds:${this.#path}`;
|
|
109
|
+
try {
|
|
110
|
+
this.#server = Bun.listen({
|
|
111
|
+
unix: this.#path,
|
|
112
|
+
socket: {
|
|
113
|
+
data: (socket, chunk) => this.#read(socket, chunk),
|
|
114
|
+
open: () => {},
|
|
115
|
+
close: (socket) => {
|
|
116
|
+
this.#buffers.delete(socket);
|
|
117
|
+
},
|
|
118
|
+
error: () => {},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
// Same-uid by construction (A2 / A4), and stated rather than left to the
|
|
125
|
+
// umask: what can be written here is what a session is told about.
|
|
126
|
+
try {
|
|
127
|
+
chmodSync(this.#path, 0o600);
|
|
128
|
+
} catch {
|
|
129
|
+
// The socket is bound and usable; a mode we could not set is not a
|
|
130
|
+
// reason to give up the channel.
|
|
131
|
+
}
|
|
132
|
+
return `uds:${this.#path}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Watch for word about one message, for as long as the caller allows. The
|
|
136
|
+
* answer is the status the session named, or nothing if it named none. */
|
|
137
|
+
async status(mid: string, withinMs: number): Promise<string | undefined> {
|
|
138
|
+
const settled = Promise.withResolvers<string | undefined>();
|
|
139
|
+
this.#waiting.set(mid, settled.resolve);
|
|
140
|
+
const deadline = setTimeout(() => settled.resolve(undefined), withinMs);
|
|
141
|
+
try {
|
|
142
|
+
return await settled.promise;
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(deadline);
|
|
145
|
+
this.#waiting.delete(mid);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
close(): void {
|
|
150
|
+
this.#server?.stop(true);
|
|
151
|
+
this.#server = undefined;
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(this.#path);
|
|
154
|
+
} catch {
|
|
155
|
+
// Already gone, which is the state this is asking for.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#read(socket: object, chunk: Uint8Array): void {
|
|
160
|
+
const parts = ((this.#buffers.get(socket) ?? "") + Buffer.from(chunk).toString("utf8")).split(
|
|
161
|
+
"\n",
|
|
162
|
+
);
|
|
163
|
+
this.#buffers.set(socket, parts.pop() ?? "");
|
|
164
|
+
for (const line of parts) this.#line(line);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One frame from a session. Only the receipts are read: the address also
|
|
168
|
+
* reaches the model as somewhere it could answer, so a reply may arrive here
|
|
169
|
+
* as an ordinary message — and a reply belongs in the conversation the
|
|
170
|
+
* contract routes it through, not in a socket that only settles sends. */
|
|
171
|
+
#line(line: string): void {
|
|
172
|
+
if (line.trim() === "") return;
|
|
173
|
+
let frame: Record<string, unknown>;
|
|
174
|
+
try {
|
|
175
|
+
const parsed: unknown = JSON.parse(line);
|
|
176
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
177
|
+
frame = parsed as Record<string, unknown>;
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (frame["action"] !== "peer_message_status") return;
|
|
182
|
+
const status = frame["status"];
|
|
183
|
+
if (typeof status !== "string") return;
|
|
184
|
+
for (const mid of named(frame)) this.#waiting.get(mid)?.(status);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Which of our messages a receipt is about: the one it answers, and any it
|
|
189
|
+
* names as lost alongside (harness 2.1.263 reports a queue-full drop against
|
|
190
|
+
* every message it shed). */
|
|
191
|
+
function named(frame: Record<string, unknown>): string[] {
|
|
192
|
+
const original = frame["orig_msg_id"];
|
|
193
|
+
const dropped = frame["dropped_msg_ids"];
|
|
194
|
+
return [
|
|
195
|
+
...(typeof original === "string" ? [original] : []),
|
|
196
|
+
...(Array.isArray(dropped) ? dropped.filter((id): id is string => typeof id === "string") : []),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The state file of one session, as far as route (a) reads it. */
|
|
201
|
+
interface HarnessTarget {
|
|
202
|
+
readonly pid: number;
|
|
203
|
+
readonly socketPath: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface SocketRouteOptions {
|
|
207
|
+
/** The one config home this instance answers for (M6). Its `sessions/` holds
|
|
208
|
+
* both the state files and the keys. */
|
|
209
|
+
readonly configHome: string;
|
|
210
|
+
readonly ackMs?: number;
|
|
211
|
+
/** How long a receipt has to arrive before the message counts as taken. */
|
|
212
|
+
readonly statusMs?: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Route (a) against the harness's messaging socket (§4.1).
|
|
216
|
+
*
|
|
217
|
+
* The path is `sessions/<pid>.json` of this instance's own config home, which
|
|
218
|
+
* is also the answer to condition 2: a key beside it that this uid can read is
|
|
219
|
+
* exactly the same-uid, same-config-home boundary the instance already stands
|
|
220
|
+
* on (A2 / A4). Nothing here searches another config home, and a session this
|
|
221
|
+
* instance cannot see a state file for is simply not reachable this way.
|
|
222
|
+
*
|
|
223
|
+
* The directory is read per send rather than taken from the sessions domain's
|
|
224
|
+
* watch: that watch runs only while a topic is subscribed (§6.3), and route (a)
|
|
225
|
+
* exists precisely for the session that subscribes to nothing. */
|
|
226
|
+
export class ClaudeCodeSocketRoute implements DirectRoute {
|
|
227
|
+
readonly #sessionsDir: string;
|
|
228
|
+
readonly #ackMs: number;
|
|
229
|
+
readonly #statusMs: number;
|
|
230
|
+
/** One status inbox per directory sessions' sockets live in. A host has one
|
|
231
|
+
* such directory in practice; the map is what keeps that from being an
|
|
232
|
+
* assumption. */
|
|
233
|
+
readonly #inboxes = new Map<string, StatusInbox>();
|
|
234
|
+
|
|
235
|
+
constructor(options: SocketRouteOptions) {
|
|
236
|
+
this.#sessionsDir = join(options.configHome, "sessions");
|
|
237
|
+
this.#ackMs = options.ackMs ?? DIRECT_ACK_MS;
|
|
238
|
+
this.#statusMs = options.statusMs ?? DIRECT_STATUS_MS;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** One send, and what the session made of it.
|
|
242
|
+
*
|
|
243
|
+
* The message is written, and then the receipt channel is watched for word
|
|
244
|
+
* about it. What can arrive is a session saying it did not take the message
|
|
245
|
+
* (§4.4); what cannot is a session saying it did, because none is sent for
|
|
246
|
+
* the ordinary case. So the outcome is refusal if it says so in time, and
|
|
247
|
+
* delivery if it says nothing — which is the same shape as the acknowledged
|
|
248
|
+
* send it stands in for, decided on a channel that carries the refusals
|
|
249
|
+
* rather than on one that carries nothing at all. */
|
|
250
|
+
async send(sid: Sid, message: InboxMessage): Promise<DirectOutcome> {
|
|
251
|
+
const target = await this.#target(sid);
|
|
252
|
+
if (target === undefined) return "unavailable";
|
|
253
|
+
const token = await this.#token(target.pid);
|
|
254
|
+
if (token === undefined) return "unavailable";
|
|
255
|
+
const inbox = this.#inbox(target.socketPath);
|
|
256
|
+
const from = inbox?.address();
|
|
257
|
+
const watching = inbox === undefined ? undefined : inbox.status(message.mid, this.#statusMs);
|
|
258
|
+
const written = await write(target.socketPath, frames(sid, token, message, from), this.#ackMs);
|
|
259
|
+
if (written !== "delivered") return written;
|
|
260
|
+
const status = await watching;
|
|
261
|
+
return status !== undefined && REFUSING.has(status) ? "refused" : "delivered";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
close(): void {
|
|
265
|
+
for (const inbox of this.#inboxes.values()) inbox.close();
|
|
266
|
+
this.#inboxes.clear();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The receipt channel for a target, bound beside its own socket. Absent
|
|
270
|
+
* when nothing could be bound there, which leaves the route working and its
|
|
271
|
+
* refusals unheard. */
|
|
272
|
+
#inbox(socketPath: string): StatusInbox | undefined {
|
|
273
|
+
const directory = dirname(socketPath);
|
|
274
|
+
const held = this.#inboxes.get(directory);
|
|
275
|
+
if (held !== undefined) return held;
|
|
276
|
+
const inbox = new StatusInbox(directory);
|
|
277
|
+
if (inbox.address() === undefined) return undefined;
|
|
278
|
+
this.#inboxes.set(directory, inbox);
|
|
279
|
+
return inbox;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The state file naming this session, if it names a socket of a generation
|
|
283
|
+
* we speak (§4.1 conditions 1). */
|
|
284
|
+
async #target(sid: Sid): Promise<HarnessTarget | undefined> {
|
|
285
|
+
let names: string[];
|
|
286
|
+
try {
|
|
287
|
+
names = await readdir(this.#sessionsDir);
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
for (const name of names) {
|
|
292
|
+
if (!/^\d+\.json$/.test(name)) continue;
|
|
293
|
+
const row = await readJson(join(this.#sessionsDir, name));
|
|
294
|
+
if (row === undefined || row["sessionId"] !== sid) continue;
|
|
295
|
+
const pid = row["pid"];
|
|
296
|
+
const socketPath = row["messagingSocketPath"];
|
|
297
|
+
if (typeof pid !== "number" || typeof socketPath !== "string" || socketPath === "") {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
return row["peerProtocol"] === PEER_PROTOCOL ? { pid, socketPath } : undefined;
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The `peerToken` the harness wrote for this session (§4.1 condition 2).
|
|
306
|
+
*
|
|
307
|
+
* Found by the pid the key is named after rather than by rebuilding the rest
|
|
308
|
+
* of the name: the digest in `<pid>.<digest>.key` is stated to be over the
|
|
309
|
+
* socket path, but neither its input spelling nor its length has been checked
|
|
310
|
+
* against a running harness, and a name we cannot rebuild is still a name we
|
|
311
|
+
* can recognise. */
|
|
312
|
+
async #token(pid: number): Promise<string | undefined> {
|
|
313
|
+
const key = new RegExp(`^${pid}\\.[0-9a-f]+\\.key$`);
|
|
314
|
+
let names: string[];
|
|
315
|
+
try {
|
|
316
|
+
names = await readdir(this.#sessionsDir);
|
|
317
|
+
} catch {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
for (const name of names) {
|
|
321
|
+
if (!key.test(name)) continue;
|
|
322
|
+
const document = await readJson(join(this.#sessionsDir, name));
|
|
323
|
+
const token = document?.["peerToken"];
|
|
324
|
+
if (typeof token === "string" && token !== "") return token;
|
|
325
|
+
}
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The two lines one send writes: the auth frame the harness's own senders
|
|
331
|
+
* write first, then the message.
|
|
332
|
+
*
|
|
333
|
+
* The body is the contract's wording for this route. On it the recipient is
|
|
334
|
+
* the model rather than a client: it reads one block of text and has no frame
|
|
335
|
+
* to look at, so `mid` and `from` have to be in the text or nothing can be
|
|
336
|
+
* answered. `<cross-session-message>` is what the harness's own senders embed
|
|
337
|
+
* in `message.content` — measured on a real send, where it reached the model
|
|
338
|
+
* literal rather than expanded — so sitting on it means the receiving harness
|
|
339
|
+
* reads an origin it already knows. The wording is the contract's and this
|
|
340
|
+
* route only carries it.
|
|
341
|
+
*
|
|
342
|
+
* `session_id` rides along because the harness checks it against its own and
|
|
343
|
+
* drops a mismatch: a state file read a moment before the pid was reused turns
|
|
344
|
+
* into a message nobody receives rather than one the wrong session does.
|
|
345
|
+
*
|
|
346
|
+
* `from` is the address of our own status inbox, and is fixed by ccmsg rather
|
|
347
|
+
* than taken from the caller (§4.1). It is what the receiving session answers
|
|
348
|
+
* to about this message, and the message's `mid` is what it answers about — so
|
|
349
|
+
* the two travel together, and a route with no inbox to offer sends neither
|
|
350
|
+
* rather than naming an address nothing is listening on. */
|
|
351
|
+
function frames(sid: Sid, token: string, message: InboxMessage, from?: string): string {
|
|
352
|
+
const auth = { type: "auth", token };
|
|
353
|
+
const user = {
|
|
354
|
+
type: "user",
|
|
355
|
+
...(from === undefined ? {} : { from }),
|
|
356
|
+
session_id: sid,
|
|
357
|
+
msg_id: message.mid,
|
|
358
|
+
message: { content: renderDirectDelivery(message) },
|
|
359
|
+
};
|
|
360
|
+
return `${JSON.stringify(auth)}\n${JSON.stringify(user)}\n`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Connect and write, and answer whether the harness holds our bytes (§4.1
|
|
364
|
+
* condition 3).
|
|
365
|
+
*
|
|
366
|
+
* That is the whole of what this can decide. The connection carries nothing
|
|
367
|
+
* back — a real send measured zero bytes on it — so waiting here for an answer
|
|
368
|
+
* would time out every delivery; what the session makes of the message travels
|
|
369
|
+
* to our status inbox instead, and the caller waits for it there. What the
|
|
370
|
+
* budget covers is connect and flush. */
|
|
371
|
+
async function write(path: string, payload: string, ackMs: number): Promise<DirectOutcome> {
|
|
372
|
+
const started = Date.now();
|
|
373
|
+
const settled = Promise.withResolvers<DirectOutcome>();
|
|
374
|
+
const bytes = Buffer.from(payload, "utf8");
|
|
375
|
+
let written = 0;
|
|
376
|
+
let flushed = false;
|
|
377
|
+
|
|
378
|
+
const done = (): void => {
|
|
379
|
+
flushed = true;
|
|
380
|
+
settled.resolve(Date.now() - started >= ackMs ? "unavailable" : "delivered");
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const push = (socket: { write(data: Uint8Array): number }): void => {
|
|
384
|
+
written += socket.write(bytes.subarray(written));
|
|
385
|
+
if (written >= bytes.length && !flushed) done();
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
let socket: Awaited<ReturnType<typeof Bun.connect>>;
|
|
389
|
+
try {
|
|
390
|
+
socket = await Bun.connect({
|
|
391
|
+
unix: path,
|
|
392
|
+
socket: {
|
|
393
|
+
open: push,
|
|
394
|
+
drain: (conn) => {
|
|
395
|
+
if (!flushed) push(conn);
|
|
396
|
+
},
|
|
397
|
+
data: () => {},
|
|
398
|
+
// The connection ending before the last byte left is the message not
|
|
399
|
+
// having reached anyone; after that it is the harness closing a
|
|
400
|
+
// connection it has no more use for.
|
|
401
|
+
close: () => settled.resolve(flushed ? "delivered" : "unavailable"),
|
|
402
|
+
error: () => settled.resolve("unavailable"),
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
} catch {
|
|
406
|
+
// No socket at the path, or nothing listening on it: the session ended and
|
|
407
|
+
// took its socket with it, or never had one (§4.1 condition 1).
|
|
408
|
+
return "unavailable";
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const deadline = setTimeout(
|
|
412
|
+
() => settled.resolve("unavailable"),
|
|
413
|
+
Math.max(0, ackMs - (Date.now() - started)),
|
|
414
|
+
);
|
|
415
|
+
try {
|
|
416
|
+
return await settled.promise;
|
|
417
|
+
} finally {
|
|
418
|
+
clearTimeout(deadline);
|
|
419
|
+
socket.end();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
|
|
424
|
+
try {
|
|
425
|
+
const document: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
426
|
+
if (typeof document !== "object" || document === null) return undefined;
|
|
427
|
+
return document as Record<string, unknown>;
|
|
428
|
+
} catch {
|
|
429
|
+
// Missing, unreadable by this uid, or half written — all of them are
|
|
430
|
+
// "route (a) does not apply here" (§4.1 conditions 1 and 2).
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Delivery } from "./delivery.ts";
|
|
2
|
+
import type { Notify } from "./notify.ts";
|
|
3
|
+
|
|
4
|
+
/** The ops that reach the messaging plane. They carry no logic of their own:
|
|
5
|
+
* dispatch has validated and allowed the call, so each handler is the domain's
|
|
6
|
+
* own entry point under the contract's name. */
|
|
7
|
+
export function messagingHandlers(delivery: Delivery, notify: Notify) {
|
|
8
|
+
return {
|
|
9
|
+
message_send: delivery.send,
|
|
10
|
+
notify_send: notify.send,
|
|
11
|
+
say_post: notify.post,
|
|
12
|
+
say_mark_read: notify.markRead,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
INBOX_MAX_PER_SID,
|
|
12
|
+
INBOX_RETENTION_MS,
|
|
13
|
+
type InboxMessage,
|
|
14
|
+
type Sid,
|
|
15
|
+
type Timestamp,
|
|
16
|
+
} from "@ccmsg/protocol";
|
|
17
|
+
|
|
18
|
+
export const INBOX_FILE = "inbox.jsonl";
|
|
19
|
+
|
|
20
|
+
/** One line of the file. Three verbs, all of them appends: a message arriving
|
|
21
|
+
* that could not be handed over, that message reaching its session, and that
|
|
22
|
+
* message being dropped to make room for a newer one.
|
|
23
|
+
*
|
|
24
|
+
* The alternative — rewriting the file whenever a message leaves — would make
|
|
25
|
+
* every removal a whole-file write, and a daemon killed during one loses
|
|
26
|
+
* messages that were neither delivered nor meant to go. Appending means the
|
|
27
|
+
* only line a kill can damage is the last one, and a damaged last line is a
|
|
28
|
+
* message the sender was never told was safe. */
|
|
29
|
+
type Record_ =
|
|
30
|
+
| { readonly v: "add"; readonly sid: Sid; readonly message: InboxMessage }
|
|
31
|
+
| { readonly v: "delivered"; readonly sid: Sid; readonly mid: string }
|
|
32
|
+
| { readonly v: "dropped"; readonly sid: Sid; readonly mid: string };
|
|
33
|
+
|
|
34
|
+
/** What was said to a session and has not reached it.
|
|
35
|
+
*
|
|
36
|
+
* The one thing here that nothing else can reconstruct (§3.6): the sender's
|
|
37
|
+
* `message_send` has already been answered, no transcript holds a message that
|
|
38
|
+
* was never handed over, and the text lives nowhere else. Losing this file
|
|
39
|
+
* loses the words.
|
|
40
|
+
*
|
|
41
|
+
* One file rather than one per sid. Both are append-only and both mean the same
|
|
42
|
+
* thing for removal and expiry (§4.3); a single file makes the write path one
|
|
43
|
+
* open handle and makes "what is undelivered right now" one replay. */
|
|
44
|
+
export class Inbox {
|
|
45
|
+
readonly #held = new Map<Sid, InboxMessage[]>();
|
|
46
|
+
|
|
47
|
+
constructor(private readonly file: string) {}
|
|
48
|
+
|
|
49
|
+
/** Replay the file, drop what has expired, and write back what is left.
|
|
50
|
+
*
|
|
51
|
+
* The rewrite is the only whole-file write, and it happens before anything
|
|
52
|
+
* can be appended: it is what keeps the file from growing by every message
|
|
53
|
+
* ever delivered. A kill during it leaves the previous file, since it lands
|
|
54
|
+
* through a temporary and a rename. */
|
|
55
|
+
load(now: Timestamp = Date.now()): void {
|
|
56
|
+
let text: string;
|
|
57
|
+
try {
|
|
58
|
+
text = readFileSync(this.file, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const line of text.split("\n")) {
|
|
63
|
+
if (line.trim() === "") continue;
|
|
64
|
+
let record: Record_;
|
|
65
|
+
try {
|
|
66
|
+
record = JSON.parse(line) as Record_;
|
|
67
|
+
} catch {
|
|
68
|
+
// The last line of a file the daemon was killed while writing.
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
this.#replay(record);
|
|
72
|
+
}
|
|
73
|
+
this.#expire(now);
|
|
74
|
+
this.#compact();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Everything still undelivered for one session, oldest first. Expiry is
|
|
78
|
+
* evaluated here rather than by a timer: a message nobody is asking about has
|
|
79
|
+
* nothing to expire for (M3). */
|
|
80
|
+
undelivered(sid: Sid, now: Timestamp = Date.now()): InboxMessage[] {
|
|
81
|
+
this.#expire(now, sid);
|
|
82
|
+
return [...(this.#held.get(sid) ?? [])];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Hold a message for a session that could not take it.
|
|
86
|
+
*
|
|
87
|
+
* Answers whether the oldest was dropped to make room, which is the whole of
|
|
88
|
+
* `inbox_full`: the message is held either way, and what the sender is told
|
|
89
|
+
* differs because something of theirs is now gone. */
|
|
90
|
+
hold(sid: Sid, message: InboxMessage, now: Timestamp = Date.now()): { evicted: boolean } {
|
|
91
|
+
this.#expire(now, sid);
|
|
92
|
+
const held = this.#held.get(sid) ?? [];
|
|
93
|
+
this.#held.set(sid, held);
|
|
94
|
+
held.push(message);
|
|
95
|
+
this.#append({ v: "add", sid, message });
|
|
96
|
+
if (held.length <= INBOX_MAX_PER_SID) return { evicted: false };
|
|
97
|
+
const oldest = held.shift();
|
|
98
|
+
if (oldest !== undefined) this.#append({ v: "dropped", sid, mid: oldest.mid });
|
|
99
|
+
return { evicted: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Note that messages reached their session, which is what takes them out of
|
|
103
|
+
* the inbox (§4.3). */
|
|
104
|
+
delivered(sid: Sid, mids: readonly string[]): void {
|
|
105
|
+
const held = this.#held.get(sid);
|
|
106
|
+
if (held === undefined || mids.length === 0) return;
|
|
107
|
+
const gone = new Set(mids);
|
|
108
|
+
const left = held.filter((message) => !gone.has(message.mid));
|
|
109
|
+
if (left.length === 0) this.#held.delete(sid);
|
|
110
|
+
else this.#held.set(sid, left);
|
|
111
|
+
for (const mid of mids) this.#append({ v: "delivered", sid, mid });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every session something is waiting for. What reads it is the offer of
|
|
115
|
+
* §4.3: when a session becomes able to receive, what it is owed has to be
|
|
116
|
+
* findable without asking about each sid in turn. */
|
|
117
|
+
sids(): Sid[] {
|
|
118
|
+
return [...this.#held.keys()];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The highest counter this instance has already issued, so a restart does
|
|
122
|
+
* not hand out a `mid` that a held message already carries. */
|
|
123
|
+
lastCounter(prefix: string): number {
|
|
124
|
+
let highest = 0;
|
|
125
|
+
for (const held of this.#held.values()) {
|
|
126
|
+
for (const message of held) {
|
|
127
|
+
if (!message.mid.startsWith(prefix)) continue;
|
|
128
|
+
const counter = Number(message.mid.slice(prefix.length));
|
|
129
|
+
if (Number.isSafeInteger(counter) && counter > highest) highest = counter;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return highest;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#replay(record: Record_): void {
|
|
136
|
+
if (record.v === "add") {
|
|
137
|
+
const held = this.#held.get(record.sid) ?? [];
|
|
138
|
+
this.#held.set(record.sid, held);
|
|
139
|
+
held.push(record.message);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const held = this.#held.get(record.sid);
|
|
143
|
+
if (held === undefined) return;
|
|
144
|
+
const left = held.filter((message) => message.mid !== record.mid);
|
|
145
|
+
if (left.length === 0) this.#held.delete(record.sid);
|
|
146
|
+
else this.#held.set(record.sid, left);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Drop what is past the window the contract sets (DV-Q4). Nothing is
|
|
150
|
+
* appended for an expiry: the same clock reaches the same verdict on the next
|
|
151
|
+
* replay, so writing it down would record a conclusion rather than an event. */
|
|
152
|
+
#expire(now: Timestamp, only?: Sid): void {
|
|
153
|
+
for (const [sid, held] of this.#held) {
|
|
154
|
+
if (only !== undefined && sid !== only) continue;
|
|
155
|
+
const left = held.filter((message) => now - message.sent_at <= INBOX_RETENTION_MS);
|
|
156
|
+
if (left.length === held.length) continue;
|
|
157
|
+
if (left.length === 0) this.#held.delete(sid);
|
|
158
|
+
else this.#held.set(sid, left);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#append(record: Record_): void {
|
|
163
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
164
|
+
appendFileSync(this.file, `${JSON.stringify(record)}\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#compact(): void {
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
for (const [sid, held] of this.#held) {
|
|
170
|
+
for (const message of held) lines.push(JSON.stringify({ v: "add", sid, message }));
|
|
171
|
+
}
|
|
172
|
+
if (lines.length === 0) {
|
|
173
|
+
try {
|
|
174
|
+
unlinkSync(this.file);
|
|
175
|
+
} catch {
|
|
176
|
+
// Nothing was there to begin with.
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
181
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
182
|
+
writeFileSync(temporary, `${lines.join("\n")}\n`);
|
|
183
|
+
renameSync(temporary, this.file);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Where the inbox lives for an instance whose state directory is `stateDir`
|
|
188
|
+
* (§8.1: every per-instance path is derived from its config home). */
|
|
189
|
+
export function inboxPath(stateDir: string): string {
|
|
190
|
+
return join(stateDir, INBOX_FILE);
|
|
191
|
+
}
|