@ccmsg/cli 0.2.13 → 0.3.1

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.
@@ -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
@@ -82,6 +98,12 @@ export interface SessionsDeps {
82
98
  * domain cannot judge: a peer's, whose claim is settled by an exchange of its
83
99
  * own rather than by anything a session says (§7.2). */
84
100
  readonly mesh?: MeshSource;
101
+ /** Where a person opens the terminal a session runs in, which `hello` states
102
+ * as `terminal_gateway`. The same value that gates the `terminal` capability
103
+ * (`sessionCapabilities`), so a client told the capability is on is told
104
+ * where to reach it in the same greeting. Absent on an instance with no
105
+ * gateway configured. */
106
+ readonly terminalGateway?: string;
85
107
  }
86
108
 
87
109
  /** What `hello` needs of the mesh: verify the greeting of a peer, and say which
@@ -155,7 +177,7 @@ interface Connected {
155
177
  * built, and never stored (M4). */
156
178
  export class Sessions implements UpstreamResource {
157
179
  readonly #connected = new Map<Sid, Connected>();
158
- readonly #harness: HarnessSessions;
180
+ readonly #harness: OwnSessions;
159
181
  readonly #terminals: TerminalCache | undefined;
160
182
  readonly #lastLive: LastLiveStore;
161
183
  /** Sessions seen live since the last recompute, kept so the moment one stops
@@ -191,8 +213,9 @@ export class Sessions implements UpstreamResource {
191
213
  readonly #stopping = new Map<Sid, Timestamp>();
192
214
 
193
215
  constructor(private readonly deps: SessionsDeps) {
194
- this.#harness = new HarnessSessions(
195
- join(deps.configHome, "sessions"),
216
+ this.#harness = ownSessions(
217
+ deps.harness,
218
+ deps.configHome,
196
219
  deps.self,
197
220
  () => this.changed(),
198
221
  deps.pollMs,
@@ -203,7 +226,7 @@ export class Sessions implements UpstreamResource {
203
226
  deps.terminals === undefined
204
227
  ? undefined
205
228
  : new TerminalCache(deps.terminals, () => this.changed());
206
- this.#live = this.#liveNow(Date.now(), this.#rows());
229
+ this.#live = this.#liveNow(Date.now(), this.#own());
207
230
  }
208
231
 
209
232
  /** `hello`, which is where a session becomes something this instance can
@@ -274,6 +297,9 @@ export class Sessions implements UpstreamResource {
274
297
  capabilities: [...this.deps.capabilities],
275
298
  version: this.deps.version,
276
299
  started_at: this.deps.startedAt,
300
+ ...(this.deps.terminalGateway === undefined
301
+ ? {}
302
+ : { terminal_gateway: this.deps.terminalGateway }),
277
303
  ...(expiresAt === undefined ? {} : { auth_expires_at: expiresAt }),
278
304
  };
279
305
  }
@@ -283,18 +309,18 @@ export class Sessions implements UpstreamResource {
283
309
  classify(
284
310
  sid: Sid,
285
311
  now: Timestamp = Date.now(),
286
- rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
312
+ own: Own = this.#own(),
287
313
  ): SessionState | undefined {
288
- return classify(this.inputs(sid, rows), now);
314
+ return classify(this.inputs(sid, own), now);
289
315
  }
290
316
 
291
317
  /** The harness's sessions as they are at this instant. One read serves one
292
318
  * question, and a caller answering several about the same instant passes the
293
319
  * result on rather than reading again. */
294
- #rows(): ReadonlyMap<Sid, AgentInfo> {
295
- const rows = this.#harness.scan();
320
+ #own(): Own {
321
+ const rows = this.#harness.rows();
296
322
  const terminals = this.#terminals;
297
- if (terminals === undefined) return rows;
323
+ if (terminals === undefined) return { rows, present: this.#harness.present() };
298
324
  // What the scan found is what exists: a pid that has left it is one whose
299
325
  // terminal is no longer anybody's, and one that has arrived is read once.
300
326
  terminals.observe([...rows.values()].map((row) => row.pid));
@@ -314,7 +340,7 @@ export class Sessions implements UpstreamResource {
314
340
  },
315
341
  );
316
342
  }
