@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. 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
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./delivery.ts";
2
+ export * from "./direct.ts";
3
+ export * from "./handlers.ts";
4
+ export * from "./inbox.ts";
5
+ export * from "./notify.ts";