@neta-art/cohub-cli 8.1.1 → 8.2.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,12 +1,13 @@
1
1
  import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { createClient } from "../client.js";
5
5
  import { currentIdentityKey } from "../space.js";
6
6
  import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
7
- import { readNativeTranscript, readNativeTranscriptHeader } from "./native-transcript.js";
7
+ import { readNativeTranscript, readNativeTranscriptHeader, codexSessionsDirectory, codexArchivedSessionsDirectory, codexRolloutIdOf } from "./native-transcript.js";
8
8
  import { findRuntimeNativeSession } from "./session-store.js";
9
9
  import { listNativeSyncStores, nativeIdentityHash, NativeSyncStore } from "./native-sync-store.js";
10
+ import { serializeDiagnosticError } from "./diagnostics.js";
10
11
  export const nativeRuntimeRoot = (spaceId) => join(homedir(), ".local", "state", "cohub", "runtime", spaceId);
11
12
  export const nativeSyncConfigPath = (runtimeRoot, identity) => join(runtimeRoot, "native", nativeIdentityHash(identity), "config.json");
12
13
  const piSessionDirectory = (cwd) => {
@@ -17,10 +18,11 @@ const piSessionDirectory = (cwd) => {
17
18
  const safePath = `--${cwd.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
18
19
  return join(agentDir, "sessions", safePath);
19
20
  };
20
- const codexSessionDirectory = () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions");
21
21
  const LARGE_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
22
22
  async function nativeTranscriptPaths(root, harness, signal) {
23
- const pending = [harness === "pi" ? piSessionDirectory(root) : codexSessionDirectory()];
23
+ // Codex keeps rollouts active or archived, plain or `.zst`-compressed; Pi writes plain JSONL.
24
+ const pending = harness === "pi" ? [piSessionDirectory(root)] : [codexSessionsDirectory(), codexArchivedSessionsDirectory()];
25
+ const accepts = harness === "pi" ? (name) => name.endsWith(".jsonl") : (name) => name.endsWith(".jsonl") || name.endsWith(".jsonl.zst");
24
26
  const paths = [];
25
27
  const errors = [];
26
28
  while (pending.length) {
@@ -38,7 +40,7 @@ async function nativeTranscriptPaths(root, harness, signal) {
38
40
  const path = join(directory, entry.name);
39
41
  if (entry.isDirectory())
40
42
  pending.push(path);
41
- else if (entry.isFile() && entry.name.endsWith(".jsonl"))
43
+ else if (entry.isFile() && accepts(entry.name))
42
44
  paths.push(path);
43
45
  }
44
46
  }
@@ -48,10 +50,68 @@ async function nativeTranscriptPaths(root, harness, signal) {
48
50
  export async function discoverNativeImportCandidates(root, harnesses, options = {}) {
49
51
  const candidates = [];
50
52
  const errors = [];
53
+ /** Same-thread rollouts left out of import; they may carry Turns no candidate contains. */
54
+ const superseded = [];
51
55
  for (const harness of harnesses) {
52
56
  options.signal?.throwIfAborted();
53
57
  const scanned = await nativeTranscriptPaths(root, harness, options.signal);
54
58
  errors.push(...scanned.errors);
59
+ // One rollout chain per thread: every rollout referenced by a same-thread file is
60
+ // superseded by it (rollover / revert); a parent referenced only from a different thread
61
+ // (fork) still owns its own Turns and stays importable. Headers are read once and reused.
62
+ const headers = new Map();
63
+ const absorbedRollouts = new Set();
64
+ if (harness === "codex") {
65
+ // Bounded concurrency: one descriptor per open file across the global Codex sessions tree.
66
+ let next = 0;
67
+ const readHeaders = async () => {
68
+ for (;;) {
69
+ const path = scanned.paths[next++];
70
+ if (!path)
71
+ return;
72
+ try {
73
+ headers.set(path, await readNativeTranscriptHeader(path, harness, options.signal));
74
+ }
75
+ catch (error) {
76
+ if (options.signal?.aborted)
77
+ throw error;
78
+ errors.push({ harness, path, message: error instanceof Error ? error.message : String(error) });
79
+ }
80
+ }
81
+ };
82
+ await Promise.all(Array.from({ length: Math.min(16, scanned.paths.length) }, readHeaders));
83
+ const threadByRollout = new Map();
84
+ for (const header of headers.values())
85
+ if (header.rolloutId)
86
+ threadByRollout.set(header.rolloutId, header.nativeSessionId);
87
+ for (const header of headers.values()) {
88
+ const base = header.historyBase;
89
+ if (base && threadByRollout.get(base.rolloutId) === header.nativeSessionId)
90
+ absorbedRollouts.add(base.rolloutId);
91
+ }
92
+ }
93
+ // One candidate per thread: several same-thread rollouts can survive absorption (two
94
+ // siblings reverting from the same parent). The newest by rollout filename timestamp wins;
95
+ // the losers are reported, never imported twice under one nativeSessionId.
96
+ const supersededSiblings = new Set();
97
+ if (harness === "codex") {
98
+ const byThread = new Map();
99
+ for (const [path, header] of headers) {
100
+ if (absorbedRollouts.has(header.rolloutId ?? ""))
101
+ continue;
102
+ const list = byThread.get(header.nativeSessionId) ?? [];
103
+ list.push({ path, rolloutId: header.rolloutId ?? "", sortKey: basename(path) });
104
+ byThread.set(header.nativeSessionId, list);
105
+ }
106
+ for (const list of byThread.values()) {
107
+ if (list.length < 2)
108
+ continue;
109
+ const newest = list.reduce((a, b) => (a.sortKey > b.sortKey ? a : b));
110
+ for (const sibling of list)
111
+ if (sibling !== newest)
112
+ supersededSiblings.add(sibling.path);
113
+ }
114
+ }
55
115
  let start = 0;
56
116
  while (start < scanned.paths.length) {
57
117
  options.signal?.throwIfAborted();
@@ -60,9 +120,16 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
60
120
  const concurrency = sizes.some((size) => size > LARGE_TRANSCRIPT_BYTES) ? 1 : 2;
61
121
  const batch = await Promise.all(paths.slice(0, concurrency).map(async (path) => {
62
122
  try {
63
- const header = await readNativeTranscriptHeader(path, harness, options.signal);
123
+ // Pre-scan failures are already reported; do not read the header again.
124
+ const header = headers.get(path) ?? (harness === "pi" ? await readNativeTranscriptHeader(path, harness, options.signal) : null);
125
+ if (!header)
126
+ return null;
64
127
  if (await canonicalRuntimeRoot(header.cwd) !== root)
65
128
  return null;
129
+ if (header.rolloutId && absorbedRollouts.has(header.rolloutId))
130
+ return null;
131
+ if (supersededSiblings.has(path))
132
+ return { superseded: { path, nativeSessionId: header.nativeSessionId, message: "Superseded by a newer rollout of the same conversation; its Turns are not imported" } };
66
133
  const transcript = await readNativeTranscript(path, harness, { settled: true, signal: options.signal });
67
134
  if (transcript.turns.length === 0)
68
135
  return { error: { harness, path, message: "Transcript contains no importable Turns" } };
@@ -77,10 +144,10 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
77
144
  options.signal?.throwIfAborted();
78
145
  if (!result)
79
146
  continue;
80
- if ("error" in result) {
81
- if (result.error)
82
- errors.push(result.error);
83
- }
147
+ if ("superseded" in result)
148
+ superseded.push(result.superseded);
149
+ else if ("error" in result)
150
+ errors.push(result.error);
84
151
  else
85
152
  candidates.push(result);
86
153
  options.onProgress?.({ harness, scanned: start + batch.length, candidates: candidates.length });
@@ -88,7 +155,7 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
88
155
  start += concurrency;
89
156
  }
90
157
  }
91
- return { candidates, errors };
158
+ return { candidates, errors, superseded };
92
159
  }
93
160
  export function nativeArchiveTransport(spaceId, identity) {
94
161
  const client = createClient().space(spaceId);
@@ -149,21 +216,30 @@ export async function captureNativeSession(input) {
149
216
  const transcript = await readNativeTranscript(path, input.harness, input);
150
217
  if (await canonicalPathOrNull(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
151
218
  throw new Error("Native transcript belongs to another project or Session");
219
+ // The path stays in the cache key: restored Pi working copies share a native ID but not a file.
152
220
  const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
153
221
  let store = nativeStores.get(key);
154
222
  if (!store) {
155
223
  const transport = nativeArchiveTransport(space.spaceId, identity);
224
+ const lineage = input.harness === "codex" ? transcript.lineageRolloutIds ?? [] : [];
156
225
  const candidates = (await listNativeSyncStores(runtimeRoot, space.spaceId, identity, transport))
157
226
  .filter((candidate) => candidate.options.harness === input.harness && candidate.options.nativeSessionId === transcript.nativeSessionId);
158
- for (const candidate of candidates)
159
- if ((await candidate.binding()).path === path) {
227
+ // A Codex rollover moves appends to a fresh rollout that references the old one: a store
228
+ // bound anywhere in the new leaf's lineage is the same conversation, so keep using it —
229
+ // the binding migrates inside capture. Exact-path matches win; lineage matches follow.
230
+ for (const candidate of candidates) {
231
+ const binding = await candidate.binding();
232
+ if (binding.path === path) {
160
233
  store = candidate;
161
234
  break;
162
235
  }
236
+ if (!store && lineage.includes(codexRolloutIdOf(basename(binding.path)) ?? ""))
237
+ store = candidate;
238
+ }
163
239
  if (!store) {
164
240
  const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
165
241
  const managedPath = managed ? await canonicalPathOrNull(managed.path) : null;
166
- if (candidates.length && managedPath !== path)
242
+ if (candidates.length && managedPath !== path && !(managedPath && lineage.includes(codexRolloutIdOf(basename(managedPath)) ?? "")))
167
243
  throw new Error("Native path changed; original bindings retained");
168
244
  // Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
169
245
  store = new NativeSyncStore({ runtimeRoot, spaceId: space.spaceId, identity, harness: input.harness, nativeSessionId: transcript.nativeSessionId,
@@ -173,7 +249,7 @@ export async function captureNativeSession(input) {
173
249
  nativeStores.delete(nativeStores.keys().next().value ?? "");
174
250
  nativeStores.set(key, store);
175
251
  }
176
- await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin });
252
+ await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin, signal: input.signal });
177
253
  return store;
178
254
  }
179
255
  /** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
@@ -187,6 +263,35 @@ export function nativeWebSocketTransport(spaceId, identity, send) {
187
263
  heartbeatNativeTurn: async (sessionId, turnId) => await send({ type: "heartbeat", sessionId, turnId }),
188
264
  };
189
265
  }
266
+ export const NATIVE_SYNC_SESSION_CONCURRENCY = 4;
267
+ export async function runNativeSyncPool(items, concurrency, signal, processItem) {
268
+ let next = 0;
269
+ const worker = async () => {
270
+ while (!signal.aborted) {
271
+ const index = next++;
272
+ if (index >= items.length)
273
+ return;
274
+ await processItem(items[index]);
275
+ }
276
+ };
277
+ const results = await Promise.allSettled(Array.from({ length: Math.min(items.length, Math.max(1, concurrency)) }, worker));
278
+ const failure = results.find((result) => result.status === "rejected");
279
+ if (failure)
280
+ throw failure.reason;
281
+ }
282
+ export function summarizeNativeSyncErrors(errors) {
283
+ const groups = new Map();
284
+ for (const error of errors) {
285
+ const value = serializeDiagnosticError(error);
286
+ const key = JSON.stringify([value.name, value.code, value.status, value.causeCode, value.message]);
287
+ const group = groups.get(key);
288
+ if (group)
289
+ group.count += 1;
290
+ else
291
+ groups.set(key, { name: value.name ?? "Error", message: value.message, ...(value.code ? { code: value.code } : {}), ...(value.status ? { status: value.status } : {}), count: 1 });
292
+ }
293
+ return [...groups.values()];
294
+ }
190
295
  export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
191
296
  if (currentIdentityKey() !== identity)
192
297
  return false;
@@ -199,12 +304,12 @@ export async function flushNativeSessions(spaceId, identity, signal, report, tra
199
304
  return false;
200
305
  const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
201
306
  let failed = false;
202
- for (const store of stores) {
203
- signal.throwIfAborted();
204
- const binding = await store.binding();
205
- if (!config.harnesses.includes(binding.harness))
206
- continue;
307
+ await runNativeSyncPool(stores, NATIVE_SYNC_SESSION_CONCURRENCY, signal, async (store) => {
207
308
  try {
309
+ signal.throwIfAborted();
310
+ const binding = await store.binding();
311
+ if (!config.harnesses.includes(binding.harness))
312
+ return;
208
313
  // Codex hooks trigger captures; re-reading here also closes gaps after reconnects or missed hooks.
209
314
  if (binding.harness === "codex")
210
315
  await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
@@ -216,6 +321,6 @@ export async function flushNativeSessions(spaceId, identity, signal, report, tra
216
321
  report(error);
217
322
  }
218
323
  }
219
- }
324
+ });
220
325
  return failed;
221
326
  }
@@ -12,6 +12,12 @@ export type NativeTranscriptTurn = {
12
12
  boundaries: Record<number, string>;
13
13
  sha256: string;
14
14
  result: NativeTurnComplete | null;
15
+ /** Codex lineage: the rollout file this Turn was read from. */
16
+ path?: string;
17
+ /** Codex lineage: the rollout id of the file this Turn was read from. */
18
+ rolloutId?: string;
19
+ /** Codex lineage: merged order across rollout files. */
20
+ sequence?: number;
15
21
  };
16
22
  export type NativeTranscript = {
17
23
  nativeSessionId: string;
@@ -19,14 +25,36 @@ export type NativeTranscript = {
19
25
  cloudSessionId?: string;
20
26
  turns: NativeTranscriptTurn[];
21
27
  prefixes: ReadonlyMap<number, string>;
28
+ /** Codex lineage: every rollout file oldest-to-leaf, including the leaf itself. */
29
+ lineagePaths?: string[];
30
+ /** Codex lineage: rollout ids matching `lineagePaths` order. */
31
+ lineageRolloutIds?: string[];
22
32
  };
23
33
  export declare const NATIVE_MAX_RECORD_BYTES: number;
24
34
  export declare const NATIVE_MAX_TRANSCRIPT_BYTES: number;
25
- /** Read only the session header before deciding whether a global Codex file belongs to this project. */
26
- export declare function readNativeTranscriptHeader(path: string, harness: "pi" | "codex", signal?: AbortSignal): Promise<{
35
+ export declare const codexSessionsDirectory: () => string;
36
+ export declare const codexArchivedSessionsDirectory: () => string;
37
+ /** Ordinary rollout names encode one id; reverted threads append `_rollout-id`. */
38
+ export declare function codexRolloutIdOf(fileName: string): string | null;
39
+ export type CodexHistoryBase = {
40
+ rolloutId: string;
41
+ endOrdinal: number;
42
+ endBytes: number;
43
+ };
44
+ export type NativeTranscriptHeader = {
27
45
  nativeSessionId: string;
28
46
  cwd: string;
29
- }>;
47
+ rolloutId?: string;
48
+ historyBase?: CodexHistoryBase;
49
+ };
50
+ /** Read only the session header before deciding whether a global Codex file belongs to this project. */
51
+ export declare function readNativeTranscriptHeader(path: string, harness: "pi" | "codex", signal?: AbortSignal): Promise<NativeTranscriptHeader>;
52
+ /** Raw JSONL bytes of a rollout, transparently decompressing `.zst` and honoring a lineage cutoff. */
53
+ export declare function codexRolloutBytes(path: string, endBytes: number | null, signal?: AbortSignal): AsyncGenerator<Buffer>;
54
+ /** Cache location for a rollout's decompressed copy; shared by capture and archival. */
55
+ export declare function codexRolloutCachePath(source: string, cacheDirectory: string): string;
56
+ /** Archives need plain JSONL bytes; decompress a cold `.zst` rollout into a cache once. */
57
+ export declare function ensurePlainCodexRollout(path: string, cacheDirectory: string, signal?: AbortSignal, maxBytes?: number): Promise<string>;
30
58
  /** Partial trailing records are retried, never parsed or acknowledged as complete. */
31
59
  export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
32
60
  settled?: boolean;