@ccmsg/cli 0.2.12 → 0.3.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,6 +1,7 @@
1
1
  import { type FSWatcher, readdirSync, readFileSync, watch } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { AgentInfo, InstanceId, Sid } from "@ccmsg/protocol";
4
+ import type { Harness } from "../harness/index.ts";
4
5
 
5
6
  /** The status the harness writes while a dialog is open and it is waiting for
6
7
  * an answer, alongside a `waitingFor` naming what it waits on.
@@ -23,6 +24,96 @@ export const CONFIRM_POLL_MS = 5_000;
23
24
 
24
25
  const STATE_FILE = /^\d+\.json$/;
25
26
 
27
+ /** Which sessions one harness says exist right now, read from its config home.
28
+ *
29
+ * Two answers rather than one, because the harnesses do not say the same
30
+ * amount. Claude Code writes a file per session carrying its pid, its working
31
+ * directory and what it is doing, which is the shape the contract's `AgentInfo`
32
+ * states and what the `agents` topic is; Codex says only that a thread has a
33
+ * live writer, which answers "is it there" and nothing else. So `rows` is what
34
+ * can be reported and `present` is what the classification reads, and a harness
35
+ * that reports nothing still has its sessions classified (§5.1). */
36
+ export interface OwnSessions {
37
+ readonly running: boolean;
38
+ /** Begins watching. Called when the first subscriber arrives and not before
39
+ * (§6.3 / §8.3: no upstream is read until somebody is listening). */
40
+ start(): void;
41
+ stop(): void;
42
+ /** The harness's own rows, as `agents` answers with them. Empty for a
43
+ * harness whose own view is not the one that contract states. */
44
+ rows(): ReadonlyMap<Sid, AgentInfo>;
45
+ /** The sessions the harness says are there at this instant. */
46
+ present(): ReadonlySet<Sid>;
47
+ }
48
+
49
+ /** The one this config home runs (§3.8). */
50
+ export function ownSessions(
51
+ harness: Harness,
52
+ configHome: string,
53
+ instance: InstanceId,
54
+ onChange: () => void,
55
+ pollMs?: number,
56
+ ): OwnSessions {
57
+ return harness === "codex"
58
+ ? new CodexThreads(join(configHome, CODEX_LOCKS), onChange, pollMs)
59
+ : new HarnessSessions(join(configHome, "sessions"), instance, onChange, pollMs);
60
+ }
61
+
62
+ /** Where the Codex thread store takes a lock while a thread has a live writer,
63
+ * and what one of those locks is called.
64
+ *
65
+ * Measured against codex-cli 0.153.4: the file appears under this directory
66
+ * while a thread is being written and is gone once the process that had it
67
+ * ends normally. The `.coordination.lock` beside them belongs to the store's
68
+ * own cleanup and names no thread, which the shape below excludes.
69
+ *
70
+ * A process killed outright leaves its lock behind (measured), so a thread
71
+ * whose session died without a word reads as present until Codex itself sweeps
72
+ * the stale lock. That is the same direction as the state file Claude Code
73
+ * leaves behind — except that a lock names no pid, so there is nothing here to
74
+ * ask whether anybody still holds it. */
75
+ const CODEX_LOCKS = "thread-writer-locks";
76
+ const THREAD_LOCK = /^([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.lock$/;
77
+
78
+ /** The Codex threads with a live writer, read from one config home.
79
+ *
80
+ * It reports no rows: `AgentInfo` is Claude Code's own list (contract), and a
81
+ * lock file carries none of what that shape states. What a Codex session is —
82
+ * where it works, what it is called — is what it said when it greeted, and
83
+ * that is held by the registry for every harness alike. */
84
+ class CodexThreads implements OwnSessions {
85
+ readonly #watch: DirectoryWatch;
86
+
87
+ constructor(dir: string, onChange: () => void, pollMs?: number) {
88
+ this.#watch = new DirectoryWatch(dir, onChange, pollMs);
89
+ }
90
+
91
+ get running(): boolean {
92
+ return this.#watch.running;
93
+ }
94
+
95
+ start(): void {
96
+ this.#watch.start();
97
+ }
98
+
99
+ stop(): void {
100
+ this.#watch.stop();
101
+ }
102
+
103
+ rows(): ReadonlyMap<Sid, AgentInfo> {
104
+ return new Map();
105
+ }
106
+
107
+ present(): ReadonlySet<Sid> {
108
+ const live = new Set<Sid>();
109
+ for (const name of this.#watch.names()) {
110
+ const sid = THREAD_LOCK.exec(name)?.[1];
111
+ if (sid !== undefined) live.add(sid);
112
+ }
113
+ return live;
114
+ }
115
+ }
116
+
26
117
  /** The sessions the harness itself reports, read from one config home.
27
118
  *
28
119
  * The directory is the whole input: it says which sessions exist and which is
@@ -34,42 +125,38 @@ const STATE_FILE = /^\d+\.json$/;
34
125
  * a question, and is done whenever one is asked. Watching it says the answer
35
126
  * may have changed, which is only worth knowing while somebody is subscribed —
36
127
  * so the watch is what the subscription drives, and no answer waits on it. */
37
- export class HarnessSessions {
38
- #watcher: FSWatcher | undefined;
39
- #timer: ReturnType<typeof setInterval> | undefined;
128
+ export class HarnessSessions implements OwnSessions {
129
+ readonly #watch: DirectoryWatch;
40
130
 
41
131
  constructor(
42
132
  private readonly dir: string,
43
133
  private readonly instance: InstanceId,
44
- private readonly onChange: () => void,
45
- private readonly pollMs: number = CONFIRM_POLL_MS,
46
- ) {}
134
+ onChange: () => void,
135
+ pollMs?: number,
136
+ ) {
137
+ this.#watch = new DirectoryWatch(dir, onChange, pollMs);
138
+ }
47
139
 
48
140
  get running(): boolean {
49
- return this.#watcher !== undefined || this.#timer !== undefined;
141
+ return this.#watch.running;
50
142
  }
51
143
 
52
- /** Begins watching. Called when the first subscriber arrives and not before
53
- * (§6.3 / §8.3: no upstream is read until somebody is listening). */
54
144
  start(): void {
55
- if (this.running) return;
56
- try {
57
- this.#watcher = watch(this.dir, this.onChange);
58
- } catch {
59
- // The directory does not exist yet — a config home whose harness has not
60
- // run. The poll below both covers the wait and picks it up when it
61
- // appears, so this is not a failure to start.
62
- this.#watcher = undefined;
63
- }
64
- this.#timer = setInterval(this.onChange, this.pollMs);
65
- this.onChange();
145
+ this.#watch.start();
66
146
  }
67
147
 
68
148
  stop(): void {
69
- this.#watcher?.close();
70
- this.#watcher = undefined;
71
- if (this.#timer !== undefined) clearInterval(this.#timer);
72
- this.#timer = undefined;
149
+ this.#watch.stop();
150
+ }
151
+
152
+ rows(): ReadonlyMap<Sid, AgentInfo> {
153
+ return this.scan();
154
+ }
155
+
156
+ /** Every session with a state file, which for this harness is the same
157
+ * reading its rows came from. */
158
+ present(): ReadonlySet<Sid> {
159
+ return new Set(this.scan().keys());
73
160
  }
74
161
 
75
162
  /** The directory as it is at this instant.
@@ -87,13 +174,7 @@ export class HarnessSessions {
87
174
  * uid's own config home (M6) — a syscall or two per session, not a wait. */
88
175
  scan(): ReadonlyMap<Sid, AgentInfo> {
89
176
  const rows = new Map<Sid, AgentInfo>();
90
- let names: string[];
91
- try {
92
- names = readdirSync(this.dir);
93
- } catch {
94
- return rows;
95
- }
96
- for (const name of names) {
177
+ for (const name of this.#watch.names()) {
97
178
  if (!STATE_FILE.test(name)) continue;
98
179
  let document: unknown;
99
180
  try {
@@ -108,6 +189,60 @@ export class HarnessSessions {
108
189
  }
109
190
  }
110
191
 
192
+ /** One directory that says what the harness's sessions are, watched while
193
+ * somebody is subscribed and read whenever an answer is wanted.
194
+ *
195
+ * The two things §6.3 separates live here. Reading the directory answers a
196
+ * question, and is done whenever one is asked. Watching it says the answer may
197
+ * have changed, which is only worth knowing while somebody is listening — so
198
+ * the watch is what the subscription drives, and no answer waits on it. */
199
+ class DirectoryWatch {
200
+ #watcher: FSWatcher | undefined;
201
+ #timer: ReturnType<typeof setInterval> | undefined;
202
+
203
+ constructor(
204
+ private readonly dir: string,
205
+ private readonly onChange: () => void,
206
+ private readonly pollMs: number = CONFIRM_POLL_MS,
207
+ ) {}
208
+
209
+ get running(): boolean {
210
+ return this.#watcher !== undefined || this.#timer !== undefined;
211
+ }
212
+
213
+ start(): void {
214
+ if (this.running) return;
215
+ try {
216
+ this.#watcher = watch(this.dir, this.onChange);
217
+ } catch {
218
+ // The directory does not exist yet — a config home whose harness has not
219
+ // run. The poll below both covers the wait and picks it up when it
220
+ // appears, so this is not a failure to start.
221
+ this.#watcher = undefined;
222
+ }
223
+ this.#timer = setInterval(this.onChange, this.pollMs);
224
+ this.onChange();
225
+ }
226
+
227
+ stop(): void {
228
+ this.#watcher?.close();
229
+ this.#watcher = undefined;
230
+ if (this.#timer !== undefined) clearInterval(this.#timer);
231
+ this.#timer = undefined;
232
+ }
233
+
234
+ /** What is in the directory now. Read in place because it is a handful of
235
+ * small entries of this uid's own config home (M6) — a syscall or two, not a
236
+ * wait. */
237
+ names(): string[] {
238
+ try {
239
+ return readdirSync(this.dir);
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
244
+ }
245
+
111
246
  /** Whether the harness says this session is waiting on a dialog. */
112
247
  export function isWaiting(row: AgentInfo): boolean {
113
248
  return row.status === WAITING;
@@ -19,16 +19,32 @@ import {
19
19
  } from "@ccmsg/protocol";
20
20
  import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
21
21
  import { within } from "../files/index.ts";
22
+ import { HARNESS, type Harness } from "../harness/index.ts";
22
23
  import type { TranscriptFacts } from "../transcript/index.ts";
23
24
  import type { TopicValue, UpstreamResource } from "../topics/index.ts";
24
25
  import { classify, type SessionInputs } from "./classify.ts";
25
- import { HarnessSessions, isWaiting } from "./harness.ts";
26
+ import { isWaiting, type OwnSessions, ownSessions } from "./harness.ts";
26
27
  import { LastLiveStore, type StoredEntry } from "./last-live.ts";
27
28
  import { stoppedOn } from "./status.ts";
28
29
  import { TerminalCache, type TerminalReader } from "./terminals.ts";
29
30
 
31
+ /** What the harness says at one instant: the rows it reports, and which
32
+ * sessions it says are there (§3.8).
33
+ *
34
+ * Two readings of one moment, passed together so a caller answering several
35
+ * questions about that moment reads once. They are the same set for a harness
36
+ * that reports a row per session and differ for one that reports none, which
37
+ * is why the classification reads `present` and never the rows' keys. */
38
+ interface Own {
39
+ readonly rows: ReadonlyMap<Sid, AgentInfo>;
40
+ readonly present: ReadonlySet<Sid>;
41
+ }
42
+
30
43
  /** What the sessions domain needs from the instance around it. */
31
44
  export interface SessionsDeps {
45
+ /** Which harness this config home runs (§3.8). It decides what says a
46
+ * session is there and, through that, what `agents` can report. */
47
+ readonly harness: Harness;
32
48
  readonly self: InstanceId;
33
49
  /** Where this instance says it is reached, which `hello` states beside the
34
50
  * id: the caller got here by some URL of its own — a proxy's, an alias — and
@@ -155,7 +171,7 @@ interface Connected {
155
171
  * built, and never stored (M4). */
156
172
  export class Sessions implements UpstreamResource {
157
173
  readonly #connected = new Map<Sid, Connected>();
158
- readonly #harness: HarnessSessions;
174
+ readonly #harness: OwnSessions;
159
175
  readonly #terminals: TerminalCache | undefined;
160
176
  readonly #lastLive: LastLiveStore;
161
177
  /** Sessions seen live since the last recompute, kept so the moment one stops
@@ -191,8 +207,9 @@ export class Sessions implements UpstreamResource {
191
207
  readonly #stopping = new Map<Sid, Timestamp>();
192
208
 
193
209
  constructor(private readonly deps: SessionsDeps) {
194
- this.#harness = new HarnessSessions(
195
- join(deps.configHome, "sessions"),
210
+ this.#harness = ownSessions(
211
+ deps.harness,
212
+ deps.configHome,
196
213
  deps.self,
197
214
  () => this.changed(),
198
215
  deps.pollMs,
@@ -203,7 +220,7 @@ export class Sessions implements UpstreamResource {
203
220
  deps.terminals === undefined
204
221
  ? undefined
205
222
  : new TerminalCache(deps.terminals, () => this.changed());
206
- this.#live = this.#liveNow(Date.now(), this.#rows());
223
+ this.#live = this.#liveNow(Date.now(), this.#own());
207
224
  }
208
225
 
209
226
  /** `hello`, which is where a session becomes something this instance can
@@ -283,18 +300,18 @@ export class Sessions implements UpstreamResource {
283
300
  classify(
284
301
  sid: Sid,
285
302
  now: Timestamp = Date.now(),
286
- rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
303
+ own: Own = this.#own(),
287
304
  ): SessionState | undefined {
288
- return classify(this.inputs(sid, rows), now);
305
+ return classify(this.inputs(sid, own), now);
289
306
  }
290
307
 
291
308
  /** The harness's sessions as they are at this instant. One read serves one
292
309
  * question, and a caller answering several about the same instant passes the
293
310
  * result on rather than reading again. */
294
- #rows(): ReadonlyMap<Sid, AgentInfo> {
295
- const rows = this.#harness.scan();
311
+ #own(): Own {
312
+ const rows = this.#harness.rows();
296
313
  const terminals = this.#terminals;
297
- if (terminals === undefined) return rows;
314
+ if (terminals === undefined) return { rows, present: this.#harness.present() };
298
315
  // What the scan found is what exists: a pid that has left it is one whose
299
316
  // terminal is no longer anybody's, and one that has arrived is read once.
300
317
  terminals.observe([...rows.values()].map((row) => row.pid));
@@ -314,7 +331,7 @@ export class Sessions implements UpstreamResource {
314
331
  },
315
332
  );
316
333
  }
317
- return named;
334
+ return { rows: named, present: this.#harness.present() };
318
335
  }
319
336
 
320
337
  /** Everything the classification of one session reads, exposed so the rule
@@ -327,21 +344,22 @@ export class Sessions implements UpstreamResource {
327
344
  * "a session exists" mean "somebody is listening", which is how a live
328
345
  * session becomes `session_not_found` to a sender and how a session that is
329
346
  * still running is written into `last_live` as gone. */
330
- inputs(sid: Sid, rows: ReadonlyMap<Sid, AgentInfo> = this.#rows()): SessionInputs {
331
- const row = rows.get(sid);
347
+ inputs(sid: Sid, own: Own = this.#own()): SessionInputs {
348
+ const row = own.rows.get(sid);
349
+ const present = own.present.has(sid);
332
350
  const stored = this.#lastLive.get(sid);
333
351
  const facts = this.deps.transcript?.facts(sid);
334
- const gatewayActiveAt = this.#gatewayActiveAt(sid, row !== undefined);
352
+ const gatewayActiveAt = this.#gatewayActiveAt(sid, present);
335
353
  return {
336
354
  connected: this.#connected.has(sid),
337
355
  ...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
338
356
  ...(facts === undefined || stoppedOn(facts) === undefined ? {} : { api_error_stopped: true }),
339
- ...(row === undefined
357
+ ...(!present
340
358
  ? {}
341
359
  : {
342
360
  harness: {
343
- waiting: isWaiting(row),
344
- ...(row.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
361
+ waiting: row !== undefined && isWaiting(row),
362
+ ...(row?.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
345
363
  },
346
364
  }),
347
365
  ...(stored === undefined ? {} : { last_live: { stopped_at: stored.stopped_at } }),
@@ -379,7 +397,7 @@ export class Sessions implements UpstreamResource {
379
397
  * so a session that named neither is one no path is admitted for. */
380
398
  where(sid: Sid): { root?: string; cwd?: string } {
381
399
  const meta = this.#connected.get(sid)?.meta;
382
- const cwd = meta?.cwd ?? this.#rows().get(sid)?.cwd;
400
+ const cwd = meta?.cwd ?? this.#own().rows.get(sid)?.cwd;
383
401
  // The container when the session named one, the working directory
384
402
  // otherwise — the same order `repo_root` is meant in (§4.2).
385
403
  const root = meta?.repo_root ?? cwd;
@@ -394,7 +412,7 @@ export class Sessions implements UpstreamResource {
394
412
  * through this: the watch runs only while somebody is subscribed (§6.3), and
395
413
  * a pid from a poll that has not run is a number belonging to nobody. */
396
414
  rowsNow(): ReadonlyMap<Sid, AgentInfo> {
397
- return this.#rows();
415
+ return this.#own().rows;
398
416
  }
399
417
 
400
418
  /** Drop one entry from `last_live`, which is what
@@ -445,8 +463,8 @@ export class Sessions implements UpstreamResource {
445
463
  }
446
464
 
447
465
  snapshot(topic: string): readonly TopicValue[] {
448
- const rows = this.#rows();
449
- const data = topic === "agents" ? this.agents(rows) : this.peers(Date.now(), rows);
466
+ const own = this.#own();
467
+ const data = topic === "agents" ? this.agents(own) : this.peers(Date.now(), own);
450
468
  return [{ instance: this.deps.self, data }];
451
469
  }
452
470
 
@@ -473,14 +491,14 @@ export class Sessions implements UpstreamResource {
473
491
  * from stating that nothing is reachable. */
474
492
  peers(
475
493
  now: Timestamp = Date.now(),
476
- rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
494
+ own: Own = this.#own(),
477
495
  ): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
478
496
  const instances = this.deps.mesh?.instances();
479
497
  return {
480
- peers: [...this.#connected.values()].map((session) => this.#peer(session, now, rows)),
498
+ peers: [...this.#connected.values()].map((session) => this.#peer(session, now, own)),
481
499
  last_live: this.#lastLive.entries(now).map((entry) => ({
482
500
  ...entry,
483
- state: this.classify(entry.sid, now, rows) ?? "disappeared",
501
+ state: this.classify(entry.sid, now, own) ?? "disappeared",
484
502
  pinned: this.#pinned(entry.sid),
485
503
  })),
486
504
  ...(instances === undefined ? {} : { instances }),
@@ -493,8 +511,8 @@ export class Sessions implements UpstreamResource {
493
511
  * make every confirmation poll a value the list did not have before, so the
494
512
  * one suppression every topic shares (M5) would let a five-second heartbeat
495
513
  * through for a directory that had not changed. */
496
- agents(rows: ReadonlyMap<Sid, AgentInfo> = this.#rows()): { agents: AgentInfo[] } {
497
- return { agents: [...rows.values()] };
514
+ agents(own: Own = this.#own()): { agents: AgentInfo[] } {
515
+ return { agents: [...own.rows.values()] };
498
516
  }
499
517
 
500
518
  /** Bind a session to this instance, and take what it says about itself. Its
@@ -515,7 +533,7 @@ export class Sessions implements UpstreamResource {
515
533
  const held = this.#connected.get(sid);
516
534
  const meta = {
517
535
  ...this.#stated.get(sid),
518
- ...metaOf(args, this.deps.configHome, (refused) => {
536
+ ...metaOf(this.deps, args, (refused) => {
519
537
  this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
520
538
  }),
521
539
  };
@@ -552,8 +570,8 @@ export class Sessions implements UpstreamResource {
552
570
  * mechanism and is written once for every topic (M5) — a payload equal to
553
571
  * the last one goes no further than that. */
554
572
  private changed(now: Timestamp = Date.now()): void {
555
- const rows = this.#rows();
556
- const live = this.#liveNow(now, rows);
573
+ const own = this.#own();
574
+ const live = this.#liveNow(now, own);
557
575
  for (const [sid, entry] of this.#live) {
558
576
  if (live.has(sid)) continue;
559
577
  // The declaration came first and the departure has now arrived, which is
@@ -571,23 +589,23 @@ export class Sessions implements UpstreamResource {
571
589
  this.#stated.delete(sid);
572
590
  }
573
591
  this.#live = live;
574
- this.deps.publish("peers", this.peers(now, rows));
575
- this.deps.publish("agents", this.agents(rows));
592
+ this.deps.publish("peers", this.peers(now, own));
593
+ this.deps.publish("agents", this.agents(own));
576
594
  this.deps.onChanged?.();
577
595
  }
578
596
 
579
597
  /** Every session live right now, in the form its `last_live` entry takes if
580
598
  * it stops being live. */
581
- #liveNow(now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): Map<Sid, StoredEntry> {
599
+ #liveNow(now: Timestamp, own: Own): Map<Sid, StoredEntry> {
582
600
  const live = new Map<Sid, StoredEntry>();
583
- for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now, rows));
584
- for (const sid of rows.keys()) live.set(sid, this.#entry(sid, now, rows));
601
+ for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now, own));
602
+ for (const sid of own.present) live.set(sid, this.#entry(sid, now, own));
585
603
  return live;
586
604
  }
587
605
 
588
- #entry(sid: Sid, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): StoredEntry {
606
+ #entry(sid: Sid, now: Timestamp, own: Own): StoredEntry {
589
607
  const held = this.#connected.get(sid);
590
- const row = rows.get(sid);
608
+ const row = own.rows.get(sid);
591
609
  // What answered last, not what the session named when it greeted: the
592
610
  // greeting is one instant and `/model` moves afterwards, so the fold is
593
611
  // asked first and the greeting only fills in for a transcript that has
@@ -602,7 +620,7 @@ export class Sessions implements UpstreamResource {
602
620
  // The harness knows a title for a session that stated none itself, so
603
621
  // it goes first and what the session named overrides it.
604
622
  ...(row?.name === undefined ? {} : { title: row.name }),
605
- ...this.#where(sid, rows),
623
+ ...this.#where(sid, own),
606
624
  ...(model === undefined ? {} : { model }),
607
625
  ...(effort === undefined ? {} : { effort }),
608
626
  ...(held === undefined ? {} : { connected_at: held.connected_at }),
@@ -610,7 +628,7 @@ export class Sessions implements UpstreamResource {
610
628
  };
611
629
  }
612
630
 
613
- #peer(session: Connected, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): PeerInfo {
631
+ #peer(session: Connected, now: Timestamp, own: Own): PeerInfo {
614
632
  // The two "last activity" values are different questions (§5.3): the one
615
633
  // above moves on every request the session makes, this one only when a
616
634
  // person speaks, and the fold is the only place that knows the second.
@@ -618,12 +636,12 @@ export class Sessions implements UpstreamResource {
618
636
  // What the gateway last saw run for this session: an attribute of the row
619
637
  // beside the classification, not folded into it (§5.1). Absent from an
620
638
  // instance with no gateway, where nothing observes inference at all.
621
- const gatewayActiveAt = this.#gatewayActiveAt(session.sid, rows.has(session.sid));
639
+ const gatewayActiveAt = this.#gatewayActiveAt(session.sid, own.present.has(session.sid));
622
640
  return {
623
641
  sid: session.sid,
624
642
  instance: this.deps.self,
625
- ...this.#where(session.sid, rows),
626
- state: this.classify(session.sid, now, rows) ?? "live",
643
+ ...this.#where(session.sid, own),
644
+ state: this.classify(session.sid, now, own) ?? "live",
627
645
  pinned: this.#pinned(session.sid),
628
646
  connected_at: session.connected_at,
629
647
  last_activity_at: session.last_activity_at,
@@ -675,10 +693,10 @@ export class Sessions implements UpstreamResource {
675
693
  * until one does. */
676
694
  #where(
677
695
  sid: Sid,
678
- rows: ReadonlyMap<Sid, AgentInfo>,
696
+ own: Own,
679
697
  ): Pick<PeerInfo, "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title"> {
680
698
  const meta = this.#stated.get(sid) ?? {};
681
- const cwd = meta.cwd ?? rows.get(sid)?.cwd ?? "";
699
+ const cwd = meta.cwd ?? own.rows.get(sid)?.cwd ?? "";
682
700
  return {
683
701
  repo: meta.repo ?? "",
684
702
  ws: meta.ws ?? "",
@@ -697,7 +715,7 @@ export class Sessions implements UpstreamResource {
697
715
  *
698
716
  * `transcript_path` is the exception, because it is the one field that is not
699
717
  * only displayed: it names a file this instance then reads and follows. What is
700
- * taken is a path under this config home's `projects/`, resolved, and nothing
718
+ * taken is a path under this config home's transcript tree, resolved, and nothing
701
719
  * else — a session naming a file elsewhere is a session that named nothing,
702
720
  * which is what a session that stayed silent already is (M6). It is not an
703
721
  * error: how a session describes itself is its own business, and the instance
@@ -705,8 +723,8 @@ export class Sessions implements UpstreamResource {
705
723
  * not taken is told to `refused`, which is the operator's answer to a field
706
724
  * that is simply absent from what `peers` says. */
707
725
  function metaOf(
726
+ deps: Pick<SessionsDeps, "configHome" | "harness">,
708
727
  args: HelloArgs,
709
- configHome: string,
710
728
  refused: (reason: string) => void,
711
729
  ): SessionMeta {
712
730
  const meta: Record<string, string> = {};
@@ -714,7 +732,7 @@ function metaOf(
714
732
  const value = args[field];
715
733
  if (value === undefined) continue;
716
734
  if (field === "transcript_path") {
717
- const taken = ownTranscript(value, configHome);
735
+ const taken = ownTranscript(value, deps);
718
736
  if (typeof taken === "string") meta[field] = taken;
719
737
  else refused(taken.refused);
720
738
  continue;
@@ -727,7 +745,7 @@ function metaOf(
727
745
  /** A transcript path this instance will read, or nothing.
728
746
 
729
747
  * The test is where the file would be, not whether it is there. M6 is a
730
- * boundary on what this instance reads, and a path inside `projects/` stays
748
+ * boundary on what this instance reads, and a path inside the tree stays
731
749
  * inside it whether or not anything has been written there yet — a session
732
750
  * greeting at its very start names a transcript the harness has created
733
751
  * neither the file nor the directory for, and refusing it would mean the one
@@ -741,23 +759,27 @@ function metaOf(
741
759
  * along it that leads out of the tree lands outside and is refused. What is
742
760
  * already there must be a file: a directory by that name is not a transcript.
743
761
  *
744
- * `projects/` itself is settled the same way, so a config home whose first
762
+ * The tree itself is settled the same way, so a config home whose first
745
763
  * session has yet to write anything is a boundary all the same: the directory
746
764
  * that is there is followed, the part that is not is taken as spelled, and the
747
765
  * comparison is between two paths resolved by one rule. The config home is not
748
766
  * treated that way — an instance answers for a home it is running out of, and
749
767
  * one that is not there names no tree to be inside of. */
750
- function ownTranscript(named: string, configHome: string): string | Refused {
768
+ function ownTranscript(
769
+ named: string,
770
+ deps: Pick<SessionsDeps, "configHome" | "harness">,
771
+ ): string | Refused {
751
772
  if (!isAbsolute(named)) return { refused: "not an absolute path" };
752
- let projects: string | undefined;
773
+ let tree: string | undefined;
753
774
  try {
754
- projects = resolveAsFarAsItGoes(join(realpathSync(configHome), "projects"));
775
+ const home = realpathSync(deps.configHome);
776
+ tree = resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
755
777
  } catch {
756
778
  return { refused: "the config home is not there" };
757
779
  }
758
780
  const settled = resolveAsFarAsItGoes(named);
759
- if (projects === undefined || settled === undefined || !within(settled, projects)) {
760
- return { refused: "outside this config home's projects tree" };
781
+ if (tree === undefined || settled === undefined || !within(settled, tree)) {
782
+ return { refused: "outside this config home's transcript tree" };
761
783
  }
762
784
  const stat = statSync(settled, { throwIfNoEntry: false });
763
785
  if (stat !== undefined && !stat.isFile()) return { refused: "not a file" };
@@ -239,11 +239,11 @@ function says(text: string, clauses: readonly Clause[]): boolean {
239
239
 
240
240
  /** Whether the project directory could be the working directory asked for.
241
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
242
+ * A harness that files by working directory flattens it into one name, and the
243
+ * flattening is lossy — separators, dots and underscores all become dashes — so this only
244
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;
245
+ function looksLike(project: string | undefined, words: readonly string[]): boolean {
246
+ if (words.length === 0 || project === undefined) return true;
247
247
  const flat = flatten(project);
248
248
  return words.every((word) => flat.includes(flatten(word)));
249
249
  }