iteration-cli 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -4,11 +4,10 @@ Install one CLI for Iteration's supported coding-agent sources:
4
4
 
5
5
  ```sh
6
6
  npm install --global iteration-cli
7
- iteration connect codex
8
- iteration status codex
7
+ iteration
9
8
  ```
10
9
 
11
- The Codex collector currently runs on macOS with Node.js 22 or later. `iteration connect codex --include-history` also imports earlier local sessions after you approve access. Run `iteration help` for the available commands.
10
+ The Codex collector currently runs on macOS with Node.js 22 or later. Connecting imports existing local Codex sessions and collects new activity. Run `iteration status` to check uploads, or `iteration help` for other commands.
12
11
 
13
12
  If you previously installed the Codex-specific package, remove it with the package manager you used:
14
13
 
@@ -18,4 +17,4 @@ pnpm remove --global iteration-codex-collector
18
17
  npm uninstall --global iteration-codex-collector
19
18
  ```
20
19
 
21
- Then install `iteration-cli` and run `iteration start codex`. This points the existing background collector at the new CLI. Its local configuration and pending events remain in place.
20
+ Then install `iteration-cli` and run `iteration start codex`. This points the existing background collector at the new CLI and schedules a one-time backfill of earlier local sessions. Its local configuration and pending events remain in place.
@@ -164,13 +164,13 @@ async function pollForAccessToken(tokenUrl, clientId, deviceCode, expiresIn, ini
164
164
  case "access_denied":
165
165
  throw new Error("You denied Iteration collector access.");
166
166
  case "expired_token":
167
- throw new Error("The sign-in code expired. Run init again.");
167
+ throw new Error("The sign-in code expired. Run iteration again.");
168
168
  default:
169
169
  throw new Error("Clerk device sign-in failed: " +
170
170
  (providerError.data.error_description ?? providerError.data.error));
171
171
  }
172
172
  }
173
- throw new Error("The sign-in code expired. Run init again.");
173
+ throw new Error("The sign-in code expired. Run iteration again.");
174
174
  }
175
175
  async function clerkDeviceSignIn(configuration) {
176
176
  const endpoints = await discoverDeviceEndpoints(configuration.issuerUrl);
@@ -218,7 +218,7 @@ async function chooseOrganization(organizations, hasMore) {
218
218
  process.stdout.write("More workspaces exist; enter an organization ID to choose one not listed.\n");
219
219
  }
220
220
  if (process.stdin.isTTY !== true) {
221
- throw new Error("Run init interactively or pass --organization-id.");
221
+ throw new Error("Run iteration interactively or pass --organization-id.");
222
222
  }
223
223
  const prompt = createInterface({
224
224
  input: process.stdin,
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
8
8
  import { importCodexHistory } from "./history-import.js";
9
9
  import { runCollector } from "./service.js";
10
10
  import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
11
- import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
11
+ import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
12
12
  const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
13
13
  function argumentsForCli() {
14
14
  const parsed = parseArgs({
@@ -30,7 +30,7 @@ function argumentsForCli() {
30
30
  throw new Error("Too many command arguments. Run iteration help for usage.");
31
31
  }
32
32
  return {
33
- command: parsed.positionals[0] ?? "help",
33
+ command: parsed.positionals[0] ?? "",
34
34
  source: parsed.positionals[1],
35
35
  values: parsed.values,
36
36
  };
@@ -45,16 +45,63 @@ function optionalString(values, name) {
45
45
  }
46
46
  return parsed.data;
47
47
  }
48
- function usage() {
48
+ function usage(command) {
49
+ if (command === "connect") {
50
+ process.stdout.write([
51
+ "Connect Codex to Iteration",
52
+ " iteration connect codex",
53
+ "",
54
+ "Existing local sessions and new activity are collected automatically.",
55
+ "",
56
+ "Options for advanced setups:",
57
+ " --device-name NAME Name this Mac in Iteration",
58
+ " --organization-id UUID Select a workspace without a prompt",
59
+ " --codex-home DIR Use a different Codex data directory",
60
+ " --data-directory DIR Use a different Iteration state directory",
61
+ " --endpoint URL Use a different Iteration server",
62
+ ].join("\n") + "\n");
63
+ return;
64
+ }
49
65
  process.stdout.write([
50
66
  "Iteration CLI",
51
- " iteration connect codex [--endpoint URL] [--device-name NAME] [--organization-id UUID] [--include-history] [--codex-home DIR] [--data-directory DIR]",
52
- " iteration status codex [--data-directory DIR]",
53
- " iteration import-history codex [--data-directory DIR] [--yes]",
54
- " iteration start codex [--data-directory DIR]",
55
- " iteration disconnect codex",
67
+ "",
68
+ "Get started:",
69
+ " iteration Connect Codex, or show connection status",
70
+ "",
71
+ "Commands:",
72
+ " iteration connect codex Connect Codex directly",
73
+ " iteration status Check uploads and connection",
74
+ " iteration disconnect codex Stop collecting",
75
+ "",
76
+ "Run iteration connect codex --help for advanced setup options.",
56
77
  ].join("\n") + "\n");
57
78
  }
79
+ async function configured(paths) {
80
+ try {
81
+ await fs.access(paths.config);
82
+ return true;
83
+ }
84
+ catch (error) {
85
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
86
+ return false;
87
+ }
88
+ throw new Error(`Could not inspect collector config at ${paths.config}.`, {
89
+ cause: error,
90
+ });
91
+ }
92
+ }
93
+ async function defaultCommand() {
94
+ const paths = collectorPaths(dataDirectory({}));
95
+ if (await configured(paths)) {
96
+ await status({});
97
+ return;
98
+ }
99
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
100
+ process.stdout.write("Run iteration in a terminal to connect Codex.\n");
101
+ return;
102
+ }
103
+ await initialize({});
104
+ }
58
105
  function dataDirectory(values) {
59
106
  return path.resolve(optionalString(values, "data-directory") ??
60
107
  path.join(os.homedir(), ".config", "iteration", "codex-collector"));
@@ -69,12 +116,11 @@ async function initialize(values) {
69
116
  const organizationId = optionalString(values, "organization-id");
70
117
  const directory = dataDirectory(values);
71
118
  const paths = collectorPaths(directory);
72
- const includeHistory = values["include-history"] === true;
73
119
  const codexHome = path.resolve(optionalString(values, "codex-home") ??
74
120
  process.env.CODEX_HOME ??
75
121
  path.join(os.homedir(), ".codex"));
76
122
  await assertFreshCollectorDirectory(paths);
77
- const preparedState = await prepareInitialState(codexHome, includeHistory);
123
+ const preparedState = await prepareInitialState(codexHome, true);
78
124
  const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId);
79
125
  const config = {
80
126
  endpoint: endpointUrl.toString(),
@@ -89,14 +135,13 @@ async function initialize(values) {
89
135
  catch (error) {
90
136
  throw new Error("Collector access was approved, but local setup did not finish. Run iteration start codex to repair it.", { cause: error });
91
137
  }
92
- let agentFile;
93
138
  try {
94
- agentFile = await installLaunchAgent(directory);
139
+ await installLaunchAgent(directory);
95
140
  }
96
141
  catch (error) {
97
142
  throw new Error("Collector access was approved, but background startup failed. Run iteration start codex to retry it.", { cause: error });
98
143
  }
99
- process.stdout.write(`Collector installed for ${enrollment.organizationName} and set to start at login. ${includeHistory ? "Existing sessions will be imported." : "Existing sessions are skipped; new activity will be collected."}\nLaunch agent: ${agentFile}\n`);
144
+ process.stdout.write(`Codex connected to ${enrollment.organizationName}. Existing local sessions and new activity will upload in the background. Run iteration status to check uploads.\n`);
100
145
  }
101
146
  async function importHistory(values) {
102
147
  if (process.platform !== "darwin") {
@@ -112,7 +157,7 @@ async function importHistory(values) {
112
157
  return;
113
158
  }
114
159
  const result = await importCodexHistory(config, paths, true);
115
- process.stdout.write(`History scan scheduled for ${result.size.files} local session files (${result.size.bytes} bytes). Scanning and uploads run in bounded batches and may take time. Older sessions appear under the date Iteration receives them. iteration status codex shows events waiting to upload, not total scan progress.${result.backgroundRunning ? "" : " The collector was not running; run iteration start codex to begin scanning."}\n`);
160
+ process.stdout.write(`History scan scheduled for ${result.size.files} local session files (${result.size.bytes} bytes). Scanning and uploads run in bounded batches and may take time. Older sessions appear under the date Iteration receives them. Use iteration status to see local scan progress and pending uploads.${result.backgroundRunning ? "" : " The collector was not running; run iteration start codex to begin scanning."}\n`);
116
161
  }
117
162
  async function start(values) {
118
163
  if (process.platform !== "darwin") {
@@ -123,39 +168,49 @@ async function start(values) {
123
168
  const config = await loadConfig(paths);
124
169
  const repairedState = await ensureStartableState(config, paths);
125
170
  await installLaunchAgent(directory);
126
- process.stdout.write(`Collector started. Run iteration status codex to check uploads.${repairedState ? " Incomplete setup was repaired with new activity only; use iteration import-history codex if you want to include earlier local sessions." : ""}\n`);
171
+ process.stdout.write(`Collector started. Existing local sessions and new activity will upload in the background. Run iteration status to check uploads.${repairedState ? " Incomplete setup was repaired." : ""}\n`);
127
172
  }
128
173
  async function status(values) {
129
174
  const paths = collectorPaths(dataDirectory(values));
130
- try {
131
- await fs.access(paths.config);
132
- }
133
- catch (error) {
134
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
135
- process.stdout.write("Installed: false\nRunning: false\nPending events: 0\n");
136
- return;
137
- }
138
- throw new Error(`Could not inspect collector config at ${paths.config}.`, {
139
- cause: error,
140
- });
175
+ if (!(await configured(paths))) {
176
+ process.stdout.write("Installed: false\nRunning: false\nPending events: 0\n");
177
+ return;
141
178
  }
142
- await loadConfig(paths);
179
+ const config = await loadConfig(paths);
143
180
  const running = await launchAgentStatus();
144
- const [size, ids] = await Promise.all([
181
+ const [size, ids, scan] = await Promise.all([
145
182
  outboxSize(paths),
146
183
  pendingEventIds(paths),
184
+ historyScanProgress(config.codexHome, paths),
147
185
  ]);
186
+ const scanLabel = scan.kind === "waiting"
187
+ ? "Local history: waiting for collector start"
188
+ : scan.totalBytes === 0
189
+ ? "Local scan: no session data found"
190
+ : `Local scan: ${scan.scannedBytes} of ${scan.totalBytes} bytes processed`;
148
191
  process.stdout.write([
149
192
  "Installed: true",
150
193
  `Running: ${running}`,
194
+ scanLabel,
151
195
  `Pending events: ${ids.size}`,
152
196
  `Pending bytes: ${size}`,
153
197
  ].join("\n") + "\n");
154
198
  }
155
199
  async function main() {
156
200
  const { command, source, values } = argumentsForCli();
157
- if (values.help === true || command === "help")
158
- return usage();
201
+ if (values.help === true || command === "help") {
202
+ return usage(command === "help" ? source : command);
203
+ }
204
+ if (values["include-history"] === true &&
205
+ !(command === "connect" && source === "codex")) {
206
+ throw new Error("Existing sessions are included automatically. Run iteration to connect.");
207
+ }
208
+ if (command === "" || (command === "connect" && source === undefined)) {
209
+ if (Object.keys(values).length > 0) {
210
+ throw new Error("Options require a source. Run iteration connect codex --help for details.");
211
+ }
212
+ return defaultCommand();
213
+ }
159
214
  if (command === "connect" && source === "codex")
160
215
  return initialize(values);
161
216
  if (command === "run") {
@@ -163,7 +218,7 @@ async function main() {
163
218
  throw new Error("The run command takes no source.");
164
219
  return runCollector(optionalString(values, "data-directory") ?? undefined);
165
220
  }
166
- if (command === "status" && source === "codex")
221
+ if (command === "status" && (source === undefined || source === "codex"))
167
222
  return status(values);
168
223
  if (command === "import-history" && source === "codex")
169
224
  return importHistory(values);
@@ -177,6 +232,6 @@ async function main() {
177
232
  throw new Error(`Unknown Iteration command: ${[command, source].filter(Boolean).join(" ")}. Run iteration help for usage.`);
178
233
  }
179
234
  main().catch((error) => {
180
- process.stderr.write(`${error instanceof Error ? error.message : "Unknown collector error."}\n`);
235
+ process.stderr.write(`${error instanceof Error ? error.message : "Unknown Iteration CLI error."}\n`);
181
236
  process.exitCode = 1;
182
237
  });
@@ -2,3 +2,4 @@ export declare function launchAgentPlist(cliPath?: string, nodePath?: string, lo
2
2
  export declare function installLaunchAgent(dataDirectory?: string): Promise<string>;
3
3
  export declare function uninstallLaunchAgent(): Promise<void>;
4
4
  export declare function launchAgentStatus(): Promise<boolean>;
5
+ export declare function launchAgentRunning(output: string): boolean;
@@ -79,6 +79,7 @@ export async function installLaunchAgent(dataDirectory) {
79
79
  const target = launchctlTarget();
80
80
  await bootoutIfLoaded(target);
81
81
  await execFile("launchctl", ["bootstrap", target, file]);
82
+ await execFile("launchctl", ["kickstart", "-p", `${target}/${LABEL}`]);
82
83
  return file;
83
84
  }
84
85
  export async function uninstallLaunchAgent() {
@@ -93,10 +94,17 @@ export async function launchAgentStatus() {
93
94
  if (process.platform !== "darwin")
94
95
  return false;
95
96
  try {
96
- await execFile("launchctl", ["print", `${launchctlTarget()}/${LABEL}`]);
97
- return true;
97
+ const { stdout } = await execFile("launchctl", [
98
+ "print",
99
+ `${launchctlTarget()}/${LABEL}`,
100
+ ]);
101
+ return launchAgentRunning(stdout);
98
102
  }
99
103
  catch {
100
104
  return false;
101
105
  }
102
106
  }
107
+ export function launchAgentRunning(output) {
108
+ const state = output.match(/^\s*state = ([^\r\n]+)$/m);
109
+ return state?.[1]?.trim() === "running";
110
+ }
package/dist/service.js CHANGED
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
4
  import { collectorTick } from "./collector.js";
5
- import { collectorPaths, ensurePrivateDirectory, loadConfig, } from "./storage.js";
5
+ import { collectorPaths, ensureHistoryBackfill, ensurePrivateDirectory, loadConfig, } from "./storage.js";
6
6
  function errorCode(error) {
7
7
  if (error instanceof Error && "code" in error)
8
8
  return String(error.code);
@@ -75,6 +75,7 @@ export async function runCollector(directory) {
75
75
  let retryDelayMs = 5000;
76
76
  try {
77
77
  const config = await loadConfig(paths);
78
+ await ensureHistoryBackfill(config.codexHome, paths);
78
79
  while (!controller.signal.aborted) {
79
80
  try {
80
81
  const result = await collectorTick(config, paths, fetch, controller.signal);
package/dist/storage.d.ts CHANGED
@@ -16,6 +16,7 @@ export type CollectorPaths = Readonly<{
16
16
  state: string;
17
17
  outbox: string;
18
18
  lock: string;
19
+ historyBackfill: string;
19
20
  }>;
20
21
  export declare function validateCollectorEndpoint(endpoint: string): URL;
21
22
  export declare function collectorPaths(directory?: string): CollectorPaths;
@@ -33,6 +34,14 @@ export declare function acknowledgeEvents(acknowledgedIds: ReadonlySet<string>,
33
34
  export declare function initializeState(codexHome: string, paths: CollectorPaths, includeHistory: boolean): Promise<void>;
34
35
  export declare function prepareInitialState(codexHome: string, includeHistory: boolean): Promise<CollectorState>;
35
36
  export declare function ensureStartableState(config: CollectorConfig, paths: CollectorPaths): Promise<boolean>;
37
+ export declare function ensureHistoryBackfill(codexHome: string, paths: CollectorPaths): Promise<boolean>;
38
+ export declare function historyScanProgress(codexHome: string, paths: CollectorPaths): Promise<Readonly<{
39
+ kind: "waiting";
40
+ } | {
41
+ kind: "scanning";
42
+ scannedBytes: number;
43
+ totalBytes: number;
44
+ }>>;
36
45
  export declare function resetHistoryCursors(codexHome: string, paths: CollectorPaths): Promise<Readonly<{
37
46
  files: number;
38
47
  bytes: number;
package/dist/storage.js CHANGED
@@ -73,6 +73,7 @@ export function collectorPaths(directory = defaultDirectory()) {
73
73
  state: path.join(directory, "state.json"),
74
74
  outbox: path.join(directory, "outbox.jsonl"),
75
75
  lock: path.join(directory, "collector.lock"),
76
+ historyBackfill: path.join(directory, "history-backfill.json"),
76
77
  };
77
78
  }
78
79
  export async function assertFreshCollectorDirectory(paths) {
@@ -323,10 +324,10 @@ export async function prepareInitialState(codexHome, includeHistory) {
323
324
  const sessionsRoot = path.join(sourceDirectory, "sessions");
324
325
  const files = await rolloutFiles(sessionsRoot);
325
326
  const cursors = {};
326
- for (const filename of files) {
327
- cursors[filename] = includeHistory
328
- ? 0
329
- : await lastCompleteRecordOffset(filename, (await fs.stat(filename)).size);
327
+ if (!includeHistory) {
328
+ for (const filename of files) {
329
+ cursors[filename] = await lastCompleteRecordOffset(filename, (await fs.stat(filename)).size);
330
+ }
330
331
  }
331
332
  return {
332
333
  cursors,
@@ -346,9 +347,80 @@ export async function ensureStartableState(config, paths) {
346
347
  if ((await outboxSize(paths)) > 0) {
347
348
  throw new Error("Pending events exist without an initialized checkpoint; repair the collector state before starting uploads.");
348
349
  }
349
- await initializeState(config.codexHome, paths, false);
350
+ await initializeState(config.codexHome, paths, true);
350
351
  return true;
351
352
  }
353
+ async function hasHistoryBackfillMarker(paths) {
354
+ let marker;
355
+ try {
356
+ marker = await fs.readFile(paths.historyBackfill, "utf8");
357
+ }
358
+ catch (error) {
359
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
360
+ throw error;
361
+ marker = null;
362
+ }
363
+ if (marker !== null) {
364
+ if (marker !== '{"version":1}\n') {
365
+ throw new Error("Collector history backfill marker has an invalid shape.");
366
+ }
367
+ return true;
368
+ }
369
+ return false;
370
+ }
371
+ export async function ensureHistoryBackfill(codexHome, paths) {
372
+ if (await hasHistoryBackfillMarker(paths))
373
+ return false;
374
+ const state = await loadState(paths);
375
+ if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
376
+ throw new Error("Collector state does not match CODEX_HOME; reconnect Codex before uploading.");
377
+ }
378
+ await rolloutFiles(path.join(codexHome, "sessions"));
379
+ await saveState({ ...state, cursors: {}, skipping: [] }, paths);
380
+ await writeJsonAtomic(paths.historyBackfill, { version: 1 });
381
+ return true;
382
+ }
383
+ export async function historyScanProgress(codexHome, paths) {
384
+ if (!(await hasHistoryBackfillMarker(paths)))
385
+ return { kind: "waiting" };
386
+ const state = await loadState(paths);
387
+ if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
388
+ throw new Error("Collector state does not match CODEX_HOME.");
389
+ }
390
+ const files = await rolloutFiles(path.join(codexHome, "sessions"));
391
+ let scannedBytes = 0;
392
+ let totalBytes = 0;
393
+ for (let start = 0; start < files.length; start += 64) {
394
+ const batch = files.slice(start, start + 64);
395
+ const sizes = await Promise.all(batch.map(async (filename) => {
396
+ try {
397
+ return (await fs.stat(filename)).size;
398
+ }
399
+ catch (error) {
400
+ if (error instanceof Error &&
401
+ "code" in error &&
402
+ error.code === "ENOENT")
403
+ return null;
404
+ throw error;
405
+ }
406
+ }));
407
+ for (const [index, size] of sizes.entries()) {
408
+ if (size === null)
409
+ continue;
410
+ const filename = batch[index];
411
+ if (filename === undefined)
412
+ continue;
413
+ totalBytes += size;
414
+ const cursor = state.cursors[filename] ?? 0;
415
+ scannedBytes += cursor <= size ? cursor : 0;
416
+ if (!Number.isSafeInteger(totalBytes) ||
417
+ !Number.isSafeInteger(scannedBytes)) {
418
+ throw new Error("Codex history size exceeds the supported limit.");
419
+ }
420
+ }
421
+ }
422
+ return { kind: "scanning", scannedBytes, totalBytes };
423
+ }
352
424
  export async function resetHistoryCursors(codexHome, paths) {
353
425
  const state = await loadState(paths);
354
426
  const sourceDirectory = path.resolve(codexHome);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",