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.
package/dist/cli.js CHANGED
@@ -2,14 +2,18 @@
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { promises as fs } from "node:fs";
5
+ import { createInterface } from "node:readline/promises";
5
6
  import { parseArgs } from "node:util";
6
7
  import * as z from "zod";
8
+ import { COLLECTOR_SOURCES, collectorSourceLabel, } from "iteration-trace-contracts";
7
9
  import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
8
- import { importCodexHistory } from "./history-import.js";
10
+ import { activateSource, connectedConfig, detectedSources, expandSource, loadMachineConsent, readCollectorScope, saveMachineConsent, sourceHome, } from "./auto-connect.js";
11
+ import { importSourceHistory } from "./history-import.js";
9
12
  import { runCollector } from "./service.js";
10
- import { renderDetailedStatus, renderHome, } from "./status-view.js";
13
+ import { renderDetailedStatus, renderHome, safeWorkspaceName, } from "./status-view.js";
14
+ import { installTurnSummary, turnSummaryApprovalHint, uninstallTurnSummary, } from "./turn-summary.js";
11
15
  import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
12
- import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
16
+ import { assertFreshCollectorDirectory, collectorSource, collectorSourceDirectory, collectorPaths, defaultCollectorDirectory, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
13
17
  const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
14
18
  function argumentsForCli() {
15
19
  const parsed = parseArgs({
@@ -22,6 +26,7 @@ function argumentsForCli() {
22
26
  "device-name": { type: "string" },
23
27
  "organization-id": { type: "string" },
24
28
  "codex-home": { type: "string" },
29
+ "claude-home": { type: "string" },
25
30
  "data-directory": { type: "string" },
26
31
  "include-history": { type: "boolean" },
27
32
  yes: { type: "boolean" },
@@ -49,8 +54,10 @@ function optionalString(values, name) {
49
54
  function usage(command) {
50
55
  if (command === "connect") {
51
56
  process.stdout.write([
52
- "Connect Codex to Iteration",
57
+ "Connect a coding agent to Iteration",
58
+ " iteration connect Connect all detected agents on this Mac",
53
59
  " iteration connect codex",
60
+ " iteration connect claude",
54
61
  "",
55
62
  "Existing local sessions and new activity are collected automatically.",
56
63
  "",
@@ -58,7 +65,9 @@ function usage(command) {
58
65
  " --device-name NAME Name this Mac in Iteration",
59
66
  " --organization-id UUID Select a workspace without a prompt",
60
67
  " --codex-home DIR Use a different Codex data directory",
61
- " --data-directory DIR Use a different Iteration state directory",
68
+ " --claude-home DIR Use a different Claude data directory",
69
+ " --yes Approve uploads without a terminal prompt",
70
+ " --data-directory DIR Use a different state directory with a source-specific command",
62
71
  " --endpoint URL Use a different Iteration server",
63
72
  ].join("\n") + "\n");
64
73
  return;
@@ -67,14 +76,15 @@ function usage(command) {
67
76
  "Iteration CLI",
68
77
  "",
69
78
  "Get started:",
70
- " iteration Connect Codex, or see its current activity",
79
+ " iteration connect Connect detected agents and import sessions",
71
80
  "",
72
81
  "Commands:",
73
- " iteration connect codex Connect Codex directly",
82
+ " iteration See connection status",
74
83
  " iteration status See scan, upload, and error details",
75
- " iteration disconnect codex Stop collecting",
84
+ " iteration connect codex|claude Connect one source (advanced)",
85
+ " iteration disconnect codex|claude Stop a source",
76
86
  "",
77
- "Run iteration connect codex --help for advanced setup options.",
87
+ "Run iteration connect --help for advanced setup options.",
78
88
  ].join("\n") + "\n");
79
89
  }
80
90
  async function fileExists(filename) {
@@ -92,22 +102,46 @@ async function fileExists(filename) {
92
102
  }
93
103
  }
94
104
  async function defaultCommand() {
95
- const snapshot = await statusSnapshot({});
96
- if (snapshot.kind !== "not_connected") {
97
- process.stdout.write(`${renderHome(snapshot)}\n`);
105
+ const [codex, claude] = await Promise.all([
106
+ statusSnapshot("codex", {}),
107
+ statusSnapshot("claude", {}),
108
+ ]);
109
+ const connected = [
110
+ { source: "codex", snapshot: codex },
111
+ { source: "claude", snapshot: claude },
112
+ ].filter(({ snapshot }) => snapshot.kind !== "not_connected");
113
+ if (connected.length > 0) {
114
+ process.stdout.write(`${connected.map(({ source, snapshot }) => renderHome(snapshot, Date.now(), source)).join("\n\n")}\n`);
115
+ const discovered = await detectedSources({
116
+ codex: sourceHome("codex"),
117
+ claude: sourceHome("claude"),
118
+ });
119
+ const missing = discovered.filter((source) => (source === "codex" ? codex : claude).kind === "not_connected");
120
+ if (missing.length > 0) {
121
+ process.stdout.write(`\n${missing.map(collectorSourceLabel).join(" and ")} found on this Mac but not connected. Run iteration connect to include ${missing.length === 1 ? "it" : "them"}.\n`);
122
+ }
98
123
  return;
99
124
  }
100
125
  if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
101
- process.stdout.write("Run iteration in a terminal to connect Codex.\n");
126
+ process.stdout.write("Run iteration connect in a terminal.\n");
102
127
  return;
103
128
  }
104
- await initialize({});
129
+ await connectAll({});
105
130
  }
106
- function dataDirectory(values) {
131
+ function dataDirectory(source, values) {
107
132
  return path.resolve(optionalString(values, "data-directory") ??
108
- path.join(os.homedir(), ".config", "iteration", "codex-collector"));
133
+ defaultCollectorDirectory(source));
109
134
  }
110
- async function initialize(values) {
135
+ async function turnSummaryNotice(config) {
136
+ try {
137
+ await installTurnSummary(config);
138
+ return ` A one-line friction summary now appears after agent turns in your repositories.${turnSummaryApprovalHint(config)}`;
139
+ }
140
+ catch (error) {
141
+ return ` The turn summary could not be installed: ${error instanceof Error ? error.message : "unknown error"} Run iteration start ${collectorSource(config)} to retry it.`;
142
+ }
143
+ }
144
+ async function initialize(source, values) {
111
145
  if (process.platform !== "darwin") {
112
146
  throw new Error("Automatic startup is currently supported on macOS only.");
113
147
  }
@@ -115,65 +149,204 @@ async function initialize(values) {
115
149
  const endpointUrl = validateCollectorEndpoint(endpoint);
116
150
  const deviceName = optionalString(values, "device-name") ?? os.hostname();
117
151
  const organizationId = optionalString(values, "organization-id");
118
- const directory = dataDirectory(values);
152
+ const directory = dataDirectory(source, values);
119
153
  const paths = collectorPaths(directory);
120
- const codexHome = path.resolve(optionalString(values, "codex-home") ??
121
- process.env.CODEX_HOME ??
122
- path.join(os.homedir(), ".codex"));
123
- await assertFreshCollectorDirectory(paths);
124
- const preparedState = await prepareInitialState(codexHome, true);
125
- const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId);
126
- const config = {
154
+ const sourceHome = path.resolve(source === "codex"
155
+ ? (optionalString(values, "codex-home") ??
156
+ process.env.CODEX_HOME ??
157
+ path.join(os.homedir(), ".codex"))
158
+ : (optionalString(values, "claude-home") ??
159
+ process.env.CLAUDE_CONFIG_DIR ??
160
+ path.join(os.homedir(), ".claude")));
161
+ await assertFreshCollectorDirectory(paths, source);
162
+ const preparedState = await prepareSourceInitialState(source, sourceHome, true);
163
+ const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId, source);
164
+ const connection = {
127
165
  endpoint: endpointUrl.toString(),
128
166
  token: enrollment.token,
129
- codexHome,
130
167
  dataDirectory: directory,
168
+ organizationId: enrollment.organizationId,
131
169
  organizationName: enrollment.organizationName,
132
170
  };
171
+ const config = source === "codex"
172
+ ? { ...connection, codexHome: sourceHome }
173
+ : { ...connection, source: "claude", claudeHome: sourceHome };
133
174
  await saveConfig(config, paths);
134
175
  try {
135
176
  await saveState(preparedState, paths);
136
177
  }
137
178
  catch (error) {
138
- throw new Error("Collector access was approved, but local setup did not finish. Run iteration start codex to repair it.", { cause: error });
179
+ throw new Error(`Collector access was approved, but local setup did not finish. Run iteration start ${source} to repair it.`, { cause: error });
139
180
  }
140
181
  try {
141
- await installLaunchAgent(directory);
182
+ await installLaunchAgent(directory, source);
142
183
  }
143
184
  catch (error) {
144
- throw new Error("Collector access was approved, but background startup failed. Run iteration start codex to retry it.", { cause: error });
185
+ throw new Error(`Collector access was approved, but background startup failed. Run iteration start ${source} to retry it.`, { cause: error });
186
+ }
187
+ const notice = await turnSummaryNotice(config);
188
+ process.stdout.write(`${collectorSourceLabel(source)} is connected to ${enrollment.organizationName}. Existing sessions are importing and new activity is collected automatically. Run iteration to check progress.${notice}\n`);
189
+ return config;
190
+ }
191
+ async function approveAllSources(sources, workspaceName, yes) {
192
+ const names = sources.map(collectorSourceLabel).join(" and ");
193
+ const safeName = safeWorkspaceName(workspaceName);
194
+ process.stdout.write(`Detected ${names || "no saved agent sessions"} on this Mac.\n` +
195
+ `Connect supported agents${safeName ? ` to ${safeName}` : " to the workspace you select"}? Existing sessions and future activity, including prompts, code, and tool output, will be uploaded. Workspace members can read them. Newly detected supported agents will connect automatically.\n`);
196
+ if (yes)
197
+ return;
198
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
199
+ throw new Error("Run iteration connect interactively or pass --yes to approve uploads.");
200
+ }
201
+ const prompt = createInterface({
202
+ input: process.stdin,
203
+ output: process.stdout,
204
+ });
205
+ try {
206
+ const answer = (await prompt.question("Connect agents? [y/N] "))
207
+ .trim()
208
+ .toLowerCase();
209
+ if (answer !== "y" && answer !== "yes")
210
+ throw new Error("No agents were connected.");
211
+ }
212
+ finally {
213
+ prompt.close();
145
214
  }
146
- process.stdout.write(`Codex is connected to ${enrollment.organizationName}. Existing sessions are importing and new activity is collected automatically. Run iteration to check progress.\n`);
147
215
  }
148
- async function importHistory(values) {
216
+ async function connectAll(values) {
149
217
  if (process.platform !== "darwin") {
150
218
  throw new Error("Automatic startup is currently supported on macOS only.");
151
219
  }
152
- const directory = dataDirectory(values);
220
+ if (values["data-directory"] !== undefined) {
221
+ throw new Error("--data-directory requires a source-specific connect command.");
222
+ }
223
+ const configs = await Promise.all(COLLECTOR_SOURCES.map(connectedConfig));
224
+ const sourceHomes = {
225
+ codex: path.resolve(optionalString(values, "codex-home") ??
226
+ (configs[0] && "codexHome" in configs[0]
227
+ ? configs[0].codexHome
228
+ : sourceHome("codex"))),
229
+ claude: path.resolve(optionalString(values, "claude-home") ??
230
+ (configs[1] && "claudeHome" in configs[1]
231
+ ? configs[1].claudeHome
232
+ : sourceHome("claude"))),
233
+ };
234
+ const detected = await detectedSources(sourceHomes);
235
+ const existing = COLLECTOR_SOURCES.flatMap((source, index) => configs[index] ? [{ source, config: configs[index] }] : []);
236
+ if (detected.length === 0 && existing.length === 0) {
237
+ process.stdout.write("No supported local coding agents found yet. Run iteration connect after using Codex or Claude Code on this Mac.\n");
238
+ return;
239
+ }
240
+ const anchorSource = existing[0]?.source ?? detected[0];
241
+ const anchorExisting = existing[0]?.config ?? null;
242
+ const endpoint = validateCollectorEndpoint(optionalString(values, "endpoint") ??
243
+ anchorExisting?.endpoint ??
244
+ DEFAULT_INGEST_ENDPOINT).toString();
245
+ const existingScopes = await Promise.all(existing.map(async (item) => ({
246
+ ...item,
247
+ scope: await readCollectorScope(item.config),
248
+ })));
249
+ for (const item of existingScopes) {
250
+ if (item.config.endpoint !== endpoint) {
251
+ throw new Error("Existing sources use different Iteration servers. Connect one source at a time.");
252
+ }
253
+ if (existingScopes[0]?.scope.organizationId !== item.scope.organizationId) {
254
+ throw new Error("Existing sources use different workspaces. Connect one source at a time.");
255
+ }
256
+ }
257
+ const requestedOrganizationId = optionalString(values, "organization-id");
258
+ if (requestedOrganizationId &&
259
+ existingScopes[0] &&
260
+ requestedOrganizationId !== existingScopes[0].scope.organizationId) {
261
+ throw new Error("The existing collector belongs to a different workspace.");
262
+ }
263
+ const previousConsent = await loadMachineConsent();
264
+ if (previousConsent === null ||
265
+ anchorExisting === null ||
266
+ previousConsent.endpoint !== endpoint ||
267
+ previousConsent.anchorSource !== anchorSource ||
268
+ previousConsent.anchorDataDirectory !== anchorExisting.dataDirectory ||
269
+ previousConsent.organizationId !==
270
+ existingScopes[0]?.scope.organizationId ||
271
+ previousConsent.sourceHomes.codex !== sourceHomes.codex ||
272
+ previousConsent.sourceHomes.claude !== sourceHomes.claude) {
273
+ await approveAllSources(detected, existingScopes[0]?.scope.organizationName ?? null, values.yes === true);
274
+ }
275
+ let anchor = anchorExisting ?? (await initialize(anchorSource, values));
276
+ let organizationId = requestedOrganizationId ??
277
+ existingScopes[0]?.scope.organizationId ??
278
+ anchor.organizationId ??
279
+ null;
280
+ for (const source of detected) {
281
+ if (source === anchorSource ||
282
+ configs[COLLECTOR_SOURCES.indexOf(source)] !== null)
283
+ continue;
284
+ const paths = collectorPaths(defaultCollectorDirectory(source));
285
+ await assertFreshCollectorDirectory(paths, source);
286
+ const state = await prepareSourceInitialState(source, sourceHomes[source], true);
287
+ const enrollment = await expandSource(anchor, source, organizationId);
288
+ const config = await activateSource(source, sourceHomes[source], endpoint, enrollment, state);
289
+ organizationId = enrollment.organizationId;
290
+ const notice = await turnSummaryNotice(config);
291
+ process.stdout.write(`${collectorSourceLabel(source)} is connected to ${enrollment.organizationName}.${notice}\n`);
292
+ }
293
+ if (organizationId === null) {
294
+ organizationId = (await readCollectorScope(anchor)).organizationId;
295
+ }
296
+ if (anchor.organizationId === undefined) {
297
+ anchor = { ...anchor, organizationId };
298
+ await saveConfig(anchor, collectorPaths(anchor.dataDirectory));
299
+ }
300
+ await saveMachineConsent({
301
+ version: 1,
302
+ endpoint,
303
+ organizationId,
304
+ anchorSource,
305
+ anchorDataDirectory: anchor.dataDirectory,
306
+ sourceHomes,
307
+ });
308
+ for (const item of existing) {
309
+ await ensureStartableState(item.config, collectorPaths(item.config.dataDirectory));
310
+ await installLaunchAgent(item.config.dataDirectory, item.source);
311
+ const notice = await turnSummaryNotice(item.config);
312
+ process.stdout.write(`${collectorSourceLabel(item.source)} is still connected.${notice}\n`);
313
+ }
314
+ process.stdout.write("Detected agents are connected. New supported agents on this Mac will connect automatically.\n");
315
+ }
316
+ async function importHistory(source, values) {
317
+ if (process.platform !== "darwin") {
318
+ throw new Error("Automatic startup is currently supported on macOS only.");
319
+ }
320
+ const directory = dataDirectory(source, values);
153
321
  const paths = collectorPaths(directory);
154
322
  const config = await loadConfig(paths);
155
- const estimate = (await importCodexHistory(config, paths, false)).size;
156
- process.stdout.write(`Found ${estimate.files} local Codex session files (${estimate.bytes} bytes). History may include prompts, code, and tool output that workspace members can read.\n`);
323
+ if (collectorSource(config) !== source)
324
+ throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
325
+ const estimate = (await importSourceHistory(config, paths, false)).size;
326
+ process.stdout.write(`Found ${estimate.files} local ${source} session files (${estimate.bytes} bytes). History may include prompts, code, and tool output that workspace members can read.\n`);
157
327
  if (values.yes !== true) {
158
- process.stdout.write("No changes made. Run iteration import-history codex --yes to confirm this upload.\n");
328
+ process.stdout.write(`No changes made. Run iteration import-history ${source} --yes to confirm this upload.\n`);
159
329
  return;
160
330
  }
161
- const result = await importCodexHistory(config, paths, true);
162
- 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`);
331
+ const result = await importSourceHistory(config, paths, true);
332
+ 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 ${source} to see local scan progress and pending uploads.${result.backgroundRunning ? "" : ` The collector was not running; run iteration start ${source} to begin scanning.`}\n`);
163
333
  }
164
- async function start(values) {
334
+ async function start(source, values) {
165
335
  if (process.platform !== "darwin") {
166
336
  throw new Error("Automatic startup is currently supported on macOS only.");
167
337
  }
168
- const directory = dataDirectory(values);
338
+ const directory = dataDirectory(source, values);
169
339
  const paths = collectorPaths(directory);
170
340
  const config = await loadConfig(paths);
341
+ if (collectorSource(config) !== source)
342
+ throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
171
343
  const repairedState = await ensureStartableState(config, paths);
172
- await installLaunchAgent(directory);
173
- process.stdout.write(`Codex is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}\n`);
344
+ await installLaunchAgent(directory, source);
345
+ const notice = await turnSummaryNotice(config);
346
+ process.stdout.write(`${collectorSourceLabel(source)} is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}${notice}\n`);
174
347
  }
175
- async function statusSnapshot(values) {
176
- const paths = collectorPaths(dataDirectory(values));
348
+ async function statusSnapshot(source, values) {
349
+ const paths = collectorPaths(dataDirectory(source, values));
177
350
  if (!(await fileExists(paths.config))) {
178
351
  const [stateExists, pendingBytes] = await Promise.all([
179
352
  fileExists(paths.state),
@@ -185,11 +358,13 @@ async function statusSnapshot(values) {
185
358
  return { kind: "not_connected" };
186
359
  }
187
360
  const config = await loadConfig(paths);
361
+ if (collectorSource(config) !== source)
362
+ throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
188
363
  const [running, size, ids, scan, localHealth] = await Promise.all([
189
- launchAgentStatus(),
364
+ launchAgentStatus(source),
190
365
  outboxSize(paths),
191
366
  pendingEventIds(paths),
192
- historyScanProgress(config.codexHome, paths),
367
+ historyScanProgress(collectorSourceDirectory(config), paths, source),
193
368
  healthSnapshot(paths),
194
369
  ]);
195
370
  const pendingEvents = size === 0 ? 0 : ids.size;
@@ -204,43 +379,71 @@ async function statusSnapshot(values) {
204
379
  health: localHealth.health,
205
380
  healthAvailable: localHealth.kind === "available",
206
381
  tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
207
- logPath: path.join(os.homedir(), "Library", "Logs", "iteration-codex-collector.error.log"),
382
+ logPath: path.join(os.homedir(), "Library", "Logs", `iteration-${source}-collector.error.log`),
208
383
  };
209
384
  }
210
- async function status(values) {
211
- const snapshot = await statusSnapshot(values);
212
- process.stdout.write(`${renderDetailedStatus(snapshot)}\n`);
385
+ async function status(source, values) {
386
+ if (source !== undefined) {
387
+ const snapshot = await statusSnapshot(source, values);
388
+ process.stdout.write(`${renderDetailedStatus(snapshot, Date.now(), source)}\n`);
389
+ return;
390
+ }
391
+ const [codex, claude] = await Promise.all([
392
+ statusSnapshot("codex", values),
393
+ statusSnapshot("claude", values),
394
+ ]);
395
+ process.stdout.write(`${renderDetailedStatus(codex, Date.now(), "codex")}\n\n${renderDetailedStatus(claude, Date.now(), "claude")}\n`);
213
396
  }
214
397
  async function main() {
215
398
  const { command, source, values } = argumentsForCli();
399
+ const selectedSource = source === "codex" || source === "claude" ? source : null;
216
400
  if (values.help === true || command === "help") {
217
401
  return usage(command === "help" ? source : command);
218
402
  }
219
403
  if (values["include-history"] === true &&
220
- !(command === "connect" && source === "codex")) {
404
+ !(command === "connect" && selectedSource !== null)) {
221
405
  throw new Error("Existing sessions are included automatically. Run iteration to connect.");
222
406
  }
223
- if (command === "" || (command === "connect" && source === undefined)) {
407
+ if (command === "connect" && source === undefined) {
408
+ return connectAll(values);
409
+ }
410
+ if (command === "") {
224
411
  if (Object.keys(values).length > 0) {
225
- throw new Error("Options require a source. Run iteration connect codex --help for details.");
412
+ throw new Error("Options require a source. Run iteration connect --help for details.");
226
413
  }
227
414
  return defaultCommand();
228
415
  }
229
- if (command === "connect" && source === "codex")
230
- return initialize(values);
416
+ if (selectedSource !== null) {
417
+ if (selectedSource === "codex" && values["claude-home"] !== undefined)
418
+ throw new Error("--claude-home requires the Claude source.");
419
+ if (selectedSource === "claude" && values["codex-home"] !== undefined)
420
+ throw new Error("--codex-home requires the Codex source.");
421
+ }
422
+ if (command === "connect" && selectedSource !== null) {
423
+ await initialize(selectedSource, values);
424
+ return;
425
+ }
231
426
  if (command === "run") {
232
- if (source !== undefined)
233
- throw new Error("The run command takes no source.");
234
- return runCollector(optionalString(values, "data-directory") ?? undefined);
235
- }
236
- if (command === "status" && (source === undefined || source === "codex"))
237
- return status(values);
238
- if (command === "import-history" && source === "codex")
239
- return importHistory(values);
240
- if (command === "start" && source === "codex")
241
- return start(values);
242
- if (command === "disconnect" && source === "codex") {
243
- await uninstallLaunchAgent();
427
+ if (source !== undefined && selectedSource === null)
428
+ throw new Error("The run source must be codex or claude.");
429
+ return runCollector(optionalString(values, "data-directory") ?? undefined, selectedSource ?? undefined);
430
+ }
431
+ if (command === "status" &&
432
+ (source === undefined || selectedSource !== null)) {
433
+ if (source === undefined && values["data-directory"] !== undefined)
434
+ throw new Error("--data-directory requires a source with status.");
435
+ return status(selectedSource ?? undefined, values);
436
+ }
437
+ if (command === "import-history" && selectedSource !== null)
438
+ return importHistory(selectedSource, values);
439
+ if (command === "start" && selectedSource !== null)
440
+ return start(selectedSource, values);
441
+ if (command === "disconnect" && selectedSource !== null) {
442
+ await uninstallLaunchAgent(selectedSource);
443
+ const paths = collectorPaths(dataDirectory(selectedSource, values));
444
+ if (await fileExists(paths.config)) {
445
+ await uninstallTurnSummary(await loadConfig(paths));
446
+ }
244
447
  process.stdout.write("Collector startup removed. Local pending data was retained.\n");
245
448
  return;
246
449
  }
@@ -4,7 +4,7 @@ export declare class TraceUploadError extends Error {
4
4
  readonly status: number | null;
5
5
  constructor(kind: "http" | "network" | "response", status: number | null, message: string, options?: ErrorOptions);
6
6
  }
7
- export declare function scanRolloutsOnce(config: CollectorConfig, paths: CollectorPaths, signal?: AbortSignal): Promise<Readonly<{
7
+ export declare function scanSourceOnce(config: CollectorConfig, paths: CollectorPaths, signal?: AbortSignal): Promise<Readonly<{
8
8
  discovered: number;
9
9
  caughtUp: boolean;
10
10
  }>>;
package/dist/collector.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseTraceEventAcknowledgement, } from "iteration-trace-contracts";
4
+ import { parseClaudeLine } from "./claude-transcript.js";
4
5
  import { parseRolloutLine } from "./rollout.js";
5
- import { acknowledgeEvents, appendEvents, loadState, nextBatch, outboxSize, pendingEventIds, rolloutFiles, saveState, } from "./storage.js";
6
+ import { acknowledgeEvents, appendEvents, collectorSource, collectorSourceDirectory, loadState, nextBatch, outboxSize, pendingEventIds, sourceFiles, saveState, } from "./storage.js";
6
7
  const MAX_SCAN_BYTES_PER_FILE = 16 * 1024 * 1024;
7
8
  const MAX_SCAN_BYTES_PER_TICK = 16 * 1024 * 1024;
8
9
  const MAX_RECORD_BYTES = 3 * 1024 * 1024;
@@ -29,16 +30,31 @@ function logGap(filename, offset, reason) {
29
30
  reason,
30
31
  }));
31
32
  }
32
- export async function scanRolloutsOnce(config, paths, signal) {
33
+ function parseSourceRecord(source, line, filename, byteOffset) {
34
+ if (source === "claude")
35
+ return parseClaudeLine(line, filename, byteOffset);
36
+ if (source === "codex") {
37
+ const parsed = parseRolloutLine(line, filename, byteOffset);
38
+ return parsed.kind === "event"
39
+ ? { events: [parsed.event], gaps: [] }
40
+ : parsed.kind === "gap"
41
+ ? { events: [], gaps: [parsed.reason] }
42
+ : { events: [], gaps: [] };
43
+ }
44
+ const unhandled = source;
45
+ throw new Error(`Unsupported collector source: ${unhandled}`);
46
+ }
47
+ export async function scanSourceOnce(config, paths, signal) {
33
48
  signal?.throwIfAborted();
49
+ const source = collectorSource(config);
50
+ const sourceDirectory = path.resolve(collectorSourceDirectory(config));
34
51
  const state = await loadState(paths);
35
- if (!state.initialized ||
36
- state.sourceDirectory !== path.resolve(config.codexHome)) {
37
- throw new Error("Collector state does not match CODEX_HOME; run iteration connect codex again.");
52
+ if (!state.initialized || state.sourceDirectory !== sourceDirectory) {
53
+ throw new Error(`Collector state does not match the ${source} directory; run iteration connect ${source} again.`);
38
54
  }
39
55
  const cursors = { ...state.cursors };
40
56
  const skipping = new Set(state.skipping);
41
- const files = await rolloutFiles(path.join(config.codexHome, "sessions"));
57
+ const files = await sourceFiles(source, sourceDirectory);
42
58
  const queuedIds = await pendingEventIds(paths);
43
59
  const events = [];
44
60
  let remainingBudget = MAX_SCAN_BYTES_PER_TICK;
@@ -108,17 +124,19 @@ export async function scanRolloutsOnce(config, paths, signal) {
108
124
  lineStart = index + 1;
109
125
  continue;
110
126
  }
111
- const parsed = parseRolloutLine(line, filename, byteOffset);
112
- if (parsed.kind === "gap")
113
- logGap(filename, byteOffset, parsed.reason);
114
- if (parsed.kind === "event" && !queuedIds.has(parsed.event.eventId)) {
115
- const eventBytes = Buffer.byteLength(JSON.stringify(parsed.event), "utf8");
127
+ const parsed = parseSourceRecord(source, line, filename, byteOffset);
128
+ for (const reason of parsed.gaps)
129
+ logGap(filename, byteOffset, reason);
130
+ for (const parsedEvent of parsed.events) {
131
+ if (queuedIds.has(parsedEvent.eventId))
132
+ continue;
133
+ const eventBytes = Buffer.byteLength(JSON.stringify(parsedEvent), "utf8");
116
134
  if (eventBytes > MAX_EVENT_BYTES) {
117
135
  logGap(filename, byteOffset, "normalized_event_exceeds_upload_limit");
118
136
  }
119
137
  else {
120
- events.push(parsed.event);
121
- queuedIds.add(parsed.event.eventId);
138
+ events.push(parsedEvent);
139
+ queuedIds.add(parsedEvent.eventId);
122
140
  }
123
141
  }
124
142
  lineStart = index + 1;
@@ -192,7 +210,7 @@ async function uploadPendingOnce(config, paths, fetcher = fetch, signal, onAckno
192
210
  }
193
211
  export async function collectorTick(config, paths, fetcher = fetch, signal, onAcknowledged) {
194
212
  let uploaded = await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
195
- const scan = await scanRolloutsOnce(config, paths, signal);
213
+ const scan = await scanSourceOnce(config, paths, signal);
196
214
  uploaded += await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
197
215
  return {
198
216
  discovered: scan.discovered,
@@ -9,7 +9,7 @@ export type HistoryImportControl = Readonly<{
9
9
  start: (directory: string) => Promise<unknown>;
10
10
  acquireLock: (paths: CollectorPaths) => Promise<() => Promise<void>>;
11
11
  }>;
12
- export declare function importCodexHistory(config: CollectorConfig, paths: CollectorPaths, confirmed: boolean, control?: HistoryImportControl): Promise<Readonly<{
12
+ export declare function importSourceHistory(config: CollectorConfig, paths: CollectorPaths, confirmed: boolean, control?: HistoryImportControl): Promise<Readonly<{
13
13
  imported: boolean;
14
14
  size: HistoryImportSize;
15
15
  backgroundRunning: boolean | null;
@@ -1,20 +1,23 @@
1
1
  import path from "node:path";
2
2
  import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
3
3
  import { waitForCollectorLock } from "./service.js";
4
- import { historyImportSize, loadState, resetHistoryCursors, } from "./storage.js";
5
- const macControl = {
6
- isRunning: launchAgentStatus,
7
- stop: uninstallLaunchAgent,
8
- start: installLaunchAgent,
9
- acquireLock: waitForCollectorLock,
10
- };
11
- export async function importCodexHistory(config, paths, confirmed, control = macControl) {
4
+ import { collectorSource, collectorSourceDirectory, historyImportSize, loadState, resetHistoryCursors, } from "./storage.js";
5
+ function macControl(source) {
6
+ return {
7
+ isRunning: () => launchAgentStatus(source),
8
+ stop: () => uninstallLaunchAgent(source),
9
+ start: (directory) => installLaunchAgent(directory, source),
10
+ acquireLock: waitForCollectorLock,
11
+ };
12
+ }
13
+ export async function importSourceHistory(config, paths, confirmed, control = macControl(collectorSource(config))) {
14
+ const source = collectorSource(config);
15
+ const directory = collectorSourceDirectory(config);
12
16
  const state = await loadState(paths);
13
- if (!state.initialized ||
14
- state.sourceDirectory !== path.resolve(config.codexHome)) {
15
- throw new Error("Connect the Codex source before importing its history.");
17
+ if (!state.initialized || state.sourceDirectory !== path.resolve(directory)) {
18
+ throw new Error(`Connect the ${source} source before importing its history.`);
16
19
  }
17
- const estimate = await historyImportSize(config.codexHome);
20
+ const estimate = await historyImportSize(directory, source);
18
21
  if (!confirmed)
19
22
  return { imported: false, size: estimate, backgroundRunning: null };
20
23
  const wasRunning = await control.isRunning();
@@ -29,7 +32,7 @@ export async function importCodexHistory(config, paths, confirmed, control = mac
29
32
  }
30
33
  const releaseLock = await control.acquireLock(paths);
31
34
  try {
32
- size = await resetHistoryCursors(config.codexHome, paths);
35
+ size = await resetHistoryCursors(directory, paths, source);
33
36
  rewound = true;
34
37
  }
35
38
  finally {
@@ -45,8 +48,8 @@ export async function importCodexHistory(config, paths, confirmed, control = mac
45
48
  }
46
49
  catch (error) {
47
50
  throw new Error(rewound
48
- ? "History checkpoints were rewound, but the collector could not restart. Run iteration start codex to resume uploads."
49
- : "The collector could not be restored after a failed history import. Run iteration start codex to resume uploads.", {
51
+ ? `History checkpoints were rewound, but the collector could not restart. Run iteration start ${source} to resume uploads.`
52
+ : `The collector could not be restored after a failed history import. Run iteration start ${source} to resume uploads.`, {
50
53
  cause: failure ? new AggregateError([failure.error, error]) : error,
51
54
  });
52
55
  }
@@ -1,12 +1,13 @@
1
+ import type { CollectorSource } from "iteration-trace-contracts";
1
2
  export type LaunchAgentRestart = {
2
3
  isLoaded(timeoutMs: number): Promise<boolean>;
3
4
  bootstrap(timeoutMs: number): Promise<void>;
4
5
  wait(delayMs: number): Promise<void>;
5
6
  now(): number;
6
7
  };
7
- export declare function launchAgentPlist(cliPath?: string, nodePath?: string, logDirectory?: string, dataDirectory?: string): string;
8
+ export declare function launchAgentPlist(cliPath?: string, nodePath?: string, logDirectory?: string, dataDirectory?: string, source?: CollectorSource): string;
8
9
  export declare function bootstrapAfterBootout(wasLoaded: boolean, control: LaunchAgentRestart): Promise<void>;
9
- export declare function installLaunchAgent(dataDirectory?: string): Promise<string>;
10
- export declare function uninstallLaunchAgent(): Promise<void>;
11
- export declare function launchAgentStatus(): Promise<boolean>;
10
+ export declare function installLaunchAgent(dataDirectory?: string, source?: CollectorSource): Promise<string>;
11
+ export declare function uninstallLaunchAgent(source?: CollectorSource): Promise<void>;
12
+ export declare function launchAgentStatus(source?: CollectorSource): Promise<boolean>;
12
13
  export declare function launchAgentRunning(output: string): boolean;