iteration-cli 0.4.4 → 0.6.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,4 +1,8 @@
1
- function safeWorkspaceName(value) {
1
+ import { collectorSourceLabel, } from "iteration-trace-contracts";
2
+ function sourceName(source) {
3
+ return collectorSourceLabel(source);
4
+ }
5
+ export function safeWorkspaceName(value) {
2
6
  const cleaned = value
3
7
  ?.split("")
4
8
  .map((character) => {
@@ -41,11 +45,11 @@ function relativeTime(atMs, nowMs) {
41
45
  function pendingDescription(status) {
42
46
  return `${status.pendingEvents} event${status.pendingEvents === 1 ? "" : "s"} (${readableBytes(status.pendingBytes)}) waiting on this Mac`;
43
47
  }
44
- function progressDescription(scan) {
48
+ function progressDescription(scan, source) {
45
49
  if (scan.kind === "waiting")
46
50
  return "Local history is preparing to scan.";
47
51
  if (scan.totalBytes === 0)
48
- return "No local Codex sessions found yet.";
52
+ return `No local ${sourceName(source)} sessions found yet.`;
49
53
  if (scan.scannedBytes >= scan.totalBytes)
50
54
  return `Local history scanned (${readableBytes(scan.totalBytes)}).`;
51
55
  const remainingBytes = scan.totalBytes - scan.scannedBytes;
@@ -54,9 +58,9 @@ function progressDescription(scan) {
54
58
  const percentage = Math.min(99, Math.floor((scan.scannedBytes / scan.totalBytes) * 100));
55
59
  return `About ${percentage}% of local history scanned (${readableBytes(scan.scannedBytes)} of ${readableBytes(scan.totalBytes)}).`;
56
60
  }
57
- function historyDescription(status) {
61
+ function historyDescription(status, source) {
58
62
  if (status.health.historyCaughtUpAtMs === null) {
59
- return progressDescription(status.scan);
63
+ return progressDescription(status.scan, source);
60
64
  }
61
65
  if (status.scan.kind === "waiting")
62
66
  return "History is preparing to scan.";
@@ -67,7 +71,8 @@ function historyDescription(status) {
67
71
  return "History imported; scanning current activity.";
68
72
  return `History imported; ${readableBytes(remainingBytes)} of new activity waiting to scan.`;
69
73
  }
70
- function failureDescription(status) {
74
+ function failureDescription(status, source) {
75
+ const name = sourceName(source);
71
76
  const failure = status.health.lastFailure;
72
77
  if (failure === null) {
73
78
  throw new Error("A collector failure is required to describe it.");
@@ -76,7 +81,7 @@ function failureDescription(status) {
76
81
  const code = failure.status === null ? "HTTP error" : `HTTP ${failure.status}`;
77
82
  if (failure.status === 401 || failure.status === 403) {
78
83
  return {
79
- heading: "Codex upload access needs attention",
84
+ heading: `${name} upload access needs attention`,
80
85
  explanation: `Iteration denied this collector (${code}). Queued data stays on this Mac. Contact Iteration support to reconnect it safely.`,
81
86
  };
82
87
  }
@@ -84,51 +89,52 @@ function failureDescription(status) {
84
89
  failure.status < 500 &&
85
90
  failure.status !== 429) {
86
91
  return {
87
- heading: "Codex uploads need attention",
92
+ heading: `${name} uploads need attention`,
88
93
  explanation: `Iteration rejected an event batch (${code}). Check the collector log for details.`,
89
94
  };
90
95
  }
91
96
  return {
92
- heading: "Codex uploads are delayed",
97
+ heading: `${name} uploads are delayed`,
93
98
  explanation: `Iteration could not accept an upload (${code}). Retrying automatically.`,
94
99
  };
95
100
  }
96
101
  if (failure.kind === "network") {
97
102
  return {
98
- heading: "Codex uploads are delayed",
103
+ heading: `${name} uploads are delayed`,
99
104
  explanation: "Could not reach Iteration. Retrying automatically.",
100
105
  };
101
106
  }
102
107
  if (failure.kind === "response") {
103
108
  return {
104
- heading: "Codex uploads need attention",
109
+ heading: `${name} uploads need attention`,
105
110
  explanation: "Iteration returned an unexpected upload response. Retrying automatically.",
106
111
  };
107
112
  }
108
113
  return {
109
- heading: "Codex collector needs attention",
110
- explanation: "A local error interrupted collection. See iteration status codex for details.",
114
+ heading: `${name} collector needs attention`,
115
+ explanation: `A local error interrupted collection. See iteration status ${source} for details.`,
111
116
  };
112
117
  }
113
- export function renderHome(status, nowMs = Date.now()) {
118
+ export function renderHome(status, nowMs = Date.now(), source = "codex") {
119
+ const name = sourceName(source);
114
120
  if (status.kind === "not_connected") {
115
121
  return [
116
122
  "Iteration",
117
123
  "",
118
- "Codex is not connected.",
119
- "Run iteration connect codex to import your existing sessions.",
124
+ `${name} is not connected.`,
125
+ "Run iteration connect to import your existing sessions.",
120
126
  ].join("\n");
121
127
  }
122
128
  if (status.kind === "needs_repair") {
123
129
  return [
124
130
  "Iteration",
125
131
  "",
126
- "Codex connection needs repair",
132
+ `${name} connection needs repair`,
127
133
  "Local collector data exists, but its connection details are missing.",
128
134
  status.pendingBytes > 0
129
135
  ? `${readableBytes(status.pendingBytes)} of queued data remains on this Mac.`
130
136
  : "Existing local progress remains on this Mac.",
131
- "Run iteration status codex for recovery details.",
137
+ `Run iteration status ${source} for recovery details.`,
132
138
  ].join("\n");
133
139
  }
134
140
  const workspaceName = safeWorkspaceName(status.workspaceName);
@@ -137,54 +143,54 @@ export function renderHome(status, nowMs = Date.now()) {
137
143
  "",
138
144
  ];
139
145
  if (!status.running) {
140
- lines.push("Codex is stopped", "New sessions are not being collected.");
146
+ lines.push(`${name} is stopped`, "New sessions are not being collected.");
141
147
  if (status.pendingEvents > 0)
142
148
  lines.push(`${pendingDescription(status)}.`);
143
- lines.push("Resume with iteration start codex.");
149
+ lines.push(`Resume with iteration start ${source}.`);
144
150
  }
145
151
  else if (!status.healthAvailable) {
146
- lines.push("Codex is running; local status needs attention", "Upload history could not be read. Collection continues in the background.");
152
+ lines.push(`${name} is running; local status needs attention`, "Upload history could not be read. Collection continues in the background.");
147
153
  if (status.pendingEvents > 0)
148
154
  lines.push(`${pendingDescription(status)}.`);
149
- lines.push("Details: iteration status codex");
155
+ lines.push(`Details: iteration status ${source}`);
150
156
  }
151
157
  else if (status.health.lastFailure !== null) {
152
- const failure = failureDescription(status);
158
+ const failure = failureDescription(status, source);
153
159
  lines.push(failure.heading, failure.explanation);
154
160
  if (status.pendingEvents > 0)
155
161
  lines.push(`${pendingDescription(status)}.`);
156
162
  if (status.health.lastUploadAtMs !== null) {
157
163
  lines.push(`Last successful upload ${relativeTime(status.health.lastUploadAtMs, nowMs)}.`);
158
164
  }
159
- lines.push("Details: iteration status codex");
165
+ lines.push(`Details: iteration status ${source}`);
160
166
  }
161
167
  else if (status.scan.kind === "waiting") {
162
- lines.push("Codex is preparing your history", progressDescription(status.scan));
168
+ lines.push(`${name} is preparing your history`, progressDescription(status.scan, source));
163
169
  }
164
170
  else if (status.scan.kind === "scanning" &&
165
171
  status.scan.totalBytes > 0 &&
166
172
  status.scan.scannedBytes < status.scan.totalBytes &&
167
173
  status.health.historyCaughtUpAtMs === null) {
168
- lines.push("Codex is importing existing sessions", historyDescription(status), "New activity is collected automatically.");
174
+ lines.push(`${name} is importing existing sessions`, historyDescription(status, source), "New activity is collected automatically.");
169
175
  }
170
176
  else if (status.scan.kind === "scanning" &&
171
177
  status.scan.totalBytes - status.scan.scannedBytes >= 1_000_000) {
172
- lines.push("Codex is catching up with new activity", historyDescription(status));
178
+ lines.push(`${name} is catching up with new activity`, historyDescription(status, source));
173
179
  if (status.pendingEvents > 0)
174
180
  lines.push(`${pendingDescription(status)}.`);
175
181
  }
176
182
  else if (status.pendingEvents > 0) {
177
- lines.push("Codex is uploading captured sessions", `${pendingDescription(status)}.`);
183
+ lines.push(`${name} is uploading captured sessions`, `${pendingDescription(status)}.`);
178
184
  }
179
185
  else if (status.scan.totalBytes === 0) {
180
- lines.push("Codex is connected", "Waiting for Codex sessions on this Mac.");
186
+ lines.push(`${name} is connected`, `Waiting for ${name} sessions on this Mac.`);
181
187
  }
182
188
  else if (status.scan.kind === "scanning" &&
183
189
  status.scan.scannedBytes < status.scan.totalBytes) {
184
- lines.push("Codex is collecting new activity", "Local history is imported; current activity is being scanned.");
190
+ lines.push(`${name} is collecting new activity`, "Local history is imported; current activity is being scanned.");
185
191
  }
186
192
  else {
187
- lines.push("Codex is collecting", "Local scan is caught up and no uploads are waiting.", "New activity is collected automatically.");
193
+ lines.push(`${name} is collecting`, "Local scan is caught up and no uploads are waiting.", "New activity is collected automatically.");
188
194
  }
189
195
  if (status.running &&
190
196
  status.health.lastFailure === null &&
@@ -194,13 +200,14 @@ export function renderHome(status, nowMs = Date.now()) {
194
200
  lines.push("", `Explore traces ${status.tracesUrl}`);
195
201
  return lines.join("\n");
196
202
  }
197
- export function renderDetailedStatus(status, nowMs = Date.now()) {
203
+ export function renderDetailedStatus(status, nowMs = Date.now(), source = "codex") {
204
+ const name = sourceName(source);
198
205
  if (status.kind === "not_connected") {
199
- return "Codex is not connected. Run iteration connect codex to connect.";
206
+ return `${name} is not connected. Run iteration connect to connect.`;
200
207
  }
201
208
  if (status.kind === "needs_repair") {
202
209
  return [
203
- "Codex connection needs repair",
210
+ `${name} connection needs repair`,
204
211
  "Local collector data exists without its connection details.",
205
212
  `Queued data: ${readableBytes(status.pendingBytes)}`,
206
213
  `Restore the missing config from backup: ${status.configPath}`,
@@ -209,10 +216,10 @@ export function renderDetailedStatus(status, nowMs = Date.now()) {
209
216
  }
210
217
  const workspaceName = safeWorkspaceName(status.workspaceName);
211
218
  const lines = [
212
- "Codex collector",
219
+ `${name} collector`,
213
220
  ...(workspaceName === null ? [] : [`Workspace: ${workspaceName}`]),
214
221
  `Background process: ${status.running ? "Running" : "Stopped"}`,
215
- `History: ${historyDescription(status).replace(/^History /, "")}`,
222
+ `History: ${historyDescription(status, source).replace(/^History /, "")}`,
216
223
  `Uploads: ${status.pendingEvents === 0 ? "None waiting" : pendingDescription(status)}`,
217
224
  ];
218
225
  if (!status.healthAvailable) {
@@ -233,7 +240,7 @@ export function renderDetailedStatus(status, nowMs = Date.now()) {
233
240
  lines.push(`Last issue: ${label} (${relativeTime(failure.atMs, nowMs)})`, `Collector log: ${status.logPath}`);
234
241
  }
235
242
  if (!status.running)
236
- lines.push("Resume: iteration start codex");
243
+ lines.push(`Resume: iteration start ${source}`);
237
244
  lines.push(`Explore traces: ${status.tracesUrl}`);
238
245
  return lines.join("\n");
239
246
  }
package/dist/storage.d.ts CHANGED
@@ -1,11 +1,19 @@
1
- import { type TraceEvent } from "iteration-trace-contracts";
2
- export type CollectorConfig = Readonly<{
1
+ import { type CollectorSource, type TraceEvent } from "iteration-trace-contracts";
2
+ type CollectorConnection = Readonly<{
3
3
  endpoint: string;
4
4
  token: string;
5
- codexHome: string;
6
5
  dataDirectory: string;
6
+ organizationId?: string;
7
7
  organizationName?: string;
8
8
  }>;
9
+ export type CollectorConfig = (CollectorConnection & Readonly<{
10
+ codexHome: string;
11
+ }>) | (CollectorConnection & Readonly<{
12
+ source: "claude";
13
+ claudeHome: string;
14
+ }>);
15
+ export declare function collectorSource(config: CollectorConfig): CollectorSource;
16
+ export declare function collectorSourceDirectory(config: CollectorConfig): string;
9
17
  export type CollectorState = Readonly<{
10
18
  cursors: Readonly<Record<string, number>>;
11
19
  skipping: readonly string[];
@@ -37,9 +45,10 @@ export type CollectorPaths = Readonly<{
37
45
  historyBackfill: string;
38
46
  health: string;
39
47
  }>;
48
+ export declare function defaultCollectorDirectory(source: CollectorSource): string;
40
49
  export declare function validateCollectorEndpoint(endpoint: string): URL;
41
50
  export declare function collectorPaths(directory?: string): CollectorPaths;
42
- export declare function assertFreshCollectorDirectory(paths: CollectorPaths): Promise<void>;
51
+ export declare function assertFreshCollectorDirectory(paths: CollectorPaths, source?: CollectorSource): Promise<void>;
43
52
  export declare function ensurePrivateDirectory(directory: string): Promise<void>;
44
53
  export declare function saveConfig(config: CollectorConfig, paths: CollectorPaths): Promise<void>;
45
54
  export declare function loadHealth(paths: CollectorPaths): Promise<CollectorHealth>;
@@ -53,23 +62,26 @@ export declare function pendingEventIds(paths: CollectorPaths): Promise<Set<stri
53
62
  export declare function appendEvents(events: readonly TraceEvent[], paths: CollectorPaths): Promise<void>;
54
63
  export declare function nextBatch(paths: CollectorPaths, maxBytes: number): Promise<readonly TraceEvent[]>;
55
64
  export declare function acknowledgeEvents(acknowledgedIds: ReadonlySet<string>, paths: CollectorPaths): Promise<void>;
56
- export declare function initializeState(codexHome: string, paths: CollectorPaths, includeHistory: boolean): Promise<void>;
57
- export declare function prepareInitialState(codexHome: string, includeHistory: boolean): Promise<CollectorState>;
65
+ export declare function initializeState(source: CollectorSource, home: string, paths: CollectorPaths, includeHistory: boolean): Promise<void>;
66
+ export declare function prepareSourceInitialState(source: CollectorSource, home: string, includeHistory: boolean): Promise<CollectorState>;
58
67
  export declare function ensureStartableState(config: CollectorConfig, paths: CollectorPaths): Promise<boolean>;
59
68
  export declare function ensureHistoryBackfill(codexHome: string, paths: CollectorPaths): Promise<boolean>;
60
- export declare function historyScanProgress(codexHome: string, paths: CollectorPaths): Promise<Readonly<{
69
+ export declare function ensureClaudeHistoryBackfill(claudeHome: string, paths: CollectorPaths): Promise<boolean>;
70
+ export declare function historyScanProgress(home: string, paths: CollectorPaths, source?: CollectorSource): Promise<Readonly<{
61
71
  kind: "waiting";
62
72
  } | {
63
73
  kind: "scanning";
64
74
  scannedBytes: number;
65
75
  totalBytes: number;
66
76
  }>>;
67
- export declare function resetHistoryCursors(codexHome: string, paths: CollectorPaths): Promise<Readonly<{
77
+ export declare function resetHistoryCursors(home: string, paths: CollectorPaths, source?: CollectorSource): Promise<Readonly<{
68
78
  files: number;
69
79
  bytes: number;
70
80
  }>>;
71
- export declare function historyImportSize(codexHome: string): Promise<Readonly<{
81
+ export declare function historyImportSize(home: string, source?: CollectorSource): Promise<Readonly<{
72
82
  files: number;
73
83
  bytes: number;
74
84
  }>>;
75
- export declare function rolloutFiles(root: string): Promise<string[]>;
85
+ export declare function claudeFiles(root: string): Promise<string[]>;
86
+ export declare function sourceFiles(source: CollectorSource, home: string): Promise<string[]>;
87
+ export {};
package/dist/storage.js CHANGED
@@ -1,20 +1,34 @@
1
+ import { once } from "node:events";
1
2
  import { createWriteStream, promises as fs } from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
- import { once } from "node:events";
5
5
  import { createInterface } from "node:readline";
6
- import * as z from "zod";
7
6
  import { MAX_TRACE_EVENTS_PER_BATCH, parseTraceEventBatch, } from "iteration-trace-contracts";
7
+ import * as z from "zod";
8
8
  const MAX_OUTBOX_BYTES = 64 * 1024 * 1024;
9
- const configSchema = z
10
- .object({
9
+ export function collectorSource(config) {
10
+ return "claudeHome" in config ? "claude" : "codex";
11
+ }
12
+ export function collectorSourceDirectory(config) {
13
+ return "claudeHome" in config ? config.claudeHome : config.codexHome;
14
+ }
15
+ const connectionSchema = {
11
16
  endpoint: z.string(),
12
17
  token: z.string(),
13
- codexHome: z.string(),
14
18
  dataDirectory: z.string(),
19
+ organizationId: z.uuid().optional(),
15
20
  organizationName: z.string().min(1).max(160).optional(),
16
- })
17
- .strict();
21
+ };
22
+ const configSchema = z.union([
23
+ z.object({ ...connectionSchema, codexHome: z.string() }).strict(),
24
+ z
25
+ .object({
26
+ ...connectionSchema,
27
+ source: z.literal("claude"),
28
+ claudeHome: z.string(),
29
+ })
30
+ .strict(),
31
+ ]);
18
32
  const stateSchema = z
19
33
  .object({
20
34
  cursors: z.record(z.string(), z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)),
@@ -55,8 +69,8 @@ function parseOutboxEvent(line) {
55
69
  throw new Error("Trace outbox event is missing.");
56
70
  return event;
57
71
  }
58
- function defaultDirectory() {
59
- return path.join(os.homedir(), ".config", "iteration", "codex-collector");
72
+ export function defaultCollectorDirectory(source) {
73
+ return path.join(os.homedir(), ".config", "iteration", `${source}-collector`);
60
74
  }
61
75
  export function validateCollectorEndpoint(endpoint) {
62
76
  let parsed;
@@ -82,7 +96,7 @@ export function validateCollectorEndpoint(endpoint) {
82
96
  }
83
97
  return parsed;
84
98
  }
85
- export function collectorPaths(directory = defaultDirectory()) {
99
+ export function collectorPaths(directory = defaultCollectorDirectory("codex")) {
86
100
  return {
87
101
  config: path.join(directory, "config.json"),
88
102
  state: path.join(directory, "state.json"),
@@ -92,7 +106,7 @@ export function collectorPaths(directory = defaultDirectory()) {
92
106
  health: path.join(directory, "health.json"),
93
107
  };
94
108
  }
95
- export async function assertFreshCollectorDirectory(paths) {
109
+ export async function assertFreshCollectorDirectory(paths, source = "codex") {
96
110
  const state = await loadState(paths);
97
111
  const configExists = await fs
98
112
  .access(paths.config)
@@ -103,7 +117,7 @@ export async function assertFreshCollectorDirectory(paths) {
103
117
  throw error;
104
118
  });
105
119
  if (configExists) {
106
- throw new Error("This collector data directory is already configured. Run iteration status codex or iteration start codex to repair startup. Use a different --data-directory for another workspace, or iteration import-history codex for earlier sessions.");
120
+ throw new Error(`This collector data directory is already configured. Run iteration status ${source} or iteration start ${source} to repair startup. Use a different --data-directory for another workspace, or iteration import-history ${source} for earlier sessions.`);
107
121
  }
108
122
  if (state.initialized || (await outboxSize(paths)) > 0) {
109
123
  throw new Error("Collector state or pending events exist without a config. Use a different --data-directory; this data cannot be safely assigned to a new workspace.");
@@ -202,12 +216,12 @@ export async function loadConfig(paths) {
202
216
  throw new Error("Collector config has an invalid shape.", {
203
217
  cause: parsed.error,
204
218
  });
205
- const { endpoint, token, codexHome, dataDirectory, organizationName } = parsed.data;
206
- validateCollectorEndpoint(endpoint);
219
+ validateCollectorEndpoint(parsed.data.endpoint);
220
+ const { token } = parsed.data;
207
221
  if (token.length !== 43 || !/^[A-Za-z0-9_-]+$/.test(token)) {
208
222
  throw new Error("Collector token has an invalid format.");
209
223
  }
210
- return { endpoint, token, codexHome, dataDirectory, organizationName };
224
+ return parsed.data;
211
225
  }
212
226
  export async function loadState(paths) {
213
227
  let contents;
@@ -372,24 +386,23 @@ export async function acknowledgeEvents(acknowledgedIds, paths) {
372
386
  throw error;
373
387
  }
374
388
  }
375
- export async function initializeState(codexHome, paths, includeHistory) {
389
+ export async function initializeState(source, home, paths, includeHistory) {
376
390
  const state = await loadState(paths);
377
- const sourceDirectory = path.resolve(codexHome);
391
+ const sourceDirectory = path.resolve(home);
378
392
  if (state.initialized && state.sourceDirectory !== sourceDirectory) {
379
- throw new Error("Collector state belongs to another Codex directory; use a separate --data-directory.");
393
+ throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
380
394
  }
381
395
  if (state.initialized && state.sourceDirectory === sourceDirectory) {
382
396
  if (includeHistory) {
383
- throw new Error("Codex is already connected. Run iteration import-history codex to import existing sessions.");
397
+ throw new Error(`${source} is already connected. Run iteration import-history ${source} to import existing sessions.`);
384
398
  }
385
399
  return;
386
400
  }
387
- await saveState(await prepareInitialState(codexHome, includeHistory), paths);
401
+ await saveState(await prepareSourceInitialState(source, home, includeHistory), paths);
388
402
  }
389
- export async function prepareInitialState(codexHome, includeHistory) {
390
- const sourceDirectory = path.resolve(codexHome);
391
- const sessionsRoot = path.join(sourceDirectory, "sessions");
392
- const files = await rolloutFiles(sessionsRoot);
403
+ export async function prepareSourceInitialState(source, home, includeHistory) {
404
+ const sourceDirectory = path.resolve(home);
405
+ const files = await sourceFiles(source, sourceDirectory);
393
406
  const cursors = {};
394
407
  if (!includeHistory) {
395
408
  for (const filename of files) {
@@ -404,20 +417,26 @@ export async function prepareInitialState(codexHome, includeHistory) {
404
417
  };
405
418
  }
406
419
  export async function ensureStartableState(config, paths) {
420
+ const source = collectorSource(config);
421
+ const directory = collectorSourceDirectory(config);
407
422
  const state = await loadState(paths);
408
423
  if (state.initialized) {
409
- if (state.sourceDirectory !== path.resolve(config.codexHome)) {
410
- throw new Error("Collector state belongs to another Codex directory; use a separate --data-directory.");
424
+ if (state.sourceDirectory !== path.resolve(directory)) {
425
+ throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
411
426
  }
412
427
  return false;
413
428
  }
414
429
  if ((await outboxSize(paths)) > 0) {
415
430
  throw new Error("Pending events exist without an initialized checkpoint; repair the collector state before starting uploads.");
416
431
  }
417
- await initializeState(config.codexHome, paths, true);
432
+ await saveState(await prepareSourceInitialState(source, directory, true), paths);
418
433
  return true;
419
434
  }
420
- async function hasHistoryBackfillMarker(paths) {
435
+ const HISTORY_BACKFILL_VERSION = 4;
436
+ const historyBackfillMarkerSchema = z
437
+ .object({ version: z.number().int().min(1).max(HISTORY_BACKFILL_VERSION) })
438
+ .strict();
439
+ async function readHistoryBackfillVersion(paths) {
421
440
  let marker;
422
441
  try {
423
442
  marker = await fs.readFile(paths.historyBackfill, "utf8");
@@ -425,18 +444,28 @@ async function hasHistoryBackfillMarker(paths) {
425
444
  catch (error) {
426
445
  if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
427
446
  throw error;
428
- marker = null;
447
+ return null;
429
448
  }
430
- if (marker !== null) {
431
- if (marker !== '{"version":1}\n') {
432
- throw new Error("Collector history backfill marker has an invalid shape.");
433
- }
434
- return true;
449
+ let decoded;
450
+ try {
451
+ decoded = JSON.parse(marker);
452
+ }
453
+ catch (error) {
454
+ throw new Error("Collector history backfill marker is not valid JSON.", {
455
+ cause: error,
456
+ });
435
457
  }
436
- return false;
458
+ const parsed = historyBackfillMarkerSchema.safeParse(decoded);
459
+ if (!parsed.success) {
460
+ throw new Error("Collector history backfill marker has an invalid shape.", {
461
+ cause: parsed.error,
462
+ });
463
+ }
464
+ return parsed.data.version;
437
465
  }
438
466
  export async function ensureHistoryBackfill(codexHome, paths) {
439
- if (await hasHistoryBackfillMarker(paths))
467
+ const version = await readHistoryBackfillVersion(paths);
468
+ if (version !== null && version >= HISTORY_BACKFILL_VERSION)
440
469
  return false;
441
470
  const state = await loadState(paths);
442
471
  if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
@@ -444,17 +473,33 @@ export async function ensureHistoryBackfill(codexHome, paths) {
444
473
  }
445
474
  await rolloutFiles(path.join(codexHome, "sessions"));
446
475
  await saveState({ ...state, cursors: {}, skipping: [] }, paths);
476
+ await writeJsonAtomic(paths.historyBackfill, {
477
+ version: HISTORY_BACKFILL_VERSION,
478
+ });
479
+ return true;
480
+ }
481
+ export async function ensureClaudeHistoryBackfill(claudeHome, paths) {
482
+ const version = await readHistoryBackfillVersion(paths);
483
+ if (version !== null && version >= 1)
484
+ return false;
485
+ const state = await loadState(paths);
486
+ if (!state.initialized ||
487
+ state.sourceDirectory !== path.resolve(claudeHome)) {
488
+ throw new Error("Collector state does not match the Claude directory; reconnect Claude before uploading.");
489
+ }
490
+ await claudeFiles(path.join(claudeHome, "projects"));
491
+ await saveState({ ...state, cursors: {}, skipping: [] }, paths);
447
492
  await writeJsonAtomic(paths.historyBackfill, { version: 1 });
448
493
  return true;
449
494
  }
450
- export async function historyScanProgress(codexHome, paths) {
451
- if (!(await hasHistoryBackfillMarker(paths)))
495
+ export async function historyScanProgress(home, paths, source = "codex") {
496
+ if ((await readHistoryBackfillVersion(paths)) === null)
452
497
  return { kind: "waiting" };
453
498
  const state = await loadState(paths);
454
- if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
455
- throw new Error("Collector state does not match CODEX_HOME.");
499
+ if (!state.initialized || state.sourceDirectory !== path.resolve(home)) {
500
+ throw new Error(`Collector state does not match the ${source} directory.`);
456
501
  }
457
- const files = await rolloutFiles(path.join(codexHome, "sessions"));
502
+ const files = await sourceFiles(source, home);
458
503
  let scannedBytes = 0;
459
504
  let totalBytes = 0;
460
505
  for (let start = 0; start < files.length; start += 64) {
@@ -482,26 +527,26 @@ export async function historyScanProgress(codexHome, paths) {
482
527
  scannedBytes += cursor <= size ? cursor : 0;
483
528
  if (!Number.isSafeInteger(totalBytes) ||
484
529
  !Number.isSafeInteger(scannedBytes)) {
485
- throw new Error("Codex history size exceeds the supported limit.");
530
+ throw new Error(`${source} history size exceeds the supported limit.`);
486
531
  }
487
532
  }
488
533
  }
489
534
  return { kind: "scanning", scannedBytes, totalBytes };
490
535
  }
491
- export async function resetHistoryCursors(codexHome, paths) {
536
+ export async function resetHistoryCursors(home, paths, source = "codex") {
492
537
  const state = await loadState(paths);
493
- const sourceDirectory = path.resolve(codexHome);
538
+ const sourceDirectory = path.resolve(home);
494
539
  if (!state.initialized || state.sourceDirectory !== sourceDirectory) {
495
- throw new Error("Connect the Codex source before importing its history.");
540
+ throw new Error(`Connect the ${source} source before importing its history.`);
496
541
  }
497
- const files = await rolloutFiles(path.join(sourceDirectory, "sessions"));
542
+ const files = await sourceFiles(source, sourceDirectory);
498
543
  const cursors = {};
499
544
  let bytes = 0;
500
545
  for (const filename of files) {
501
546
  cursors[filename] = 0;
502
547
  bytes += (await fs.stat(filename)).size;
503
548
  if (!Number.isSafeInteger(bytes)) {
504
- throw new Error("Codex history size exceeds the supported limit.");
549
+ throw new Error(`${source} history size exceeds the supported limit.`);
505
550
  }
506
551
  }
507
552
  const localHealth = await healthSnapshot(paths);
@@ -517,13 +562,13 @@ export async function resetHistoryCursors(codexHome, paths) {
517
562
  }, paths);
518
563
  return { files: files.length, bytes };
519
564
  }
520
- export async function historyImportSize(codexHome) {
521
- const files = await rolloutFiles(path.join(path.resolve(codexHome), "sessions"));
565
+ export async function historyImportSize(home, source = "codex") {
566
+ const files = await sourceFiles(source, home);
522
567
  let bytes = 0;
523
568
  for (const filename of files) {
524
569
  bytes += (await fs.stat(filename)).size;
525
570
  if (!Number.isSafeInteger(bytes)) {
526
- throw new Error("Codex history size exceeds the supported limit.");
571
+ throw new Error(`${source} history size exceeds the supported limit.`);
527
572
  }
528
573
  }
529
574
  return { files: files.length, bytes };
@@ -557,7 +602,7 @@ async function lastCompleteRecordOffset(filename, size) {
557
602
  await handle.close();
558
603
  }
559
604
  }
560
- export async function rolloutFiles(root) {
605
+ async function rolloutFiles(root) {
561
606
  const files = [];
562
607
  const pending = [root];
563
608
  while (pending.length > 0) {
@@ -587,3 +632,51 @@ export async function rolloutFiles(root) {
587
632
  }
588
633
  return files.sort();
589
634
  }
635
+ async function directoryEntries(directory) {
636
+ try {
637
+ return await fs.readdir(directory, { withFileTypes: true });
638
+ }
639
+ catch (error) {
640
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
641
+ return [];
642
+ throw new Error(`Could not scan Claude session directory ${directory}.`, {
643
+ cause: error,
644
+ });
645
+ }
646
+ }
647
+ export async function claudeFiles(root) {
648
+ const files = [];
649
+ for (const project of await directoryEntries(root)) {
650
+ if (!project.isDirectory())
651
+ continue;
652
+ const projectDirectory = path.join(root, project.name);
653
+ for (const entry of await directoryEntries(projectDirectory)) {
654
+ const filename = path.join(projectDirectory, entry.name);
655
+ if (entry.isFile() &&
656
+ /^[^/]+\.jsonl$/.test(entry.name) &&
657
+ !entry.name.includes(".orphaned-") &&
658
+ !entry.name.includes(".superseded-")) {
659
+ files.push(filename);
660
+ }
661
+ else if (entry.isDirectory()) {
662
+ const subagents = path.join(filename, "subagents");
663
+ for (const agent of await directoryEntries(subagents)) {
664
+ if (agent.isFile() && /^agent-.+\.jsonl$/.test(agent.name))
665
+ files.push(path.join(subagents, agent.name));
666
+ }
667
+ }
668
+ if (files.length > 10_000)
669
+ throw new Error("Claude session file count exceeds the scan limit.");
670
+ }
671
+ }
672
+ return files.sort();
673
+ }
674
+ export function sourceFiles(source, home) {
675
+ const directory = path.resolve(home);
676
+ if (source === "codex")
677
+ return rolloutFiles(path.join(directory, "sessions"));
678
+ if (source === "claude")
679
+ return claudeFiles(path.join(directory, "projects"));
680
+ const unhandled = source;
681
+ throw new Error(`Unsupported collector source: ${unhandled}`);
682
+ }
@@ -0,0 +1,21 @@
1
+ import { type CollectorFrictionSummary } from "iteration-trace-contracts";
2
+ import { type CollectorConfig } from "./storage.js";
3
+ export type TurnSummaryPaths = Readonly<{
4
+ directory: string;
5
+ script: string;
6
+ requests: string;
7
+ lines: string;
8
+ shown: string;
9
+ }>;
10
+ export declare function turnSummaryPaths(dataDirectory: string): TurnSummaryPaths;
11
+ export declare function installTurnSummary(config: CollectorConfig): Promise<void>;
12
+ export declare function uninstallTurnSummary(config: CollectorConfig): Promise<void>;
13
+ export declare function turnSummaryMessage(summary: CollectorFrictionSummary): string | null;
14
+ export declare class TurnSummaryRefreshError extends Error {
15
+ constructor(message: string, options?: ErrorOptions);
16
+ }
17
+ export declare function refreshTurnSummaries(config: CollectorConfig, refreshedAt: Map<string, Readonly<{
18
+ directory: string;
19
+ atMs: number;
20
+ }>>, nowMs: number, fetcher?: typeof fetch, signal?: AbortSignal): Promise<number>;
21
+ export declare function turnSummaryApprovalHint(config: CollectorConfig): string;