@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,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;
@@ -133,6 +133,11 @@ export class TranscriptFold {
133
133
  return false;
134
134
  }
135
135
  if (!isRecord(row)) return false;
136
+ // A Codex rollout line settles one of these facts and none of the others,
137
+ // so it is folded on its own rather than run past readers of records it
138
+ // does not have (§3.8).
139
+ const rollout = rolloutRecord(row, str(row["type"]));
140
+ if (rollout !== undefined) return this.#foldRollout(rollout);
136
141
  // Every value this fold derives, derived from the one parse (M5).
137
142
  let changed = this.#foldApiError(row);
138
143
  if (this.#foldAnswered(row)) changed = true;
@@ -223,6 +228,20 @@ export class TranscriptFold {
223
228
  *
224
229
  * A sidechain user row is a subagent being prompted by its parent, which is
225
230
  * a session speaking to itself rather than a person speaking to it. */
231
+ /** What a Codex rollout says: when a person last spoke.
232
+ *
233
+ * The rest of what this fold holds — the api error, the model of the latest
234
+ * turn, todos, teammates, background work — are records Claude Code writes
235
+ * and a rollout does not, so they stay as they are for a Codex session
236
+ * rather than being guessed at from something that resembles them. */
237
+ #foldRollout(record: TranscriptRecord): boolean {
238
+ if (record.said_by !== "user" || record.said_at === undefined) return false;
239
+ if (record.text === undefined || !isHuman(record.text)) return false;
240
+ if ((this.#lastUserInputAt ?? 0) >= record.said_at) return false;
241
+ this.#lastUserInputAt = record.said_at;
242
+ return true;
243
+ }
244
+
226
245
  #foldUserInput(row: Record<string, unknown>): boolean {
227
246
  if (row["type"] !== "user" || row["isSidechain"] === true) return false;
228
247
  if (row["isMeta"] === true || row["promptSource"] === "system") return false;
@@ -744,6 +763,8 @@ export function readRecord(line: string): TranscriptRecord | undefined {
744
763
  }
745
764
  if (!isRecord(row)) return undefined;
746
765
  const type = str(row["type"]);
766
+ const rollout = rolloutRecord(row, type);
767
+ if (rollout !== undefined) return rollout;
747
768
  const message = isRecord(row["message"]) ? row["message"] : undefined;
748
769
  const model = message === undefined ? undefined : str(message["model"]);
749
770
  return {
@@ -760,6 +781,52 @@ export function readRecord(line: string): TranscriptRecord | undefined {
760
781
  };
761
782
  }
762
783
 
784
+ /** One line of a Codex rollout, or nothing where the line is not one.
785
+ *
786
+ * A rollout says what kind of line it is in its own `type`, and the words it
787
+ * uses appear in no Claude Code transcript — so the two formats are told apart
788
+ * by the line rather than by anything the reader was told beforehand.
789
+ *
790
+ * What is read is what §5 asks a transcript for and a rollout answers: when a
791
+ * person last spoke, and where the session runs. The rest of the fold's facts —
792
+ * a session's todos, its teammates, the files it named — are Claude Code's own
793
+ * records, and a Codex session simply declares none of them.
794
+ *
795
+ * `developer` is not a person: Codex writes the instructions a turn runs under
796
+ * as messages of that role, so only `user` and `assistant` are read as somebody
797
+ * speaking. One of the `user` rows is not a person either — Codex opens a
798
+ * thread by stating the environment as `<environment_context>` — and it is the
799
+ * fold that turns that away, by the same rule that turns away every injected
800
+ * opening a Claude Code transcript carries: a row that opens with a tag is the
801
+ * harness talking. So the role decides who is read here, and what is read
802
+ * decides whether a person said it. */
803
+ function rolloutRecord(
804
+ row: Record<string, unknown>,
805
+ type: string | undefined,
806
+ ): TranscriptRecord | undefined {
807
+ if (type === "session_meta") {
808
+ const payload = isRecord(row["payload"]) ? row["payload"] : undefined;
809
+ return {
810
+ sidechain: false,
811
+ ...optional("said_at", instant(row["timestamp"])),
812
+ ...optional("cwd", payload === undefined ? undefined : str(payload["cwd"])),
813
+ };
814
+ }
815
+ if (type !== "response_item") return undefined;
816
+ const payload = isRecord(row["payload"]) ? row["payload"] : undefined;
817
+ if (payload === undefined || str(payload["type"]) !== "message") return { sidechain: false };
818
+ const role = str(payload["role"]);
819
+ return {
820
+ sidechain: false,
821
+ ...optional("said_at", instant(row["timestamp"])),
822
+ ...optional<"said_by", "user" | "agent">(
823
+ "said_by",
824
+ role === "user" ? "user" : role === "assistant" ? "agent" : undefined,
825
+ ),
826
+ ...optional("text", blockText(payload["content"])),
827
+ };
828
+ }
829
+
763
830
  /** The harness's record types, in the contract's two words. Its `assistant` is
764
831
  * the contract's `agent`; every other type is a record neither side spoke. */
765
832
  function saidBy(type: string | undefined): "user" | "agent" | undefined {
@@ -771,6 +838,8 @@ function optional<K extends string, V>(key: K, value: V | undefined): Record<K,
771
838
  return value === undefined ? {} : { [key]: value };
772
839
  }
773
840
 
841
+ const TEXT_BLOCK = new Set(["text", "input_text", "output_text"]);
842
+
774
843
  /** The text a message states, whoever wrote it. A plain prompt is a string; a
775
844
  * prompt with an attachment, and every row the harness writes, is a block
776
845
  * array whose text blocks carry the words. An array holding only tool results
@@ -781,7 +850,9 @@ function blockText(content: unknown): string | undefined {
781
850
  if (!Array.isArray(content)) return undefined;
782
851
  const parts: string[] = [];
783
852
  for (const block of content) {
784
- if (!isRecord(block) || block["type"] !== "text") continue;
853
+ // `text` is what Claude Code writes; `input_text` and `output_text` are
854
+ // what a Codex rollout writes for the same thing, one per direction.
855
+ if (!isRecord(block) || !TEXT_BLOCK.has(block["type"] as string)) continue;
785
856
  const text = str(block["text"]);
786
857
  if (text !== undefined) parts.push(text);
787
858
  }