iteration-cli 0.4.4 → 0.5.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.
package/dist/storage.js CHANGED
@@ -1,20 +1,33 @@
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(),
15
19
  organizationName: z.string().min(1).max(160).optional(),
16
- })
17
- .strict();
20
+ };
21
+ const configSchema = z.union([
22
+ z.object({ ...connectionSchema, codexHome: z.string() }).strict(),
23
+ z
24
+ .object({
25
+ ...connectionSchema,
26
+ source: z.literal("claude"),
27
+ claudeHome: z.string(),
28
+ })
29
+ .strict(),
30
+ ]);
18
31
  const stateSchema = z
19
32
  .object({
20
33
  cursors: z.record(z.string(), z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)),
@@ -55,8 +68,8 @@ function parseOutboxEvent(line) {
55
68
  throw new Error("Trace outbox event is missing.");
56
69
  return event;
57
70
  }
58
- function defaultDirectory() {
59
- return path.join(os.homedir(), ".config", "iteration", "codex-collector");
71
+ export function defaultCollectorDirectory(source) {
72
+ return path.join(os.homedir(), ".config", "iteration", `${source}-collector`);
60
73
  }
61
74
  export function validateCollectorEndpoint(endpoint) {
62
75
  let parsed;
@@ -82,7 +95,7 @@ export function validateCollectorEndpoint(endpoint) {
82
95
  }
83
96
  return parsed;
84
97
  }
85
- export function collectorPaths(directory = defaultDirectory()) {
98
+ export function collectorPaths(directory = defaultCollectorDirectory("codex")) {
86
99
  return {
87
100
  config: path.join(directory, "config.json"),
88
101
  state: path.join(directory, "state.json"),
@@ -92,7 +105,7 @@ export function collectorPaths(directory = defaultDirectory()) {
92
105
  health: path.join(directory, "health.json"),
93
106
  };
94
107
  }
95
- export async function assertFreshCollectorDirectory(paths) {
108
+ export async function assertFreshCollectorDirectory(paths, source = "codex") {
96
109
  const state = await loadState(paths);
97
110
  const configExists = await fs
98
111
  .access(paths.config)
@@ -103,7 +116,7 @@ export async function assertFreshCollectorDirectory(paths) {
103
116
  throw error;
104
117
  });
105
118
  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.");
119
+ 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
120
  }
108
121
  if (state.initialized || (await outboxSize(paths)) > 0) {
109
122
  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 +215,12 @@ export async function loadConfig(paths) {
202
215
  throw new Error("Collector config has an invalid shape.", {
203
216
  cause: parsed.error,
204
217
  });
205
- const { endpoint, token, codexHome, dataDirectory, organizationName } = parsed.data;
206
- validateCollectorEndpoint(endpoint);
218
+ validateCollectorEndpoint(parsed.data.endpoint);
219
+ const { token } = parsed.data;
207
220
  if (token.length !== 43 || !/^[A-Za-z0-9_-]+$/.test(token)) {
208
221
  throw new Error("Collector token has an invalid format.");
209
222
  }
210
- return { endpoint, token, codexHome, dataDirectory, organizationName };
223
+ return parsed.data;
211
224
  }
212
225
  export async function loadState(paths) {
213
226
  let contents;
@@ -372,24 +385,23 @@ export async function acknowledgeEvents(acknowledgedIds, paths) {
372
385
  throw error;
373
386
  }
374
387
  }
375
- export async function initializeState(codexHome, paths, includeHistory) {
388
+ export async function initializeState(source, home, paths, includeHistory) {
376
389
  const state = await loadState(paths);
377
- const sourceDirectory = path.resolve(codexHome);
390
+ const sourceDirectory = path.resolve(home);
378
391
  if (state.initialized && state.sourceDirectory !== sourceDirectory) {
379
- throw new Error("Collector state belongs to another Codex directory; use a separate --data-directory.");
392
+ throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
380
393
  }
381
394
  if (state.initialized && state.sourceDirectory === sourceDirectory) {
382
395
  if (includeHistory) {
383
- throw new Error("Codex is already connected. Run iteration import-history codex to import existing sessions.");
396
+ throw new Error(`${source} is already connected. Run iteration import-history ${source} to import existing sessions.`);
384
397
  }
385
398
  return;
386
399
  }
387
- await saveState(await prepareInitialState(codexHome, includeHistory), paths);
400
+ await saveState(await prepareSourceInitialState(source, home, includeHistory), paths);
388
401
  }
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);
402
+ export async function prepareSourceInitialState(source, home, includeHistory) {
403
+ const sourceDirectory = path.resolve(home);
404
+ const files = await sourceFiles(source, sourceDirectory);
393
405
  const cursors = {};
394
406
  if (!includeHistory) {
395
407
  for (const filename of files) {
@@ -404,20 +416,26 @@ export async function prepareInitialState(codexHome, includeHistory) {
404
416
  };
405
417
  }
406
418
  export async function ensureStartableState(config, paths) {
419
+ const source = collectorSource(config);
420
+ const directory = collectorSourceDirectory(config);
407
421
  const state = await loadState(paths);
408
422
  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.");
423
+ if (state.sourceDirectory !== path.resolve(directory)) {
424
+ throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
411
425
  }
412
426
  return false;
413
427
  }
414
428
  if ((await outboxSize(paths)) > 0) {
415
429
  throw new Error("Pending events exist without an initialized checkpoint; repair the collector state before starting uploads.");
416
430
  }
417
- await initializeState(config.codexHome, paths, true);
431
+ await saveState(await prepareSourceInitialState(source, directory, true), paths);
418
432
  return true;
419
433
  }
420
- async function hasHistoryBackfillMarker(paths) {
434
+ const HISTORY_BACKFILL_VERSION = 4;
435
+ const historyBackfillMarkerSchema = z
436
+ .object({ version: z.number().int().min(1).max(HISTORY_BACKFILL_VERSION) })
437
+ .strict();
438
+ async function readHistoryBackfillVersion(paths) {
421
439
  let marker;
422
440
  try {
423
441
  marker = await fs.readFile(paths.historyBackfill, "utf8");
@@ -425,18 +443,28 @@ async function hasHistoryBackfillMarker(paths) {
425
443
  catch (error) {
426
444
  if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
427
445
  throw error;
428
- marker = null;
446
+ return null;
429
447
  }
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;
448
+ let decoded;
449
+ try {
450
+ decoded = JSON.parse(marker);
451
+ }
452
+ catch (error) {
453
+ throw new Error("Collector history backfill marker is not valid JSON.", {
454
+ cause: error,
455
+ });
435
456
  }
436
- return false;
457
+ const parsed = historyBackfillMarkerSchema.safeParse(decoded);
458
+ if (!parsed.success) {
459
+ throw new Error("Collector history backfill marker has an invalid shape.", {
460
+ cause: parsed.error,
461
+ });
462
+ }
463
+ return parsed.data.version;
437
464
  }
438
465
  export async function ensureHistoryBackfill(codexHome, paths) {
439
- if (await hasHistoryBackfillMarker(paths))
466
+ const version = await readHistoryBackfillVersion(paths);
467
+ if (version !== null && version >= HISTORY_BACKFILL_VERSION)
440
468
  return false;
441
469
  const state = await loadState(paths);
442
470
  if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
@@ -444,17 +472,33 @@ export async function ensureHistoryBackfill(codexHome, paths) {
444
472
  }
445
473
  await rolloutFiles(path.join(codexHome, "sessions"));
446
474
  await saveState({ ...state, cursors: {}, skipping: [] }, paths);
475
+ await writeJsonAtomic(paths.historyBackfill, {
476
+ version: HISTORY_BACKFILL_VERSION,
477
+ });
478
+ return true;
479
+ }
480
+ export async function ensureClaudeHistoryBackfill(claudeHome, paths) {
481
+ const version = await readHistoryBackfillVersion(paths);
482
+ if (version !== null && version >= 1)
483
+ return false;
484
+ const state = await loadState(paths);
485
+ if (!state.initialized ||
486
+ state.sourceDirectory !== path.resolve(claudeHome)) {
487
+ throw new Error("Collector state does not match the Claude directory; reconnect Claude before uploading.");
488
+ }
489
+ await claudeFiles(path.join(claudeHome, "projects"));
490
+ await saveState({ ...state, cursors: {}, skipping: [] }, paths);
447
491
  await writeJsonAtomic(paths.historyBackfill, { version: 1 });
448
492
  return true;
449
493
  }
450
- export async function historyScanProgress(codexHome, paths) {
451
- if (!(await hasHistoryBackfillMarker(paths)))
494
+ export async function historyScanProgress(home, paths, source = "codex") {
495
+ if ((await readHistoryBackfillVersion(paths)) === null)
452
496
  return { kind: "waiting" };
453
497
  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.");
498
+ if (!state.initialized || state.sourceDirectory !== path.resolve(home)) {
499
+ throw new Error(`Collector state does not match the ${source} directory.`);
456
500
  }
457
- const files = await rolloutFiles(path.join(codexHome, "sessions"));
501
+ const files = await sourceFiles(source, home);
458
502
  let scannedBytes = 0;
459
503
  let totalBytes = 0;
460
504
  for (let start = 0; start < files.length; start += 64) {
@@ -482,26 +526,26 @@ export async function historyScanProgress(codexHome, paths) {
482
526
  scannedBytes += cursor <= size ? cursor : 0;
483
527
  if (!Number.isSafeInteger(totalBytes) ||
484
528
  !Number.isSafeInteger(scannedBytes)) {
485
- throw new Error("Codex history size exceeds the supported limit.");
529
+ throw new Error(`${source} history size exceeds the supported limit.`);
486
530
  }
487
531
  }
488
532
  }
489
533
  return { kind: "scanning", scannedBytes, totalBytes };
490
534
  }
491
- export async function resetHistoryCursors(codexHome, paths) {
535
+ export async function resetHistoryCursors(home, paths, source = "codex") {
492
536
  const state = await loadState(paths);
493
- const sourceDirectory = path.resolve(codexHome);
537
+ const sourceDirectory = path.resolve(home);
494
538
  if (!state.initialized || state.sourceDirectory !== sourceDirectory) {
495
- throw new Error("Connect the Codex source before importing its history.");
539
+ throw new Error(`Connect the ${source} source before importing its history.`);
496
540
  }
497
- const files = await rolloutFiles(path.join(sourceDirectory, "sessions"));
541
+ const files = await sourceFiles(source, sourceDirectory);
498
542
  const cursors = {};
499
543
  let bytes = 0;
500
544
  for (const filename of files) {
501
545
  cursors[filename] = 0;
502
546
  bytes += (await fs.stat(filename)).size;
503
547
  if (!Number.isSafeInteger(bytes)) {
504
- throw new Error("Codex history size exceeds the supported limit.");
548
+ throw new Error(`${source} history size exceeds the supported limit.`);
505
549
  }
506
550
  }
507
551
  const localHealth = await healthSnapshot(paths);
@@ -517,13 +561,13 @@ export async function resetHistoryCursors(codexHome, paths) {
517
561
  }, paths);
518
562
  return { files: files.length, bytes };
519
563
  }
520
- export async function historyImportSize(codexHome) {
521
- const files = await rolloutFiles(path.join(path.resolve(codexHome), "sessions"));
564
+ export async function historyImportSize(home, source = "codex") {
565
+ const files = await sourceFiles(source, home);
522
566
  let bytes = 0;
523
567
  for (const filename of files) {
524
568
  bytes += (await fs.stat(filename)).size;
525
569
  if (!Number.isSafeInteger(bytes)) {
526
- throw new Error("Codex history size exceeds the supported limit.");
570
+ throw new Error(`${source} history size exceeds the supported limit.`);
527
571
  }
528
572
  }
529
573
  return { files: files.length, bytes };
@@ -557,7 +601,7 @@ async function lastCompleteRecordOffset(filename, size) {
557
601
  await handle.close();
558
602
  }
559
603
  }
560
- export async function rolloutFiles(root) {
604
+ async function rolloutFiles(root) {
561
605
  const files = [];
562
606
  const pending = [root];
563
607
  while (pending.length > 0) {
@@ -587,3 +631,51 @@ export async function rolloutFiles(root) {
587
631
  }
588
632
  return files.sort();
589
633
  }
634
+ async function directoryEntries(directory) {
635
+ try {
636
+ return await fs.readdir(directory, { withFileTypes: true });
637
+ }
638
+ catch (error) {
639
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
640
+ return [];
641
+ throw new Error(`Could not scan Claude session directory ${directory}.`, {
642
+ cause: error,
643
+ });
644
+ }
645
+ }
646
+ export async function claudeFiles(root) {
647
+ const files = [];
648
+ for (const project of await directoryEntries(root)) {
649
+ if (!project.isDirectory())
650
+ continue;
651
+ const projectDirectory = path.join(root, project.name);
652
+ for (const entry of await directoryEntries(projectDirectory)) {
653
+ const filename = path.join(projectDirectory, entry.name);
654
+ if (entry.isFile() &&
655
+ /^[^/]+\.jsonl$/.test(entry.name) &&
656
+ !entry.name.includes(".orphaned-") &&
657
+ !entry.name.includes(".superseded-")) {
658
+ files.push(filename);
659
+ }
660
+ else if (entry.isDirectory()) {
661
+ const subagents = path.join(filename, "subagents");
662
+ for (const agent of await directoryEntries(subagents)) {
663
+ if (agent.isFile() && /^agent-.+\.jsonl$/.test(agent.name))
664
+ files.push(path.join(subagents, agent.name));
665
+ }
666
+ }
667
+ if (files.length > 10_000)
668
+ throw new Error("Claude session file count exceeds the scan limit.");
669
+ }
670
+ }
671
+ return files.sort();
672
+ }
673
+ export function sourceFiles(source, home) {
674
+ const directory = path.resolve(home);
675
+ if (source === "codex")
676
+ return rolloutFiles(path.join(directory, "sessions"));
677
+ if (source === "claude")
678
+ return claudeFiles(path.join(directory, "projects"));
679
+ const unhandled = source;
680
+ throw new Error(`Unsupported collector source: ${unhandled}`);
681
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "zod": "4.6.4",
21
- "iteration-trace-contracts": "0.3.0"
21
+ "iteration-trace-contracts": "0.5.0"
22
22
  },
23
23
  "scripts": {
24
24
  "build": "pnpm --filter iteration-trace-contracts build && tsc -p tsconfig.json"