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/README.md CHANGED
@@ -4,10 +4,15 @@ Install one CLI for Iteration's supported coding-agent sources:
4
4
 
5
5
  ```sh
6
6
  npm install --global iteration-cli
7
- iteration
7
+ iteration connect codex
8
+ iteration connect claude
8
9
  ```
9
10
 
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` to see whether collection and uploads are healthy, `iteration status` for details, or `iteration help` for other commands.
11
+ The collectors currently run on macOS with Node.js 22 or later. Connect either source or both on the same Mac. Each source has its own background process, local checkpoint, and upload queue. Connecting imports existing local sessions and collects new activity. Run `iteration` to see connected sources, `iteration status codex` or `iteration status claude` for details, and `iteration help` for other commands.
12
+
13
+ Claude Code sessions are read from `~/.claude/projects`, including session subagents. If Claude uses `CLAUDE_CONFIG_DIR`, the collector follows that setting during connection; `iteration connect claude --claude-home DIR` selects another directory explicitly. The collector reads only saved local JSONL records. Claude Code may remove old transcripts under its own retention settings, so connect before local history is removed. The records can contain prompts, reasoning, tool input and output, and context snapshots; workspace members can read what uploads. Unknown record shapes are reported as capture gaps in the collector log. Remote Claude sessions are outside this local collector.
14
+
15
+ To rescan older local sessions, preview with `iteration import-history claude`, then confirm with `iteration import-history claude --yes`. Use `iteration start claude` to restart collection and `iteration disconnect claude` to stop its background process without deleting local pending data.
11
16
 
12
17
  If you previously installed the Codex-specific package, remove it with the package manager you used:
13
18
 
@@ -0,0 +1,6 @@
1
+ import { type TraceEvent } from "iteration-trace-contracts";
2
+ export type ClaudeParseResult = Readonly<{
3
+ events: readonly TraceEvent[];
4
+ gaps: readonly string[];
5
+ }>;
6
+ export declare function parseClaudeLine(line: string, filename: string, byteOffset: number): ClaudeParseResult;
@@ -0,0 +1,255 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import * as z from "zod";
4
+ import { parseTraceEventBatch, } from "iteration-trace-contracts";
5
+ const CONTEXT_RECORD_TYPES = new Set([
6
+ "attachment",
7
+ "system",
8
+ "last-prompt",
9
+ "mode",
10
+ "permission-mode",
11
+ "atis-latch",
12
+ "ai-title",
13
+ "file-history-snapshot",
14
+ "file-history-delta",
15
+ "cost-state",
16
+ "queue-operation",
17
+ ]);
18
+ function object(value) {
19
+ const parsed = z.record(z.string(), z.json()).safeParse(value);
20
+ return parsed.success ? parsed.data : null;
21
+ }
22
+ function string(value) {
23
+ const parsed = z.string().safeParse(value);
24
+ return parsed.success ? parsed.data : null;
25
+ }
26
+ function digest(value) {
27
+ return createHash("sha256").update(value, "utf8").digest("hex");
28
+ }
29
+ const URL_NAMESPACE = Buffer.from("6ba7b8119dad11d180b400c04fd430c8", "hex");
30
+ function childThreadId(parentId, agentId) {
31
+ const bytes = createHash("sha1")
32
+ .update(URL_NAMESPACE)
33
+ .update(`https://github.com/Thytu/Iteration/claude/${parentId}/agent/${agentId}`)
34
+ .digest()
35
+ .subarray(0, 16);
36
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
37
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
38
+ const hex = bytes.toString("hex");
39
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
40
+ }
41
+ const SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+ function claudeSessionIdentity(filename) {
43
+ if (!filename.endsWith(".jsonl"))
44
+ return null;
45
+ const basename = path.basename(filename, ".jsonl");
46
+ if (path.basename(path.dirname(filename)) === "subagents") {
47
+ const parent = path.basename(path.dirname(path.dirname(filename)));
48
+ if (!basename.startsWith("agent-") || !SESSION_UUID.test(parent))
49
+ return null;
50
+ const agentId = basename.slice("agent-".length);
51
+ if (agentId.length === 0)
52
+ return null;
53
+ const threadId = childThreadId(parent, agentId);
54
+ const sessionId = `claude:${threadId}`;
55
+ const runId = `${parent}/agent/${agentId}`;
56
+ if (sessionId.length > 200 || runId.length > 200)
57
+ return null;
58
+ return {
59
+ sessionId,
60
+ parentSessionId: `claude:${parent}`,
61
+ agentId,
62
+ runId,
63
+ parentRunId: parent,
64
+ };
65
+ }
66
+ const sessionId = `claude:${basename}`;
67
+ return sessionId.length <= 200 && SESSION_UUID.test(basename)
68
+ ? {
69
+ sessionId,
70
+ parentSessionId: null,
71
+ agentId: null,
72
+ runId: basename,
73
+ parentRunId: null,
74
+ }
75
+ : null;
76
+ }
77
+ function timestamp(record) {
78
+ const value = string(record.timestamp);
79
+ return value !== null &&
80
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) &&
81
+ !Number.isNaN(Date.parse(value))
82
+ ? value
83
+ : null;
84
+ }
85
+ function event(identity, record, byteOffset, index, eventType, payload, callId = null, stableKey) {
86
+ return {
87
+ eventId: `${String(index).padStart(4, "0")}:${digest(`${identity.sessionId}:${stableKey ?? string(record.uuid) ?? `${byteOffset}:${JSON.stringify(record)}`}`)}`,
88
+ sessionId: identity.sessionId,
89
+ sequence: byteOffset,
90
+ callId,
91
+ projectId: null,
92
+ occurredAt: timestamp(record),
93
+ eventType,
94
+ payload,
95
+ };
96
+ }
97
+ function contentText(value) {
98
+ const literal = string(value);
99
+ if (literal !== null)
100
+ return literal;
101
+ if (!Array.isArray(value))
102
+ return null;
103
+ const parts = value.flatMap((part) => {
104
+ const block = object(part);
105
+ const text = block === null ? null : string(block.text);
106
+ return text === null ? [] : [text];
107
+ });
108
+ return parts.length > 0 ? parts.join("\n") : null;
109
+ }
110
+ export function parseClaudeLine(line, filename, byteOffset) {
111
+ const identity = claudeSessionIdentity(filename);
112
+ if (identity === null)
113
+ return { events: [], gaps: ["invalid_claude_session_path"] };
114
+ let decoded;
115
+ try {
116
+ decoded = JSON.parse(line);
117
+ }
118
+ catch {
119
+ return { events: [], gaps: ["malformed_jsonl_record"] };
120
+ }
121
+ const record = object(decoded);
122
+ if (record === null)
123
+ return { events: [], gaps: ["record_is_not_an_object"] };
124
+ const recordType = string(record.type);
125
+ const sourceSessionId = string(record.sessionId);
126
+ if ((identity.agentId !== null && sourceSessionId === null) ||
127
+ (sourceSessionId !== null &&
128
+ `claude:${sourceSessionId}` !==
129
+ (identity.parentSessionId ?? identity.sessionId))) {
130
+ return { events: [], gaps: ["session_id_does_not_match_path"] };
131
+ }
132
+ if (identity.agentId !== null && string(record.agentId) !== identity.agentId)
133
+ return { events: [], gaps: ["agent_id_does_not_match_path"] };
134
+ const gaps = [];
135
+ if (record.timestamp !== undefined && timestamp(record) === null)
136
+ gaps.push("invalid_source_timestamp");
137
+ const events = [];
138
+ const meta = {
139
+ id: identity.runId,
140
+ parent_thread_id: identity.parentRunId,
141
+ cwd: string(record.cwd),
142
+ git: { branch: string(record.gitBranch) },
143
+ version: string(record.version),
144
+ agentId: identity.agentId,
145
+ };
146
+ events.push({
147
+ ...event(identity, record, 0, 0, "session_meta", meta, null, "session_meta"),
148
+ run: {
149
+ kind: "identity",
150
+ id: identity.runId,
151
+ parentId: identity.parentRunId,
152
+ ownStart: true,
153
+ startOrdinal: null,
154
+ },
155
+ });
156
+ if (recordType === "user") {
157
+ const message = object(record.message);
158
+ const content = message === null ? null : string(message.content);
159
+ if (message === null)
160
+ gaps.push("user_without_message");
161
+ else if (content !== null) {
162
+ events.push(event(identity, record, byteOffset, 1, "user_message", {
163
+ text: content,
164
+ record,
165
+ }));
166
+ }
167
+ else if (Array.isArray(message.content)) {
168
+ for (const [index, value] of message.content.entries()) {
169
+ const block = object(value);
170
+ if (block === null) {
171
+ gaps.push("invalid_user_content_block");
172
+ continue;
173
+ }
174
+ if (block.type === "tool_result") {
175
+ const callId = string(block.tool_use_id);
176
+ if (callId === null) {
177
+ gaps.push("tool_result_without_call_id");
178
+ continue;
179
+ }
180
+ events.push(event(identity, record, byteOffset, index + 1, "tool_result", {
181
+ text: contentText(block.content),
182
+ record,
183
+ }, callId));
184
+ }
185
+ else {
186
+ events.push(event(identity, record, byteOffset, index + 1, "user_message", {
187
+ text: string(block.text) ?? contentText(block.content),
188
+ record,
189
+ }));
190
+ }
191
+ }
192
+ }
193
+ else
194
+ gaps.push("unsupported_user_content");
195
+ }
196
+ else if (recordType === "assistant") {
197
+ const message = object(record.message);
198
+ if (message === null || !Array.isArray(message.content))
199
+ gaps.push("assistant_without_content_blocks");
200
+ else {
201
+ for (const [index, value] of message.content.entries()) {
202
+ const block = object(value);
203
+ if (block === null) {
204
+ gaps.push("invalid_assistant_content_block");
205
+ continue;
206
+ }
207
+ const blockType = string(block.type);
208
+ const eventType = blockType === "text"
209
+ ? "assistant_message"
210
+ : blockType === "thinking" || blockType === "redacted_thinking"
211
+ ? "reasoning"
212
+ : blockType === "tool_use"
213
+ ? "tool_call"
214
+ : null;
215
+ if (eventType === null) {
216
+ gaps.push(`unsupported_assistant_block:${blockType ?? "unknown"}`);
217
+ continue;
218
+ }
219
+ const callId = eventType === "tool_call" ? string(block.id) : null;
220
+ if (eventType === "tool_call" && callId === null) {
221
+ gaps.push("tool_call_without_call_id");
222
+ continue;
223
+ }
224
+ events.push(event(identity, record, byteOffset, index + 1, eventType, {
225
+ text: string(block.text) ?? string(block.thinking),
226
+ name: string(block.name),
227
+ record,
228
+ }, callId));
229
+ }
230
+ if (object(message.usage) !== null && string(message.id) !== null) {
231
+ events.push(event(identity, record, byteOffset, 9999, "token_usage_record", { model: string(message.model), usage: message.usage }, null, `usage:${message.id}`));
232
+ }
233
+ }
234
+ }
235
+ else if (recordType !== null && CONTEXT_RECORD_TYPES.has(recordType)) {
236
+ const attachment = object(record.attachment);
237
+ events.push(event(identity, record, byteOffset, 1, "source_record", {
238
+ sourceType: recordType,
239
+ subtype: string(record.subtype) ??
240
+ (attachment === null ? null : string(attachment.type)),
241
+ record,
242
+ }));
243
+ }
244
+ else
245
+ gaps.push(`unsupported_claude_record:${recordType ?? "unknown"}`);
246
+ const valid = [];
247
+ for (const candidate of events) {
248
+ const checked = parseTraceEventBatch({ events: [candidate] });
249
+ if (checked.success)
250
+ valid.push(candidate);
251
+ else
252
+ gaps.push(`invalid_normalized_event:${candidate.eventType}`);
253
+ }
254
+ return { events: valid, gaps };
255
+ }
@@ -1,4 +1,5 @@
1
- export declare function authenticateAndEnrollCollector(endpoint: URL, deviceNameValue: string, organizationId: string | null): Promise<Readonly<{
1
+ import { type CollectorSource } from "iteration-trace-contracts";
2
+ export declare function authenticateAndEnrollCollector(endpoint: URL, deviceNameValue: string, organizationId: string | null, source?: CollectorSource): Promise<Readonly<{
2
3
  token: string;
3
4
  deviceId: string;
4
5
  organizationName: string;
@@ -186,8 +186,10 @@ async function clerkDeviceSignIn(configuration) {
186
186
  await openBrowser(verificationUrl);
187
187
  return pollForAccessToken(endpoints.tokenUrl, configuration.clientId, challenge.device_code, challenge.expires_in, challenge.interval);
188
188
  }
189
- async function enrollDevice(endpoint, accessToken, deviceName, organizationId) {
189
+ async function enrollDevice(endpoint, accessToken, deviceName, organizationId, source) {
190
190
  const body = { deviceName };
191
+ if (source === "claude")
192
+ body.source = source;
191
193
  if (organizationId !== null)
192
194
  body.organizationId = organizationId;
193
195
  const response = await fetch(apiRoute(endpoint, "./collector-auth/enroll"), {
@@ -248,7 +250,7 @@ async function chooseOrganization(organizations, hasMore) {
248
250
  prompt.close();
249
251
  }
250
252
  }
251
- export async function authenticateAndEnrollCollector(endpoint, deviceNameValue, organizationId) {
253
+ export async function authenticateAndEnrollCollector(endpoint, deviceNameValue, organizationId, source = "codex") {
252
254
  const deviceName = z
253
255
  .string()
254
256
  .trim()
@@ -260,10 +262,10 @@ export async function authenticateAndEnrollCollector(endpoint, deviceNameValue,
260
262
  }
261
263
  const configuration = await loadOAuthConfiguration(endpoint);
262
264
  const clerkAccessToken = await clerkDeviceSignIn(configuration);
263
- let result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, organizationId);
265
+ let result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, organizationId, source);
264
266
  if (result.status === "choose_organization") {
265
267
  const selectedOrganizationId = await chooseOrganization(result.organizations, result.hasMore);
266
- result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, selectedOrganizationId);
268
+ result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, selectedOrganizationId, source);
267
269
  }
268
270
  if (result.status !== "enrolled") {
269
271
  throw new Error("The collector could not be assigned to that workspace.");
package/dist/cli.js CHANGED
@@ -4,12 +4,13 @@ import path from "node:path";
4
4
  import { promises as fs } from "node:fs";
5
5
  import { parseArgs } from "node:util";
6
6
  import * as z from "zod";
7
+ import { collectorSourceLabel, } from "iteration-trace-contracts";
7
8
  import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
8
- import { importCodexHistory } from "./history-import.js";
9
+ import { importSourceHistory } from "./history-import.js";
9
10
  import { runCollector } from "./service.js";
10
11
  import { renderDetailedStatus, renderHome, } from "./status-view.js";
11
12
  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";
13
+ import { assertFreshCollectorDirectory, collectorSource, collectorSourceDirectory, collectorPaths, defaultCollectorDirectory, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
13
14
  const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
14
15
  function argumentsForCli() {
15
16
  const parsed = parseArgs({
@@ -22,6 +23,7 @@ function argumentsForCli() {
22
23
  "device-name": { type: "string" },
23
24
  "organization-id": { type: "string" },
24
25
  "codex-home": { type: "string" },
26
+ "claude-home": { type: "string" },
25
27
  "data-directory": { type: "string" },
26
28
  "include-history": { type: "boolean" },
27
29
  yes: { type: "boolean" },
@@ -49,8 +51,9 @@ function optionalString(values, name) {
49
51
  function usage(command) {
50
52
  if (command === "connect") {
51
53
  process.stdout.write([
52
- "Connect Codex to Iteration",
54
+ "Connect a coding agent to Iteration",
53
55
  " iteration connect codex",
56
+ " iteration connect claude",
54
57
  "",
55
58
  "Existing local sessions and new activity are collected automatically.",
56
59
  "",
@@ -58,6 +61,7 @@ function usage(command) {
58
61
  " --device-name NAME Name this Mac in Iteration",
59
62
  " --organization-id UUID Select a workspace without a prompt",
60
63
  " --codex-home DIR Use a different Codex data directory",
64
+ " --claude-home DIR Use a different Claude data directory",
61
65
  " --data-directory DIR Use a different Iteration state directory",
62
66
  " --endpoint URL Use a different Iteration server",
63
67
  ].join("\n") + "\n");
@@ -67,12 +71,13 @@ function usage(command) {
67
71
  "Iteration CLI",
68
72
  "",
69
73
  "Get started:",
70
- " iteration Connect Codex, or see its current activity",
74
+ " iteration See connected sources or connect Codex",
71
75
  "",
72
76
  "Commands:",
73
77
  " iteration connect codex Connect Codex directly",
78
+ " iteration connect claude Connect Claude Code directly",
74
79
  " iteration status See scan, upload, and error details",
75
- " iteration disconnect codex Stop collecting",
80
+ " iteration disconnect codex|claude Stop a source",
76
81
  "",
77
82
  "Run iteration connect codex --help for advanced setup options.",
78
83
  ].join("\n") + "\n");
@@ -92,22 +97,29 @@ async function fileExists(filename) {
92
97
  }
93
98
  }
94
99
  async function defaultCommand() {
95
- const snapshot = await statusSnapshot({});
96
- if (snapshot.kind !== "not_connected") {
97
- process.stdout.write(`${renderHome(snapshot)}\n`);
100
+ const [codex, claude] = await Promise.all([
101
+ statusSnapshot("codex", {}),
102
+ statusSnapshot("claude", {}),
103
+ ]);
104
+ const connected = [
105
+ { source: "codex", snapshot: codex },
106
+ { source: "claude", snapshot: claude },
107
+ ].filter(({ snapshot }) => snapshot.kind !== "not_connected");
108
+ if (connected.length > 0) {
109
+ process.stdout.write(`${connected.map(({ source, snapshot }) => renderHome(snapshot, Date.now(), source)).join("\n\n")}\n`);
98
110
  return;
99
111
  }
100
112
  if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
101
- process.stdout.write("Run iteration in a terminal to connect Codex.\n");
113
+ process.stdout.write("Run iteration connect codex or iteration connect claude in a terminal.\n");
102
114
  return;
103
115
  }
104
- await initialize({});
116
+ await initialize("codex", {});
105
117
  }
106
- function dataDirectory(values) {
118
+ function dataDirectory(source, values) {
107
119
  return path.resolve(optionalString(values, "data-directory") ??
108
- path.join(os.homedir(), ".config", "iteration", "codex-collector"));
120
+ defaultCollectorDirectory(source));
109
121
  }
110
- async function initialize(values) {
122
+ async function initialize(source, values) {
111
123
  if (process.platform !== "darwin") {
112
124
  throw new Error("Automatic startup is currently supported on macOS only.");
113
125
  }
@@ -115,65 +127,75 @@ async function initialize(values) {
115
127
  const endpointUrl = validateCollectorEndpoint(endpoint);
116
128
  const deviceName = optionalString(values, "device-name") ?? os.hostname();
117
129
  const organizationId = optionalString(values, "organization-id");
118
- const directory = dataDirectory(values);
130
+ const directory = dataDirectory(source, values);
119
131
  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 = {
132
+ const sourceHome = path.resolve(source === "codex"
133
+ ? (optionalString(values, "codex-home") ??
134
+ process.env.CODEX_HOME ??
135
+ path.join(os.homedir(), ".codex"))
136
+ : (optionalString(values, "claude-home") ??
137
+ process.env.CLAUDE_CONFIG_DIR ??
138
+ path.join(os.homedir(), ".claude")));
139
+ await assertFreshCollectorDirectory(paths, source);
140
+ const preparedState = await prepareSourceInitialState(source, sourceHome, true);
141
+ const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId, source);
142
+ const connection = {
127
143
  endpoint: endpointUrl.toString(),
128
144
  token: enrollment.token,
129
- codexHome,
130
145
  dataDirectory: directory,
131
146
  organizationName: enrollment.organizationName,
132
147
  };
148
+ const config = source === "codex"
149
+ ? { ...connection, codexHome: sourceHome }
150
+ : { ...connection, source: "claude", claudeHome: sourceHome };
133
151
  await saveConfig(config, paths);
134
152
  try {
135
153
  await saveState(preparedState, paths);
136
154
  }
137
155
  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 });
156
+ throw new Error(`Collector access was approved, but local setup did not finish. Run iteration start ${source} to repair it.`, { cause: error });
139
157
  }
140
158
  try {
141
- await installLaunchAgent(directory);
159
+ await installLaunchAgent(directory, source);
142
160
  }
143
161
  catch (error) {
144
- throw new Error("Collector access was approved, but background startup failed. Run iteration start codex to retry it.", { cause: error });
162
+ throw new Error(`Collector access was approved, but background startup failed. Run iteration start ${source} to retry it.`, { cause: error });
145
163
  }
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`);
164
+ 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.\n`);
147
165
  }
148
- async function importHistory(values) {
166
+ async function importHistory(source, values) {
149
167
  if (process.platform !== "darwin") {
150
168
  throw new Error("Automatic startup is currently supported on macOS only.");
151
169
  }
152
- const directory = dataDirectory(values);
170
+ const directory = dataDirectory(source, values);
153
171
  const paths = collectorPaths(directory);
154
172
  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`);
173
+ if (collectorSource(config) !== source)
174
+ throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
175
+ const estimate = (await importSourceHistory(config, paths, false)).size;
176
+ 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
177
  if (values.yes !== true) {
158
- process.stdout.write("No changes made. Run iteration import-history codex --yes to confirm this upload.\n");
178
+ process.stdout.write(`No changes made. Run iteration import-history ${source} --yes to confirm this upload.\n`);
159
179
  return;
160
180
  }
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`);
181
+ const result = await importSourceHistory(config, paths, true);
182
+ 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
183
  }
164
- async function start(values) {
184
+ async function start(source, values) {
165
185
  if (process.platform !== "darwin") {
166
186
  throw new Error("Automatic startup is currently supported on macOS only.");
167
187
  }
168
- const directory = dataDirectory(values);
188
+ const directory = dataDirectory(source, values);
169
189
  const paths = collectorPaths(directory);
170
190
  const config = await loadConfig(paths);
191
+ if (collectorSource(config) !== source)
192
+ throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
171
193
  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`);
194
+ await installLaunchAgent(directory, source);
195
+ process.stdout.write(`${collectorSourceLabel(source)} is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}\n`);
174
196
  }
175
- async function statusSnapshot(values) {
176
- const paths = collectorPaths(dataDirectory(values));
197
+ async function statusSnapshot(source, values) {
198
+ const paths = collectorPaths(dataDirectory(source, values));
177
199
  if (!(await fileExists(paths.config))) {
178
200
  const [stateExists, pendingBytes] = await Promise.all([
179
201
  fileExists(paths.state),
@@ -185,11 +207,13 @@ async function statusSnapshot(values) {
185
207
  return { kind: "not_connected" };
186
208
  }
187
209
  const config = await loadConfig(paths);
210
+ if (collectorSource(config) !== source)
211
+ throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
188
212
  const [running, size, ids, scan, localHealth] = await Promise.all([
189
- launchAgentStatus(),
213
+ launchAgentStatus(source),
190
214
  outboxSize(paths),
191
215
  pendingEventIds(paths),
192
- historyScanProgress(config.codexHome, paths),
216
+ historyScanProgress(collectorSourceDirectory(config), paths, source),
193
217
  healthSnapshot(paths),
194
218
  ]);
195
219
  const pendingEvents = size === 0 ? 0 : ids.size;
@@ -204,43 +228,62 @@ async function statusSnapshot(values) {
204
228
  health: localHealth.health,
205
229
  healthAvailable: localHealth.kind === "available",
206
230
  tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
207
- logPath: path.join(os.homedir(), "Library", "Logs", "iteration-codex-collector.error.log"),
231
+ logPath: path.join(os.homedir(), "Library", "Logs", `iteration-${source}-collector.error.log`),
208
232
  };
209
233
  }
210
- async function status(values) {
211
- const snapshot = await statusSnapshot(values);
212
- process.stdout.write(`${renderDetailedStatus(snapshot)}\n`);
234
+ async function status(source, values) {
235
+ if (source !== undefined) {
236
+ const snapshot = await statusSnapshot(source, values);
237
+ process.stdout.write(`${renderDetailedStatus(snapshot, Date.now(), source)}\n`);
238
+ return;
239
+ }
240
+ const [codex, claude] = await Promise.all([
241
+ statusSnapshot("codex", values),
242
+ statusSnapshot("claude", values),
243
+ ]);
244
+ process.stdout.write(`${renderDetailedStatus(codex, Date.now(), "codex")}\n\n${renderDetailedStatus(claude, Date.now(), "claude")}\n`);
213
245
  }
214
246
  async function main() {
215
247
  const { command, source, values } = argumentsForCli();
248
+ const selectedSource = source === "codex" || source === "claude" ? source : null;
216
249
  if (values.help === true || command === "help") {
217
250
  return usage(command === "help" ? source : command);
218
251
  }
219
252
  if (values["include-history"] === true &&
220
- !(command === "connect" && source === "codex")) {
253
+ !(command === "connect" && selectedSource !== null)) {
221
254
  throw new Error("Existing sessions are included automatically. Run iteration to connect.");
222
255
  }
223
256
  if (command === "" || (command === "connect" && source === undefined)) {
224
257
  if (Object.keys(values).length > 0) {
225
- throw new Error("Options require a source. Run iteration connect codex --help for details.");
258
+ throw new Error("Options require a source. Run iteration connect --help for details.");
226
259
  }
227
260
  return defaultCommand();
228
261
  }
229
- if (command === "connect" && source === "codex")
230
- return initialize(values);
262
+ if (selectedSource !== null) {
263
+ if (selectedSource === "codex" && values["claude-home"] !== undefined)
264
+ throw new Error("--claude-home requires the Claude source.");
265
+ if (selectedSource === "claude" && values["codex-home"] !== undefined)
266
+ throw new Error("--codex-home requires the Codex source.");
267
+ }
268
+ if (command === "connect" && selectedSource !== null)
269
+ return initialize(selectedSource, values);
231
270
  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();
271
+ if (source !== undefined && selectedSource === null)
272
+ throw new Error("The run source must be codex or claude.");
273
+ return runCollector(optionalString(values, "data-directory") ?? undefined, selectedSource ?? undefined);
274
+ }
275
+ if (command === "status" &&
276
+ (source === undefined || selectedSource !== null)) {
277
+ if (source === undefined && values["data-directory"] !== undefined)
278
+ throw new Error("--data-directory requires a source with status.");
279
+ return status(selectedSource ?? undefined, values);
280
+ }
281
+ if (command === "import-history" && selectedSource !== null)
282
+ return importHistory(selectedSource, values);
283
+ if (command === "start" && selectedSource !== null)
284
+ return start(selectedSource, values);
285
+ if (command === "disconnect" && selectedSource !== null) {
286
+ await uninstallLaunchAgent(selectedSource);
244
287
  process.stdout.write("Collector startup removed. Local pending data was retained.\n");
245
288
  return;
246
289
  }
@@ -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
  }>>;