@ccmsg/cli 0.12.0 → 0.14.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.
@@ -1,7 +1,8 @@
1
- import { readFileSync, statSync } from "node:fs";
1
+ import { readFile, stat } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import type { ForkOrigin, Sid } from "@ccmsg/protocol";
4
4
  import { readRecord, type TranscriptFiles } from "../transcript/index.ts";
5
+ import { breathe, due } from "../transcript/scan.ts";
5
6
 
6
7
  /** How large a transcript may be and still be swept.
7
8
  *
@@ -36,18 +37,21 @@ const SWEEP_MAX_BYTES = 64 * 1024 * 1024;
36
37
  * Absent covers a session that is no fork, one whose ancestor file is gone,
37
38
  * and that undecidable pair. Nothing left on disk tells them apart, and none
38
39
  * of them has a seam to place. */
39
- export function forkOrigin(sid: Sid, files: TranscriptFiles): ForkOrigin | undefined {
40
+ export async function forkOrigin(
41
+ sid: Sid,
42
+ files: TranscriptFiles,
43
+ ): Promise<ForkOrigin | undefined> {
40
44
  const file = files.session(sid);
41
- const ours = recordIds(file);
45
+ const ours = await recordIds(file);
42
46
  const head = ours?.[0];
43
47
  if (ours === undefined || head === undefined) return undefined;
44
48
  const mine = new Set(ours);
45
49
 
46
50
  const dir = dirname(file);
47
51
  let best: { sid: Sid; copied: number } | undefined;
48
- for (const candidate of files.all()) {
52
+ for (const candidate of await files.all()) {
49
53
  if (candidate.file === file || dirname(candidate.file) !== dir) continue;
50
- const theirs = recordIds(candidate.file);
54
+ const theirs = await recordIds(candidate.file);
51
55
  if (theirs === undefined || theirs[0] !== head) continue;
52
56
  const copied = run(ours, new Set(theirs));
53
57
  const back = run(theirs, mine);
@@ -55,7 +59,7 @@ export function forkOrigin(sid: Sid, files: TranscriptFiles): ForkOrigin | undef
55
59
  // than their copy; an equal one says the records cannot tell, and creation
56
60
  // order is what is left.
57
61
  if (copied === 0 || back > copied) continue;
58
- if (back === copied && !older(candidate.file, file)) continue;
62
+ if (back === copied && !(await older(candidate.file, file))) continue;
59
63
  // Sibling forks of one ancestor share a prefix too, so several files can
60
64
  // match; the longest run is the nearest ancestor and the true seam.
61
65
  if (best === undefined || copied > best.copied) best = { sid: candidate.sid, copied };
@@ -81,28 +85,31 @@ function run(ids: readonly string[], other: ReadonlySet<string>): number {
81
85
  * A creation time of zero is a filesystem that does not record one, which is
82
86
  * not an ancient file: two of those are simply not ordered, and the pair they
83
87
  * belong to gets no answer. */
84
- function older(candidate: string, file: string): boolean {
85
- const theirs = bornAt(candidate);
86
- const ours = bornAt(file);
88
+ async function older(candidate: string, file: string): Promise<boolean> {
89
+ const theirs = await bornAt(candidate);
90
+ const ours = await bornAt(file);
87
91
  if (theirs === undefined || ours === undefined) return false;
88
92
  return theirs < ours;
89
93
  }
90
94
 
91
95
  /** Every record id in a file, in order. Undefined for a file too large to
92
96
  * sweep or one that could not be read. */
93
- function recordIds(file: string): string[] | undefined {
97
+ async function recordIds(file: string): Promise<string[] | undefined> {
94
98
  let text: string;
95
99
  try {
96
- if (statSync(file).size > SWEEP_MAX_BYTES) return undefined;
97
- text = readFileSync(file, "utf8");
100
+ if ((await stat(file)).size > SWEEP_MAX_BYTES) return undefined;
101
+ text = await readFile(file, "utf8");
98
102
  } catch {
99
103
  return undefined;
100
104
  }
101
105
  const ids: string[] = [];
106
+ let read = 0;
102
107
  for (const line of text.split("\n")) {
103
108
  // Most of a transcript's bytes sit in a handful of very large records, and
104
109
  // parsing one to learn it carries no id is the cost this avoids.
105
110
  if (line === "" || !line.includes('"uuid"')) continue;
111
+ read += 1;
112
+ if (due(read)) await breathe();
106
113
  const uuid = readRecord(line)?.uuid;
107
114
  if (uuid !== undefined) ids.push(uuid);
108
115
  }
@@ -117,9 +124,9 @@ function recordIds(file: string): string[] | undefined {
117
124
  * millisecond the contract states instants in either — two transcripts written
118
125
  * moments apart share one, and the whole use of this value is telling which
119
126
  * came first. */
120
- function bornAt(file: string): number | undefined {
127
+ async function bornAt(file: string): Promise<number | undefined> {
121
128
  try {
122
- const born = statSync(file).birthtimeMs;
129
+ const born = (await stat(file)).birthtimeMs;
123
130
  return born > 0 ? born : undefined;
124
131
  } catch {
125
132
  return undefined;
@@ -122,9 +122,11 @@ export function sessionHandlers(deps: SessionOpsDeps) {
122
122
  * cycle, happen where the config is read. */
123
123
  "dump.presets.read": (): DumpPresetsReadResult => ({ presets: [...deps.presets] }),
124
124
 
125
- "session.fork.origin.read": (input: HandlerInput): SessionForkOriginReadResult => {
125
+ "session.fork.origin.read": async (
126
+ input: HandlerInput,
127
+ ): Promise<SessionForkOriginReadResult> => {
126
128
  const args = input.args as unknown as SessionForkOriginReadArgs;
127
- const origin = forkOrigin(args.sid, deps.files);
129
+ const origin = await forkOrigin(args.sid, deps.files);
128
130
  return origin === undefined ? {} : { origin };
129
131
  },
130
132
 
@@ -135,7 +137,7 @@ export function sessionHandlers(deps: SessionOpsDeps) {
135
137
  return { removed: deps.forget(args.sid) };
136
138
  },
137
139
 
138
- "transcript.read": (input: HandlerInput): TranscriptReadResult => {
140
+ "transcript.read": async (input: HandlerInput): Promise<TranscriptReadResult> => {
139
141
  const args = input.args as unknown as TranscriptReadArgs;
140
142
  if (!sees(args.sid, viewer(input))) {
141
143
  // The role sets the visible range, not the permission (DESIGN §2.2): outside
@@ -144,8 +146,8 @@ export function sessionHandlers(deps: SessionOpsDeps) {
144
146
  // the caller was not entitled to ask.
145
147
  throw new OpError("not_found", `no transcript is known for ${args.sid}`);
146
148
  }
147
- const file = deps.files.locate(args.sid, args);
148
- return readSlice(args.sid, file, args.before, args.max_bytes);
149
+ const file = await deps.files.locate(args.sid, args);
150
+ return await readSlice(args.sid, file, args.before, args.max_bytes);
149
151
  },
150
152
 
151
153
  /** The same transcript, as the items it was read into.
@@ -153,12 +155,12 @@ export function sessionHandlers(deps: SessionOpsDeps) {
153
155
  * The role narrows it the way it narrows the raw read: what a role may see
154
156
  * is one rule whatever is being read, and a caller that cannot see a
155
157
  * session cannot see it in either vocabulary. */
156
- "transcript.items.read": (input: HandlerInput): TranscriptItemsReadResult => {
158
+ "transcript.items.read": async (input: HandlerInput): Promise<TranscriptItemsReadResult> => {
157
159
  const args = input.args as unknown as TranscriptItemsReadArgs;
158
160
  if (!sees(args.sid, viewer(input))) {
159
161
  throw new OpError("not_found", `no transcript is known for ${args.sid}`);
160
162
  }
161
- return itemsRead(args, { files: deps.files, presets: deps.presets });
163
+ return await itemsRead(args, { files: deps.files, presets: deps.presets });
162
164
  },
163
165
  };
164
166
  }
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFile } from "node:fs/promises";
2
2
  import type {
3
3
  DumpPreset,
4
4
  TranscriptItemsReadArgs,
@@ -8,15 +8,14 @@ import { OpError } from "../dispatch/index.ts";
8
8
  import type { TranscriptFiles } from "../transcript/index.ts";
9
9
  import {
10
10
  bounded,
11
- classify,
12
11
  type Item,
13
12
  ledger,
14
- located,
15
13
  select,
16
14
  selection,
17
15
  within,
18
16
  } from "../transcript/items/index.ts";
19
17
  import { READ_LIMIT } from "../transcript/read.ts";
18
+ import { classified } from "../transcript/scan.ts";
20
19
 
21
20
  /** How many items one read may carry.
22
21
  *
@@ -52,23 +51,26 @@ export interface ItemsReadDeps {
52
51
  * is. That is what makes a link answerable: a result inside the range whose
53
52
  * call fell before it still names the call, and the caller can ask for the
54
53
  * call by the id it was given. */
55
- export function itemsRead(
54
+ export async function itemsRead(
56
55
  args: TranscriptItemsReadArgs,
57
56
  deps: ItemsReadDeps,
58
- ): TranscriptItemsReadResult {
57
+ ): Promise<TranscriptItemsReadResult> {
59
58
  bounded(args);
60
- const file = deps.files.locate(
59
+ const file = await deps.files.locate(
61
60
  args.sid,
62
61
  args.agent_id === undefined ? {} : { agent_id: args.agent_id },
63
62
  );
64
63
  let text: string;
65
64
  try {
66
- text = readFileSync(file, "utf8");
65
+ text = await readFile(file, "utf8");
67
66
  } catch {
68
67
  throw new OpError("not_found", `the transcript of ${args.sid} could not be read`);
69
68
  }
70
69
  const keep = selection(args.types === undefined ? {} : { types: args.types }, deps.presets);
71
- const { items } = select(within(classify(located(text), deps.files.subjectOf(file)), args), keep);
70
+ const { items } = select(
71
+ within(await classified(text, await deps.files.subjectOf(file)), args),
72
+ keep,
73
+ );
72
74
  const page = paged(items, args.limit, backwards(args));
73
75
  return {
74
76
  items: page.items,
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, rename, writeFile } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  import {
4
5
  LAST_LIVE_RETENTION_MS,
@@ -53,6 +54,9 @@ const VERSION = 1;
53
54
  export class LastLiveStore {
54
55
  #entries = new Map<Sid, StoredEntry>();
55
56
 
57
+ /** The writes already asked for, as one chain. */
58
+ #written: Promise<void> = Promise.resolve();
59
+
56
60
  /** `id` is this instance's own: every entry this store holds is by
57
61
  * definition an observation *this* instance made, so `instance` is forced
58
62
  * to it on both ends (load and record) rather than trusted from whatever
@@ -114,6 +118,12 @@ export class LastLiveStore {
114
118
  return true;
115
119
  }
116
120
 
121
+ /** Settle once every write asked for so far has landed. What a stop waits on,
122
+ * and what a reader of the file has to wait for to see the last change. */
123
+ async flush(): Promise<void> {
124
+ await this.#written;
125
+ }
126
+
117
127
  #prune(now: Timestamp): boolean {
118
128
  let dropped = false;
119
129
  for (const [sid, entry] of this.#entries) {
@@ -125,13 +135,26 @@ export class LastLiveStore {
125
135
  }
126
136
 
127
137
  /** Written whole through a temporary file, so a daemon killed mid-write
128
- * leaves the previous list rather than half of this one. */
138
+ * leaves the previous list rather than half of this one.
139
+ *
140
+ * A session appearing or going is an ordinary event of a running instance, so
141
+ * the write does not hold it still (DR-0015). The body is taken here, before
142
+ * anything is awaited, and each write is chained onto the one before it: what
143
+ * lands last is what the list said last, and two of them cannot be sharing
144
+ * one temporary file. */
129
145
  #save(): void {
130
146
  const document: Document = { version: VERSION, sessions: [...this.#entries.values()] };
131
147
  const temporary = `${this.file}.${process.pid}.tmp`;
132
- mkdirSync(dirname(this.file), { recursive: true });
133
- writeFileSync(temporary, `${JSON.stringify(document)}\n`);
134
- renameSync(temporary, this.file);
148
+ this.#written = this.#written.then(async () => {
149
+ try {
150
+ await mkdir(dirname(this.file), { recursive: true });
151
+ await writeFile(temporary, `${JSON.stringify(document)}\n`);
152
+ await rename(temporary, this.file);
153
+ } catch {
154
+ // A list that could not be written costs the Paused and Disappeared
155
+ // rows of the next run, and nothing of this one.
156
+ }
157
+ });
135
158
  }
136
159
  }
137
160
 
@@ -1,4 +1,5 @@
1
- import { realpathSync, statSync } from "node:fs";
1
+ import type { Stats } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
2
3
  import { basename, dirname, isAbsolute, join } from "node:path";
3
4
  import {
4
5
  type AgentInfo,
@@ -22,7 +23,7 @@ import {
22
23
  import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
23
24
  import { within } from "../files/index.ts";
24
25
  import { HARNESS, type Harness } from "../harness/index.ts";
25
- import { clusterView } from "../mesh/instances.ts";
26
+ import { meshView } from "../mesh/instances.ts";
26
27
  import type { TranscriptFacts } from "../transcript/index.ts";
27
28
  import { Elements, type TopicValue, type UpstreamResource } from "../topics/index.ts";
28
29
  import { classify, type SessionInputs } from "./classify.ts";
@@ -243,6 +244,13 @@ export class Sessions implements UpstreamResource {
243
244
  this.#reclaim(this.#live);
244
245
  }
245
246
 
247
+ /** Settle the list of lost sessions on disk. The writes happen as sessions
248
+ * come and go (DR-0015); this is for whoever has to see the file as it stands
249
+ * rather than as it was a moment ago — a stop, or a reader of the file. */
250
+ async flush(): Promise<void> {
251
+ await this.#lastLive.flush();
252
+ }
253
+
246
254
  /** Drop the `last_live` entry of every session that is live, which is what
247
255
  * keeps one session off both lists.
248
256
  *
@@ -258,10 +266,10 @@ export class Sessions implements UpstreamResource {
258
266
  * can speak about, and where everything this instance knows about where that
259
267
  * session lives comes from. The greeting names its sid because the op it
260
268
  * arrived under is the one whose schema asks for one. */
261
- helloSession = (input: HandlerInput): HelloResult => {
269
+ helloSession = async (input: HandlerInput): Promise<HelloResult> => {
262
270
  const args = input.args as unknown as HelloSessionArgs;
263
271
  this.#greetable(input, args.protocol_version);
264
- this.register(args.sid, args);
272
+ await this.register(args.sid, args);
265
273
  input.conn.onClose(() => this.release(args.sid));
266
274
  return this.#greeted(input);
267
275
  };
@@ -315,8 +323,8 @@ export class Sessions implements UpstreamResource {
315
323
  instance: this.deps.self,
316
324
  ...(this.deps.endpoint === undefined ? {} : { endpoint: this.deps.endpoint }),
317
325
  // The same view the `instances` topic carries, worked out in one place
318
- // so a greeting and a subscription cannot state two different clusters.
319
- instances: clusterView(this.deps.self, this.deps.endpoint, this.deps.mesh),
326
+ // so a greeting and a subscription cannot state two different meshs.
327
+ instances: meshView(this.deps.self, this.deps.endpoint, this.deps.mesh),
320
328
  capabilities: [...this.deps.capabilities],
321
329
  version: this.deps.version,
322
330
  started_at: this.deps.startedAt,
@@ -609,15 +617,15 @@ export class Sessions implements UpstreamResource {
609
617
  * silence for a retraction would let each of them erase what the last one
610
618
  * knew, and the session would be described by whichever process spoke most
611
619
  * recently rather than by everything it has said. */
612
- private register(sid: Sid, args: HelloSessionArgs): void {
613
- const now = Date.now();
620
+ private async register(sid: Sid, args: HelloSessionArgs): Promise<void> {
621
+ const stated = await metaOf(this.deps, args, (refused) => {
622
+ this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
623
+ });
624
+ // Read after the path has been settled, so that what this writes is built
625
+ // on the session as it stands now rather than as it stood before.
614
626
  const held = this.#connected.get(sid);
615
- const meta = {
616
- ...this.#stated.get(sid),
617
- ...metaOf(this.deps, args, (refused) => {
618
- this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
619
- }),
620
- };
627
+ const now = Date.now();
628
+ const meta = { ...this.#stated.get(sid), ...stated };
621
629
  this.#connected.set(sid, {
622
630
  sid,
623
631
  connected_at: held?.connected_at ?? now,
@@ -841,17 +849,17 @@ export class Sessions implements UpstreamResource {
841
849
  * simply does not act on a description it cannot stand behind. Why a path was
842
850
  * not taken is told to `refused`, which is the operator's answer to a field
843
851
  * that is simply absent from what `peers` says. */
844
- function metaOf(
852
+ async function metaOf(
845
853
  deps: Pick<SessionsDeps, "configHome" | "harness">,
846
854
  args: HelloSessionArgs,
847
855
  refused: (reason: string) => void,
848
- ): SessionMeta {
856
+ ): Promise<SessionMeta> {
849
857
  const meta: Record<string, string> = {};
850
858
  for (const field of META_FIELDS) {
851
859
  const value = args[field];
852
860
  if (value === undefined) continue;
853
861
  if (field === "transcript_path") {
854
- const taken = ownTranscript(value, deps);
862
+ const taken = await ownTranscript(value, deps);
855
863
  if (typeof taken === "string") meta[field] = taken;
856
864
  else refused(taken.refused);
857
865
  continue;
@@ -884,27 +892,36 @@ function metaOf(
884
892
  * comparison is between two paths resolved by one rule. The config home is not
885
893
  * treated that way — an instance answers for a home it is running out of, and
886
894
  * one that is not there names no tree to be inside of. */
887
- function ownTranscript(
895
+ async function ownTranscript(
888
896
  named: string,
889
897
  deps: Pick<SessionsDeps, "configHome" | "harness">,
890
- ): string | Refused {
898
+ ): Promise<string | Refused> {
891
899
  if (!isAbsolute(named)) return { refused: "not an absolute path" };
892
900
  let tree: string | undefined;
893
901
  try {
894
- const home = realpathSync(deps.configHome);
895
- tree = resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
902
+ const home = await realpath(deps.configHome);
903
+ tree = await resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
896
904
  } catch {
897
905
  return { refused: "the config home is not there" };
898
906
  }
899
- const settled = resolveAsFarAsItGoes(named);
907
+ const settled = await resolveAsFarAsItGoes(named);
900
908
  if (tree === undefined || settled === undefined || !within(settled, tree)) {
901
909
  return { refused: "outside this config home's transcript tree" };
902
910
  }
903
- const stat = statSync(settled, { throwIfNoEntry: false });
904
- if (stat !== undefined && !stat.isFile()) return { refused: "not a file" };
911
+ const known = await stated(settled);
912
+ if (known !== undefined && !known.isFile()) return { refused: "not a file" };
905
913
  return settled;
906
914
  }
907
915
 
916
+ /** What is at a path, or nothing where there is nothing at it. */
917
+ async function stated(path: string): Promise<Stats | undefined> {
918
+ try {
919
+ return await stat(path);
920
+ } catch {
921
+ return undefined;
922
+ }
923
+ }
924
+
908
925
  /** Why a stated path was not taken, in the words the log states it in. */
909
926
  interface Refused {
910
927
  readonly refused: string;
@@ -917,12 +934,12 @@ interface Refused {
917
934
  * kept as it was spelled. The result is compared against the tree as a whole,
918
935
  * which is what makes a `..` among the unwritten segments land wherever it
919
936
  * actually points rather than pass for being spelled inside. */
920
- function resolveAsFarAsItGoes(path: string): string | undefined {
937
+ async function resolveAsFarAsItGoes(path: string): Promise<string | undefined> {
921
938
  const unwritten: string[] = [];
922
939
  let at = path;
923
940
  for (;;) {
924
941
  try {
925
- return join(realpathSync(at), ...unwritten);
942
+ return join(await realpath(at), ...unwritten);
926
943
  } catch {
927
944
  const parent = dirname(at);
928
945
  // The root itself always resolves, so this is a path that named
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { parse, sep } from "node:path";
3
3
  import type {
4
4
  InstanceId,
@@ -9,6 +9,7 @@ import type {
9
9
  } from "@ccmsg/protocol";
10
10
  import { OpError } from "../dispatch/index.ts";
11
11
  import { readRecord, type TranscriptFile, type TranscriptFiles } from "../transcript/index.ts";
12
+ import { breathe, due } from "../transcript/scan.ts";
12
13
 
13
14
  /** What one search may read, and what it may answer with.
14
15
  *
@@ -57,7 +58,10 @@ export interface SearchDeps {
57
58
  * directory listing and a `stat` already say — the session id, the working
58
59
  * directory as the project directory spells it, when the file was last touched
59
60
  * — and only what survives that is read. */
60
- export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearchResult {
61
+ export async function search(
62
+ args: SessionSearchArgs,
63
+ deps: SearchDeps,
64
+ ): Promise<SessionSearchResult> {
61
65
  if ((args.config_dirs ?? [deps.configHome]).every((dir) => dir !== deps.configHome)) {
62
66
  // Every config home the caller named is one this instance does not know,
63
67
  // which the contract says to ignore — leaving nothing to search.
@@ -72,7 +76,7 @@ export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearch
72
76
  const hits: SessionSearchHit[] = [];
73
77
  let budget = SCAN_BUDGET_BYTES;
74
78
  let truncated = false;
75
- for (const candidate of deps.files.all()) {
79
+ for (const candidate of await deps.files.all()) {
76
80
  if (sid !== undefined && !candidate.sid.toLowerCase().includes(sid)) continue;
77
81
  if (candidate.updated_at < since) continue;
78
82
  if (!looksLike(candidate.project, cwdWords)) continue;
@@ -84,7 +88,7 @@ export function search(args: SessionSearchArgs, deps: SearchDeps): SessionSearch
84
88
  break;
85
89
  }
86
90
  budget -= candidate.size;
87
- const hit = read(candidate, clauses, wanted, deps);
91
+ const hit = await read(candidate, clauses, wanted, deps);
88
92
  // The working directory the project directory only approximates: a hit is
89
93
  // kept when the transcript's own `cwd` holds every word asked for.
90
94
  if (hit !== undefined && holds(hit.cwd, cwdWords)) hits.push(hit);
@@ -169,15 +173,15 @@ function compile(args: SessionSearchArgs): { clauses: Clause[]; budgets: Budget[
169
173
  * The pass is one: the records that carry the query also carry the working
170
174
  * directory, the title and what the session last ran as, so a hit is built
171
175
  * from the reading that decided it rather than from a second one. */
172
- function read(
176
+ async function read(
173
177
  candidate: TranscriptFile,
174
178
  clauses: readonly Clause[],
175
179
  wanted: { user: boolean; agent: boolean },
176
180
  deps: SearchDeps,
177
- ): SessionSearchHit | undefined {
181
+ ): Promise<SessionSearchHit | undefined> {
178
182
  let text: string;
179
183
  try {
180
- text = readFileSync(candidate.file, "utf8");
184
+ text = await readFile(candidate.file, "utf8");
181
185
  } catch {
182
186
  // Gone since it was listed, which is a session that ended mid-search.
183
187
  return undefined;
@@ -188,8 +192,13 @@ function read(
188
192
  let model: string | undefined;
189
193
  let effort: string | undefined;
190
194
  let createdAt: number | undefined;
195
+ let read = 0;
191
196
  for (const line of text.split("\n")) {
192
197
  if (line === "") continue;
198
+ read += 1;
199
+ // The file arrived in one `await` and reading it is CPU from here on, so
200
+ // the pass hands the loop back as it goes rather than at the file's end.
201
+ if (due(read)) await breathe();
193
202
  const record = readRecord(line);
194
203
  if (record === undefined) continue;
195
204
  cwd ??= record.cwd;
@@ -5,7 +5,7 @@ import type {
5
5
  SessionStatusSnapshot,
6
6
  Sid,
7
7
  } from "@ccmsg/protocol";
8
- import { canonical, within } from "../files/containment.ts";
8
+ import { canonicalSync, within } from "../files/containment.ts";
9
9
  import type { TopicValue, UpstreamResource } from "../topics/index.ts";
10
10
  import { topicParam } from "../topics/index.ts";
11
11
  import type { TranscriptFacts } from "../transcript/index.ts";
@@ -43,7 +43,7 @@ export function sessionStatusOf(
43
43
  sid: Sid;
44
44
  } {
45
45
  const stopped = stoppedOn(facts);
46
- const root = where.root === undefined ? undefined : canonical(where.root);
46
+ const root = where.root === undefined ? undefined : canonicalSync(where.root);
47
47
  return {
48
48
  sid,
49
49
  todos: [...facts.todos],
@@ -54,7 +54,7 @@ export function sessionStatusOf(
54
54
  external_files:
55
55
  root === undefined
56
56
  ? []
57
- : facts.named_files.filter((file) => !within(canonical(file.path), root)),
57
+ : facts.named_files.filter((file) => !within(canonicalSync(file.path), root)),
58
58
  workspace_folders: workspaceFolders(where.cwd),
59
59
  ...(stopped === undefined ? {} : { api_error: stopped }),
60
60
  };
@@ -6,9 +6,9 @@ import type { Requester } from "../dispatch/index.ts";
6
6
  * list cannot see a change arrive sooner than the display draws it, so frames
7
7
  * closer together than a few display frames are spent on nothing, while a wait
8
8
  * long enough to be read as lag starts around a quarter of a second. Towards
9
- * the cluster: a relayed frame waits once per hop, so the delay a subscriber
9
+ * the mesh: a relayed frame waits once per hop, so the delay a subscriber
10
10
  * sees is this value times the hops between it and the instance that produced
11
- * the value — at 100 ms a two-hop cluster still answers inside the window a
11
+ * the value — at 100 ms a two-hop mesh still answers inside the window a
12
12
  * person reads as immediate, which a longer period would leave.
13
13
  *
14
14
  * It is not a poll. Nothing is looked at when the period elapses: the timer is
Binary file