@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,785 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
4
+ import {
5
+ type AgentInfo,
6
+ type Capability,
7
+ type HelloArgs,
8
+ type HelloResult,
9
+ type Endpoint,
10
+ type InstanceId,
11
+ type InstanceInfo,
12
+ type LastLiveSession,
13
+ type PeerInfo,
14
+ PROTOCOL_VERSION,
15
+ type SessionState,
16
+ type SessionStoppingResult,
17
+ type Sid,
18
+ type Timestamp,
19
+ } from "@ccmsg/protocol";
20
+ import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
21
+ import { within } from "../files/index.ts";
22
+ import type { TranscriptFacts } from "../transcript/index.ts";
23
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
24
+ import { classify, type SessionInputs } from "./classify.ts";
25
+ import { HarnessSessions, isWaiting } from "./harness.ts";
26
+ import { LastLiveStore, type StoredEntry } from "./last-live.ts";
27
+ import { stoppedOn } from "./status.ts";
28
+ import { TerminalCache, type TerminalReader } from "./terminals.ts";
29
+
30
+ /** What the sessions domain needs from the instance around it. */
31
+ export interface SessionsDeps {
32
+ readonly self: InstanceId;
33
+ /** Where this instance says it is reached, which `hello` states beside the
34
+ * id: the caller got here by some URL of its own — a proxy's, an alias — and
35
+ * what a peer is to dial is neither that nor derivable from the id. Absent on
36
+ * an instance reached by the unix socket alone, which has no URL to state. */
37
+ readonly endpoint?: Endpoint;
38
+ /** The one config home this instance answers for (§8.2). Its `sessions/` is
39
+ * the only directory read, and no other config home is ever looked for (M6). */
40
+ readonly configHome: string;
41
+ /** Where `last_live` is written. Derived from the config home by the caller,
42
+ * which is where every per-instance path is decided (§8.1). */
43
+ readonly stateDir: string;
44
+ readonly capabilities: readonly Capability[];
45
+ /** The daemon build, reported by `hello` for display. */
46
+ readonly version: string;
47
+ readonly startedAt: Timestamp;
48
+ /** The one way a value reaches subscribers (§6.1). */
49
+ readonly publish: (topic: string, data: unknown) => void;
50
+ /** What the transcript fold says about a session (§5.1). Absent while
51
+ * nothing folds transcripts, in which case the two values it settles are
52
+ * simply unknown and every rule that reads them behaves as it does for a
53
+ * session whose transcript has said nothing. */
54
+ readonly transcript?: TranscriptSource;
55
+ /** What the gateway has seen of a session (§5.1). Absent on an instance with
56
+ * no gateway configured, which costs the classification one of its five
57
+ * inputs and none of its states. */
58
+ readonly gateway?: GatewaySource;
59
+ /** The sessions this instance speaks about, or what the fold says about one,
60
+ * has changed. What rests on either — the topics whose value is derived from
61
+ * the same fold, and the tails they keep running (§6.3) — is told to catch
62
+ * up. Absent when nothing does. */
63
+ readonly onChanged?: () => void;
64
+ /** How often the confirmation poll runs, for a test that cannot wait. */
65
+ readonly pollMs?: number;
66
+ /** How the terminal a session runs in is read from its process. Absent on a
67
+ * host where no process's environment can be read, where every row's
68
+ * terminal stays unknown — which is a state the classification has. */
69
+ readonly terminals?: TerminalReader;
70
+ /** The mesh, on an instance that has one. It answers the one greeting this
71
+ * domain cannot judge: a peer's, whose claim is settled by an exchange of its
72
+ * own rather than by anything a session says (§7.2). */
73
+ readonly mesh?: MeshSource;
74
+ }
75
+
76
+ /** What `hello` needs of the mesh: verify the greeting of a peer, and say which
77
+ * instances there are and which of them can be reached (§7.5). */
78
+ export interface MeshSource {
79
+ greet(conn: Requester, claim: MeshClaim): Promise<void>;
80
+ instances(): InstanceInfo[];
81
+ }
82
+
83
+ /** The mesh claim a peer greets with, as the contract states it. */
84
+ type MeshClaim = NonNullable<HelloArgs["mesh"]>;
85
+
86
+ /** The fold, as the sessions domain reads it: two values about one session,
87
+ * asked for when a payload is built rather than copied here when they change
88
+ * (§3.3 — the current value lives with whoever owns it). */
89
+ export interface TranscriptSource {
90
+ facts(sid: Sid): TranscriptFacts;
91
+ }
92
+
93
+ /** The gateway, as the sessions domain reads it: when it last saw inference
94
+ * for one session, asked for when a payload is built (§3.3). */
95
+ export interface GatewaySource {
96
+ activeAt(sid: Sid): Timestamp | undefined;
97
+ }
98
+
99
+ /** What a session said about itself when it greeted.
100
+ *
101
+ * The contract states these fields once and every place that describes a
102
+ * session refers to them, so what a greeting carries and what `peers` repeats
103
+ * are the same fields under the same names — nothing is renamed on the way
104
+ * through, and nothing is invented for a field the session left unsaid. */
105
+ type SessionMeta = Pick<
106
+ HelloArgs,
107
+ "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title" | "model" | "effort"
108
+ >;
109
+
110
+ const META_FIELDS = [
111
+ "repo",
112
+ "ws",
113
+ "cwd",
114
+ "transcript_path",
115
+ "repo_root",
116
+ "branch",
117
+ "title",
118
+ "model",
119
+ "effort",
120
+ ] as const;
121
+
122
+ /** One session holding a connection to us. */
123
+ interface Connected {
124
+ readonly sid: Sid;
125
+ readonly connected_at: Timestamp;
126
+ readonly protocol_version: number;
127
+ readonly client_version?: string;
128
+ readonly meta: SessionMeta;
129
+ /** The most recent request on any of its connections. Distinct from when a
130
+ * person last spoke to it, which is folded out of the transcript and is the
131
+ * one an attention-ordered list wants (§5.3). */
132
+ last_activity_at: Timestamp;
133
+ /** More than one client process of a session may hold a connection. */
134
+ conns: number;
135
+ }
136
+
137
+ /** The sessions this instance can speak about, and the two topics that carry
138
+ * them.
139
+ *
140
+ * The current value lives here rather than in the topic mechanism (§3.3): what
141
+ * is connected is held in memory and dies with the process, what the harness
142
+ * reports is re-read from `sessions/`, and only `last_live` survives a restart.
143
+ * The classification of §5.2 is derived from those three whenever a payload is
144
+ * built, and never stored (M4). */
145
+ export class Sessions implements UpstreamResource {
146
+ readonly #connected = new Map<Sid, Connected>();
147
+ readonly #harness: HarnessSessions;
148
+ readonly #terminals: TerminalCache | undefined;
149
+ readonly #lastLive: LastLiveStore;
150
+ /** Sessions seen live since the last recompute, kept so the moment one stops
151
+ * being live is what writes its `last_live` entry. */
152
+ #live = new Map<Sid, StoredEntry>();
153
+ /** The topic names currently subscribed. Both topics rest on the same
154
+ * directory watch, so it runs while either has a listener (§6.3). */
155
+ readonly #wanted = new Set<string>();
156
+ /** What a session said about itself when it last greeted, kept for as long
157
+ * as the harness still names the session.
158
+ *
159
+ * A greeting is one instant and a connection is shorter than a session: a
160
+ * session-start hook says where it works and leaves, and every client process
161
+ * of the session comes and goes. What it said does not stop being true when
162
+ * the process that said it exits, so holding it only while a connection is
163
+ * open would mean the instance forgetting a session's repository the moment
164
+ * it stopped being told it — and then writing it down as gone with nothing
165
+ * but a sid on the entry.
166
+ *
167
+ * What bounds it is the harness: the words are kept while `sessions/` still
168
+ * names the sid, and dropped in the same breath as the `last_live` entry that
169
+ * spends them. Nothing here is written to disk (M4) — a restart forgets it,
170
+ * and the next greeting says it again. */
171
+ readonly #stated = new Map<Sid, SessionMeta>();
172
+ /** Sessions that have said they are about to go, and when they said it.
173
+ *
174
+ * Held here rather than written to `last_live`, because the declaration
175
+ * arrives while the session is still connected and `last_live` holds what is
176
+ * gone: the entry is written when the connection closes, and this is what
177
+ * stamps it then (contract, `session_stopping`). A session that declares and
178
+ * then carries on stays connected and keeps its declaration, which is spent
179
+ * whenever it does leave. */
180
+ readonly #stopping = new Map<Sid, Timestamp>();
181
+
182
+ constructor(private readonly deps: SessionsDeps) {
183
+ this.#harness = new HarnessSessions(
184
+ join(deps.configHome, "sessions"),
185
+ deps.self,
186
+ () => this.changed(),
187
+ deps.pollMs,
188
+ );
189
+ this.#lastLive = new LastLiveStore(join(deps.stateDir, "last-live.json"));
190
+ this.#lastLive.load();
191
+ this.#terminals =
192
+ deps.terminals === undefined
193
+ ? undefined
194
+ : new TerminalCache(deps.terminals, () => this.changed());
195
+ this.#live = this.#liveNow(Date.now(), this.#rows());
196
+ }
197
+
198
+ /** `hello`, which is where a session becomes something this instance can
199
+ * speak about, and where everything this instance knows about where that
200
+ * session lives comes from.
201
+ *
202
+ * What registers a session is the greeting naming a sid, not the role it
203
+ * claims: the sid is the session it speaks for, and reading the role here
204
+ * would put the contract's "a session names its sid" rule in a second place
205
+ * (M1). */
206
+ hello = (input: HandlerInput): HelloResult | Promise<HelloResult> => {
207
+ const args = input.args as unknown as HelloArgs;
208
+ // A role is set once and fixed for the connection's life (contract, `Role`),
209
+ // so a second greeting is not a re-identification: it is a request to be
210
+ // somebody else on a connection that already is somebody.
211
+ if (input.conn.identity.state === "settled") {
212
+ throw new OpError("bad_request", "a connection greets once, and this one already has");
213
+ }
214
+ if (args.protocol_version !== PROTOCOL_VERSION) {
215
+ throw new OpError("bad_request", `this instance speaks protocol ${PROTOCOL_VERSION}`);
216
+ }
217
+ if (args.role === "instance") {
218
+ // A peer's greeting is answered only once the connection has been proven
219
+ // to be the endpoint it names. The verification rejects when it is not,
220
+ // and the connection stays anonymous because nothing settles an identity
221
+ // but a reply (mesh-peer-auth §5, daemon-v2 §3.2 step 7). This is the one
222
+ // greeting that has to wait for something, which is why it is the one
223
+ // that answers with a promise.
224
+ if (args.mesh === undefined) {
225
+ throw new OpError("invalid_args", "an instance greets with its mesh claim");
226
+ }
227
+ const mesh = this.deps.mesh;
228
+ if (mesh === undefined) {
229
+ throw new OpError(
230
+ "capability_unavailable",
231
+ "this instance has no mesh, so no peer connection can be proven",
232
+ );
233
+ }
234
+ return mesh.greet(input.conn, args.mesh).then(() => this.#greeted(args, input));
235
+ }
236
+ return this.#greeted(args, input);
237
+ };
238
+
239
+ /** What every greeting answers, once whatever had to be settled has been. */
240
+ #greeted(args: HelloArgs, input: HandlerInput): HelloResult {
241
+ const sid = requiredSid(args);
242
+ if (sid !== undefined) {
243
+ this.register(sid, args, this.deps.configHome);
244
+ input.conn.onClose(() => this.release(sid));
245
+ }
246
+ return {
247
+ protocol_version: PROTOCOL_VERSION,
248
+ instance: this.deps.self,
249
+ ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
250
+ // Without a mesh the cluster is this instance alone. It appears in the
251
+ // list only where it has a URL to be named by: an instance serving the
252
+ // unix socket alone is reached by nothing that could dial an endpoint,
253
+ // and `instance` above has already said who is answering.
254
+ instances:
255
+ this.deps.mesh?.instances() ??
256
+ (this.deps.endpoint === undefined
257
+ ? []
258
+ : [
259
+ {
260
+ id: this.deps.self,
261
+ endpoint: this.deps.endpoint,
262
+ host: hostname(),
263
+ reachable: true,
264
+ },
265
+ ]),
266
+ capabilities: [...this.deps.capabilities],
267
+ version: this.deps.version,
268
+ started_at: this.deps.startedAt,
269
+ };
270
+ }
271
+
272
+ /** Where a session stands (§5.2). Undefined for a sid this instance has
273
+ * never seen live and does not hold in `last_live`. */
274
+ classify(
275
+ sid: Sid,
276
+ now: Timestamp = Date.now(),
277
+ rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
278
+ ): SessionState | undefined {
279
+ return classify(this.inputs(sid, rows), now);
280
+ }
281
+
282
+ /** The harness's sessions as they are at this instant. One read serves one
283
+ * question, and a caller answering several about the same instant passes the
284
+ * result on rather than reading again. */
285
+ #rows(): ReadonlyMap<Sid, AgentInfo> {
286
+ const rows = this.#harness.scan();
287
+ const terminals = this.#terminals;
288
+ if (terminals === undefined) return rows;
289
+ // What the scan found is what exists: a pid that has left it is one whose
290
+ // terminal is no longer anybody's, and one that has arrived is read once.
291
+ terminals.observe([...rows.values()].map((row) => row.pid));
292
+ const named = new Map<Sid, AgentInfo>();
293
+ for (const [sid, row] of rows) {
294
+ const terminal = terminals.get(row.pid);
295
+ named.set(
296
+ sid,
297
+ terminal === undefined
298
+ ? row
299
+ : {
300
+ ...row,
301
+ terminal_id: terminal.id,
302
+ ...(terminal.namespace === undefined
303
+ ? {}
304
+ : { terminal_namespace: terminal.namespace }),
305
+ },
306
+ );
307
+ }
308
+ return named;
309
+ }
310
+
311
+ /** Everything the classification of one session reads, exposed so the rule
312
+ * and its inputs can be tested apart from each other.
313
+ *
314
+ * The harness's rows are read here rather than taken from the watch. Which
315
+ * sessions the harness has is a fact about this config home, true whether or
316
+ * not anybody subscribed to hear about it (§5.1) — the watch of §6.3 exists
317
+ * to push a change to subscribers, and reading its cache instead would make
318
+ * "a session exists" mean "somebody is listening", which is how a live
319
+ * session becomes `session_not_found` to a sender and how a session that is
320
+ * still running is written into `last_live` as gone. */
321
+ inputs(sid: Sid, rows: ReadonlyMap<Sid, AgentInfo> = this.#rows()): SessionInputs {
322
+ const row = rows.get(sid);
323
+ const stored = this.#lastLive.get(sid);
324
+ const facts = this.deps.transcript?.facts(sid);
325
+ const gatewayActiveAt = this.#gatewayActiveAt(sid, row !== undefined);
326
+ return {
327
+ connected: this.#connected.has(sid),
328
+ ...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
329
+ ...(facts === undefined || stoppedOn(facts) === undefined ? {} : { api_error_stopped: true }),
330
+ ...(row === undefined
331
+ ? {}
332
+ : {
333
+ harness: {
334
+ waiting: isWaiting(row),
335
+ ...(row.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
336
+ },
337
+ }),
338
+ ...(stored === undefined ? {} : { last_live: { stopped_at: stored.stopped_at } }),
339
+ };
340
+ }
341
+
342
+ /** The sessions holding a connection to us. Whoever follows their
343
+ * transcripts needs the set, and a greeting is what puts a session in it. */
344
+ connectedSids(): Sid[] {
345
+ return [...this.#connected.keys()];
346
+ }
347
+
348
+ /** Note that a session asked for something. `last_activity_at` is the most
349
+ * recent request on any of its connections, so every request restamps the one
350
+ * row all of them share.
351
+ *
352
+ * Nothing is published here. The value travels on the next `peers` payload
353
+ * whatever caused it, and publishing per request would put a frame on the
354
+ * wire for every call a session makes — a row that changed only in its clock
355
+ * is not news a subscriber asked for. */
356
+ touch(sid: Sid, at: Timestamp = Date.now()): void {
357
+ const held = this.#connected.get(sid);
358
+ if (held !== undefined) held.last_activity_at = at;
359
+ }
360
+
361
+ /** Where a session's transcript is, as it announced it (§5.1). Whoever
362
+ * follows one needs the path, and the greeting is the only thing that
363
+ * states it. */
364
+ transcriptPath(sid: Sid): string | undefined {
365
+ return this.#stated.get(sid)?.transcript_path;
366
+ }
367
+
368
+ /** Where a session works, as it greeted: the container its files are reached
369
+ * through, and the directory it runs in. Both are stated only by a greeting,
370
+ * so a session that named neither is one no path is admitted for. */
371
+ where(sid: Sid): { root?: string; cwd?: string } {
372
+ const meta = this.#connected.get(sid)?.meta;
373
+ const cwd = meta?.cwd ?? this.#rows().get(sid)?.cwd;
374
+ // The container when the session named one, the working directory
375
+ // otherwise — the same order `repo_root` is meant in (§4.2).
376
+ const root = meta?.repo_root ?? cwd;
377
+ return {
378
+ ...(root === undefined || root === "" ? {} : { root }),
379
+ ...(cwd === undefined || cwd === "" ? {} : { cwd }),
380
+ };
381
+ }
382
+
383
+ /** The harness's sessions as they are right now, read rather than taken
384
+ * from the watch's cache. What acts on a session's process resolves its pid
385
+ * through this: the watch runs only while somebody is subscribed (§6.3), and
386
+ * a pid from a poll that has not run is a number belonging to nobody. */
387
+ rowsNow(): ReadonlyMap<Sid, AgentInfo> {
388
+ return this.#rows();
389
+ }
390
+
391
+ /** Drop one entry from `last_live`, which is what
392
+ * `session_last_live_remove` asks for. The removal touches that list alone:
393
+ * the session stays resumable by every other route. */
394
+ forget(sid: Sid): boolean {
395
+ const removed = this.#lastLive.remove(sid);
396
+ if (removed) this.changed();
397
+ return removed;
398
+ }
399
+
400
+ /** Recompute and state both topics. What the fold settles is an input to the
401
+ * classification and to `peers`, so a fold that changed says so here. */
402
+ refresh(): void {
403
+ this.changed();
404
+ }
405
+
406
+ /** `session_stopping`: a session saying it is about to go, which is what
407
+ * makes it Paused rather than Disappeared once it is gone (§5.2).
408
+ *
409
+ * Nothing is recorded now and nothing is published: the session is still
410
+ * here, and the list this changes is the one it is not on yet. What the
411
+ * declaration does is wait for the disconnection that follows it. */
412
+ stopping = (input: HandlerInput): SessionStoppingResult => {
413
+ const sid = input.identity?.sid;
414
+ if (sid === undefined) {
415
+ throw new OpError(
416
+ "bad_request",
417
+ "a session says it is stopping, and this greeting named none",
418
+ );
419
+ }
420
+ const at = Date.now();
421
+ this.#stopping.set(sid, at);
422
+ return { stopped_at: at };
423
+ };
424
+
425
+ // --- UpstreamResource (§6.3): the directory is read while, and only while,
426
+ // somebody is subscribed to a topic that rests on it.
427
+
428
+ start(topic: string): void {
429
+ this.#wanted.add(topic);
430
+ this.#harness.start();
431
+ }
432
+
433
+ stop(topic: string): void {
434
+ this.#wanted.delete(topic);
435
+ if (this.#wanted.size === 0) this.#harness.stop();
436
+ }
437
+
438
+ snapshot(topic: string): readonly TopicValue[] {
439
+ const rows = this.#rows();
440
+ const data = topic === "agents" ? this.agents(rows) : this.peers(Date.now(), rows);
441
+ return [{ instance: this.deps.self, data }];
442
+ }
443
+
444
+ /** Whether the directory watch is running, which is what "the subscription
445
+ * drives the resource" means in practice. */
446
+ get watching(): boolean {
447
+ return this.#harness.running;
448
+ }
449
+
450
+ /** The `peers` payload: what is connected now, and what was connected when
451
+ * this instance last saw it. Both travel together because registering is
452
+ * exactly what moves a session from the second list to the first.
453
+ *
454
+ * Every row states its `state` and its `pinned`. The contract lets an
455
+ * instance leave them out, and a client then shows a session it cannot group
456
+ * — this instance is one that classifies, so it says so on every row rather
457
+ * than on the rows it happens to have an answer for.
458
+ *
459
+ * `instances` is the same view `hello` answers with, restated here so that a
460
+ * link going down reaches a subscriber on the topic it is already on rather
461
+ * than only on its next greeting (§7.5). It is this instance's view: what a
462
+ * peer relayed here carries the peer's own, and neither is folded into the
463
+ * other. An instance with no mesh states none, which is a different thing
464
+ * from stating that nothing is reachable. */
465
+ peers(
466
+ now: Timestamp = Date.now(),
467
+ rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
468
+ ): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
469
+ const instances = this.deps.mesh?.instances();
470
+ return {
471
+ peers: [...this.#connected.values()].map((session) => this.#peer(session, now, rows)),
472
+ last_live: this.#lastLive.entries(now).map((entry) => ({
473
+ ...entry,
474
+ state: this.classify(entry.sid, now, rows) ?? "disappeared",
475
+ pinned: this.#pinned(entry.sid),
476
+ })),
477
+ ...(instances === undefined ? {} : { instances }),
478
+ };
479
+ }
480
+
481
+ /** The `agents` payload: the harness's own view, as it stated it.
482
+ *
483
+ * `polled_at` is left out. Stating when the read behind the list ran would
484
+ * make every confirmation poll a value the list did not have before, so the
485
+ * one suppression every topic shares (M5) would let a five-second heartbeat
486
+ * through for a directory that had not changed. */
487
+ agents(rows: ReadonlyMap<Sid, AgentInfo> = this.#rows()): { agents: AgentInfo[] } {
488
+ return { agents: [...rows.values()] };
489
+ }
490
+
491
+ /** Bind a session to this instance, and take what it says about itself. Its
492
+ * entry in `last_live` goes the moment it registers, which is the whole of
493
+ * "an entry leaves the list when its session comes back".
494
+ *
495
+ * What a greeting names is taken field by field, and not naming a field
496
+ * means it is unchanged rather than withdrawn. One session reaches this
497
+ * instance as a run of short-lived processes — a session-start hook, a
498
+ * `post`, a session-end hook — and none of them knows every field: only the
499
+ * hooks are told where the transcript is, and only a command running in the
500
+ * session's own directory can work out the repository. A greeting that took
501
+ * silence for a retraction would let each of them erase what the last one
502
+ * knew, and the session would be described by whichever process spoke most
503
+ * recently rather than by everything it has said. */
504
+ private register(sid: Sid, args: HelloArgs, configHome: string): void {
505
+ const now = Date.now();
506
+ const held = this.#connected.get(sid);
507
+ const meta = { ...this.#stated.get(sid), ...metaOf(args, configHome) };
508
+ this.#connected.set(sid, {
509
+ sid,
510
+ connected_at: held?.connected_at ?? now,
511
+ protocol_version: args.protocol_version,
512
+ ...(args.client_version === undefined ? {} : { client_version: args.client_version }),
513
+ meta,
514
+ last_activity_at: now,
515
+ conns: (held?.conns ?? 0) + 1,
516
+ });
517
+ this.#stated.set(sid, meta);
518
+ this.#lastLive.remove(sid);
519
+ this.changed(now);
520
+ }
521
+
522
+ /** One of a session's connections closed. The session is only gone when its
523
+ * last one is. */
524
+ private release(sid: Sid): void {
525
+ const held = this.#connected.get(sid);
526
+ if (held === undefined) return;
527
+ if (held.conns > 1) {
528
+ this.#connected.set(sid, { ...held, conns: held.conns - 1 });
529
+ return;
530
+ }
531
+ this.#connected.delete(sid);
532
+ this.changed();
533
+ }
534
+
535
+ /** Recompute, record what stopped being live, and state both topics.
536
+ *
537
+ * Publishing is unconditional here because suppression belongs to the topic
538
+ * mechanism and is written once for every topic (M5) — a payload equal to
539
+ * the last one goes no further than that. */
540
+ private changed(now: Timestamp = Date.now()): void {
541
+ const rows = this.#rows();
542
+ const live = this.#liveNow(now, rows);
543
+ for (const [sid, entry] of this.#live) {
544
+ if (live.has(sid)) continue;
545
+ // The declaration came first and the departure has now arrived, which is
546
+ // the order the two are one event in (contract, `session_stopping`).
547
+ const stoppedAt = this.#stopping.get(sid);
548
+ this.#stopping.delete(sid);
549
+ this.#lastLive.record({
550
+ ...entry,
551
+ last_seen_at: now,
552
+ ...(stoppedAt === undefined ? {} : { stopped_at: stoppedAt }),
553
+ });
554
+ // The words are spent: the entry just written carries them, and the
555
+ // session they were about is one the harness no longer names. A sid that
556
+ // comes back says them again.
557
+ this.#stated.delete(sid);
558
+ }
559
+ this.#live = live;
560
+ this.deps.publish("peers", this.peers(now, rows));
561
+ this.deps.publish("agents", this.agents(rows));
562
+ this.deps.onChanged?.();
563
+ }
564
+
565
+ /** Every session live right now, in the form its `last_live` entry takes if
566
+ * it stops being live. */
567
+ #liveNow(now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): Map<Sid, StoredEntry> {
568
+ const live = new Map<Sid, StoredEntry>();
569
+ for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now, rows));
570
+ for (const sid of rows.keys()) live.set(sid, this.#entry(sid, now, rows));
571
+ return live;
572
+ }
573
+
574
+ #entry(sid: Sid, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): StoredEntry {
575
+ const held = this.#connected.get(sid);
576
+ const row = rows.get(sid);
577
+ // What answered last, not what the session named when it greeted: the
578
+ // greeting is one instant and `/model` moves afterwards, so the fold is
579
+ // asked first and the greeting only fills in for a transcript that has
580
+ // said nothing yet.
581
+ const answered = this.deps.transcript?.facts(sid);
582
+ const stated = this.#stated.get(sid);
583
+ const model = answered?.model ?? stated?.model;
584
+ const effort = answered?.model === undefined ? stated?.effort : answered.effort;
585
+ return {
586
+ sid,
587
+ instance: this.deps.self,
588
+ // The harness knows a title for a session that stated none itself, so
589
+ // it goes first and what the session named overrides it.
590
+ ...(row?.name === undefined ? {} : { title: row.name }),
591
+ ...this.#where(sid, rows),
592
+ ...(model === undefined ? {} : { model }),
593
+ ...(effort === undefined ? {} : { effort }),
594
+ ...(held === undefined ? {} : { connected_at: held.connected_at }),
595
+ last_seen_at: now,
596
+ };
597
+ }
598
+
599
+ #peer(session: Connected, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): PeerInfo {
600
+ // The two "last activity" values are different questions (§5.3): the one
601
+ // above moves on every request the session makes, this one only when a
602
+ // person speaks, and the fold is the only place that knows the second.
603
+ const userInput = this.deps.transcript?.facts(session.sid).last_user_input_at;
604
+ // What the gateway last saw run for this session: an attribute of the row
605
+ // beside the classification, not folded into it (§5.1). Absent from an
606
+ // instance with no gateway, where nothing observes inference at all.
607
+ const gatewayActiveAt = this.#gatewayActiveAt(session.sid, rows.has(session.sid));
608
+ return {
609
+ sid: session.sid,
610
+ instance: this.deps.self,
611
+ ...this.#where(session.sid, rows),
612
+ state: this.classify(session.sid, now, rows) ?? "live",
613
+ pinned: this.#pinned(session.sid),
614
+ connected_at: session.connected_at,
615
+ last_activity_at: session.last_activity_at,
616
+ ...(userInput === undefined ? {} : { last_user_input_at: userInput }),
617
+ ...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
618
+ ...(session.client_version === undefined ? {} : { client_version: session.client_version }),
619
+ protocol_version: session.protocol_version,
620
+ };
621
+ }
622
+
623
+ /** When the gateway last saw inference for a session, for a session this
624
+ * instance knows (§5.1).
625
+ *
626
+ * The gateway sits above every config home and its events name only a session
627
+ * id, so what it reports is not by itself evidence about *this* instance's
628
+ * sessions: a session id belonging to another config home would otherwise
629
+ * classify as live here, put a row on this instance's `peers`, and make
630
+ * `message_send` accept a message for a session that has no inbox here and
631
+ * never will. So the reading is narrowed to the sids this instance knows —
632
+ * one that has greeted us, still connected or remembered in `last_live`, or
633
+ * one the harness's own `sessions/` names. The events themselves are not
634
+ * dropped: `llm_requests` carries what the gateway saw whoever it was for,
635
+ * because that topic is a view of the gateway rather than of this instance's
636
+ * sessions. */
637
+ #gatewayActiveAt(sid: Sid, inHarness: boolean): Timestamp | undefined {
638
+ const known = inHarness || this.#connected.has(sid) || this.#lastLive.get(sid) !== undefined;
639
+ return known ? this.deps.gateway?.activeAt(sid) : undefined;
640
+ }
641
+
642
+ /** Whether a person has pinned this session. Nothing can set a pin yet, so
643
+ * this is false for every session — stated rather than left out, because an
644
+ * absent `pinned` and a false one mean the same thing to a client and this
645
+ * instance states what it knows on every row. */
646
+ #pinned(_sid: Sid): boolean {
647
+ return false;
648
+ }
649
+
650
+ /** Where a session is, as the contract's shared fields.
651
+ *
652
+ * The session's own greeting is the source. The harness's row supplies the
653
+ * working directory for a session that greeted without one, and for one that
654
+ * never greeted at all — it is the only field the harness also knows.
655
+ *
656
+ * `repo` and `ws` have no fallback: they are display names for a layout this
657
+ * instance has no stated way to read out of a path, so a session that does
658
+ * not name them is shown without them rather than with a guess. The same
659
+ * goes for `repo_root`, which §4.2 says to derive from `cwd` when it is not
660
+ * given — no primary source states that derivation, so it is left unstated
661
+ * until one does. */
662
+ #where(
663
+ sid: Sid,
664
+ rows: ReadonlyMap<Sid, AgentInfo>,
665
+ ): Pick<PeerInfo, "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title"> {
666
+ const meta = this.#stated.get(sid) ?? {};
667
+ const cwd = meta.cwd ?? rows.get(sid)?.cwd ?? "";
668
+ return {
669
+ repo: meta.repo ?? "",
670
+ ws: meta.ws ?? "",
671
+ cwd,
672
+ ...(meta.transcript_path === undefined ? {} : { transcript_path: meta.transcript_path }),
673
+ ...(meta.repo_root === undefined ? {} : { repo_root: meta.repo_root }),
674
+ ...(meta.branch === undefined ? {} : { branch: meta.branch }),
675
+ ...(meta.title === undefined ? {} : { title: meta.title }),
676
+ };
677
+ }
678
+ }
679
+
680
+ /** What a greeting said about the session, and nothing more: the fields the
681
+ * contract shares between `hello` and `peers`, copied across under their own
682
+ * names.
683
+ *
684
+ * `transcript_path` is the exception, because it is the one field that is not
685
+ * only displayed: it names a file this instance then reads and follows. What is
686
+ * taken is a path under this config home's `projects/`, resolved, and nothing
687
+ * else — a session naming a file elsewhere is a session that named nothing,
688
+ * which is what a session that stayed silent already is (M6). It is not an
689
+ * error: how a session describes itself is its own business, and the instance
690
+ * simply does not act on a description it cannot stand behind. */
691
+ function metaOf(args: HelloArgs, configHome: string): SessionMeta {
692
+ const meta: Record<string, string> = {};
693
+ for (const field of META_FIELDS) {
694
+ const value = args[field];
695
+ if (value === undefined) continue;
696
+ if (field === "transcript_path") {
697
+ const path = ownTranscript(value, configHome);
698
+ if (path !== undefined) meta[field] = path;
699
+ continue;
700
+ }
701
+ meta[field] = value;
702
+ }
703
+ return meta as SessionMeta;
704
+ }
705
+
706
+ /** A transcript path this instance will read, or nothing.
707
+
708
+ * The test is where the file would be, not whether it is there. M6 is a
709
+ * boundary on what this instance reads, and a path inside `projects/` stays
710
+ * inside it whether or not anything has been written there yet — a session
711
+ * greeting at its very start names a transcript the harness has created
712
+ * neither the file nor the directory for, and refusing it would mean the one
713
+ * greeting that says where a session's transcript is is the one greeting whose
714
+ * answer is thrown away. Nothing is read early by accepting it: the tail
715
+ * starts when somebody follows the session, and a file that is not there yet
716
+ * is one it waits for.
717
+ *
718
+ * As much of the path as exists is resolved, so a path spelled through a
719
+ * symlink and one spelled directly are the same path, and a link anywhere
720
+ * along it that leads out of the tree lands outside and is refused. What is
721
+ * already there must be a file: a directory by that name is not a transcript.
722
+ */
723
+ function ownTranscript(named: string, configHome: string): string | undefined {
724
+ if (!isAbsolute(named)) return undefined;
725
+ let projects: string;
726
+ try {
727
+ projects = realpathSync(join(configHome, "projects"));
728
+ } catch {
729
+ return undefined;
730
+ }
731
+ const settled = resolveAsFarAsItGoes(named);
732
+ if (settled === undefined || !within(settled, projects)) return undefined;
733
+ const stat = statSync(settled, { throwIfNoEntry: false });
734
+ return stat === undefined || stat.isFile() ? settled : undefined;
735
+ }
736
+
737
+ /** The path with every segment of it that exists resolved.
738
+ *
739
+ * A segment that is there may be a link and is followed; a segment that is not
740
+ * there cannot be a link to anywhere, because there is nothing at it, so it is
741
+ * kept as it was spelled. The result is compared against the tree as a whole,
742
+ * which is what makes a `..` among the unwritten segments land wherever it
743
+ * actually points rather than pass for being spelled inside. */
744
+ function resolveAsFarAsItGoes(path: string): string | undefined {
745
+ const unwritten: string[] = [];
746
+ let at = path;
747
+ for (;;) {
748
+ try {
749
+ return join(realpathSync(at), ...unwritten);
750
+ } catch {
751
+ const parent = dirname(at);
752
+ // The root itself always resolves, so this is a path that named
753
+ // something no filesystem root holds.
754
+ if (parent === at) return undefined;
755
+ unwritten.unshift(basename(at));
756
+ at = parent;
757
+ }
758
+ }
759
+ }
760
+
761
+ /** What each role must and must not say when it greets.
762
+ *
763
+ * The sid is what registers a session, so which role is entitled to name one is
764
+ * decided here rather than left to whoever reads the field: a `user` naming a
765
+ * sid would be a person registering as the session, and a `session` without one
766
+ * is a session this instance cannot speak about. */
767
+ function requiredSid(args: HelloArgs): Sid | undefined {
768
+ switch (args.role) {
769
+ case "session":
770
+ if (args.sid === undefined) throw new OpError("invalid_args", "a session names its sid");
771
+ return args.sid;
772
+ case "user":
773
+ if (args.sid !== undefined) {
774
+ throw new OpError("invalid_args", "a sid is the greeting of a session, not of a person");
775
+ }
776
+ return undefined;
777
+ case "instance":
778
+ // A peer speaks for no session: what it is has already been settled by
779
+ // the handshake, and a sid here would be it registering as one.
780
+ if (args.sid !== undefined) {
781
+ throw new OpError("invalid_args", "a sid is the greeting of a session, not of an instance");
782
+ }
783
+ return undefined;
784
+ }
785
+ }