@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,278 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parse, sep } from "node:path";
3
+ import type {
4
+ InstanceId,
5
+ SessionSearchArgs,
6
+ SessionSearchHit,
7
+ SessionSearchMatch,
8
+ SessionSearchResult,
9
+ } from "@ccmsg/protocol";
10
+ import { OpError } from "../dispatch/index.ts";
11
+ import { readRecord, type TranscriptFile, type TranscriptFiles } from "../transcript/index.ts";
12
+
13
+ /** What one search may read, and what it may answer with.
14
+ *
15
+ * A search walks transcripts that were never opened for it and cannot know how
16
+ * many will match, so both ends are bounded: the bytes stop a query that
17
+ * matches nothing from reading a whole config home, and the hits stop one that
18
+ * matches everything from becoming a payload nobody can use. Reaching either
19
+ * is `truncated` rather than an error — the hits found are still hits. */
20
+ const SCAN_BUDGET_BYTES = 64 * 1024 * 1024;
21
+ const HITS = 50;
22
+ /** What one hit shows of what it matched. Enough to recognise the passage;
23
+ * the transcript itself is one `transcript_read` away. */
24
+ const MATCHES_PER_HIT = 5;
25
+ const MATCH_CHARS = 400;
26
+
27
+ /** The longest one clause may be. A query is something a person types, and the
28
+ * pattern's own size is one of the things that decides what matching it costs;
29
+ * past this it is a program rather than a query. */
30
+ const MAX_CLAUSE_CHARS = 1000;
31
+
32
+ /** What one regular-expression clause may spend on matching, over the whole
33
+ * search.
34
+ *
35
+ * The contract lets a caller state a regular expression and says nothing about
36
+ * which ones, so the patterns that backtrack super-linearly are admitted — and
37
+ * they are not only the crafted ones: `[a-z]+ing` over records of ordinary
38
+ * prose is quadratic in each record's length, and measured here it spends 86
39
+ * seconds on the scan budget below where a literal or an alternation spends
40
+ * 7 to 12 milliseconds on the same bytes. Two seconds is two orders of
41
+ * magnitude above what a well-formed clause needs and far below what the
42
+ * instance can afford to be blocked for, since it answers one op at a time.
43
+ * Reaching it is `truncated`, which is what every other cap on this op is. */
44
+ const CLAUSE_BUDGET_MS = 2000;
45
+
46
+ export interface SearchDeps {
47
+ readonly self: InstanceId;
48
+ /** The one config home this instance answers for (M6). */
49
+ readonly configHome: string;
50
+ readonly files: TranscriptFiles;
51
+ }
52
+
53
+ /** Search the transcripts of sessions that have run on this instance.
54
+ *
55
+ * Two stages, because opening every transcript to answer a query about one is
56
+ * the cost this op is bounded against: the enumeration filters on what a
57
+ * directory listing and a `stat` already say — the session id, the working
58
+ * directory as the project directory spells it, when the file was last touched
59
+ * — and only what survives that is read. */
60
+ export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearchResult {
61
+ if ((args.config_dirs ?? [deps.configHome]).every((dir) => dir !== deps.configHome)) {
62
+ // Every config home the caller named is one this instance does not know,
63
+ // which the contract says to ignore — leaving nothing to search.
64
+ return { hits: [], truncated: false };
65
+ }
66
+ const { clauses, budgets } = compile(args);
67
+ const sid = args.sid?.toLowerCase();
68
+ const cwdWords = (args.cwd ?? "").trim().split(/\s+/).filter(Boolean);
69
+ const since = args.modified_within_ms === undefined ? 0 : Date.now() - args.modified_within_ms;
70
+ const wanted = { user: args.target_user ?? true, agent: args.target_agent ?? true };
71
+
72
+ const hits: SessionSearchHit[] = [];
73
+ let budget = SCAN_BUDGET_BYTES;
74
+ let truncated = false;
75
+ for (const candidate of deps.files.all()) {
76
+ if (sid !== undefined && !candidate.sid.toLowerCase().includes(sid)) continue;
77
+ if (candidate.updated_at < since) continue;
78
+ if (!looksLike(candidate.project, cwdWords)) continue;
79
+ // Every clause having given up leaves nothing that could still match, so
80
+ // the rest of the walk would read transcripts to decide nothing.
81
+ const spent = budgets.length > 0 && budgets.every((each) => each.spent);
82
+ if (hits.length >= HITS || budget <= 0 || spent) {
83
+ truncated = true;
84
+ break;
85
+ }
86
+ budget -= candidate.size;
87
+ const hit = read(candidate, clauses, wanted, deps);
88
+ // The working directory the project directory only approximates: a hit is
89
+ // kept when the transcript's own `cwd` holds every word asked for.
90
+ if (hit !== undefined && holds(hit.cwd, cwdWords)) hits.push(hit);
91
+ }
92
+ // A clause that ran out of time answered about fewer records than it was
93
+ // asked about, whether or not the walk itself reached an end.
94
+ return { hits, truncated: truncated || budgets.some((each) => each.spent) };
95
+ }
96
+
97
+ /** One clause of a query: the terms that must all appear for it to match.
98
+ *
99
+ * Clauses are ORed and the terms within one are ANDed, which is what lets a
100
+ * caller ask for two unrelated passages in one search. A query stating nothing
101
+ * matches every record, so a search by working directory alone is a search. */
102
+ type Clause = (text: string) => boolean;
103
+
104
+ /** What one clause has left to spend, and whether it has stopped.
105
+ *
106
+ * Only a regular-expression clause carries one. A clause of terms is a
107
+ * substring search per term, linear in what it is given, and the bytes it may
108
+ * be given are already bounded — there is nothing a clock would tell it that
109
+ * the scan budget does not. */
110
+ class Budget {
111
+ #left = CLAUSE_BUDGET_MS;
112
+ /** The clause gave up part-way, so what it did not match it did not decide
113
+ * about. */
114
+ spent = false;
115
+
116
+ run(test: () => boolean): boolean {
117
+ if (this.spent) return false;
118
+ const at = performance.now();
119
+ try {
120
+ return test();
121
+ } finally {
122
+ this.#left -= performance.now() - at;
123
+ if (this.#left <= 0) this.spent = true;
124
+ }
125
+ }
126
+ }
127
+
128
+ function compile(args: SessionSearchArgs): { clauses: Clause[]; budgets: Budget[] } {
129
+ const query = args.query?.trim();
130
+ if (query === undefined || query === "") return { clauses: [], budgets: [] };
131
+ const sensitive = args.case_sensitive === true;
132
+ const budgets: Budget[] = [];
133
+ const clauses = query
134
+ .split("\n")
135
+ .map((clause) => clause.trim())
136
+ .filter((clause) => clause !== "")
137
+ .map((clause): Clause => {
138
+ if (clause.length > MAX_CLAUSE_CHARS) {
139
+ throw new OpError(
140
+ "invalid_args",
141
+ `a query clause may be at most ${MAX_CLAUSE_CHARS} characters, and this one is ${clause.length}`,
142
+ );
143
+ }
144
+ if (args.regex === true) {
145
+ let matcher: RegExp;
146
+ try {
147
+ matcher = new RegExp(clause, sensitive ? "" : "i");
148
+ } catch (cause) {
149
+ throw new OpError(
150
+ "invalid_args",
151
+ `${clause} is not a regular expression: ${String(cause)}`,
152
+ );
153
+ }
154
+ const budget = new Budget();
155
+ budgets.push(budget);
156
+ return (text: string) => budget.run(() => matcher.test(text));
157
+ }
158
+ const terms = clause.split(/\s+/).map((term) => (sensitive ? term : term.toLowerCase()));
159
+ return (text: string) => {
160
+ const against = sensitive ? text : text.toLowerCase();
161
+ return terms.every((term) => against.includes(term));
162
+ };
163
+ });
164
+ return { clauses, budgets };
165
+ }
166
+
167
+ /** Read one transcript, and state it as a hit when it matched.
168
+ *
169
+ * The pass is one: the records that carry the query also carry the working
170
+ * directory, the title and what the session last ran as, so a hit is built
171
+ * from the reading that decided it rather than from a second one. */
172
+ function read(
173
+ candidate: TranscriptFile,
174
+ clauses: readonly Clause[],
175
+ wanted: { user: boolean; agent: boolean },
176
+ deps: SearchDeps,
177
+ ): SessionSearchHit | undefined {
178
+ let text: string;
179
+ try {
180
+ text = readFileSync(candidate.file, "utf8");
181
+ } catch {
182
+ // Gone since it was listed, which is a session that ended mid-search.
183
+ return undefined;
184
+ }
185
+ const matches: SessionSearchMatch[] = [];
186
+ let cwd: string | undefined;
187
+ let title: string | undefined;
188
+ let model: string | undefined;
189
+ let effort: string | undefined;
190
+ let createdAt: number | undefined;
191
+ for (const line of text.split("\n")) {
192
+ if (line === "") continue;
193
+ const record = readRecord(line);
194
+ if (record === undefined) continue;
195
+ cwd ??= record.cwd;
196
+ createdAt ??= record.said_at;
197
+ if (record.title !== undefined) title = record.title;
198
+ // What the session runs as is a property of its latest turn rather than of
199
+ // its first: a session whose model was changed mid-run resumes as what it
200
+ // is now. Sidechain rows are a subagent's own turns and say nothing of it.
201
+ if (!record.sidechain && record.model !== undefined) {
202
+ model = record.model;
203
+ effort = record.effort;
204
+ }
205
+ if (matches.length >= MATCHES_PER_HIT) continue;
206
+ const said = record.text;
207
+ if (said === undefined || record.said_by === undefined || !wanted[record.said_by]) continue;
208
+ if (!says(said, clauses)) continue;
209
+ matches.push({
210
+ role: record.said_by,
211
+ text: said.length > MATCH_CHARS ? `${said.slice(0, MATCH_CHARS)}…` : said,
212
+ ...(record.said_at === undefined ? {} : { said_at: record.said_at }),
213
+ });
214
+ }
215
+ if (clauses.length > 0 && matches.length === 0) return undefined;
216
+ const location = repoLocation(cwd);
217
+ return {
218
+ sid: candidate.sid,
219
+ instance: deps.self,
220
+ config_dir: deps.configHome,
221
+ file: candidate.file,
222
+ ...(cwd === undefined ? {} : { cwd }),
223
+ ...location,
224
+ ...(title === undefined ? {} : { title }),
225
+ created_at: createdAt ?? candidate.created_at,
226
+ updated_at: candidate.updated_at,
227
+ size: candidate.size,
228
+ matches,
229
+ ...(model === undefined ? {} : { model }),
230
+ ...(effort === undefined ? {} : { effort }),
231
+ };
232
+ }
233
+
234
+ /** Whether any clause matches. A query stating no clause matches everything,
235
+ * which is what makes a search by working directory alone a search. */
236
+ function says(text: string, clauses: readonly Clause[]): boolean {
237
+ return clauses.length === 0 || clauses.some((clause) => clause(text));
238
+ }
239
+
240
+ /** Whether the project directory could be the working directory asked for.
241
+ *
242
+ * The harness flattens a working directory into one name, and the flattening
243
+ * is lossy — separators, dots and underscores all become dashes — so this only
244
+ * narrows what is opened. What decides a hit is the transcript's own `cwd`. */
245
+ function looksLike(project: string, words: readonly string[]): boolean {
246
+ if (words.length === 0) return true;
247
+ const flat = flatten(project);
248
+ return words.every((word) => flat.includes(flatten(word)));
249
+ }
250
+
251
+ function holds(cwd: string | undefined, words: readonly string[]): boolean {
252
+ if (words.length === 0) return true;
253
+ if (cwd === undefined) return false;
254
+ const flat = flatten(cwd);
255
+ return words.every((word) => flat.includes(flatten(word)));
256
+ }
257
+
258
+ function flatten(value: string): string {
259
+ return value.toLowerCase().replace(/[-/._\s]/g, "");
260
+ }
261
+
262
+ /** `owner/repo` and the workspace within it, when the working directory
263
+ * follows the layout that states them. A directory that does not is reported
264
+ * without them rather than with a guess. */
265
+ function repoLocation(cwd: string | undefined): { repo?: string; ws?: string } {
266
+ if (cwd === undefined) return {};
267
+ const root = parse(cwd).root;
268
+ const parts = cwd.slice(root.length).split(sep).filter(Boolean);
269
+ for (let at = parts.length - 1; at >= 0; at--) {
270
+ if (parts[at] !== "repos" || at + 3 >= parts.length) continue;
271
+ const workspace = parts.slice(at + 4);
272
+ return {
273
+ repo: `${parts[at + 2]}/${parts[at + 3]}`,
274
+ ...(workspace.length === 0 ? {} : { ws: workspace.join(sep) }),
275
+ };
276
+ }
277
+ return {};
278
+ }
@@ -0,0 +1,209 @@
1
+ import type {
2
+ InstanceId,
3
+ SessionApiError,
4
+ SessionErrorEntry,
5
+ SessionStatusSnapshot,
6
+ Sid,
7
+ } from "@ccmsg/protocol";
8
+ import { canonical, within } from "../files/containment.ts";
9
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
10
+ import { topicParam } from "../topics/index.ts";
11
+ import type { TranscriptFacts } from "../transcript/index.ts";
12
+ import { workspaceFolders } from "./workspace.ts";
13
+
14
+ /** What the fold says stopped a session, read in one place.
15
+ *
16
+ * Three values rest on it: whether a live session is Waiting (§5.2), what
17
+ * `session_errors` lists, and the `api_error` of `session_status:<sid>`. They
18
+ * ask this rather than each reading the fold's field, so the three cannot come
19
+ * to different answers about the same session (§7.4, M5). */
20
+ export function stoppedOn(facts: TranscriptFacts): SessionApiError | undefined {
21
+ return facts.api_error;
22
+ }
23
+
24
+ /** The `session_status:<sid>` payload.
25
+ *
26
+ * Almost every field is the fold's, stated as the fold left it: one pass over
27
+ * the transcript settles the error, the task list, the files it named and what
28
+ * is running below it, and this assembles them rather than reading anything a
29
+ * second time (M5).
30
+ *
31
+ * The two fields that are not the fold's are the ones the transcript does not
32
+ * carry. `workspace_folders` is read from the editor's own workspace file, and
33
+ * `external_files` needs a root to be outside of — a greeting's fact, not a
34
+ * transcript's — so the paths the fold collected are filtered here, where the
35
+ * root is known. A session that stated no root contributes none of them rather
36
+ * than all of them: the list is the allowlist an `external` read is checked
37
+ * against, so not knowing where the session works has to admit nothing. */
38
+ export function sessionStatusOf(
39
+ sid: Sid,
40
+ facts: TranscriptFacts,
41
+ where: SessionWhere = {},
42
+ ): SessionStatusSnapshot & {
43
+ sid: Sid;
44
+ } {
45
+ const stopped = stoppedOn(facts);
46
+ const root = where.root === undefined ? undefined : canonical(where.root);
47
+ return {
48
+ sid,
49
+ todos: [...facts.todos],
50
+ workflows: [...facts.workflows],
51
+ background: [...facts.background],
52
+ teammates: [...facts.teammates],
53
+ agent_tree: facts.agent_tree,
54
+ external_files:
55
+ root === undefined
56
+ ? []
57
+ : facts.named_files.filter((file) => !within(canonical(file.path), root)),
58
+ workspace_folders: workspaceFolders(where.cwd),
59
+ ...(stopped === undefined ? {} : { api_error: stopped }),
60
+ };
61
+ }
62
+
63
+ /** Where a session works, as it greeted (§5.1). The same two values the file
64
+ * surfaces are decided against, asked for here so that what `session_status`
65
+ * says and what a read is admitted by come from one answer. */
66
+ export interface SessionWhere {
67
+ readonly root?: string;
68
+ readonly cwd?: string;
69
+ }
70
+
71
+ export interface SessionStatusDeps {
72
+ readonly self: InstanceId;
73
+ /** The sessions this instance can follow a transcript of: the ones that
74
+ * greeted, since a greeting is the only thing that names a transcript path
75
+ * (§5.1). A session it cannot follow has no error to fold. */
76
+ readonly sessions: () => readonly Sid[];
77
+ readonly facts: (sid: Sid) => TranscriptFacts;
78
+ /** Where each session works, for the two fields the transcript does not
79
+ * state. */
80
+ readonly where: (sid: Sid) => SessionWhere;
81
+ /** The tail behind a session's fold, asked for and let go by name. */
82
+ readonly hold: (sid: Sid) => void;
83
+ readonly release: (sid: Sid) => void;
84
+ /** The one way a value reaches subscribers (§6.1). */
85
+ readonly publish: (topic: string, data: unknown) => void;
86
+ }
87
+
88
+ /** The two topics the fold's error state feeds, and the tails they keep
89
+ * running (§6.3).
90
+ *
91
+ * `session_errors` is one list for the instance and `session_status:<sid>` is
92
+ * one session, so what they hold differs: the first wants every session's fold
93
+ * and the second wants one. Both wants are the same mechanism — a subscription
94
+ * arrives, the tails it needs are held, and the last subscription to go
95
+ * releases them — which is why the two topics share one owner rather than
96
+ * having a hold rule each. */
97
+ export class SessionStatus implements UpstreamResource {
98
+ /** The topic names currently subscribed. */
99
+ readonly #wanted = new Set<string>();
100
+ /** The tails held for those subscriptions, one hold per session however many
101
+ * topics want it. */
102
+ readonly #held = new Set<Sid>();
103
+ /** Holding and releasing a tail settles the fold, which reaches back here as
104
+ * a change. The outer pass is left to finish and then runs again, so the
105
+ * convergence happens once rather than in the middle of itself. */
106
+ #converging = false;
107
+ #pending = false;
108
+
109
+ constructor(private readonly deps: SessionStatusDeps) {}
110
+
111
+ // --- UpstreamResource (§6.3)
112
+
113
+ start(topic: string): void {
114
+ this.#wanted.add(topic);
115
+ this.refresh();
116
+ }
117
+
118
+ stop(topic: string): void {
119
+ this.#wanted.delete(topic);
120
+ this.refresh();
121
+ }
122
+
123
+ snapshot(topic: string): readonly TopicValue[] {
124
+ const data = this.value(topic);
125
+ return data === undefined ? [] : [{ instance: this.deps.self, data }];
126
+ }
127
+
128
+ /** The tails follow the sessions and the subscriptions: what the fold now
129
+ * says, and which sessions exist, are the two things that move either.
130
+ *
131
+ * Called by whoever changes one of them, rather than on a timer (M3). */
132
+ refresh(): void {
133
+ if (this.#converging) {
134
+ this.#pending = true;
135
+ return;
136
+ }
137
+ this.#converging = true;
138
+ try {
139
+ do {
140
+ this.#pending = false;
141
+ this.#hold();
142
+ } while (this.#pending);
143
+ } finally {
144
+ this.#converging = false;
145
+ }
146
+ for (const topic of this.#wanted) {
147
+ const data = this.value(topic);
148
+ if (data !== undefined) this.deps.publish(topic, data);
149
+ }
150
+ }
151
+
152
+ /** Whether a session's fold is being kept for these topics, which is how
153
+ * "the subscription drives the resource" is observable from outside. */
154
+ holding(sid: Sid): boolean {
155
+ return this.#held.has(sid);
156
+ }
157
+
158
+ /** Every session this instance holds stopped on an error. A session that
159
+ * recovers drops out rather than appearing with an empty error, so a client
160
+ * that missed a frame converges on the next one. */
161
+ errors(): { errors: SessionErrorEntry[] } {
162
+ const errors: SessionErrorEntry[] = [];
163
+ for (const sid of this.deps.sessions()) {
164
+ const stopped = stoppedOn(this.deps.facts(sid));
165
+ if (stopped !== undefined) errors.push({ sid, instance: this.deps.self, ...stopped });
166
+ }
167
+ return { errors };
168
+ }
169
+
170
+ /** What a topic of this owner currently says, for a snapshot and for a
171
+ * change alike — built here and nowhere else, so the two cannot drift. */
172
+ private value(topic: string): unknown {
173
+ if (topic === "session_errors") return this.errors();
174
+ const sid = topicParam(topic);
175
+ return sid === undefined
176
+ ? undefined
177
+ : sessionStatusOf(sid, this.deps.facts(sid), this.deps.where(sid));
178
+ }
179
+
180
+ /** Bring the held tails in line with what the subscriptions need. */
181
+ #hold(): void {
182
+ const wanted = this.#wantedSessions();
183
+ for (const sid of wanted) {
184
+ if (this.#held.has(sid)) continue;
185
+ this.#held.add(sid);
186
+ this.deps.hold(sid);
187
+ }
188
+ for (const sid of this.#held) {
189
+ if (wanted.has(sid)) continue;
190
+ this.#held.delete(sid);
191
+ this.deps.release(sid);
192
+ }
193
+ }
194
+
195
+ #wantedSessions(): Set<Sid> {
196
+ const wanted = new Set<Sid>();
197
+ // One list for the instance means every session's fold; the list is what
198
+ // the subscriber asked for, and it cannot be built from a subset of it.
199
+ if (this.#wanted.has("session_errors")) {
200
+ for (const sid of this.deps.sessions()) wanted.add(sid);
201
+ }
202
+ for (const topic of this.#wanted) {
203
+ if (topic === "session_errors") continue;
204
+ const sid = topicParam(topic);
205
+ if (sid !== undefined) wanted.add(sid);
206
+ }
207
+ return wanted;
208
+ }
209
+ }
@@ -0,0 +1,72 @@
1
+ import type { Terminal } from "./processes.ts";
2
+
3
+ /** Reads the terminal one process names, or nothing when it names none. The
4
+ * effect is injected for the same reason every other process effect is: the
5
+ * host's answer comes from a child, and a test states it instead. */
6
+ export type TerminalReader = (pid: number) => Promise<Terminal | undefined>;
7
+
8
+ /** The terminal each live session runs in, read once per process.
9
+ *
10
+ * `agents` states a session's terminal and the classification reads it: a live
11
+ * session that neither holds a connection here nor names a terminal is the one
12
+ * nothing can reach (§5.2, `live_unmanaged`). Both want the value on every
13
+ * row, and neither may pay for it on every read — the harness's directory is
14
+ * scanned whenever any question is asked of it, and reading every session's
15
+ * environment there would spawn a child per session per question.
16
+ *
17
+ * So the value is remembered per pid and read exactly once for a pid the scan
18
+ * has not seen before. A pid the scan no longer holds is forgotten, which is
19
+ * both how the map stays the size of the session list and how a resumed
20
+ * session — a new process, possibly in another terminal — is read afresh
21
+ * rather than answered from what the process before it named. */
22
+ export class TerminalCache {
23
+ /** A pid that has been read. Undefined as a value means the process named no
24
+ * terminal, which is remembered so it is not asked again. */
25
+ readonly #known = new Map<number, Terminal | undefined>();
26
+ readonly #reading = new Set<number>();
27
+
28
+ constructor(
29
+ private readonly read: TerminalReader,
30
+ /** A read finished, so a row may say something it did not when the list
31
+ * was last built. Whoever publishes the list is told — including when the
32
+ * process named no terminal, because "asked and told nothing" is what the
33
+ * row settles on and suppressing an unchanged payload is the topic
34
+ * mechanism's to do (M5), not this one's. */
35
+ private readonly filled: () => void,
36
+ ) {}
37
+
38
+ /** What the pid's process named, as far as is known right now. Undefined
39
+ * covers both "not read yet" and "names none": neither is a terminal to type
40
+ * into, and nothing here asks further of the difference. */
41
+ get(pid: number): Terminal | undefined {
42
+ return this.#known.get(pid);
43
+ }
44
+
45
+ /** The pids that exist now. New ones are read, gone ones are forgotten. */
46
+ observe(pids: Iterable<number>): void {
47
+ const present = new Set(pids);
48
+ for (const pid of this.#known.keys()) {
49
+ if (!present.has(pid)) this.#known.delete(pid);
50
+ }
51
+ for (const pid of present) {
52
+ if (this.#known.has(pid) || this.#reading.has(pid)) continue;
53
+ this.#reading.add(pid);
54
+ void this.#fill(pid);
55
+ }
56
+ }
57
+
58
+ async #fill(pid: number): Promise<void> {
59
+ let terminal: Terminal | undefined;
60
+ try {
61
+ terminal = await this.read(pid);
62
+ } catch {
63
+ // A process that ended while it was being read, or a host that does not
64
+ // let this one read it. The session's terminal is unknown, which is a
65
+ // state the classification has.
66
+ } finally {
67
+ this.#reading.delete(pid);
68
+ }
69
+ this.#known.set(pid, terminal);
70
+ this.filled();
71
+ }
72
+ }