317
- return named;
343
+ return { rows: named, present: this.#harness.present() };
318
344
  }
319
345
 
320
346
  /** Everything the classification of one session reads, exposed so the rule
@@ -327,21 +353,22 @@ export class Sessions implements UpstreamResource {
327
353
  * "a session exists" mean "somebody is listening", which is how a live
328
354
  * session becomes `session_not_found` to a sender and how a session that is
329
355
  * 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);
356
+ inputs(sid: Sid, own: Own = this.#own()): SessionInputs {
357
+ const row = own.rows.get(sid);
358
+ const present = own.present.has(sid);
332
359
  const stored = this.#lastLive.get(sid);
333
360
  const facts = this.deps.transcript?.facts(sid);
334
- const gatewayActiveAt = this.#gatewayActiveAt(sid, row !== undefined);
361
+ const gatewayActiveAt = this.#gatewayActiveAt(sid, present);
335
362
  return {
336
363
  connected: this.#connected.has(sid),
337
364
  ...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
338
365
  ...(facts === undefined || stoppedOn(facts) === undefined ? {} : { api_error_stopped: true }),
339
- ...(row === undefined
366
+ ...(!present
340
367
  ? {}
341
368
  : {
342
369
  harness: {
343
- waiting: isWaiting(row),
344
- ...(row.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
370
+ waiting: row !== undefined && isWaiting(row),
371
+ ...(row?.terminal_id === undefined ? {} : { terminal_id: row.terminal_id }),
345
372
  },
346
373
  }),
347
374
  ...(stored === undefined ? {} : { last_live: { stopped_at: stored.stopped_at } }),
@@ -379,7 +406,7 @@ export class Sessions implements UpstreamResource {
379
406
  * so a session that named neither is one no path is admitted for. */
