iteration-cli 0.4.3 → 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.
@@ -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;
@@ -6,7 +6,9 @@ import path from "node:path";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  const execFile = promisify(execFileCallback);
9
- const LABEL = "com.iteration.codex-collector";
9
+ function label(source) {
10
+ return `com.iteration.${source}-collector`;
11
+ }
10
12
  const LAUNCHCTL_TIMEOUT_MS = 5_000;
11
13
  const RESTART_TIMEOUT_MS = 10_000;
12
14
  const RESTART_POLL_MS = 200;
@@ -18,14 +20,15 @@ function xml(value) {
18
20
  .replaceAll('"', "&quot;")
19
21
  .replaceAll("'", "&apos;");
20
22
  }
21
- function plistPath(homeDirectory = os.homedir()) {
22
- return path.join(homeDirectory, "Library", "LaunchAgents", `${LABEL}.plist`);
23
+ function plistPath(source, homeDirectory = os.homedir()) {
24
+ return path.join(homeDirectory, "Library", "LaunchAgents", `${label(source)}.plist`);
23
25
  }
24
- export function launchAgentPlist(cliPath = fileURLToPath(new URL("./cli.js", import.meta.url)), nodePath = process.execPath, logDirectory = path.join(os.homedir(), "Library", "Logs"), dataDirectory = path.join(os.homedir(), ".config", "iteration", "codex-collector")) {
26
+ export function launchAgentPlist(cliPath = fileURLToPath(new URL("./cli.js", import.meta.url)), nodePath = process.execPath, logDirectory = path.join(os.homedir(), "Library", "Logs"), dataDirectory = path.join(os.homedir(), ".config", "iteration", "codex-collector"), source = "codex") {
25
27
  const argumentsList = [
26
28
  nodePath,
27
29
  cliPath,
28
30
  "run",
31
+ source,
29
32
  "--data-directory",
30
33
  dataDirectory,
31
34
  ];
@@ -36,13 +39,13 @@ export function launchAgentPlist(cliPath = fileURLToPath(new URL("./cli.js", imp
36
39
  '<?xml version="1.0" encoding="UTF-8"?>',
37
40
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
38
41
  '<plist version="1.0"><dict>',
39
- `<key>Label</key><string>${LABEL}</string>`,
42
+ `<key>Label</key><string>${label(source)}</string>`,
40
43
  `<key>ProgramArguments</key><array>${argumentsXml}</array>`,
41
44
  "<key>RunAtLoad</key><true/>",
42
45
  "<key>KeepAlive</key><true/>",
43
46
  "<key>ThrottleInterval</key><integer>10</integer>",
44
- `<key>StandardOutPath</key><string>${xml(path.join(logDirectory, "iteration-codex-collector.log"))}</string>`,
45
- `<key>StandardErrorPath</key><string>${xml(path.join(logDirectory, "iteration-codex-collector.error.log"))}</string>`,
47
+ `<key>StandardOutPath</key><string>${xml(path.join(logDirectory, `iteration-${source}-collector.log`))}</string>`,
48
+ `<key>StandardErrorPath</key><string>${xml(path.join(logDirectory, `iteration-${source}-collector.error.log`))}</string>`,
46
49
  "</dict></plist>",
47
50
  ].join("");
48
51
  }
@@ -61,9 +64,9 @@ function serviceMissing(error) {
61
64
  return (String(details.code) === "113" ||
62
65
  details.stderr?.includes("Could not find service") === true);
63
66
  }
64
- async function serviceLoaded(target, timeoutMs = LAUNCHCTL_TIMEOUT_MS) {
67
+ async function serviceLoaded(target, source, timeoutMs = LAUNCHCTL_TIMEOUT_MS) {
65
68
  try {
66
- await execFile("launchctl", ["print", `${target}/${LABEL}`], {
69
+ await execFile("launchctl", ["print", `${target}/${label(source)}`], {
67
70
  timeout: timeoutMs,
68
71
  });
69
72
  return true;
@@ -74,10 +77,10 @@ async function serviceLoaded(target, timeoutMs = LAUNCHCTL_TIMEOUT_MS) {
74
77
  throw error;
75
78
  }
76
79
  }
77
- async function bootoutIfLoaded(target) {
78
- if (!(await serviceLoaded(target)))
80
+ async function bootoutIfLoaded(target, source) {
81
+ if (!(await serviceLoaded(target, source)))
79
82
  return false;
80
- await execFile("launchctl", ["bootout", `${target}/${LABEL}`], {
83
+ await execFile("launchctl", ["bootout", `${target}/${label(source)}`], {
81
84
  timeout: LAUNCHCTL_TIMEOUT_MS,
82
85
  });
83
86
  return true;
@@ -124,21 +127,21 @@ export async function bootstrapAfterBootout(wasLoaded, control) {
124
127
  }
125
128
  }
126
129
  }
127
- export async function installLaunchAgent(dataDirectory) {
130
+ export async function installLaunchAgent(dataDirectory, source = "codex") {
128
131
  if (process.platform !== "darwin") {
129
132
  throw new Error("Automatic startup is currently supported on macOS only.");
130
133
  }
131
- const file = plistPath();
134
+ const file = plistPath(source);
132
135
  const launchAgents = path.dirname(file);
133
136
  const logs = path.join(os.homedir(), "Library", "Logs");
134
137
  await fs.mkdir(launchAgents, { recursive: true, mode: 0o755 });
135
138
  await fs.mkdir(logs, { recursive: true, mode: 0o755 });
136
139
  await fs.writeFile(file, launchAgentPlist(undefined, undefined, undefined, dataDirectory ??
137
- path.join(os.homedir(), ".config", "iteration", "codex-collector")), { encoding: "utf8", mode: 0o644 });
140
+ path.join(os.homedir(), ".config", "iteration", `${source}-collector`), source), { encoding: "utf8", mode: 0o644 });
138
141
  const target = launchctlTarget();
139
- const wasLoaded = await bootoutIfLoaded(target);
142
+ const wasLoaded = await bootoutIfLoaded(target, source);
140
143
  await bootstrapAfterBootout(wasLoaded, {
141
- isLoaded: (timeoutMs) => serviceLoaded(target, timeoutMs),
144
+ isLoaded: (timeoutMs) => serviceLoaded(target, source, timeoutMs),
142
145
  bootstrap: async (timeoutMs) => {
143
146
  await execFile("launchctl", ["bootstrap", target, file], {
144
147
  timeout: timeoutMs,
@@ -147,26 +150,26 @@ export async function installLaunchAgent(dataDirectory) {
147
150
  wait: sleep,
148
151
  now: Date.now,
149
152
  });
150
- await execFile("launchctl", ["kickstart", "-p", `${target}/${LABEL}`], {
153
+ await execFile("launchctl", ["kickstart", "-p", `${target}/${label(source)}`], {
151
154
  timeout: LAUNCHCTL_TIMEOUT_MS,
152
155
  });
153
156
  return file;
154
157
  }
155
- export async function uninstallLaunchAgent() {
158
+ export async function uninstallLaunchAgent(source = "codex") {
156
159
  if (process.platform !== "darwin") {
157
160
  throw new Error("Automatic startup is currently supported on macOS only.");
158
161
  }
159
- const file = plistPath();
160
- await bootoutIfLoaded(launchctlTarget());
162
+ const file = plistPath(source);
163
+ await bootoutIfLoaded(launchctlTarget(), source);
161
164
  await fs.rm(file, { force: true });
162
165
  }
163
- export async function launchAgentStatus() {
166
+ export async function launchAgentStatus(source = "codex") {
164
167
  if (process.platform !== "darwin")
165
168
  return false;
166
169
  try {
167
170
  const { stdout } = await execFile("launchctl", [
168
171
  "print",
169
- `${launchctlTarget()}/${LABEL}`,
172
+ `${launchctlTarget()}/${label(source)}`,
170
173
  ]);
171
174
  return launchAgentRunning(stdout);
172
175
  }
package/dist/rollout.js CHANGED
@@ -3,6 +3,40 @@ import path from "node:path";
3
3
  import { parseTracePayload, } from "iteration-trace-contracts";
4
4
  import * as z from "zod";
5
5
  const recordSchema = z.record(z.string(), z.unknown());
6
+ const CONTEXT_RECORDS = [
7
+ "session_meta",
8
+ "turn_context",
9
+ "compacted",
10
+ "token_usage_record",
11
+ "world_state",
12
+ "retained_context",
13
+ "security_risk_score",
14
+ ];
15
+ const RESPONSE_CONTEXT_TYPES = [
16
+ "reasoning",
17
+ "todo_list",
18
+ "compaction",
19
+ "context_compaction",
20
+ "configuration_update",
21
+ "additional_tools",
22
+ ];
23
+ const EVENT_CONTEXT_TYPES = [
24
+ "task_complete",
25
+ "task_started",
26
+ "turn_complete",
27
+ "token_count",
28
+ "thread_goal_updated",
29
+ "thread_rolled_back",
30
+ "turn_aborted",
31
+ "thread_settings_applied",
32
+ "agent_reasoning",
33
+ "agent_reasoning_raw_content",
34
+ ];
35
+ const COMPLETED_CONTEXT_TYPES = [
36
+ "reasoning",
37
+ "todo_list",
38
+ ];
39
+ const IGNORED_EVENT_MESSAGES = ["turn_started"];
6
40
  function parseJsonRecord(value) {
7
41
  const parsed = recordSchema.safeParse(value);
8
42
  return parsed.success ? parsed.data : null;
@@ -11,6 +45,18 @@ function parseJsonString(value) {
11
45
  const parsed = z.string().safeParse(value);
12
46
  return parsed.success ? parsed.data : null;
13
47
  }
48
+ function oneOf(value, allowed) {
49
+ return value !== null && allowed.includes(value);
50
+ }
51
+ const sourceOrdinalSchema = z
52
+ .number()
53
+ .int()
54
+ .min(0)
55
+ .max(Number.MAX_SAFE_INTEGER);
56
+ function sourceOrdinal(value) {
57
+ const parsed = sourceOrdinalSchema.safeParse(value);
58
+ return parsed.success ? parsed.data : null;
59
+ }
14
60
  function sessionFromPath(filename) {
15
61
  const basename = path.basename(filename, ".jsonl");
16
62
  return basename.startsWith("rollout-")
@@ -33,6 +79,32 @@ function safePayload(value) {
33
79
  return null;
34
80
  }
35
81
  }
82
+ function eventTimestamp(record) {
83
+ const timestamp = parseJsonString(record.timestamp);
84
+ return timestamp !== null && !Number.isNaN(Date.parse(timestamp))
85
+ ? timestamp
86
+ : null;
87
+ }
88
+ function capturedEvent(record, filename, byteOffset, eventType, payload, callId, run) {
89
+ const eventPayload = safePayload(payload);
90
+ if (eventPayload === null)
91
+ return { kind: "gap", reason: `invalid_${eventType}_payload` };
92
+ const sessionId = sessionFromPath(filename);
93
+ return {
94
+ kind: "event",
95
+ event: {
96
+ eventId: sha256(`${sessionId}:${byteOffset}`),
97
+ sessionId,
98
+ sequence: byteOffset,
99
+ callId,
100
+ projectId: null,
101
+ occurredAt: eventTimestamp(record),
102
+ eventType,
103
+ payload: eventPayload,
104
+ ...(run === undefined ? {} : { run }),
105
+ },
106
+ };
107
+ }
36
108
  function mapResponseItem(payload) {
37
109
  const type = parseJsonString(payload.type);
38
110
  if (type === "message") {
@@ -46,9 +118,10 @@ function mapResponseItem(payload) {
46
118
  if (type === "agent_message") {
47
119
  return { eventType: "assistant_message", callId: null };
48
120
  }
49
- if (["reasoning", "todo_list"].includes(type ?? ""))
50
- return null;
51
- if ([
121
+ if (oneOf(type, RESPONSE_CONTEXT_TYPES)) {
122
+ return { eventType: type, callId: null };
123
+ }
124
+ if (oneOf(type, [
52
125
  "function_call",
53
126
  "custom_tool_call",
54
127
  "mcp_tool_call",
@@ -56,42 +129,39 @@ function mapResponseItem(payload) {
56
129
  "tool_search_call",
57
130
  "image_generation_call",
58
131
  "local_shell_call",
59
- ].includes(type ?? "")) {
132
+ ])) {
60
133
  return { eventType: "tool_call", callId: callIdFrom(payload) };
61
134
  }
62
- if ([
135
+ if (oneOf(type, [
63
136
  "function_call_output",
64
137
  "custom_tool_call_output",
65
138
  "mcp_tool_call_output",
66
139
  "web_search_output",
67
140
  "tool_search_output",
68
- ].includes(type ?? "")) {
141
+ ])) {
69
142
  return { eventType: "tool_result", callId: callIdFrom(payload) };
70
143
  }
71
144
  return null;
72
145
  }
73
146
  function mapCompletedItem(item) {
74
147
  const type = parseJsonString(item.type);
75
- if ([
148
+ if (oneOf(type, [
76
149
  "command_execution",
77
150
  "mcp_tool_call",
78
151
  "collab_tool_call",
79
152
  "web_search",
80
153
  "file_change",
81
- ].includes(type ?? "")) {
154
+ ])) {
82
155
  return { eventType: "tool_result", callId: callIdFrom(item) };
83
156
  }
84
157
  if (type === "agent_message") {
85
158
  return { eventType: "assistant_message", callId: null };
86
159
  }
160
+ if (oneOf(type, COMPLETED_CONTEXT_TYPES)) {
161
+ return { eventType: type, callId: null };
162
+ }
87
163
  return null;
88
164
  }
89
- function eventTimestamp(record) {
90
- const timestamp = parseJsonString(record.timestamp);
91
- return timestamp !== null && !Number.isNaN(Date.parse(timestamp))
92
- ? timestamp
93
- : null;
94
- }
95
165
  export function parseRolloutLine(line, filename, byteOffset) {
96
166
  let decoded;
97
167
  try {
@@ -105,131 +175,66 @@ export function parseRolloutLine(line, filename, byteOffset) {
105
175
  return { kind: "gap", reason: "record_is_not_an_object" };
106
176
  const recordType = parseJsonString(record.type);
107
177
  const payload = parseJsonRecord(record.payload);
108
- if ([
109
- "session_meta",
110
- "turn_context",
111
- "compacted",
112
- "token_usage_record",
113
- "world_state",
114
- "retained_context",
115
- "security_risk_score",
116
- ].includes(recordType ?? "")) {
117
- return { kind: "ignored" };
178
+ if (oneOf(recordType, CONTEXT_RECORDS)) {
179
+ if (payload === null)
180
+ return { kind: "gap", reason: `${recordType}_without_payload` };
181
+ let run;
182
+ if (recordType === "session_meta" &&
183
+ parseJsonString(payload.id) === sessionFromPath(filename).slice(-36)) {
184
+ const source = parseJsonRecord(payload.source);
185
+ const subagent = source === null ? null : parseJsonRecord(source.subagent);
186
+ const spawn = subagent === null ? null : parseJsonRecord(subagent.thread_spawn);
187
+ const parentId = parseJsonString(payload.parent_thread_id) ??
188
+ (spawn === null ? null : parseJsonString(spawn.parent_thread_id));
189
+ const startOrdinal = sourceOrdinal(payload.subagent_history_start_ordinal);
190
+ run = {
191
+ kind: "identity",
192
+ id: sessionFromPath(filename).slice(-36),
193
+ parentId,
194
+ ownStart: parentId === null,
195
+ startOrdinal,
196
+ };
197
+ }
198
+ return capturedEvent(record, filename, byteOffset, recordType, payload, null, run);
118
199
  }
119
200
  if (recordType === "response_item" && payload !== null) {
120
201
  const mapped = mapResponseItem(payload);
121
202
  if (mapped === null) {
122
203
  const payloadType = parseJsonString(payload.type) ?? "unknown";
123
- if ([
124
- "reasoning",
125
- "compaction",
126
- "context_compaction",
127
- "configuration_update",
128
- "additional_tools",
129
- ].includes(payloadType)) {
130
- return { kind: "ignored" };
131
- }
132
204
  return {
133
205
  kind: "gap",
134
206
  reason: `unsupported_response_item:${payloadType}`,
135
207
  };
136
208
  }
137
- const eventPayload = safePayload(payload);
138
- if (eventPayload === null)
139
- return { kind: "gap", reason: "invalid_response_item_payload" };
140
- const sessionId = sessionFromPath(filename);
141
- return {
142
- kind: "event",
143
- event: {
144
- eventId: sha256(`${sessionId}:${byteOffset}`),
145
- sessionId,
146
- sequence: byteOffset,
147
- callId: mapped.callId,
148
- projectId: null,
149
- occurredAt: eventTimestamp(record),
150
- eventType: mapped.eventType,
151
- payload: eventPayload,
152
- },
153
- };
209
+ return capturedEvent(record, filename, byteOffset, mapped.eventType, payload, mapped.callId);
154
210
  }
155
211
  if (recordType === "event_msg" && payload !== null) {
156
212
  const messageType = parseJsonString(payload.type);
157
213
  if (messageType === "user_message") {
158
- const eventPayload = safePayload(payload);
159
- if (eventPayload === null)
160
- return { kind: "gap", reason: "invalid_user_message_payload" };
161
- const sessionId = sessionFromPath(filename);
162
- return {
163
- kind: "event",
164
- event: {
165
- eventId: sha256(`${sessionId}:${byteOffset}`),
166
- sessionId,
167
- sequence: byteOffset,
168
- callId: null,
169
- projectId: null,
170
- occurredAt: eventTimestamp(record),
171
- eventType: "user_message",
172
- payload: eventPayload,
173
- },
174
- };
214
+ return capturedEvent(record, filename, byteOffset, "user_message", payload, null);
175
215
  }
176
216
  if (messageType === "agent_message") {
177
- const eventPayload = safePayload(payload);
178
- if (eventPayload === null)
179
- return { kind: "gap", reason: "invalid_agent_message_payload" };
180
- const sessionId = sessionFromPath(filename);
181
- return {
182
- kind: "event",
183
- event: {
184
- eventId: sha256(`${sessionId}:${byteOffset}`),
185
- sessionId,
186
- sequence: byteOffset,
187
- callId: null,
188
- projectId: null,
189
- occurredAt: eventTimestamp(record),
190
- eventType: "assistant_message",
191
- payload: eventPayload,
192
- },
193
- };
217
+ return capturedEvent(record, filename, byteOffset, "assistant_message", payload, null);
194
218
  }
195
- if ([
219
+ if (oneOf(messageType, [
196
220
  "mcp_tool_call_end",
197
221
  "web_search_end",
198
222
  "image_generation_end",
199
223
  "patch_apply_end",
200
- ].includes(messageType ?? "")) {
201
- const eventPayload = safePayload(payload);
202
- if (eventPayload === null)
203
- return { kind: "gap", reason: "invalid_tool_result_payload" };
204
- const sessionId = sessionFromPath(filename);
205
- return {
206
- kind: "event",
207
- event: {
208
- eventId: sha256(`${sessionId}:${byteOffset}`),
209
- sessionId,
210
- sequence: byteOffset,
211
- callId: callIdFrom(payload),
212
- projectId: null,
213
- occurredAt: eventTimestamp(record),
214
- eventType: "tool_result",
215
- payload: eventPayload,
216
- },
217
- };
224
+ ])) {
225
+ return capturedEvent(record, filename, byteOffset, "tool_result", payload, callIdFrom(payload));
218
226
  }
219
- if ([
220
- "task_started",
221
- "turn_started",
222
- "task_complete",
223
- "turn_complete",
224
- "token_count",
225
- "thread_goal_updated",
226
- "thread_rolled_back",
227
- "turn_aborted",
228
- "thread_settings_applied",
229
- "agent_reasoning",
230
- "agent_reasoning_raw_content",
231
- ].includes(messageType ?? "")) {
227
+ if (oneOf(messageType, IGNORED_EVENT_MESSAGES))
232
228
  return { kind: "ignored" };
229
+ if (oneOf(messageType, EVENT_CONTEXT_TYPES)) {
230
+ const ordinal = sourceOrdinal(record.ordinal);
231
+ const run = messageType === "thread_settings_applied" &&
232
+ parseJsonString(payload.thread_id) ===
233
+ sessionFromPath(filename).slice(-36) &&
234
+ ordinal !== null
235
+ ? { kind: "start", sourceOrdinal: ordinal }
236
+ : undefined;
237
+ return capturedEvent(record, filename, byteOffset, messageType, payload, null, run);
233
238
  }
234
239
  if (messageType !== "item_completed") {
235
240
  return {
@@ -243,27 +248,9 @@ export function parseRolloutLine(line, filename, byteOffset) {
243
248
  const mapped = mapCompletedItem(item);
244
249
  if (mapped === null) {
245
250
  const itemType = parseJsonString(item.type) ?? "unknown";
246
- if (["reasoning", "todo_list"].includes(itemType))
247
- return { kind: "ignored" };
248
251
  return { kind: "gap", reason: `unsupported_completed_item:${itemType}` };
249
252
  }
250
- const eventPayload = safePayload(item);
251
- if (eventPayload === null)
252
- return { kind: "gap", reason: "invalid_completed_item_payload" };
253
- const sessionId = sessionFromPath(filename);
254
- return {
255
- kind: "event",
256
- event: {
257
- eventId: sha256(`${sessionId}:${byteOffset}`),
258
- sessionId,
259
- sequence: byteOffset,
260
- callId: mapped.callId,
261
- projectId: null,
262
- occurredAt: eventTimestamp(record),
263
- eventType: mapped.eventType,
264
- payload: eventPayload,
265
- },
266
- };
253
+ return capturedEvent(record, filename, byteOffset, mapped.eventType, item, mapped.callId);
267
254
  }
268
255
  return {
269
256
  kind: "gap",
package/dist/service.d.ts CHANGED
@@ -1,4 +1,5 @@
1
+ import type { CollectorSource } from "iteration-trace-contracts";
1
2
  import { type CollectorPaths } from "./storage.js";
2
3
  export declare function acquireCollectorLock(paths: CollectorPaths): Promise<() => Promise<void>>;
3
4
  export declare function waitForCollectorLock(paths: CollectorPaths): Promise<() => Promise<void>>;
4
- export declare function runCollector(directory?: string): Promise<void>;
5
+ export declare function runCollector(directory?: string, expectedSource?: CollectorSource): Promise<void>;
package/dist/service.js CHANGED
@@ -1,8 +1,8 @@
1
1
  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
- import { collectorTick } from "./collector.js";
5
- import { collectorPaths, ensureHistoryBackfill, ensurePrivateDirectory, loadConfig, } from "./storage.js";
4
+ import { collectorTick, TraceUploadError } from "./collector.js";
5
+ import { collectorSource, collectorPaths, ensureClaudeHistoryBackfill, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
6
6
  function errorCode(error) {
7
7
  if (error instanceof Error && "code" in error)
8
8
  return String(error.code);
@@ -10,6 +10,25 @@ function errorCode(error) {
10
10
  }
11
11
  class CollectorLockBusyError extends Error {
12
12
  }
13
+ function collectorFailure(error) {
14
+ return {
15
+ atMs: Date.now(),
16
+ kind: error instanceof TraceUploadError ? error.kind : "collector",
17
+ status: error instanceof TraceUploadError ? error.status : null,
18
+ };
19
+ }
20
+ async function persistHealth(health, paths) {
21
+ try {
22
+ await saveHealth(health, paths);
23
+ }
24
+ catch {
25
+ console.error(JSON.stringify({
26
+ level: "warning",
27
+ operation: "save_collector_health",
28
+ message: "Local collector status could not be saved; collection continues.",
29
+ }));
30
+ }
31
+ }
13
32
  export async function acquireCollectorLock(paths) {
14
33
  await ensurePrivateDirectory(path.dirname(paths.lock));
15
34
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -38,7 +57,7 @@ export async function acquireCollectorLock(paths) {
38
57
  }
39
58
  try {
40
59
  process.kill(lockPid, 0);
41
- throw new CollectorLockBusyError("Codex collector is already running.");
60
+ throw new CollectorLockBusyError("Collector is already running.");
42
61
  }
43
62
  catch (processError) {
44
63
  if (errorCode(processError) !== "ESRCH")
@@ -47,7 +66,7 @@ export async function acquireCollectorLock(paths) {
47
66
  }
48
67
  }
49
68
  }
50
- throw new Error("Could not obtain the Codex collector lock.");
69
+ throw new Error("Could not obtain the collector lock.");
51
70
  }
52
71
  export async function waitForCollectorLock(paths) {
53
72
  for (let attempt = 0; attempt < 81; attempt += 1) {
@@ -65,7 +84,7 @@ export async function waitForCollectorLock(paths) {
65
84
  }
66
85
  throw new Error("Could not obtain the collector lock.");
67
86
  }
68
- export async function runCollector(directory) {
87
+ export async function runCollector(directory, expectedSource) {
69
88
  const paths = collectorPaths(directory);
70
89
  const releaseLock = await acquireCollectorLock(paths);
71
90
  const controller = new AbortController();
@@ -75,13 +94,44 @@ export async function runCollector(directory) {
75
94
  let retryDelayMs = 5000;
76
95
  try {
77
96
  const config = await loadConfig(paths);
78
- await ensureHistoryBackfill(config.codexHome, paths);
97
+ const source = collectorSource(config);
98
+ if (expectedSource !== undefined && expectedSource !== source) {
99
+ throw new Error(`Collector directory belongs to ${source}, not ${expectedSource}.`);
100
+ }
101
+ const loadedHealth = await healthSnapshot(paths);
102
+ let health = loadedHealth.health;
103
+ if (loadedHealth.kind === "unavailable") {
104
+ console.error(JSON.stringify({
105
+ level: "warning",
106
+ operation: "load_collector_health",
107
+ message: "Local collector status could not be read; collection continues.",
108
+ }));
109
+ }
110
+ if ("claudeHome" in config)
111
+ await ensureClaudeHistoryBackfill(config.claudeHome, paths);
112
+ else
113
+ await ensureHistoryBackfill(config.codexHome, paths);
79
114
  while (!controller.signal.aborted) {
115
+ let acknowledgedAtMs = null;
80
116
  try {
81
- const result = await collectorTick(config, paths, fetch, controller.signal);
117
+ const result = await collectorTick(config, paths, fetch, controller.signal, () => {
118
+ acknowledgedAtMs = Date.now();
119
+ });
120
+ if (acknowledgedAtMs !== null ||
121
+ health.lastFailure !== null ||
122
+ (result.historyCaughtUp && health.historyCaughtUpAtMs === null)) {
123
+ health = {
124
+ lastUploadAtMs: acknowledgedAtMs ?? health.lastUploadAtMs,
125
+ historyCaughtUpAtMs: health.historyCaughtUpAtMs ??
126
+ (result.historyCaughtUp ? Date.now() : null),
127
+ lastFailure: null,
128
+ };
129
+ await persistHealth(health, paths);
130
+ }
82
131
  if (result.discovered > 0 || result.uploaded > 0) {
83
132
  console.info(JSON.stringify({
84
133
  level: "info",
134
+ source,
85
135
  discovered: result.discovered,
86
136
  uploaded: result.uploaded,
87
137
  outboxBytes: result.outboxBytes,
@@ -92,14 +142,21 @@ export async function runCollector(directory) {
92
142
  catch (error) {
93
143
  if (controller.signal.aborted)
94
144
  break;
145
+ health = {
146
+ lastUploadAtMs: acknowledgedAtMs ?? health.lastUploadAtMs,
147
+ historyCaughtUpAtMs: health.historyCaughtUpAtMs,
148
+ lastFailure: collectorFailure(error),
149
+ };
95
150
  console.error(JSON.stringify({
96
151
  level: "error",
152
+ source,
97
153
  operation: "collector_tick",
98
154
  errorName: error instanceof Error ? error.name : "UnknownError",
99
155
  message: error instanceof Error
100
156
  ? error.message
101
157
  : "Unknown collector failure.",
102
158
  }));
159
+ await persistHealth(health, paths);
103
160
  retryDelayMs = Math.min(retryDelayMs * 2, 60_000);
104
161
  }
105
162
  try {
@@ -0,0 +1,30 @@
1
+ import { type CollectorSource } from "iteration-trace-contracts";
2
+ import type { CollectorHealth } from "./storage.js";
3
+ type ScanProgress = Readonly<{
4
+ kind: "waiting";
5
+ } | {
6
+ kind: "scanning";
7
+ scannedBytes: number;
8
+ totalBytes: number;
9
+ }>;
10
+ export type CliStatus = Readonly<{
11
+ kind: "not_connected";
12
+ }> | Readonly<{
13
+ kind: "needs_repair";
14
+ pendingBytes: number;
15
+ configPath: string;
16
+ }> | Readonly<{
17
+ kind: "connected";
18
+ workspaceName: string | null;
19
+ running: boolean;
20
+ scan: ScanProgress;
21
+ pendingEvents: number;
22
+ pendingBytes: number;
23
+ health: CollectorHealth;
24
+ healthAvailable: boolean;
25
+ tracesUrl: string;
26
+ logPath: string;
27
+ }>;
28
+ export declare function renderHome(status: CliStatus, nowMs?: number, source?: CollectorSource): string;
29
+ export declare function renderDetailedStatus(status: CliStatus, nowMs?: number, source?: CollectorSource): string;
30
+ export {};