@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,911 @@
1
+ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ type Capability,
5
+ type Endpoint,
6
+ type InstanceId,
7
+ type InstancePingResult,
8
+ type NetOnlineEvent,
9
+ type RestartingEvent,
10
+ type Sid,
11
+ type Timestamp,
12
+ } from "@ccmsg/protocol";
13
+ import {
14
+ callerOf,
15
+ CallerError,
16
+ callerOfIdentity,
17
+ dispatch,
18
+ type DispatchResult,
19
+ failure,
20
+ type Handlers,
21
+ type Requester,
22
+ } from "../dispatch/index.ts";
23
+ import {
24
+ Containment,
25
+ fileHandlers,
26
+ sandboxCapabilities,
27
+ SandboxGrants,
28
+ sandboxHandlers,
29
+ type SessionRoots,
30
+ } from "../files/index.ts";
31
+ import {
32
+ ClaudeCodeSocketRoute,
33
+ Delivery,
34
+ DisabledDirectRoute,
35
+ type DirectRoute,
36
+ inboxPath,
37
+ messagingHandlers,
38
+ Notify,
39
+ sessionLabel,
40
+ } from "../messaging/index.ts";
41
+ import { Inbox } from "../messaging/inbox.ts";
42
+ import {
43
+ hostProcessDeps,
44
+ hostTerminalReader,
45
+ sessionCapabilities,
46
+ sessionHandlers,
47
+ SessionProcesses,
48
+ Sessions,
49
+ sessionStatusOf,
50
+ SessionStatus,
51
+ } from "../sessions/index.ts";
52
+ import { topicHandlers, Topics } from "../topics/index.ts";
53
+ import { TranscriptFiles, Transcripts } from "../transcript/index.ts";
54
+ import {
55
+ ConnRegistry,
56
+ type EntryPolicy,
57
+ type Listener,
58
+ listenUds,
59
+ serveWs,
60
+ Transport,
61
+ type UpgradeDecision,
62
+ } from "../transport/index.ts";
63
+ import { Mesh, MESH_PROTOCOL } from "../mesh/index.ts";
64
+ import {
65
+ Gateway,
66
+ gatewayCapabilities,
67
+ gatewayHandlers,
68
+ type GatewaySetup,
69
+ gatewaySetup,
70
+ } from "../upstream/index.ts";
71
+ import { KV_DIR, kvHandlers, KvStore } from "../kv/index.ts";
72
+ import { Launcher, launcherCapabilities, launcherHandlers } from "../launcher/index.ts";
73
+ import {
74
+ Translate,
75
+ translateCapabilities,
76
+ translateHandlers,
77
+ translateSetup,
78
+ } from "../translate/index.ts";
79
+ import { type EntryConfig, type InstanceConfig, loadConfig } from "./config.ts";
80
+ import { completeHandlers } from "./handlers.ts";
81
+ import { acquireLock, type Held, isHeldByUs, type Lock } from "./lock.ts";
82
+ import { Log } from "./log.ts";
83
+ import { prepareSocketDir, publishSocket, sweepOrphanSockets } from "./socket.ts";
84
+ import { instanceIdentity } from "./identity.ts";
85
+ import { type Env, type InstancePaths, resolvePaths } from "./paths.ts";
86
+ import { VERSION } from "../version.ts";
87
+
88
+ export interface StartOptions {
89
+ readonly env?: Env;
90
+ /** Mirror the log to stderr. A foreground run wants it; a test does not. */
91
+ readonly echoLog?: boolean;
92
+ /** Overrides the confirmation poll of the sessions watch, for tests. */
93
+ readonly pollMs?: number;
94
+ /** Overrides the mesh's own intervals, for a test that cannot wait out a
95
+ * heartbeat or a reconnection backoff. */
96
+ readonly meshTiming?: MeshTiming;
97
+ }
98
+
99
+ /** The mesh intervals a caller may shorten. The values themselves, and why they
100
+ * are what they are, belong to the mesh (§8.2, §8.3). */
101
+ export interface MeshTiming {
102
+ readonly heartbeatMs?: number;
103
+ readonly heartbeatTimeoutMs?: number;
104
+ readonly reconnectMinMs?: number;
105
+ readonly forwardTimeoutMs?: number;
106
+ /** The clock the retention window of §7.5 is read against, so a test can
107
+ * pass it without waiting a week. */
108
+ readonly now?: () => Timestamp;
109
+ }
110
+
111
+ /** Startup found another instance already serving this config home. Nothing
112
+ * was created and nothing has to be undone (§8.3 step 2). */
113
+ export interface AlreadyRunning {
114
+ readonly kind: "already_running";
115
+ readonly pid: number;
116
+ }
117
+
118
+ export type StartOutcome = Instance | AlreadyRunning;
119
+
120
+ export function isRunning(outcome: StartOutcome): outcome is Instance {
121
+ return outcome instanceof Instance;
122
+ }
123
+
124
+ /** Start one instance, in the order of §8.3.
125
+ *
126
+ * The order is the point of this function: the lock before anything is
127
+ * created, the config before anything is derived from it, the pid before the
128
+ * listeners so a file naming this process exists by the time anything can
129
+ * connect to it, and the dial last. */
130
+ export async function start(options: StartOptions = {}): Promise<StartOutcome> {
131
+ // 1. paths, and the directory the rest of them live in
132
+ const env = options.env ?? process.env;
133
+ const paths = resolvePaths(env);
134
+ mkdirSync(paths.stateDir, { recursive: true });
135
+
136
+ // 2. the single instance. A previous run's file with nobody behind it is
137
+ // taken over inside `acquireLock`; a live holder ends the start here.
138
+ const lock = acquireLock(paths.lockFile);
139
+ if (!isHeldByUs(lock)) {
140
+ return { kind: "already_running", pid: (lock as Held).pid };
141
+ }
142
+
143
+ const log = new Log(paths.logFile, options.echoLog ?? true);
144
+ try {
145
+ // 3. the config. A broken one ends the start rather than turning the
146
+ // setting it carried silently off (DV-Q9).
147
+ const config = loadConfig(paths.configFile, paths.configHome);
148
+ // What the config says of the gateway, resolved before anything is built
149
+ // from it: a webhook source whose secret cannot be read ends the start
150
+ // here, for the same reason a broken config does (DV-Q9).
151
+ const gateway = gatewaySetup(config.upstream, paths.configFile, env);
152
+ // The translation helper, checked the same way and for the same reason: a
153
+ // program that was named and cannot be run is a setting that cannot be
154
+ // honoured (DV-Q9).
155
+ const helper = translateSetup(config.upstream, paths.configFile);
156
+ // 4. this instance's identity, written the first time it is asked for.
157
+ //
158
+ // Before anything derived from it: `mid`, the store's keys and `last_live`
159
+ // are all keyed by it, and a config home started here for the first time —
160
+ // `daemon run` on one the shared file does not list — gets its id now
161
+ // rather than from an `add` that never happened (DR-0001 §2.1).
162
+ const id = instanceIdentity(paths.instanceIdFile);
163
+ // 5. the endpoint list, for an instance that has a mesh.
164
+ //
165
+ // `self` is configured, so nothing has to be settled; what the probe does
166
+ // is check it, and it has to arrive at a listener — so the WebSocket is
167
+ // bound here and handed to the instance. A `self` that answers as somebody
168
+ // else ends the start.
169
+ const mesh = meshFor(id, config, log, options.meshTiming);
170
+ const wiring = mesh === undefined ? undefined : await bindForMesh(config, mesh);
171
+ // 6-8 are the instance's own construction and listen.
172
+ const instance = new Instance(
173
+ paths,
174
+ config,
175
+ id,
176
+ lock,
177
+ log,
178
+ options.pollMs,
179
+ gateway,
180
+ helper,
181
+ wiring,
182
+ );
183
+ wiring?.attach(instance);
184
+ await instance.listen();
185
+ return instance;
186
+ } catch (cause) {
187
+ log.write("startup failed", { error: String(cause) });
188
+ lock.release();
189
+ throw cause;
190
+ }
191
+ }
192
+
193
+ /** The mesh, on an instance configured for one.
194
+ *
195
+ * Two things have to be true: peers to dial, and an address they can dial back.
196
+ * An instance serving only the unix socket is reachable by nothing on another
197
+ * host, so a peer list on one is a setting with no effect rather than a mesh. */
198
+ function meshFor(
199
+ id: InstanceId,
200
+ config: InstanceConfig,
201
+ log: Log,
202
+ timing?: MeshTiming,
203
+ ): Mesh | undefined {
204
+ if (config.peers.length === 0 || config.entry === undefined || config.self === undefined) {
205
+ return undefined;
206
+ }
207
+ return new Mesh({
208
+ id,
209
+ self: config.self,
210
+ peers: config.peers,
211
+ conns: new ConnRegistry(),
212
+ log: (msg, fields) => {
213
+ log.write(msg, fields);
214
+ },
215
+ ...timing,
216
+ });
217
+ }
218
+
219
+ /** What an instance is handed when its listener had to exist before it did.
220
+ *
221
+ * The connection registry is shared rather than copied: a connection accepted
222
+ * during self-identification is one of the instance's, and two registries would
223
+ * mean the stop order (§8.5 step 3) reaching only one of them. */
224
+ export interface MeshWiring {
225
+ readonly conns: ConnRegistry;
226
+ readonly ws: Listener;
227
+ readonly mesh: Mesh;
228
+ /** Point the listener at the instance, once there is one. */
229
+ attach(instance: Instance): void;
230
+ }
231
+
232
+ /** Bind the WebSocket, settle `self` against the peer list, and hand both on.
233
+ *
234
+ * The listener answers the two pre-authentication routes from the moment it is
235
+ * up — the probe of self-identification and the key of mesh-peer-auth §6 — and
236
+ * refuses everything else until the instance exists, which is a window of one
237
+ * round of probes. */
238
+ async function bindForMesh(config: InstanceConfig, mesh: Mesh): Promise<MeshWiring> {
239
+ const entry = config.entry as EntryConfig;
240
+ let instance: Instance | undefined;
241
+ const ws = serveWs({
242
+ hostname: entry.host,
243
+ port: entry.port,
244
+ conns: mesh.conns,
245
+ handle: (frame, conn) =>
246
+ instance === undefined
247
+ ? Promise.resolve(failure(undefined, "internal_error", "this instance is still starting"))
248
+ : instance.handle(frame, conn),
249
+ entry: entryPolicy(config, true),
250
+ route: async (request) => (await mesh.route(request)) ?? (await instance?.route(request)),
251
+ onConn: (conn, info) => {
252
+ mesh.accept(conn, info);
253
+ },
254
+ });
255
+ try {
256
+ await mesh.verify();
257
+ } catch (cause) {
258
+ // The listener is bound before the endpoint list is checked, so it is this
259
+ // function's to release when the check refuses — nothing else holds it yet,
260
+ // and a port left bound by a refused start is one the next start cannot
261
+ // have.
262
+ await ws.close();
263
+ throw cause;
264
+ }
265
+ return {
266
+ conns: mesh.conns,
267
+ ws,
268
+ mesh,
269
+ attach(built: Instance): void {
270
+ instance = built;
271
+ },
272
+ };
273
+ }
274
+
275
+ /** One running instance: the layers wired together, and the two lifecycle
276
+ * orders of §8.3 and §8.5. */
277
+ export class Instance {
278
+ readonly startedAt: Timestamp = Date.now();
279
+ readonly #conns: ConnRegistry;
280
+ /** The mesh, on an instance configured for one (§7). */
281
+ readonly #mesh: Mesh | undefined;
282
+ /** The WebSocket listener, when it had to be bound before this instance
283
+ * existed so that self-identification could reach it. */
284
+ readonly #boundWs: Listener | undefined;
285
+ readonly #transport = new Transport();
286
+ readonly #topics: Topics;
287
+ readonly #sessions: Sessions;
288
+ readonly #status: SessionStatus;
289
+ readonly #transcripts: Transcripts;
290
+ readonly #gateway: Gateway;
291
+ readonly #delivery: Delivery;
292
+ readonly #direct: DirectRoute;
293
+ readonly #notify: Notify;
294
+ readonly #translate: Translate | undefined;
295
+ readonly #handlers: Handlers;
296
+ readonly #capabilities: ReadonlySet<Capability>;
297
+ /** Set the moment shutdown starts, which is the re-entry guard of §8.5 step
298
+ * 1: a request arriving after it is refused rather than half-served. */
299
+ #stopping = false;
300
+ #stopped: Promise<void> | undefined;
301
+ /** The link state the last `net_online` announced, so the event marks a
302
+ * change rather than repeating what every client already holds. */
303
+ #announced: boolean | undefined;
304
+ /** Resolved once the stop order has run to the end, so a foreground run has
305
+ * something to wait on that does not depend on what asked it to stop. */
306
+ readonly #done = Promise.withResolvers<void>();
307
+
308
+ constructor(
309
+ readonly paths: InstancePaths,
310
+ readonly config: InstanceConfig,
311
+ /** What this instance is called, everywhere and to everyone (DR-0001
312
+ * §2.1). Read from the state directory, so it survives the instance moving
313
+ * to another host or another URL. */
314
+ readonly self: InstanceId,
315
+ private readonly lock: Lock,
316
+ private readonly log: Log,
317
+ pollMs?: number,
318
+ setup: GatewaySetup = {},
319
+ helper?: string,
320
+ wiring?: MeshWiring,
321
+ ) {
322
+ this.#conns = wiring?.conns ?? new ConnRegistry();
323
+ this.#mesh = wiring?.mesh;
324
+ this.#boundWs = wiring?.ws;
325
+ // Every capability rests on an upstream, so what is configured is what
326
+ // this instance can name. A client is told before it subscribes, rather
327
+ // than being refused when it does.
328
+ this.#capabilities = new Set([
329
+ ...gatewayCapabilities(setup),
330
+ ...sandboxCapabilities(config.upstream.sandbox_origin),
331
+ ...launcherCapabilities(config.upstream.launcher),
332
+ ...translateCapabilities(helper),
333
+ ...sessionCapabilities({
334
+ fork_origin: config.fork_origin,
335
+ ...(config.upstream.terminal_gateway === undefined
336
+ ? {}
337
+ : { terminal_gateway: config.upstream.terminal_gateway }),
338
+ }),
339
+ ]);
340
+ // The mesh is the rest of the cluster as the topic mechanism sees it: what
341
+ // the peers have stated, and where a local subscription has to travel to
342
+ // (§7.4). An instance without one has no other instance to hear from.
343
+ this.#topics = new Topics(this.self, this.#capabilities, this.#mesh);
344
+ this.#mesh?.bind({
345
+ handle: (frame, conn) => this.handle(frame, conn),
346
+ publish: (topic, data, instance) => {
347
+ this.#topics.publish(topic, data, instance);
348
+ },
349
+ // What `peers` says about the instances is this instance's own view, so
350
+ // it is restated when that view moves (§7.5).
351
+ changed: () => {
352
+ this.#sessions.refresh();
353
+ this.#linkMoved();
354
+ },
355
+ });
356
+
357
+ // What the gateway saw. It feeds two topics and one input of the sessions
358
+ // domain (§5.1), so it is built before both.
359
+ this.#gateway = new Gateway({
360
+ self: this.self,
361
+ setup,
362
+ publish: (topic, data) => {
363
+ this.#topics.publish(topic, data);
364
+ },
365
+ onActivity: () => {
366
+ this.#sessions.refresh();
367
+ },
368
+ log: (msg, fields) => {
369
+ this.log.write(msg, fields);
370
+ },
371
+ });
372
+
373
+ // The transcript tails and their folds. Built before the sessions domain
374
+ // and reading from it lazily: the fold is one of the sessions domain's
375
+ // inputs (§5.1) while the path to follow is one of its outputs, and the
376
+ // two meet at the moment a tail starts rather than at construction.
377
+ this.#transcripts = new Transcripts({
378
+ self: this.self,
379
+ pathOf: (sid) => this.#sessions.transcriptPath(sid),
380
+ publish: (topic, data) => {
381
+ this.#topics.publish(topic, data);
382
+ },
383
+ onFacts: () => {
384
+ this.#sessions.refresh();
385
+ },
386
+ ...(pollMs === undefined ? {} : { pollMs }),
387
+ });
388
+
389
+ // 6. `last_live` and the inbox, read as the domains are constructed.
390
+ this.#sessions = new Sessions({
391
+ self: this.self,
392
+ endpoint: selfEndpoint(config),
393
+ configHome: paths.configHome,
394
+ stateDir: paths.stateDir,
395
+ capabilities: [...this.#capabilities],
396
+ version: VERSION,
397
+ startedAt: this.startedAt,
398
+ publish: (topic, data) => {
399
+ this.#topics.publish(topic, data);
400
+ },
401
+ transcript: this.#transcripts,
402
+ gateway: this.#gateway,
403
+ terminals: hostTerminalReader(),
404
+ ...(this.#mesh === undefined ? {} : { mesh: this.#mesh }),
405
+ onChanged: () => {
406
+ this.#status.refresh();
407
+ // A session that is live again is one route (a) can be tried against,
408
+ // which is what the inbox is waiting for (§4.3).
409
+ void this.#delivery.retry();
410
+ },
411
+ ...(pollMs === undefined ? {} : { pollMs }),
412
+ });
413
+
414
+ // The topics whose value is the fold's error state, over the sessions the
415
+ // instance holds. They are the other thing that keeps a tail running: a
416
+ // subscriber watching the list of stopped sessions is watching every
417
+ // session's fold, and the tails behind it run only while it does (§6.3).
418
+ this.#status = new SessionStatus({
419
+ self: this.self,
420
+ sessions: () => this.#sessions.connectedSids(),
421
+ facts: (sid) => this.#transcripts.facts(sid),
422
+ where: (sid) => this.#sessions.where(sid),
423
+ hold: (sid) => {
424
+ this.#transcripts.hold(sid);
425
+ },
426
+ release: (sid) => {
427
+ this.#transcripts.release(sid);
428
+ },
429
+ publish: (topic, data) => {
430
+ this.#topics.publish(topic, data);
431
+ },
432
+ });
433
+
434
+ const inbox = new Inbox(inboxPath(paths.stateDir));
435
+ inbox.load();
436
+ this.#direct = config.direct_delivery
437
+ ? new ClaudeCodeSocketRoute({ configHome: paths.configHome })
438
+ : new DisabledDirectRoute();
439
+ this.#delivery = new Delivery({
440
+ self: this.self,
441
+ sessions: this.#sessions,
442
+ ...(this.#mesh === undefined ? {} : { cluster: this.#mesh }),
443
+ inbox,
444
+ direct: this.#direct,
445
+ publish: (topic, data, instance, to) => {
446
+ this.#topics.publish(topic, data, instance, to);
447
+ },
448
+ listeners: (topic, to) => this.#topics.subscriberCount(topic, to),
449
+ });
450
+
451
+ this.#notify = new Notify({
452
+ self: this.self,
453
+ label: (sid) => sessionLabel(this.#sessions, sid),
454
+ publish: (topic, data, instance) => {
455
+ this.#topics.publish(topic, data, instance);
456
+ },
457
+ });
458
+
459
+ this.#topics.attach("peers", this.#sessions);
460
+ this.#topics.attach("agents", this.#sessions);
461
+ this.#topics.attach("inbox", this.#delivery);
462
+ this.#topics.attach("notify", this.#notify);
463
+ this.#topics.attach("transcript", this.#transcripts);
464
+ this.#topics.attach("session_status", this.#status);
465
+ this.#topics.attach("session_errors", this.#status);
466
+ this.#topics.attach("llm_requests", this.#gateway.requests);
467
+ this.#topics.attach("llm_status", this.#gateway.statusResource);
468
+
469
+ // The one thing here that is written down and is nobody's derived value
470
+ // (§3.6): what a person saved through a client, which no other party holds
471
+ // a copy of. It owns `kv:<ns>` and is the only publisher of it.
472
+ const kv = new KvStore(join(paths.stateDir, KV_DIR), this.self, (topic, data) => {
473
+ this.#topics.publish(topic, data);
474
+ });
475
+ this.#topics.attach("kv", kv);
476
+
477
+ // The upstreams that answer a question rather than hold a value. Each is
478
+ // built only where its config named one, and dispatch has already refused
479
+ // the ops for the capability this instance then does not have.
480
+ const launcher =
481
+ config.upstream.launcher === undefined ? undefined : new Launcher(config.upstream.launcher);
482
+ this.#translate = helper === undefined ? undefined : new Translate(helper);
483
+
484
+ // The one decision every file op starts from. The three allowlists it reads
485
+ // are the session's own facts, gathered from where each is stated: the
486
+ // greeting says where the session works, and the fold says which folders
487
+ // its editor names and which files outside them its transcript named.
488
+ const files = new Containment({
489
+ roots: (sid): SessionRoots | undefined => {
490
+ const where = this.#sessions.where(sid);
491
+ if (where.root === undefined && where.cwd === undefined) return undefined;
492
+ const status = sessionStatusOf(sid, this.#transcripts.facts(sid), where);
493
+ return {
494
+ ...where,
495
+ workspace_folders: status.workspace_folders.map((folder) => folder.path),
496
+ external_files: status.external_files.map((file) => file.path),
497
+ };
498
+ },
499
+ });
500
+ const origin = config.upstream.sandbox_origin;
501
+
502
+ // Which transcript an op means, for the ops that read one rather than
503
+ // follow one. Only this instance's config home is ever looked in (M6): a
504
+ // session that greeted said where its transcript is, and one that never
505
+ // greeted is looked for under that home and nowhere else.
506
+ const transcriptFiles = new TranscriptFiles({
507
+ configHome: paths.configHome,
508
+ announced: (sid) => this.#sessions.transcriptPath(sid),
509
+ });
510
+
511
+ this.#handlers = completeHandlers({
512
+ hello: this.#sessions.hello,
513
+ session_stopping: this.#sessions.stopping,
514
+ ...topicHandlers(this.#topics),
515
+ ...messagingHandlers(this.#delivery, this.#notify),
516
+ ...fileHandlers(files),
517
+ ...sessionHandlers({
518
+ self: this.self,
519
+ configHome: paths.configHome,
520
+ stateDir: paths.stateDir,
521
+ files: transcriptFiles,
522
+ processes: new SessionProcesses(
523
+ hostProcessDeps(() => this.#sessions.rowsNow(), config.upstream.terminal_gateway),
524
+ ),
525
+ forget: (sid) => this.#sessions.forget(sid),
526
+ }),
527
+ // The sandbox ops answer only where an origin is configured. Without one
528
+ // there is nothing to serve a minted URL, and dispatch already refuses
529
+ // them for the capability this instance then does not have.
530
+ ...(origin === undefined ? {} : sandboxHandlers(new SandboxGrants(files, origin))),
531
+ ...(launcher === undefined ? {} : launcherHandlers(launcher)),
532
+ ...(this.#translate === undefined ? {} : translateHandlers(this.#translate)),
533
+ ...gatewayHandlers(setup),
534
+ ...kvHandlers(kv),
535
+ instance_ping: (): InstancePingResult => this.ping(),
536
+ instance_shutdown: () => {
537
+ // The reply goes out when this handler's value reaches the driver, so
538
+ // stopping is deferred past that turn of the loop rather than run
539
+ // here — the caller is told the request was accepted, which is what
540
+ // the contract says this op answers.
541
+ setTimeout(() => void this.stop(), 0);
542
+ return {};
543
+ },
544
+ });
545
+ }
546
+
547
+ /** 7-8 of §8.3: the pid, then the listeners with the unix socket first, then
548
+ * the peers. */
549
+ async listen(): Promise<void> {
550
+ // Before any listener: a client that can connect can always find the
551
+ // process behind the socket.
552
+ writeFileSync(this.paths.pidFile, `${process.pid}\n`);
553
+ prepareSocketDir(this.paths);
554
+ // What a killed run left behind, cleared before this one adds its own.
555
+ sweepOrphanSockets(this.paths);
556
+ this.#transport.add(
557
+ listenUds({
558
+ path: this.paths.socketReal,
559
+ conns: this.#conns,
560
+ handle: (frame, conn) => this.handle(frame, conn),
561
+ }),
562
+ );
563
+ // The address clients use, moved onto this process once it is accepting.
564
+ publishSocket(this.paths);
565
+ if (this.#boundWs !== undefined) {
566
+ // Already listening: it had to be, for self-identification to reach it.
567
+ this.#transport.add(this.#boundWs);
568
+ } else if (this.config.entry !== undefined) {
569
+ this.#transport.add(
570
+ serveWs({
571
+ hostname: this.config.entry.host,
572
+ port: this.config.entry.port,
573
+ conns: this.#conns,
574
+ handle: (frame, conn) => this.handle(frame, conn),
575
+ entry: entryPolicy(this.config, false),
576
+ // The gateway posts to the address this instance already serves,
577
+ // behind the same entry check (§3.1).
578
+ route: (request) => this.route(request),
579
+ }),
580
+ );
581
+ }
582
+ // 8. the peers. Every instance dials every one of them, and one that is not
583
+ // there is retried rather than waited for (§7.2).
584
+ this.#mesh?.connect();
585
+ await Promise.resolve();
586
+ this.log.write("started", {
587
+ instance: this.self,
588
+ pid: process.pid,
589
+ socket: this.paths.socket,
590
+ http: this.http,
591
+ peers: this.config.peers.length,
592
+ });
593
+ }
594
+
595
+ /** An HTTP request on the WebSocket's listener that is not the upgrade. The
596
+ * gateway's webhook is the one such route this instance answers itself; the
597
+ * mesh's two are answered before this is asked, because they are served
598
+ * before anything is proven and this instance's own routes are not. */
599
+ route(request: Request): Promise<Response | undefined> {
600
+ return this.#gateway.route(request);
601
+ }
602
+
603
+ /** Every bound WebSocket address, as `host:port`. */
604
+ get http(): string[] {
605
+ return this.#transport.listeners.filter((l) => l.kind === "ws").map((l) => l.address);
606
+ }
607
+
608
+ get socketPath(): string {
609
+ return this.paths.socket;
610
+ }
611
+
612
+ /** The mesh, on an instance that has one.
613
+ *
614
+ * Which peers are reachable is a fact about this instance that no op carries
615
+ * on its own — `hello` states it to a client, and this is where it is
616
+ * observable from inside the process, as `watching` is for the sessions
617
+ * watch. */
618
+ get mesh(): Mesh | undefined {
619
+ return this.#mesh;
620
+ }
621
+
622
+ /** Whether the sessions watch is running. It is driven by subscription
623
+ * (§6.3), so this is how "the upstream watches stopped" is observable from
624
+ * outside the domain that owns them. */
625
+ get watching(): boolean {
626
+ return this.#sessions.watching;
627
+ }
628
+
629
+ /** When the gateway last saw inference for a session (§5.1).
630
+ *
631
+ * The one input of the classification that arrives from outside this host,
632
+ * and the only place it is observable from: it is an attribute of a row
633
+ * rather than a state (§5.2), so nothing on the wire carries it yet. */
634
+ gatewayActiveAt(sid: Sid): Timestamp | undefined {
635
+ return this.#gateway.activeAt(sid);
636
+ }
637
+
638
+ ping(): InstancePingResult {
639
+ return {
640
+ instance: this.self,
641
+ version: VERSION,
642
+ pid: process.pid,
643
+ started_at: this.startedAt,
644
+ clients: this.#conns.size,
645
+ exe: process.execPath,
646
+ ...(process.argv[1] === undefined ? {} : { script: process.argv[1] }),
647
+ http: this.http,
648
+ network: this.network,
649
+ };
650
+ }
651
+
652
+ /** What this instance can say about the host link.
653
+ *
654
+ * The mesh is the only thing here that reaches off the host, so it is what
655
+ * the answer is read from: a peer that answers is the link working, and
656
+ * every configured peer silent at once is the link gone. Nothing else is
657
+ * probed — an instance does not dial the internet to have an opinion about
658
+ * it, and the peers are already being dialled for their own reasons (§8.3).
659
+ *
660
+ * Two cases state no verdict rather than guessing one. `off` is an instance
661
+ * with no mesh: nothing here watches the link at all. `unknown` is a mesh
662
+ * whose peer list holds nobody but ourselves — the link is watched, and no
663
+ * observation of it can be made. */
664
+ get network(): InstancePingResult["network"] {
665
+ if (this.#mesh === undefined) return "off";
666
+ const peers = this.#mesh.peers;
667
+ if (peers.length === 0) return "unknown";
668
+ return peers.some((peer) => this.#mesh?.reachable(peer) === true) ? "online" : "offline";
669
+ }
670
+
671
+ /** A link came up or went down. Told to every client when it changes what
672
+ * the instance would answer about the host link, and to nobody when the set
673
+ * of reachable peers moved without changing that — a five-peer cluster
674
+ * losing one is not this host going offline. */
675
+ #linkMoved(): void {
676
+ const network = this.network;
677
+ if (network !== "online" && network !== "offline") return;
678
+ const online = network === "online";
679
+ if (this.#announced === online) return;
680
+ this.#announced = online;
681
+ const event: NetOnlineEvent = { ev: "net_online", instance: this.self, online };
682
+ for (const conn of this.#conns) conn.send(event);
683
+ }
684
+
685
+ /** One frame, from either transport. The re-entry guard of §8.5 step 1 sits
686
+ * here because this is the single door every request comes through. */
687
+ async handle(frame: unknown, conn: Requester): Promise<DispatchResult> {
688
+ if (this.#stopping) {
689
+ return failure(requestIdOf(frame), "bad_request", `${this.self} is shutting down`);
690
+ }
691
+ // A frame that is not an op: the mesh handshake's own traffic, which the
692
+ // op vocabulary has no name for (contract, `Plane`). It is taken here
693
+ // because this is the one door, and it decides nothing — the judgement is
694
+ // in the `hello` handler, which is the only thing that settles a peer.
695
+ if (this.#mesh?.frame(conn, frame) === true) {
696
+ return { kind: "none" };
697
+ }
698
+ if (this.#mesh !== undefined) {
699
+ // Mid-handshake, an ordinary request is a protocol violation rather than
700
+ // an early call: the peer must send nothing before the acknowledgement,
701
+ // and one that does is dropped rather than buffered (mesh-peer-auth §5.8).
702
+ if (this.#mesh.handshaking(conn)) {
703
+ conn.close();
704
+ return failure(
705
+ requestIdOf(frame),
706
+ "bad_request",
707
+ "a peer waits for its acknowledgement before it speaks",
708
+ );
709
+ }
710
+ // Let in as a peer rather than on the entry token, and still unproven:
711
+ // the greeting is the one thing it was admitted to make.
712
+ if (this.#mesh.unproven(conn) && opOf(frame) !== "hello") {
713
+ conn.close();
714
+ return failure(
715
+ requestIdOf(frame),
716
+ "hello_required",
717
+ "a peer connection greets before anything else",
718
+ );
719
+ }
720
+ }
721
+ // What `last_activity_at` means on the `peers` row: the most recent request
722
+ // on any of the session's connections. Here, because this is the one door
723
+ // every request comes through, and a session with several connections has
724
+ // one row for all of them.
725
+ const identity = conn.identity;
726
+ if (identity.state === "settled" && identity.sid !== undefined) {
727
+ this.#sessions.touch(identity.sid);
728
+ }
729
+ // Who this request runs as. On a peer's link it is the caller the envelope
730
+ // names, believed because the link is authenticated and refused as a
731
+ // malformed request when the two fields disagree; on every other
732
+ // connection it is the connection's own identity, and a `caller` written
733
+ // there is a field the sender does not get to fill in.
734
+ const fromPeer = this.#mesh?.isLink(conn) === true;
735
+ const stated = fromPeer ? callerOf(frame) : undefined;
736
+ if (stated instanceof CallerError) {
737
+ return failure(requestIdOf(frame), "bad_request", stated.message);
738
+ }
739
+ const decided = await dispatch(frame, this.#mesh?.caller(conn, stated) ?? conn, {
740
+ self: this.self,
741
+ capabilities: this.#capabilities,
742
+ resolveInstance: (_op, fields) => this.#owner(fields),
743
+ handlers: this.#handlers,
744
+ });
745
+ if (decided.kind !== "forward") return decided;
746
+ // The op belongs to another instance. Mesh carries it and brings the
747
+ // answer back under the id the caller used (§7.3); without a mesh there is
748
+ // nothing that can reach it, which the driver names.
749
+ //
750
+ // Who it is forwarded as is stated here rather than copied from the
751
+ // request: a caller that came over a peer's link travels on unchanged, and
752
+ // anyone else is named from the connection they are actually on.
753
+ return this.#mesh === undefined
754
+ ? decided
755
+ : await this.#mesh.forward(decided.to, decided.frame, stated ?? callerOfIdentity(identity));
756
+ }
757
+
758
+ /** Which instance owns the subject of an instance-local op.
759
+ *
760
+ * The subject is the session an op names, and an op that names none is about
761
+ * this instance and stays here. A session this instance holds is its own
762
+ * whatever the cluster last said; one it does not hold is looked for in the
763
+ * routing table the `peers` topic is (§7.3). */
764
+ #owner(fields: Record<string, unknown>): InstanceId | undefined {
765
+ const sid = fields["sid"];
766
+ if (typeof sid !== "string" || this.#mesh === undefined) return undefined;
767
+ if (this.#sessions.classify(sid as Sid) !== undefined) return undefined;
768
+ return this.#mesh.ownerOf(sid as Sid);
769
+ }
770
+
771
+ /** Stop, in the order of §8.5. Repeating it waits for the first one. */
772
+ stop(): Promise<void> {
773
+ this.#stopped ??= this.#stop().finally(() => {
774
+ this.#done.resolve();
775
+ });
776
+ return this.#stopped;
777
+ }
778
+
779
+ /** Resolves when this instance has finished leaving, however that was
780
+ * asked for — the op, a signal, or a direct call. */
781
+ whenStopped(): Promise<void> {
782
+ return this.#done.promise;
783
+ }
784
+
785
+ async #stop(): Promise<void> {
786
+ // 1. refuse new work
787
+ this.#stopping = true;
788
+ // 2. stop the upstream watches. They run only while something is
789
+ // subscribed (§6.3), so dropping the subscriptions is what stops them.
790
+ for (const conn of this.#conns) this.#topics.dropAll(conn);
791
+ // A tail may also be held for a value this instance states rather than for
792
+ // a subscriber, and those holds end here.
793
+ this.#transcripts.stopAll();
794
+ // The read the gateway's own events can ask for is one more thing that
795
+ // outlives its subscribers if nothing drops it here.
796
+ this.#gateway.close();
797
+ // The translation helper is a process this instance started, so it leaves
798
+ // with it rather than outliving the daemon that has its pipe.
799
+ this.#translate?.stop();
800
+ // Route (a) holds a socket of its own, bound where the sessions' sockets
801
+ // are so their receipts can reach it (§4.1). It has a name on disk, so it
802
+ // is taken down here rather than left for the next run to find.
803
+ this.#direct.close();
804
+ // The mesh's links and its timers, let go here for the same reason: they
805
+ // are this instance's and do not outlive it (§7).
806
+ this.#mesh?.stop();
807
+ // 3. tell the connections, while they can still be told
808
+ const restarting: RestartingEvent = { ev: "restarting", instance: this.self };
809
+ for (const conn of this.#conns) conn.send(restarting);
810
+ // 4. settle what is persisted. `last_live` and the inbox are written as
811
+ // they change rather than at exit, so there is nothing held back to flush;
812
+ // the log's writer is synchronous for the same reason (§3.6).
813
+ this.log.write("stopping", { instance: this.self });
814
+ // 5. let the resources go, the unix socket last. The pid and the lock go
815
+ // before it, because a client reads a refusing socket as this instance
816
+ // having finished leaving and a successor may claim what it sees free.
817
+ remove(this.paths.pidFile);
818
+ this.lock.release();
819
+ // Closing takes the path this process bound, and only that one: the stable
820
+ // address is a symlink nothing here touches, because a successor may have
821
+ // already pointed it at itself (§8.5).
822
+ await this.#transport.close();
823
+ }
824
+ }
825
+
826
+ /** Where this instance says it is reached, or nothing when it is reached by
827
+ * no URL at all.
828
+ *
829
+ * The config's `self` when there is one, which is the answer for anything with
830
+ * a mesh. Without one the bound address stands in, and an instance serving only
831
+ * the unix socket has neither — so it states no endpoint rather than a URL that
832
+ * reaches nothing (DR-0001 §2.1). A client on the unix socket already has the
833
+ * instance it is talking to. */
834
+ export function selfEndpoint(config: InstanceConfig): Endpoint | undefined {
835
+ if (config.self !== undefined) return config.self;
836
+ const entry = config.entry;
837
+ if (entry === undefined || entry.port === 0) return undefined;
838
+ return `ws://${entry.host}:${entry.port}`;
839
+ }
840
+
841
+ /** Who may reach the WebSocket at all (§3.1): an Origin the operator named and
842
+ * an address the operator named.
843
+ *
844
+ * The two config lists are read as allowlists in both directions. An empty
845
+ * `origins` admits no browser: a permission that was never granted is not a
846
+ * permission, and the one deployment that would want "any page may connect" is
847
+ * the one that must say so. An empty `source_ips` leaves the addresses to the
848
+ * bind, which for the default loopback host is this machine.
849
+ *
850
+ * These two are the whole of it until the person's own authentication lands
851
+ * (DR-0001): the entry token they replaced only ever restated the uid boundary,
852
+ * which on a tailnet nothing here can cross anyway, and a passkey is what will
853
+ * answer "who came" rather than "could they read a file". */
854
+ function entryPolicy(config: InstanceConfig, mesh: boolean): EntryPolicy {
855
+ const entry = config.entry;
856
+ if (entry === undefined) return {};
857
+ return {
858
+ allowRequest(request: Request, source: string | undefined): boolean {
859
+ const origin = request.headers.get("origin");
860
+ // A request carrying no `Origin` is not a browser's, and there is nothing
861
+ // to compare: it stands or falls on the address and the token below.
862
+ if (origin !== null && !entry.origins.includes(origin)) return false;
863
+ if (entry.source_ips.length === 0) return true;
864
+ // The address the server observed, not one a header claims: a forwarding
865
+ // header is written by whoever is in front of us, and anyone who can
866
+ // reach the port can write it.
867
+ return source !== undefined && entry.source_ips.includes(source);
868
+ },
869
+ allowUpgrade(request: Request): UpgradeDecision {
870
+ const offered = protocolsOf(request);
871
+ // A peer is let through unproven, and what it is is decided by the
872
+ // handshake — the only place a claim can actually be checked.
873
+ if (mesh && offered.includes(MESH_PROTOCOL)) {
874
+ return { ok: true, protocol: MESH_PROTOCOL, mesh: true };
875
+ }
876
+ // The handshake echoes a subprotocol only when one was offered: a browser
877
+ // fails a connection whose reply names none of what it asked for.
878
+ const selected = offered[0];
879
+ return selected === undefined ? { ok: true } : { ok: true, protocol: selected };
880
+ },
881
+ };
882
+ }
883
+
884
+ function protocolsOf(request: Request): string[] {
885
+ const header = request.headers.get("sec-websocket-protocol");
886
+ if (header === null) return [];
887
+ return header
888
+ .split(",")
889
+ .map((name) => name.trim())
890
+ .filter((name) => name !== "");
891
+ }
892
+
893
+ function opOf(frame: unknown): string | undefined {
894
+ if (typeof frame !== "object" || frame === null) return undefined;
895
+ const op = (frame as Record<string, unknown>)["op"];
896
+ return typeof op === "string" ? op : undefined;
897
+ }
898
+
899
+ function requestIdOf(frame: unknown): string | undefined {
900
+ if (typeof frame !== "object" || frame === null) return undefined;
901
+ const id = (frame as Record<string, unknown>)["request_id"];
902
+ return typeof id === "string" && id.length > 0 ? id : undefined;
903
+ }
904
+
905
+ function remove(file: string): void {
906
+ try {
907
+ unlinkSync(file);
908
+ } catch {
909
+ // Already gone.
910
+ }
911
+ }