380
407
  where(sid: Sid): { root?: string; cwd?: string } {
381
408
  const meta = this.#connected.get(sid)?.meta;
382
- const cwd = meta?.cwd ?? this.#rows().get(sid)?.cwd;
409
+ const cwd = meta?.cwd ?? this.#own().rows.get(sid)?.cwd;
383
410
  // The container when the session named one, the working directory
384
411
  // otherwise — the same order `repo_root` is meant in (§4.2).
385
412
  const root = meta?.repo_root ?? cwd;
@@ -394,7 +421,7 @@ export class Sessions implements UpstreamResource {
394
421
  * through this: the watch runs only while somebody is subscribed (§6.3), and
395
422
  * a pid from a poll that has not run is a number belonging to nobody. */
396
423
  rowsNow(): ReadonlyMap<Sid, AgentInfo> {
397
- return this.#rows();
424
+ return this.#own().rows;
398
425
  }
399
426
 
400
427
  /** Drop one entry from `last_live`, which is what
@@ -445,8 +472,8 @@ export class Sessions implements UpstreamResource {
445
472
  }
446
473
 
447
474
  snapshot(topic: string): readonly TopicValue[] {
448
- const rows = this.#rows();
449
- const data = topic === "agents" ? this.agents(rows) : this.peers(Date.now(), rows);
475
+ const own = this.#own();
476
+ const data = topic === "agents" ? this.agents(own) : this.peers(Date.now(), own);
450
477
  return [{ instance: this.deps.self, data }];
451
478
  }
452
479
 
@@ -473,14 +500,14 @@ export class Sessions implements UpstreamResource {
473
500
  * from stating that nothing is reachable. */
474
501
  peers(
475
502
  now: Timestamp = Date.now(),
476
- rows: ReadonlyMap<Sid, AgentInfo> = this.#rows(),
503
+ own: Own = this.#own(),
477
504
  ): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
478
505
  const instances = this.deps.mesh?.instances();
479
506
  return {
480
- peers: [...this.#connected.values()].map((session) => this.#peer(session, now, rows)),
507
+ peers: [...this.#connected.values()].map((session) => this.#peer(session, now, own)),
481
508
  last_live: this.#lastLive.entries(now).map((entry) => ({
482
509
  ...entry,
483
- state: this.classify(entry.sid, now, rows) ?? "disappeared",
510
+ state: this.classify(entry.sid, now, own) ?? "disappeared",
484
511
  pinned: this.#pinned(entry.sid),
485
512
  })),
486
513
  ...(instances === undefined ? {} : { instances }),
@@ -493,8 +520,8 @@ export class Sessions implements UpstreamResource {
493
520
  * make every confirmation poll a value the list did not have before, so the
494
521
  * one suppression every topic shares (M5) would let a five-second heartbeat
495
522
  * through for a directory that had not changed. */
496
- agents(rows: ReadonlyMap<Sid, AgentInfo> = this.#rows()): { agents: AgentInfo[] } {
497
- return { agents: [...rows.values()] };
523
+ agents(own: Own = this.#own()): { agents: AgentInfo[] } {
524
+ return { agents: [...own.rows.values()] };
498
525
  }
499
526
 
500
527
  /** Bind a session to this instance, and take what it says about itself. Its
@@ -515,7 +542,7 @@ export class Sessions implements UpstreamResource {
515
542
  const held = this.#connected.get(sid);
516
543
  const meta = {
517
544
  ...this.#stated.get(sid),
518
- ...metaOf(args, this.deps.configHome, (refused) => {
545
+ ...metaOf(this.deps, args, (refused) => {
519
546
  this.deps.log?.("transcript_path not taken", { sid, path: args.transcript_path, refused });
520
547
  }),
521
548
  };
@@ -552,8 +579,8 @@ export class Sessions implements UpstreamResource {
552
579
  * mechanism and is written once for every topic (M5) — a payload equal to
553
580
  * the last one goes no further than that. */
554
581
  private changed(now: Timestamp = Date.now()): void {
555
- const rows = this.#rows();
556
- const live = this.#liveNow(now, rows);
582
+ const own = this.#own();
583
+ const live = this.#liveNow(now, own);
557
584
  for (const [sid, entry] of this.#live) {
558
585
  if (live.has(sid)) continue;
559
586
  // The declaration came first and the departure has now arrived, which is
@@ -571,23 +598,23 @@ export class Sessions implements UpstreamResource {
571
598
  this.#stated.delete(sid);
572
599
  }
573
600
  this.#live = live;
574
- this.deps.publish("peers", this.peers(now, rows));
575
- this.deps.publish("agents", this.agents(rows));
601
+ this.deps.publish("peers", this.peers(now, own));
602
+ this.deps.publish("agents", this.agents(own));
576
603
  this.deps.onChanged?.();
577
604
  }
578
605
 
579
606
  /** Every session live right now, in the form its `last_live` entry takes if
580
607
  * it stops being live. */
581
- #liveNow(now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): Map<Sid, StoredEntry> {
608
+ #liveNow(now: Timestamp, own: Own): Map<Sid, StoredEntry> {
582
609
  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));
610
+ for (const sid of this.#connected.keys()) live.set(sid, this.#entry(sid, now, own));
611
+ for (const sid of own.present) live.set(sid, this.#entry(sid, now, own));
585
612
  return live;
586
613
  }
587
614
 
588
- #entry(sid: Sid, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): StoredEntry {
615
+ #entry(sid: Sid, now: Timestamp, own: Own): StoredEntry {
589
616
  const held = this.#connected.get(sid);
590
- const row = rows.get(sid);
617
+ const row = own.rows.get(sid);
591
618
  // What answered last, not what the session named when it greeted: the
592
619
  // greeting is one instant and `/model` moves afterwards, so the fold is
593
620
  // asked first and the greeting only fills in for a transcript that has
@@ -602,7 +629,7 @@ export class Sessions implements UpstreamResource {
602
629
  // The harness knows a title for a session that stated none itself, so
603
630
  // it goes first and what the session named overrides it.
604
631
  ...(row?.name === undefined ? {} : { title: row.name }),
605
- ...this.#where(sid, rows),
632
+ ...this.#where(sid, own),
606
633
  ...(model === undefined ? {} : { model }),
607
634
  ...(effort === undefined ? {} : { effort }),
608
635
  ...(held === undefined ? {} : { connected_at: held.connected_at }),
@@ -610,7 +637,7 @@ export class Sessions implements UpstreamResource {
610
637
  };
611
638
  }
612
639
 
613
- #peer(session: Connected, now: Timestamp, rows: ReadonlyMap<Sid, AgentInfo>): PeerInfo {
640
+ #peer(session: Connected, now: Timestamp, own: Own): PeerInfo {
614
641
  // The two "last activity" values are different questions (§5.3): the one
615
642
  // above moves on every request the session makes, this one only when a
616
643
  // person speaks, and the fold is the only place that knows the second.
@@ -618,12 +645,12 @@ export class Sessions implements UpstreamResource {
618
645
  // What the gateway last saw run for this session: an attribute of the row
619
646
  // beside the classification, not folded into it (§5.1). Absent from an
620
647
  // instance with no gateway, where nothing observes inference at all.
621
- const gatewayActiveAt = this.#gatewayActiveAt(session.sid, rows.has(session.sid));
648
+ const gatewayActiveAt = this.#gatewayActiveAt(session.sid, own.present.has(session.sid));
622
649
  return {
623
650
  sid: session.sid,
624
651
  instance: this.deps.self,
625
- ...this.#where(session.sid, rows),
626
- state: this.classify(session.sid, now, rows) ?? "live",
652
+ ...this.#where(session.sid, own),
653
+ state: this.classify(session.sid, now, own) ?? "live",
627
654
  pinned: this.#pinned(session.sid),
628
655
  connected_at: session.connected_at,
629
656
  last_activity_at: session.last_activity_at,
@@ -675,10 +702,10 @@ export class Sessions implements UpstreamResource {
675
702
  * until one does. */
676
703
  #where(
677
704
  sid: Sid,
678
- rows: ReadonlyMap<Sid, AgentInfo>,
705
+ own: Own,
679
706
  ): Pick<PeerInfo, "repo" | "ws" | "cwd" | "transcript_path" | "repo_root" | "branch" | "title"> {
680
707
  const meta = this.#stated.get(sid) ?? {};
681
- const cwd = meta.cwd ?? rows.get(sid)?.cwd ?? "";
708
+ const cwd = meta.cwd ?? own.rows.get(sid)?.cwd ?? "";
682
709
  return {
683
710
  repo: meta.repo ?? "",
684
711
  ws: meta.ws ?? "",
@@ -697,7 +724,7 @@ export class Sessions implements UpstreamResource {
697
724
  *
698
725
  * `transcript_path` is the exception, because it is the one field that is not
699
726
  * 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
727
+ * taken is a path under this config home's transcript tree, resolved, and nothing
701
728
  * else — a session naming a file elsewhere is a session that named nothing,
702
729
  * which is what a session that stayed silent already is (M6). It is not an
703
730
  * error: how a session describes itself is its own business, and the instance
@@ -705,8 +732,8 @@ export class Sessions implements UpstreamResource {
705
732
  * not taken is told to `refused`, which is the operator's answer to a field
706
733
  * that is simply absent from what `peers` says. */
707
734
  function metaOf(
735
+ deps: Pick<SessionsDeps, "configHome" | "harness">,
708
736
  args: HelloArgs,
709
- configHome: string,
710
737
  refused: (reason: string) => void,
711
738
  ): SessionMeta {
712
739
  const meta: Record<string, string> = {};
@@ -714,7 +741,7 @@ function metaOf(
714
741
  const value = args[field];
715
742
  if (value === undefined) continue;
716
743
  if (field === "transcript_path") {
717
- const taken = ownTranscript(value, configHome);
744
+ const taken = ownTranscript(value, deps);
718
745
  if (typeof taken === "string") meta[field] = taken;
719
746
  else refused(taken.refused);
720
747
  continue;
@@ -727,7 +754,7 @@ function metaOf(
727
754
  /** A transcript path this instance will read, or nothing.
728
755
 
729
756
  * 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
757
+ * boundary on what this instance reads, and a path inside the tree stays
731
758
  * inside it whether or not anything has been written there yet — a session
732
759
  * greeting at its very start names a transcript the harness has created
733
760
  * neither the file nor the directory for, and refusing it would mean the one
@@ -741,23 +768,27 @@ function metaOf(
741
768
  * along it that leads out of the tree lands outside and is refused. What is
742
769
  * already there must be a file: a directory by that name is not a transcript.
743
770
  *
744
- * `projects/` itself is settled the same way, so a config home whose first
771
+ * The tree itself is settled the same way, so a config home whose first
745
772
  * session has yet to write anything is a boundary all the same: the directory
746
773
  * that is there is followed, the part that is not is taken as spelled, and the
747
774
  * comparison is between two paths resolved by one rule. The config home is not
748
775
  * treated that way — an instance answers for a home it is running out of, and
749
776
  * one that is not there names no tree to be inside of. */
750
- function ownTranscript(named: string, configHome: string): string | Refused {
777
+ function ownTranscript(
778
+ named: string,
779
+ deps: Pick<SessionsDeps, "configHome" | "harness">,
780
+ ): string | Refused {
751
781
  if (!isAbsolute(named)) return { refused: "not an absolute path" };
752
- let projects: string | undefined;
782
+ let tree: string | undefined;
753
783
  try {
754
- projects = resolveAsFarAsItGoes(join(realpathSync(configHome), "projects"));
784
+ const home = realpathSync(deps.configHome);
785
+ tree = resolveAsFarAsItGoes(join(home, HARNESS[deps.harness].transcripts));
755
786
  } catch {
756
787
  return { refused: "the config home is not there" };
757
788
  }
758
789
  const settled = resolveAsFarAsItGoes(named);
759
- if (projects === undefined || settled === undefined || !within(settled, projects)) {
760
- return { refused: "outside this config home's projects tree" };
790
+ if (tree === undefined || settled === undefined || !within(settled, tree)) {
791
+ return { refused: "outside this config home's transcript tree" };
761
792
  }
762
793
  const stat = statSync(settled, { throwIfNoEntry: false });
763
794
  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
  }
@@ -1,19 +1,59 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import type { Sid } from "@ccmsg/protocol";
4
+ import { type Harness, HARNESS } from "../harness/index.ts";
4
5
  import { OpError } from "../dispatch/index.ts";
5
6
 
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
7
  const SUFFIX = ".jsonl";
10
8
 
11
- /** A session id as the harness names files by. Validated before it is joined
9
+ /** Where one harness keeps transcripts under its config home, and how a file
10
+ * there says which session it belongs to (§3.8).
11
+ *
12
+ * Two facts, because the two harnesses file the same thing differently. Claude
13
+ * Code keeps one directory per working directory and names the file after the
14
+ * session; Codex keeps one directory per date and names the file after the
15
+ * thread with the moment it started in front. The `depth` is how many
16
+ * directories stand between the root and a file, which is what the walk needs
17
+ * and what the naming does not say.
18
+ *
19
+ * A Codex rollout that was reverted carries a second id after the thread's own,
20
+ * separated by `_`: the thread is the same and the file is a new one, so the
21
+ * name still answers "which session" and that is what is read out of it. */
22
+ interface TranscriptLayout {
23
+ readonly depth: number;
24
+ /** The session a file belongs to, or nothing when the name is not one this
25
+ * harness writes. */
26
+ readonly sidOf: (name: string) => Sid | undefined;
27
+ /** What that session's file is called, where the name follows from the sid.
28
+ * Absent where it does not, which is what makes the walk the only way in. */
29
+ readonly nameOf?: (sid: Sid) => string;
30
+ }
31
+
32
+ /** A session id as Claude Code names files by. Validated before it is joined
12
33
  * to a path, so a sid is a name rather than a route: no separator and no dot
13
34
  * can appear in it, which makes traversal unrepresentable rather than
14
35
  * unlikely. */
15
36
  const SID = /^[0-9a-fA-F-]{8,64}$/;
16
37
 
38
+ /** A Codex rollout, as its recorder writes the name: the moment it opened, the
39
+ * thread UUID, and the rollout's own id after it when the thread was reverted.
40
+ * The thread UUID is what a sid is here (measured against codex-cli 0.153.4). */
41
+ const ROLLOUT =
42
+ /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(?:_[0-9a-fA-F-]{36})?\.jsonl$/;
43
+
44
+ const LAYOUTS: Record<Harness, TranscriptLayout> = {
45
+ claude: {
46
+ depth: 1,
47
+ sidOf: (name) => {
48
+ if (!name.endsWith(SUFFIX)) return undefined;
49
+ const sid = name.slice(0, -SUFFIX.length);
50
+ return SID.test(sid) ? sid : undefined;
51
+ },
52
+ nameOf: (sid) => `${sid}${SUFFIX}`,
53
+ },
54
+ codex: { depth: 3, sidOf: (name) => ROLLOUT.exec(name)?.[1] },
55
+ };
56
+
17
57
  /** The agent id and run id shapes the harness writes under a session's own
18
58
  * directory, and the name a teammate is addressed by. Each is validated on the
19
59
  * same footing as a sid, for the same reason: all three name a file. */
@@ -30,6 +70,8 @@ const TEAMMATE = /^[A-Za-z0-9_-]{1,64}$/;
30
70
  * (M6) — nothing searches for another one. */
31
71
  export interface TranscriptFilesDeps {
32
72
  readonly configHome: string;
73
+ /** Which harness's tree is under it (§3.8). */
74
+ readonly harness: Harness;
33
75
  /** Where a connected session said its transcript is (§5.1). A session that
34
76
  * never greeted has none, and the walk below answers for it. */
35
77
  readonly announced: (sid: Sid) => string | undefined;
@@ -42,10 +84,10 @@ export class TranscriptFiles {
42
84
  *
43
85
  * Two ways to the one file, in the order of what each is good for: what the
44
86
  * session announced is exact and costs no search, and the walk finds the
45
- * file by the identity it carries in its name (`<sid>.jsonl`) for a session
46
- * that never greeted or is no longer running. Both stay inside this
47
- * instance's `projects/` — the announced path because it was taken only if
48
- * it was inside it, the walk because that tree is what it walks (M6). */
87
+ * file by the identity it carries in its name for a session that never
88
+ * greeted or is no longer running. Both stay inside this harness's own
89
+ * transcript tree — the announced path because it was taken only if it was
90
+ * inside it, the walk because that tree is what it walks (M6). */
49
91
  path(sid: Sid): string | undefined {
50
92
  const announced = this.deps.announced(sid);
51
93
  if (announced !== undefined && isFile(announced)) return announced;
@@ -117,21 +159,23 @@ export class TranscriptFiles {
117
159
  * the file and what a `stat` already said about it, so neither has to stat
118
160
  * again to decide whether to open it. */
119
161
  all(): TranscriptFile[] {
162
+ const layout = LAYOUTS[this.deps.harness];
120
163
  const found: TranscriptFile[] = [];
121
- const projects = join(this.deps.configHome, PROJECTS);
122
- for (const project of names(projects)) {
123
- const dir = join(projects, project);
164
+ for (const dir of directories(this.#root(), layout.depth)) {
124
165
  for (const entry of names(dir)) {
125
- if (!entry.endsWith(SUFFIX)) continue;
126
- const sid = entry.slice(0, -SUFFIX.length);
127
- if (!SID.test(sid)) continue;
166
+ const sid = layout.sidOf(entry);
167
+ if (sid === undefined) continue;
128
168
  const file = join(dir, entry);
129
169
  const stat = statOf(file);
130
170
  if (stat === undefined) continue;
131
171
  found.push({
132
172
  sid,
133
173
  file,
134
- project,
174
+ // Only a layout that files by working directory has one to state,
175
+ // and the field is a prefilter: a tree that says nothing about where
176
+ // a session ran narrows nothing, and the transcript's own `cwd`
177
+ // decides as it already does.
178
+ ...(this.deps.harness === "claude" ? { project: basename(dir) } : {}),
135
179
  size: stat.size,
136
180
  created_at: Math.round(stat.birthtimeMs || stat.ctimeMs),
137
181
  updated_at: Math.round(stat.mtimeMs),
@@ -142,17 +186,52 @@ export class TranscriptFiles {
142
186
  return found;
143
187
  }
144
188
 
189
+ /** The root of this harness's transcript tree, which is the boundary every
190
+ * path below is inside of (M6). */
191
+ #root(): string {
192
+ return join(this.deps.configHome, HARNESS[this.deps.harness].transcripts);
193
+ }
194
+
195
+ /** The session's file, found by the identity its name carries.
196
+ *
197
+ * A layout whose name follows from the sid is joined rather than searched,
198
+ * which is one `stat` per directory instead of a listing; one whose name
199
+ * carries more than the sid is walked, because the rest of the name is
200
+ * exactly what this does not know. */
145
201
  private find(sid: Sid): string | undefined {
146
- if (!SID.test(sid)) return undefined;
147
- const projects = join(this.deps.configHome, PROJECTS);
148
- for (const project of names(projects)) {
149
- const file = join(projects, project, `${sid}${SUFFIX}`);
150
- if (isFile(file)) return file;
202
+ const layout = LAYOUTS[this.deps.harness];
203
+ const dirs = directories(this.#root(), layout.depth);
204
+ const nameOf = layout.nameOf;
205
+ if (nameOf !== undefined) {
206
+ if (!SID.test(sid)) return undefined;
207
+ for (const dir of dirs) {
208
+ const file = join(dir, nameOf(sid));
209
+ if (isFile(file)) return file;
210
+ }
211
+ return undefined;
212
+ }
213
+ for (const dir of dirs) {
214
+ for (const entry of names(dir)) {
215
+ if (layout.sidOf(entry) === sid && isFile(join(dir, entry))) return join(dir, entry);
216
+ }
151
217
  }
152
218
  return undefined;
153
219
  }
154
220
  }
155
221
 
222
+ /** Every directory transcripts sit in, at the depth the layout files them at.
223
+ *
224
+ * Names are read rather than dates computed: what is there is what the harness
225
+ * wrote, and a tree with a directory nobody expected is one whose files are
226
+ * still found. */
227
+ function directories(root: string, depth: number): string[] {
228
+ let level = [root];
229
+ for (let step = 0; step < depth; step += 1) {
230
+ level = level.flatMap((dir) => names(dir).map((entry) => join(dir, entry)));
231
+ }
232
+ return level;
233
+ }
234
+
156
235
  export interface AgentNames {
157
236
  readonly agent_id?: string;
158
237
  readonly run_id?: string;
@@ -165,8 +244,10 @@ export interface TranscriptFile {
165
244
  readonly file: string;
166
245
  /** The project directory's name, which is the working directory flattened.
167
246
  * A lossy spelling — separators and dots all become dashes — so it prefilters
168
- * a search and never decides it. */
169
- readonly project: string;
247
+ * a search and never decides it. Absent where the harness files transcripts
248
+ * by something other than the working directory, which leaves nothing to
249
+ * prefilter on. */
250
+ readonly project?: string;
170
251
  readonly size: number;
171
252
  readonly created_at: number;
172
253
  readonly updated_at: number;