@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,140 @@
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
4
+ import type { WorkspaceFolder } from "@ccmsg/protocol";
5
+
6
+ /** The folders a session's editor workspace names, read where the editor
7
+ * writes them.
8
+ *
9
+ * Not folded out of the transcript, because the transcript does not carry them:
10
+ * the harness records what was said and done in a session, and no record of any
11
+ * kind names an editor workspace. The old daemon read them the same way, from
12
+ * the workspace file beside the session's working directory, and that file is
13
+ * the only place the folders are stated.
14
+ *
15
+ * A session that has no workspace file names no folders, which is what an empty
16
+ * list means to the contract and admits no `workspace` path at all. */
17
+ export function workspaceFolders(cwd: string | undefined): WorkspaceFolder[] {
18
+ if (cwd === undefined || !isAbsolute(cwd)) return [];
19
+ const folders: WorkspaceFolder[] = [];
20
+ const seen = new Set<string>();
21
+ for (const file of workspaceFiles(cwd)) {
22
+ for (const spec of specs(file)) {
23
+ // Relative to the workspace file, which is how an editor reads them.
24
+ const real = directory(resolve(dirname(file), spec.path));
25
+ if (real === undefined || overbroad(real) || seen.has(real)) continue;
26
+ seen.add(real);
27
+ folders.push({ name: spec.name ?? basename(real), path: real });
28
+ }
29
+ }
30
+ return folders;
31
+ }
32
+
33
+ /** The workspace files directly beside the session's working directory, in a
34
+ * fixed order so the same directory always states its folders the same way. */
35
+ function workspaceFiles(cwd: string): string[] {
36
+ let entries: string[];
37
+ try {
38
+ entries = readdirSync(cwd);
39
+ } catch {
40
+ return [];
41
+ }
42
+ return entries
43
+ .filter((name) => name.endsWith(WORKSPACE_SUFFIX))
44
+ .sort()
45
+ .map((name) => resolve(cwd, name));
46
+ }
47
+
48
+ /** What one workspace file declares: the `folders` array, and of each entry the
49
+ * path it names and the name it may give that path. */
50
+ function specs(file: string): { path: string; name?: string }[] {
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(uncommented(readFileSync(file, "utf8")));
54
+ } catch {
55
+ // Written by hand and half-saved, or not a workspace file after all.
56
+ return [];
57
+ }
58
+ if (typeof parsed !== "object" || parsed === null) return [];
59
+ const declared = (parsed as Record<string, unknown>)["folders"];
60
+ if (!Array.isArray(declared)) return [];
61
+ const specs: { path: string; name?: string }[] = [];
62
+ for (const entry of declared) {
63
+ if (typeof entry !== "object" || entry === null) continue;
64
+ const row = entry as Record<string, unknown>;
65
+ const path = row["path"];
66
+ if (typeof path !== "string" || path.length === 0) continue;
67
+ const name = row["name"];
68
+ specs.push({
69
+ path,
70
+ ...(typeof name === "string" && name.length > 0 ? { name } : {}),
71
+ });
72
+ }
73
+ return specs;
74
+ }
75
+
76
+ /** A workspace file is JSON with comments and trailing commas, which the
77
+ * editors that write it accept. Stripped rather than parsed by a second
78
+ * grammar: what survives is the JSON the file already is. Strings are tracked
79
+ * so that a `//` inside a path is not mistaken for a comment. */
80
+ function uncommented(text: string): string {
81
+ let out = "";
82
+ let inString = false;
83
+ let escaped = false;
84
+ for (let i = 0; i < text.length; i += 1) {
85
+ const char = text[i] as string;
86
+ if (inString) {
87
+ out += char;
88
+ if (escaped) escaped = false;
89
+ else if (char === "\\") escaped = true;
90
+ else if (char === '"') inString = false;
91
+ continue;
92
+ }
93
+ if (char === '"') {
94
+ inString = true;
95
+ out += char;
96
+ continue;
97
+ }
98
+ const next = text[i + 1];
99
+ if (char === "/" && next === "/") {
100
+ while (i < text.length && text[i] !== "\n") i += 1;
101
+ out += "\n";
102
+ continue;
103
+ }
104
+ if (char === "/" && next === "*") {
105
+ const end = text.indexOf("*/", i + 2);
106
+ i = end < 0 ? text.length : end + 1;
107
+ continue;
108
+ }
109
+ out += char;
110
+ }
111
+ return out.replace(/,(\s*[}\]])/g, "$1");
112
+ }
113
+
114
+ /** What the filesystem calls a folder that is one. A path naming a file, or
115
+ * nothing at all, names no folder and is dropped. */
116
+ function directory(path: string): string | undefined {
117
+ try {
118
+ const real = realpathSync(path);
119
+ return statSync(real).isDirectory() ? real : undefined;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /** Whether admitting a folder would admit far more than a workspace.
126
+ *
127
+ * The root and the home directory and anything above them are refused: a
128
+ * workspace file naming one of those turns the `workspace` surface into the
129
+ * whole filesystem, and the folders are an allowlist rather than a hint. */
130
+ function overbroad(real: string): boolean {
131
+ if (real === sep || dirname(real) === real) return true;
132
+ const home = directory(homedir());
133
+ return home !== undefined && (real === home || home.startsWith(withSep(real)));
134
+ }
135
+
136
+ function withSep(path: string): string {
137
+ return path.endsWith(sep) ? path : path + sep;
138
+ }
139
+
140
+ const WORKSPACE_SUFFIX = ".code-workspace";
@@ -0,0 +1,42 @@
1
+ import type { TopicSubscribeArgs, TopicSubscribeResult } from "@ccmsg/protocol";
2
+ import { type HandlerInput, OpError } from "../dispatch/index.ts";
3
+ import type { SubscribeOutcome, Topics } from "./topics.ts";
4
+
5
+ /** The two ops that reach the topic mechanism. They carry no logic: dispatch
6
+ * has already validated and allowed the call, so each one names a topic, hands
7
+ * it to `Topics`, and turns the outcome into the contract's own answer. */
8
+ export function topicHandlers(topics: Topics) {
9
+ return {
10
+ topic_subscribe: (input: HandlerInput): TopicSubscribeResult => {
11
+ const topic = topicOf(input);
12
+ answer(topics.subscribe(input.conn, topic), topic);
13
+ return { topic };
14
+ },
15
+ topic_unsubscribe: (input: HandlerInput): TopicSubscribeResult => {
16
+ const topic = topicOf(input);
17
+ answer(topics.unsubscribe(input.conn, topic), topic);
18
+ return { topic };
19
+ },
20
+ };
21
+ }
22
+
23
+ /** Safe to read: the op's schema accepted the frame before the handler ran. */
24
+ function topicOf(input: HandlerInput): string {
25
+ return (input.args as unknown as TopicSubscribeArgs).topic;
26
+ }
27
+
28
+ function answer(outcome: SubscribeOutcome, topic: string): void {
29
+ if (outcome === "ok") return;
30
+ throw new OpError(outcome, refusal(outcome, topic));
31
+ }
32
+
33
+ function refusal(outcome: Exclude<SubscribeOutcome, "ok">, topic: string): string {
34
+ switch (outcome) {
35
+ case "topic_unknown":
36
+ return `no such topic: ${topic}`;
37
+ case "forbidden":
38
+ return `${topic} is not open to this connection`;
39
+ case "capability_unavailable":
40
+ return `${topic} needs a capability this instance does not have`;
41
+ }
42
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./handlers.ts";
2
+ export * from "./topics.ts";
@@ -0,0 +1,290 @@
1
+ import {
2
+ type Capability,
3
+ type InstanceId,
4
+ type Sid,
5
+ TOPIC_ATTRIBUTES,
6
+ type TopicAttributes,
7
+ type TopicKind,
8
+ topicGranularity,
9
+ topicKind,
10
+ } from "@ccmsg/protocol";
11
+ import type { Requester } from "../dispatch/index.ts";
12
+
13
+ /** What a subscribe decided. `ok` and the three refusals the contract names
14
+ * for the op, so the caller turns an outcome into an error without deciding
15
+ * anything of its own. */
16
+ export type SubscribeOutcome = "ok" | "topic_unknown" | "forbidden" | "capability_unavailable";
17
+
18
+ /** One value on a topic, and the instance that produced it. */
19
+ export interface TopicValue {
20
+ readonly instance: InstanceId;
21
+ readonly data: unknown;
22
+ }
23
+
24
+ /** Whoever owns the values behind a kind of topic, driven by whether anyone is
25
+ * listening.
26
+ *
27
+ * Subscription is the only thing that starts or stops it (daemon-v2 §6.3):
28
+ * `start` runs when a topic goes from no subscribers to one, `stop` when it
29
+ * goes back to none. Both are given the full topic name, because the resource
30
+ * is per name — one tail per `transcript:<sid>`, not one per kind.
31
+ *
32
+ * `snapshot` is asked for the current value, because the owner is where the
33
+ * current value lives (§3.3): the topic mechanism sees changes go past and a
34
+ * change is not a value. For a topic whose frames are elements or an append,
35
+ * the last change is one message or one chunk, while the current value is
36
+ * every message or the tail as it now stands — only the owner can say it.
37
+ * It answers one entry per originating instance, and none at all for a topic
38
+ * with no value to state.
39
+ *
40
+ * The subscribing connection is handed to `snapshot` because one topic's
41
+ * current value differs by who is asking: `inbox` names one topic for the
42
+ * instance, and what is on it for a session is what was said to that session
43
+ * (§4.3). Owners whose value is the same for everyone ignore the argument. */
44
+ export interface UpstreamResource {
45
+ start(topic: string): void;
46
+ stop(topic: string): void;
47
+ snapshot(topic: string, conn: Requester): readonly TopicValue[];
48
+ }
49
+
50
+ /** The rest of the cluster, as the topic mechanism sees it (§7.4).
51
+ *
52
+ * Two things, both about topics that carry a whole value per instance: what
53
+ * the other instances have already stated, and whether anyone here is
54
+ * listening — because what a subscriber asks of this instance is what this
55
+ * instance asks of its peers. The mesh implements it; an instance without one
56
+ * has no other instance to hear from. */
57
+ export interface RemoteTopics {
58
+ snapshot(topic: string): readonly TopicValue[];
59
+ demand(topic: string, wanted: boolean): void;
60
+ }
61
+
62
+ /** One instance's topics: subscribers, the way a value reaches them, and the
63
+ * suppression every topic shares.
64
+ *
65
+ * There is no class per topic. A topic is a name, a set of connections and the
66
+ * form of the last frame sent under it, so the whole of daemon-v2 §6.1 is this
67
+ * one object and "this topic has no suppression" cannot happen (M5). What it
68
+ * does not hold is the current value: that belongs to whoever owns it (§3.3),
69
+ * and is asked for when a subscriber needs it. */
70
+ export class Topics {
71
+ /** Subscribers per topic name. A connection appears in as many sets as it
72
+ * has subscriptions, and leaves all of them when it closes (§6.3). */
73
+ readonly #subscribers = new Map<string, Set<Requester>>();
74
+ /** The form of the last frame sent, per topic name and then per originating
75
+ * instance — the only thing suppression needs, and the only thing kept.
76
+ * Per instance because a whole value from one instance does not replace
77
+ * another's (§6.2), so neither does it make the other a repeat. */
78
+ readonly #lastSent = new Map<string, Map<InstanceId, string>>();
79
+ readonly #upstream = new Map<TopicKind, UpstreamResource>();
80
+ /** The connections a close listener has already been registered on. Weak
81
+ * because the entry says nothing once the connection is gone. */
82
+ readonly #closers = new WeakSet<Requester>();
83
+
84
+ constructor(
85
+ private readonly self: InstanceId,
86
+ private readonly capabilities: ReadonlySet<Capability>,
87
+ private readonly remote?: RemoteTopics,
88
+ ) {}
89
+
90
+ /** Bind the resource that feeds a kind of topic. */
91
+ attach(kind: TopicKind, resource: UpstreamResource): void {
92
+ this.#upstream.set(kind, resource);
93
+ }
94
+
95
+ /** The one way a value reaches subscribers (§6.1).
96
+ *
97
+ * `instance` is where the value was produced: this instance for a value of
98
+ * our own, and the originating peer for one mesh relayed to us, which the
99
+ * frame carries onward unchanged (§7.4).
100
+ *
101
+ * `to` narrows the frame to the connections of one session. It exists for
102
+ * `inbox`, whose topic name is one for the instance while its value belongs
103
+ * to a session: without it, delivering to one session would push the message
104
+ * to every subscriber. It changes who receives the frame and nothing else —
105
+ * the frame, and the suppression before it, are the same ones every topic
106
+ * goes through (M5). */
107
+ publish(topic: string, data: unknown, instance: InstanceId = this.self, to?: Sid): void {
108
+ const kind = topicKind(topic);
109
+ if (kind === undefined) return;
110
+ if (replaces(topic)) {
111
+ // The suppression, written once for every topic it applies to (M5). The
112
+ // contract's granularity is the whole of the rule, and only a frame that
113
+ // replaces the value it repeats can be dropped for repeating it: a delta
114
+ // is an occurrence — an inbox message offered again, a `kv` entry
115
+ // restated — and dropping it would lose the offer, not a duplicate.
116
+ const wire = serialize(data);
117
+ const sent = this.#sent(topic);
118
+ if (sent.get(instance) === wire) return;
119
+ sent.set(instance, wire);
120
+ }
121
+ const frame = this.#frame(topic, instance, data, false);
122
+ for (const conn of this.#subscribers.get(topic) ?? []) {
123
+ if (holds(conn, to)) conn.send(frame);
124
+ }
125
+ }
126
+
127
+ subscribe(conn: Requester, topic: string): SubscribeOutcome {
128
+ const kind = topicKind(topic);
129
+ if (kind === undefined) return "topic_unknown";
130
+ // Read at the declared type rather than at the literal one the table
131
+ // infers, so the two checks below are the table's rule and not this
132
+ // topic's own row.
133
+ const attrs: TopicAttributes = TOPIC_ATTRIBUTES[kind];
134
+ const identity = conn.identity;
135
+ // Who may hear a topic is the same question the op table answers for ops,
136
+ // asked of the topic table (§11.2). A connection with no settled identity
137
+ // has no role to compare, and cannot reach the op that gets here anyway.
138
+ if (identity.state !== "settled" || !attrs.roles.includes(identity.role)) return "forbidden";
139
+ if (attrs.capability !== undefined && !this.capabilities.has(attrs.capability)) {
140
+ return "capability_unavailable";
141
+ }
142
+
143
+ let subscribers = this.#subscribers.get(topic);
144
+ if (subscribers === undefined) {
145
+ subscribers = new Set();
146
+ this.#subscribers.set(topic, subscribers);
147
+ // Before the connection joins, so a value the resource produces while
148
+ // starting is held rather than pushed as a change to a subscriber that
149
+ // has not had its snapshot yet.
150
+ this.#upstream.get(kind)?.start(topic);
151
+ // The subscription travels with the same trigger the local resource has:
152
+ // one listener starts it, none stops it (§6.3, §7.4).
153
+ this.remote?.demand(topic, true);
154
+ }
155
+ if (!subscribers.has(conn)) {
156
+ subscribers.add(conn);
157
+ // One listener for the connection rather than one per subscription: a
158
+ // client that subscribes and unsubscribes as it moves between views does
159
+ // so any number of times on one connection, and a listener registered
160
+ // per subscription would be kept for every one of them until it closed.
161
+ // What the single listener releases is every subscription still held,
162
+ // which is what a close means (§6.3).
163
+ if (!this.#closers.has(conn)) {
164
+ this.#closers.add(conn);
165
+ conn.onClose(() => this.dropAll(conn));
166
+ }
167
+ }
168
+ // The owner states the current value. A topic with no owner attached yet
169
+ // answers nothing, as does one with no value to state (§6.2, event), and
170
+ // in both cases the subscriber starts at the next thing that happens.
171
+ for (const value of this.#upstream.get(kind)?.snapshot(topic, conn) ?? []) {
172
+ conn.deferSend(this.#frame(topic, value.instance, value.data, true));
173
+ }
174
+ // What the other instances last stated, under their own names. A whole
175
+ // value per instance means the subscriber folds these beside ours instead
176
+ // of choosing between them (§6.2), and an instance that has gone is still
177
+ // among them until its value is given up (§7.5).
178
+ for (const value of this.remote?.snapshot(topic) ?? []) {
179
+ conn.deferSend(this.#frame(topic, value.instance, value.data, true));
180
+ }
181
+ return "ok";
182
+ }
183
+
184
+ /** Drop one subscription. Repeating it changes nothing, which is what lets
185
+ * the close listener and an explicit unsubscribe both end a subscription. */
186
+ unsubscribe(conn: Requester, topic: string): SubscribeOutcome {
187
+ const kind = topicKind(topic);
188
+ if (kind === undefined) return "topic_unknown";
189
+ const subscribers = this.#subscribers.get(topic);
190
+ if (subscribers === undefined || !subscribers.delete(conn)) return "ok";
191
+ if (subscribers.size > 0) return "ok";
192
+ this.#subscribers.delete(topic);
193
+ // Nothing is listening, so the resource stops. What it last sent is
194
+ // forgotten with it: comparing against a frame from before the resource
195
+ // stopped would suppress the first frame after it starts again.
196
+ this.#lastSent.delete(topic);
197
+ this.#upstream.get(kind)?.stop(topic);
198
+ this.remote?.demand(topic, false);
199
+ return "ok";
200
+ }
201
+
202
+ /** Drop every subscription one connection holds.
203
+ *
204
+ * What shutdown calls (§8.5 step 2) before it tells the connections
205
+ * anything: a resource runs while it has a listener (§6.3), so taking the
206
+ * listeners away is what stops the upstream watches — through the same
207
+ * `unsubscribe` a closing connection goes through, rather than a second way
208
+ * to release the same thing. */
209
+ dropAll(conn: Requester): void {
210
+ const held: string[] = [];
211
+ for (const [topic, subscribers] of this.#subscribers) {
212
+ if (subscribers.has(conn)) held.push(topic);
213
+ }
214
+ for (const topic of held) this.unsubscribe(conn, topic);
215
+ }
216
+
217
+ /** How many connections hold a subscription to a topic, counting only those
218
+ * of one session when `to` names one.
219
+ *
220
+ * What delivery asks before it publishes: a message reaches its session
221
+ * through this topic or it does not reach it at all, so whether anyone is
222
+ * listening for that session decides between handing it over and holding it
223
+ * (§4.2). */
224
+ subscriberCount(topic: string, to?: Sid): number {
225
+ let count = 0;
226
+ for (const conn of this.#subscribers.get(topic) ?? []) {
227
+ if (holds(conn, to)) count += 1;
228
+ }
229
+ return count;
230
+ }
231
+
232
+ #sent(topic: string): Map<InstanceId, string> {
233
+ const sent = this.#lastSent.get(topic) ?? new Map<InstanceId, string>();
234
+ this.#lastSent.set(topic, sent);
235
+ return sent;
236
+ }
237
+
238
+ /** The wire shape of a topic frame, built here and nowhere else so snapshot
239
+ * and change cannot drift apart (§11.1). */
240
+ #frame(topic: string, instance: InstanceId, data: unknown, snapshot: boolean): object {
241
+ return snapshot
242
+ ? { ev: "topic", topic, snapshot: true, instance, data }
243
+ : { ev: "topic", topic, instance, data };
244
+ }
245
+ }
246
+
247
+ /** What a scoped topic names: the sid of `session_status:<sid>`, the session
248
+ * of `transcript:<sid>`, the namespace of `kv:<ns>`. Which kind of topic it is
249
+ * has already been decided by whoever holds the name; this reads the parameter
250
+ * out of it, in one place for every owner that is per name (§6.3). */
251
+ export function topicParam(topic: string): string | undefined {
252
+ const separator = topic.indexOf(":");
253
+ if (separator < 0) return undefined;
254
+ const param = topic.slice(separator + 1);
255
+ return param.length > 0 ? param : undefined;
256
+ }
257
+
258
+ /** Whether a connection is one of the session's, for a frame addressed to a
259
+ * session. A connection with no sid settled holds none, so a person watching
260
+ * the topic does not receive what was said to someone else. */
261
+ function holds(conn: Requester, to: Sid | undefined): boolean {
262
+ if (to === undefined) return true;
263
+ const identity = conn.identity;
264
+ return identity.state === "settled" && identity.sid === to;
265
+ }
266
+
267
+ /** Whether a frame on this topic replaces the value it carries, which is the
268
+ * question suppression asks (§6.1).
269
+ *
270
+ * The two whole-value granularities do: a payload equal to the last one leaves
271
+ * the subscriber holding what it already holds. The others do not — an
272
+ * `element` frame adds or restates one entry, an `append` frame carries a
273
+ * chunk, an `event` frame is an occurrence — so two equal frames are two
274
+ * things happening, and the second is news. */
275
+ function replaces(topic: string): boolean {
276
+ const granularity = topicGranularity(topic);
277
+ return granularity === "whole" || granularity === "per_instance_whole";
278
+ }
279
+
280
+ /** The comparison behind suppression, in one place for every topic (M5).
281
+ *
282
+ * A topic payload is the JSON the frame will carry, so its serialized form is
283
+ * exactly what a subscriber would receive: comparing that answers "would this
284
+ * frame tell the subscriber anything new" without walking the value, and it
285
+ * cannot disagree with what goes on the wire. It is sensitive to key order,
286
+ * which is not a defect here — the values come from a domain that builds each
287
+ * topic's payload in one place, so the same value serializes the same way. */
288
+ function serialize(data: unknown): string {
289
+ return JSON.stringify(data ?? null);
290
+ }
@@ -0,0 +1,201 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import type { Sid } from "@ccmsg/protocol";
4
+ import { OpError } from "../dispatch/index.ts";
5
+
6
+ /** Where the harness keeps transcripts under a config home: one directory per
7
+ * working directory, one `<sid>.jsonl` in it. */
8
+ const PROJECTS = "projects";
9
+ const SUFFIX = ".jsonl";
10
+
11
+ /** A session id as the harness names files by. Validated before it is joined
12
+ * to a path, so a sid is a name rather than a route: no separator and no dot
13
+ * can appear in it, which makes traversal unrepresentable rather than
14
+ * unlikely. */
15
+ const SID = /^[0-9a-fA-F-]{8,64}$/;
16
+
17
+ /** The agent id and run id shapes the harness writes under a session's own
18
+ * directory, and the name a teammate is addressed by. Each is validated on the
19
+ * same footing as a sid, for the same reason: all three name a file. */
20
+ const AGENT_ID = /^a[A-Za-z0-9_-]{5,120}$/;
21
+ const RUN_ID = /^wf_[0-9a-f]{8}-[0-9a-f]{3}$/;
22
+ const TEAMMATE = /^[A-Za-z0-9_-]{1,64}$/;
23
+
24
+ /** Which transcript an op means.
25
+ *
26
+ * The caller never supplies a path: a sid resolves to the file the session
27
+ * announced, or to the one under this instance's own config home, and the
28
+ * three optional names below resolve to files under that session's own
29
+ * directory. Only the config home this instance answers for is ever looked in
30
+ * (M6) — nothing searches for another one. */
31
+ export interface TranscriptFilesDeps {
32
+ readonly configHome: string;
33
+ /** Where a connected session said its transcript is (§5.1). A session that
34
+ * never greeted has none, and the walk below answers for it. */
35
+ readonly announced: (sid: Sid) => string | undefined;
36
+ }
37
+
38
+ export class TranscriptFiles {
39
+ constructor(private readonly deps: TranscriptFilesDeps) {}
40
+
41
+ /** The session's own transcript. */
42
+ session(sid: Sid): string {
43
+ const announced = this.deps.announced(sid);
44
+ if (announced !== undefined && isFile(announced)) return announced;
45
+ const found = this.find(sid);
46
+ if (found === undefined) throw new OpError("not_found", `no transcript is held for ${sid}`);
47
+ return found;
48
+ }
49
+
50
+ /** The transcript an op's arguments name: the session's own, or one of the
51
+ * agents that ran below it.
52
+ *
53
+ * `agent_id` and `teammate` are two ways of naming the same kind of file and
54
+ * cannot be combined — a request carrying both names two files and is a
55
+ * caller's mistake rather than a choice this makes for them. */
56
+ locate(sid: Sid, names: AgentNames = {}): string {
57
+ const file = this.session(sid);
58
+ if (names.agent_id !== undefined && names.teammate !== undefined) {
59
+ throw new OpError("invalid_args", "agent_id and teammate name two different transcripts");
60
+ }
61
+ if (names.agent_id === undefined && names.teammate === undefined) {
62
+ if (names.run_id !== undefined) {
63
+ throw new OpError("invalid_args", "run_id names the run an agent_id belongs to");
64
+ }
65
+ return file;
66
+ }
67
+ const under = agentsDir(file, names.run_id);
68
+ if (names.agent_id !== undefined) {
69
+ return existing(join(under, `agent-${name(names.agent_id, AGENT_ID, "agent_id")}${SUFFIX}`));
70
+ }
71
+ return this.teammate(under, name(names.teammate ?? "", TEAMMATE, "teammate"));
72
+ }
73
+
74
+ /** A teammate's transcript, found by the name it is addressed by.
75
+ *
76
+ * The name a teammate carries in conversation is not its filename, so the
77
+ * directory's own records are read for it rather than the name being
78
+ * substituted into a path. */
79
+ private teammate(under: string, wanted: string): string {
80
+ let names: string[];
81
+ try {
82
+ names = readdirSync(under);
83
+ } catch {
84
+ throw new OpError("not_found", `no agent has run under this session`);
85
+ }
86
+ for (const each of names) {
87
+ if (!each.endsWith(".meta.json")) continue;
88
+ let document: unknown;
89
+ try {
90
+ document = JSON.parse(readFileSync(join(under, each), "utf8"));
91
+ } catch {
92
+ continue;
93
+ }
94
+ const named = (document as { name?: unknown } | null)?.name;
95
+ if (named !== wanted) continue;
96
+ return existing(join(under, `${each.slice(0, -".meta.json".length)}${SUFFIX}`));
97
+ }
98
+ throw new OpError("not_found", `no teammate of this session is addressed as ${wanted}`);
99
+ }
100
+
101
+ /** Every transcript under this instance's config home, newest first.
102
+ *
103
+ * The one enumeration a search and a fork sweep both start from. It states
104
+ * the file and what a `stat` already said about it, so neither has to stat
105
+ * again to decide whether to open it. */
106
+ all(): TranscriptFile[] {
107
+ const found: TranscriptFile[] = [];
108
+ const projects = join(this.deps.configHome, PROJECTS);
109
+ for (const project of names(projects)) {
110
+ const dir = join(projects, project);
111
+ for (const entry of names(dir)) {
112
+ if (!entry.endsWith(SUFFIX)) continue;
113
+ const sid = entry.slice(0, -SUFFIX.length);
114
+ if (!SID.test(sid)) continue;
115
+ const file = join(dir, entry);
116
+ const stat = statOf(file);
117
+ if (stat === undefined) continue;
118
+ found.push({
119
+ sid,
120
+ file,
121
+ project,
122
+ size: stat.size,
123
+ created_at: Math.round(stat.birthtimeMs || stat.ctimeMs),
124
+ updated_at: Math.round(stat.mtimeMs),
125
+ });
126
+ }
127
+ }
128
+ found.sort((a, b) => b.updated_at - a.updated_at || a.file.localeCompare(b.file));
129
+ return found;
130
+ }
131
+
132
+ private find(sid: Sid): string | undefined {
133
+ if (!SID.test(sid)) return undefined;
134
+ const projects = join(this.deps.configHome, PROJECTS);
135
+ for (const project of names(projects)) {
136
+ const file = join(projects, project, `${sid}${SUFFIX}`);
137
+ if (isFile(file)) return file;
138
+ }
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ export interface AgentNames {
144
+ readonly agent_id?: string;
145
+ readonly run_id?: string;
146
+ readonly teammate?: string;
147
+ }
148
+
149
+ /** One transcript on disk, with what the enumeration already learned of it. */
150
+ export interface TranscriptFile {
151
+ readonly sid: Sid;
152
+ readonly file: string;
153
+ /** The project directory's name, which is the working directory flattened.
154
+ * A lossy spelling — separators and dots all become dashes — so it prefilters
155
+ * a search and never decides it. */
156
+ readonly project: string;
157
+ readonly size: number;
158
+ readonly created_at: number;
159
+ readonly updated_at: number;
160
+ }
161
+
162
+ /** Where the agents of one session write, beside its own transcript. */
163
+ function agentsDir(sessionFile: string, runId?: string): string {
164
+ const dir = join(dirname(sessionFile), basename(sessionFile, SUFFIX), "subagents");
165
+ if (runId === undefined) return dir;
166
+ return join(dir, "workflows", name(runId, RUN_ID, "run_id"));
167
+ }
168
+
169
+ /** One path segment, checked against the shape its kind has. The check runs
170
+ * before the join, so a name that could leave the directory never becomes part
171
+ * of a path at all. */
172
+ function name(value: string, shape: RegExp, field: string): string {
173
+ if (!shape.test(value)) throw new OpError("invalid_args", `${field} is not a name of that kind`);
174
+ return value;
175
+ }
176
+
177
+ function existing(file: string): string {
178
+ if (!isFile(file)) throw new OpError("not_found", "no transcript is held for that agent");
179
+ return file;
180
+ }
181
+
182
+ function names(dir: string): string[] {
183
+ try {
184
+ return readdirSync(dir);
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+
190
+ function statOf(file: string) {
191
+ try {
192
+ const stat = statSync(file);
193
+ return stat.isFile() ? stat : undefined;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ function isFile(file: string): boolean {
200
+ return statOf(file) !== undefined;
201
+ }