iteration-cli 0.7.2 → 0.8.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
@@ -7,11 +7,13 @@ npm install --global iteration-cli
7
7
  iteration connect
8
8
  ```
9
9
 
10
- The collectors run on macOS or Linux with Node.js 22 or later. Linux needs systemd. The CLI automatically enables background collection after logout and installs a user service that starts at boot and restarts on failure. If server policy does not let the user enable this, an administrator must allow background services for the account. The setting applies to the whole Linux account and remains enabled after `iteration disconnect`; the CLI does not turn off other user services. `iteration connect` finds supported agents with local session directories, asks once before uploading their saved sessions and future activity, and connects them to one workspace. A connected collector checks for newly detected supported agents and connects them under that approval. Each source has its own background process, local checkpoint, and upload queue. Run `iteration` to see connected sources, `iteration status codex` or `iteration status claude` for details, and `iteration help` for other commands. `iteration connect codex` and `iteration connect claude` remain available for advanced source-specific setups.
10
+ The collectors run on macOS or Linux with Node.js 22 or later. Cursor's local Agents window collector is tested on macOS. Linux needs systemd. The CLI automatically enables background collection after logout and installs a user service that starts at boot and restarts on failure. If server policy does not let the user enable this, an administrator must allow background services for the account. The setting applies to the whole Linux account and remains enabled after `iteration disconnect`; the CLI does not turn off other user services. `iteration connect` finds supported agents with local session directories, asks once before uploading their saved sessions and future activity, and connects them to one workspace. A connected collector checks for newly detected supported agents and connects them under that approval. Each source has its own background process, local checkpoint, and upload queue. Run `iteration` to see connected sources, `iteration status cursor` for Cursor details, and `iteration help` for other commands. Source-specific connect commands remain available for advanced setups.
11
+
12
+ Cursor's **This Mac** Agents sessions are read from `~/.cursor/projects/*/agent-transcripts/<conversation>/<conversation>.jsonl`. Connecting Cursor also installs user-level hooks in `~/.cursor/hooks.json` automatically. They capture tool output, thought blocks, model and usage fields, subagent transcripts, and other lifecycle records that the main transcript omits; the collector queues those records locally before upload. Existing hooks are preserved, and `iteration disconnect cursor` removes only Iteration's entries. No Cursor approval or settings step is needed. A custom Cursor directory can be selected with `iteration connect cursor --cursor-home DIR`. Local history predating connection has transcript messages and tool calls, but tool results that Cursor never wrote to its transcript cannot be recovered. Cloud Agents are outside this local collector.
11
13
 
12
14
  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.
13
15
 
14
- Connecting also adds a Stop hook to the coding agent. After an agent turn in a git repository where you had friction episodes this week, it shows one line such as `◆ Iteration · 3 friction episodes in this repo this week · 1 new since yesterday`. The hook reads local files only and adds a few milliseconds to a turn. Codex asks you to approve it once with `/hooks`. `iteration disconnect` removes it.
16
+ Connecting Codex or Claude Code also adds a Stop hook to the coding agent. After an agent turn in a git repository where you had friction episodes this week, it shows one line such as `◆ Iteration · 3 friction episodes in this repo this week · 1 new since yesterday`. The hook reads local files only and adds a few milliseconds to a turn. Codex asks you to approve it once with `/hooks`. `iteration disconnect` removes it.
15
17
 
16
18
  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.
17
19
 
@@ -10,6 +10,7 @@ export type MachineConsent = Readonly<{
10
10
  sourceHomes: Readonly<{
11
11
  codex: string;
12
12
  claude: string;
13
+ cursor: string;
13
14
  }>;
14
15
  }>;
15
16
  export declare function sourceHome(source: CollectorSource): string;
@@ -7,6 +7,7 @@ import { COLLECTOR_SOURCES, parseCollectorEnrollmentResponse, } from "iteration-
7
7
  import { installBackgroundService } from "./background-service.js";
8
8
  import { collectorFetch } from "./collector-fetch.js";
9
9
  import { installTurnSummary } from "./turn-summary.js";
10
+ import { installCursorHooks } from "./cursor-hooks.js";
10
11
  import { assertFreshCollectorDirectory, collectorPaths, defaultCollectorDirectory, ensurePrivateDirectory, loadConfig, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
11
12
  const consentSchema = z
12
13
  .object({
@@ -15,7 +16,13 @@ const consentSchema = z
15
16
  organizationId: z.uuid(),
16
17
  anchorSource: z.enum(COLLECTOR_SOURCES),
17
18
  anchorDataDirectory: z.string(),
18
- sourceHomes: z.object({ codex: z.string(), claude: z.string() }).strict(),
19
+ sourceHomes: z
20
+ .object({
21
+ codex: z.string(),
22
+ claude: z.string(),
23
+ cursor: z.string().default(path.join(os.homedir(), ".cursor")),
24
+ })
25
+ .strict(),
19
26
  })
20
27
  .strict();
21
28
  function consentPath() {
@@ -24,7 +31,9 @@ function consentPath() {
24
31
  export function sourceHome(source) {
25
32
  return path.resolve(source === "codex"
26
33
  ? (process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"))
27
- : (process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude")));
34
+ : source === "claude"
35
+ ? (process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"))
36
+ : path.join(os.homedir(), ".cursor"));
28
37
  }
29
38
  export async function detectedSources(sourceHomes) {
30
39
  const detected = [];
@@ -156,10 +165,14 @@ export async function activateSource(source, home, endpoint, enrollment, state)
156
165
  };
157
166
  const config = source === "codex"
158
167
  ? { ...common, codexHome: home }
159
- : { ...common, source: "claude", claudeHome: home };
168
+ : source === "claude"
169
+ ? { ...common, source: "claude", claudeHome: home }
170
+ : { ...common, source: "cursor", cursorHome: home };
160
171
  await saveConfig(config, paths);
161
172
  try {
162
173
  await saveState(state, paths);
174
+ if (source === "cursor")
175
+ await installCursorHooks(home, directory);
163
176
  await installBackgroundService(directory, source);
164
177
  }
165
178
  catch (error) {
@@ -192,7 +205,8 @@ export async function connectNewDetectedSources(anchor, consent) {
192
205
  const config = await activateSource(source, consent.sourceHomes[source], consent.endpoint, enrollment, state);
193
206
  connected.push(source);
194
207
  try {
195
- await installTurnSummary(config);
208
+ if (source !== "cursor")
209
+ await installTurnSummary(config);
196
210
  }
197
211
  catch (error) {
198
212
  console.error(JSON.stringify({
@@ -188,7 +188,7 @@ async function clerkDeviceSignIn(configuration) {
188
188
  }
189
189
  async function enrollDevice(endpoint, accessToken, deviceName, organizationId, source) {
190
190
  const body = { deviceName };
191
- if (source === "claude")
191
+ if (source !== "codex")
192
192
  body.source = source;
193
193
  if (organizationId !== null)
194
194
  body.organizationId = organizationId;
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { COLLECTOR_SOURCES, collectorSourceLabel, } from "iteration-trace-contra
9
9
  import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
10
10
  import { activateSource, connectedConfig, detectedSources, expandSource, loadMachineConsent, readCollectorScope, saveMachineConsent, sourceHome, } from "./auto-connect.js";
11
11
  import { importSourceHistory } from "./history-import.js";
12
+ import { cursorHookFailureAtMs, installCursorHooks, uninstallCursorHooks, } from "./cursor-hooks.js";
12
13
  import { runCollector } from "./service.js";
13
14
  import { renderDetailedStatus, renderHome, safeWorkspaceName, } from "./status-view.js";
14
15
  import { installTurnSummary, codexTurnSummaryApproval, uninstallTurnSummary, } from "./turn-summary.js";
@@ -27,6 +28,7 @@ function argumentsForCli() {
27
28
  "organization-id": { type: "string" },
28
29
  "codex-home": { type: "string" },
29
30
  "claude-home": { type: "string" },
31
+ "cursor-home": { type: "string" },
30
32
  "data-directory": { type: "string" },
31
33
  "include-history": { type: "boolean" },
32
34
  yes: { type: "boolean" },
@@ -58,6 +60,7 @@ function usage(command) {
58
60
  " iteration connect Connect all detected agents on this computer",
59
61
  " iteration connect codex",
60
62
  " iteration connect claude",
63
+ " iteration connect cursor",
61
64
  "",
62
65
  "Existing local sessions and new activity are collected automatically.",
63
66
  "",
@@ -66,6 +69,7 @@ function usage(command) {
66
69
  " --organization-id UUID Select a workspace without a prompt",
67
70
  " --codex-home DIR Use a different Codex data directory",
68
71
  " --claude-home DIR Use a different Claude data directory",
72
+ " --cursor-home DIR Use a different Cursor data directory",
69
73
  " --yes Approve uploads without a terminal prompt",
70
74
  " --data-directory DIR Use a different state directory with a source-specific command",
71
75
  " --endpoint URL Use a different Iteration server",
@@ -81,8 +85,8 @@ function usage(command) {
81
85
  "Commands:",
82
86
  " iteration See connection status",
83
87
  " iteration status See scan, upload, and error details",
84
- " iteration connect codex|claude Connect one source (advanced)",
85
- " iteration disconnect codex|claude Stop a source",
88
+ " iteration connect codex|claude|cursor Connect one source (advanced)",
89
+ " iteration disconnect codex|claude|cursor Stop a source",
86
90
  "",
87
91
  "Run iteration connect --help for advanced setup options.",
88
92
  ].join("\n") + "\n");
@@ -102,21 +106,20 @@ async function fileExists(filename) {
102
106
  }
103
107
  }
104
108
  async function defaultCommand() {
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");
109
+ const snapshots = await Promise.all(COLLECTOR_SOURCES.map(async (source) => ({
110
+ source,
111
+ snapshot: await statusSnapshot(source, {}),
112
+ })));
113
+ const connected = snapshots.filter(({ snapshot }) => snapshot.kind !== "not_connected");
113
114
  if (connected.length > 0) {
114
115
  process.stdout.write(`${connected.map(({ source, snapshot }) => renderHome(snapshot, Date.now(), source)).join("\n\n")}\n`);
115
116
  const discovered = await detectedSources({
116
117
  codex: sourceHome("codex"),
117
118
  claude: sourceHome("claude"),
119
+ cursor: sourceHome("cursor"),
118
120
  });
119
- const missing = discovered.filter((source) => (source === "codex" ? codex : claude).kind === "not_connected");
121
+ const missing = discovered.filter((source) => snapshots.find((item) => item.source === source)?.snapshot.kind ===
122
+ "not_connected");
120
123
  if (missing.length > 0) {
121
124
  process.stdout.write(`\n${missing.map(collectorSourceLabel).join(" and ")} found on this computer but not connected. Run iteration connect to include ${missing.length === 1 ? "it" : "them"}.\n`);
122
125
  }
@@ -133,6 +136,8 @@ function dataDirectory(source, values) {
133
136
  defaultCollectorDirectory(source));
134
137
  }
135
138
  async function turnSummaryNotice(config) {
139
+ if ("cursorHome" in config)
140
+ return " Cursor hooks capture tool results and usage automatically.";
136
141
  try {
137
142
  await installTurnSummary(config);
138
143
  if ((await codexTurnSummaryApproval(config)) === "needs_approval") {
@@ -152,15 +157,18 @@ async function initialize(source, values) {
152
157
  const organizationId = optionalString(values, "organization-id");
153
158
  const directory = dataDirectory(source, values);
154
159
  const paths = collectorPaths(directory);
155
- const sourceHome = path.resolve(source === "codex"
160
+ const selectedHome = path.resolve(source === "codex"
156
161
  ? (optionalString(values, "codex-home") ??
157
162
  process.env.CODEX_HOME ??
158
163
  path.join(os.homedir(), ".codex"))
159
- : (optionalString(values, "claude-home") ??
160
- process.env.CLAUDE_CONFIG_DIR ??
161
- path.join(os.homedir(), ".claude")));
164
+ : source === "claude"
165
+ ? (optionalString(values, "claude-home") ??
166
+ process.env.CLAUDE_CONFIG_DIR ??
167
+ path.join(os.homedir(), ".claude"))
168
+ : (optionalString(values, "cursor-home") ??
169
+ path.join(os.homedir(), ".cursor")));
162
170
  await assertFreshCollectorDirectory(paths, source);
163
- const preparedState = await prepareSourceInitialState(source, sourceHome, true);
171
+ const preparedState = await prepareSourceInitialState(source, selectedHome, true);
164
172
  await prepareBackgroundPersistence();
165
173
  const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId, source);
166
174
  const connection = {
@@ -171,11 +179,15 @@ async function initialize(source, values) {
171
179
  organizationName: enrollment.organizationName,
172
180
  };
173
181
  const config = source === "codex"
174
- ? { ...connection, codexHome: sourceHome }
175
- : { ...connection, source: "claude", claudeHome: sourceHome };
182
+ ? { ...connection, codexHome: selectedHome }
183
+ : source === "claude"
184
+ ? { ...connection, source: "claude", claudeHome: selectedHome }
185
+ : { ...connection, source: "cursor", cursorHome: selectedHome };
176
186
  await saveConfig(config, paths);
177
187
  try {
178
188
  await saveState(preparedState, paths);
189
+ if (source === "cursor")
190
+ await installCursorHooks(selectedHome, directory);
179
191
  }
180
192
  catch (error) {
181
193
  throw new Error(`Collector access was approved, but local setup did not finish. Run iteration start ${source} to repair it.`, { cause: error });
@@ -230,11 +242,15 @@ async function connectAll(values) {
230
242
  (configs[1] && "claudeHome" in configs[1]
231
243
  ? configs[1].claudeHome
232
244
  : sourceHome("claude"))),
245
+ cursor: path.resolve(optionalString(values, "cursor-home") ??
246
+ (configs[2] && "cursorHome" in configs[2]
247
+ ? configs[2].cursorHome
248
+ : sourceHome("cursor"))),
233
249
  };
234
250
  const detected = await detectedSources(sourceHomes);
235
251
  const existing = COLLECTOR_SOURCES.flatMap((source, index) => configs[index] ? [{ source, config: configs[index] }] : []);
236
252
  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 computer.\n");
253
+ process.stdout.write("No supported local coding agents found yet. Run iteration connect after using Codex, Claude Code, or Cursor on this computer.\n");
238
254
  return;
239
255
  }
240
256
  const anchorSource = existing[0]?.source ?? detected[0];
@@ -269,7 +285,8 @@ async function connectAll(values) {
269
285
  previousConsent.organizationId !==
270
286
  existingScopes[0]?.scope.organizationId ||
271
287
  previousConsent.sourceHomes.codex !== sourceHomes.codex ||
272
- previousConsent.sourceHomes.claude !== sourceHomes.claude) {
288
+ previousConsent.sourceHomes.claude !== sourceHomes.claude ||
289
+ previousConsent.sourceHomes.cursor !== sourceHomes.cursor) {
273
290
  await approveAllSources(detected, existingScopes[0]?.scope.organizationName ?? null, values.yes === true);
274
291
  }
275
292
  await prepareBackgroundPersistence();
@@ -308,6 +325,8 @@ async function connectAll(values) {
308
325
  });
309
326
  for (const item of existing) {
310
327
  await ensureStartableState(item.config, collectorPaths(item.config.dataDirectory));
328
+ if ("cursorHome" in item.config)
329
+ await installCursorHooks(item.config.cursorHome, item.config.dataDirectory);
311
330
  await installBackgroundService(item.config.dataDirectory, item.source);
312
331
  const notice = await turnSummaryNotice(item.config);
313
332
  process.stdout.write(`${collectorSourceLabel(item.source)} is still connected.${notice}\n`);
@@ -338,6 +357,8 @@ async function start(source, values) {
338
357
  if (collectorSource(config) !== source)
339
358
  throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
340
359
  const repairedState = await ensureStartableState(config, paths);
360
+ if ("cursorHome" in config)
361
+ await installCursorHooks(config.cursorHome, config.dataDirectory);
341
362
  await installBackgroundService(directory, source);
342
363
  const notice = await turnSummaryNotice(config);
343
364
  process.stdout.write(`${collectorSourceLabel(source)} is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}${notice}\n`);
@@ -357,13 +378,16 @@ async function statusSnapshot(source, values) {
357
378
  const config = await loadConfig(paths);
358
379
  if (collectorSource(config) !== source)
359
380
  throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
360
- const [running, size, ids, scan, localHealth, approval] = await Promise.all([
381
+ const [running, size, ids, scan, localHealth, approval, hookFailureAtMs] = await Promise.all([
361
382
  backgroundServiceStatus(source),
362
383
  outboxSize(paths),
363
384
  pendingEventIds(paths),
364
385
  historyScanProgress(collectorSourceDirectory(config), paths, source),
365
386
  healthSnapshot(paths),
366
387
  codexTurnSummaryApproval(config),
388
+ source === "cursor"
389
+ ? cursorHookFailureAtMs(config.dataDirectory)
390
+ : Promise.resolve(null),
367
391
  ]);
368
392
  const pendingEvents = size === 0 ? 0 : ids.size;
369
393
  const pendingBytes = ids.size === 0 ? 0 : size;
@@ -377,6 +401,7 @@ async function statusSnapshot(source, values) {
377
401
  health: localHealth.health,
378
402
  healthAvailable: localHealth.kind === "available",
379
403
  turnSummaryNeedsApproval: approval === "needs_approval",
404
+ hookFailureAtMs,
380
405
  tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
381
406
  logPath: collectorLogPath(source),
382
407
  };
@@ -387,15 +412,17 @@ async function status(source, values) {
387
412
  process.stdout.write(`${renderDetailedStatus(snapshot, Date.now(), source)}\n`);
388
413
  return;
389
414
  }
390
- const [codex, claude] = await Promise.all([
391
- statusSnapshot("codex", values),
392
- statusSnapshot("claude", values),
393
- ]);
394
- process.stdout.write(`${renderDetailedStatus(codex, Date.now(), "codex")}\n\n${renderDetailedStatus(claude, Date.now(), "claude")}\n`);
415
+ const snapshots = await Promise.all(COLLECTOR_SOURCES.map(async (item) => ({
416
+ source: item,
417
+ snapshot: await statusSnapshot(item, values),
418
+ })));
419
+ process.stdout.write(`${snapshots.map(({ source: item, snapshot }) => renderDetailedStatus(snapshot, Date.now(), item)).join("\n\n")}\n`);
395
420
  }
396
421
  async function main() {
397
422
  const { command, source, values } = argumentsForCli();
398
- const selectedSource = source === "codex" || source === "claude" ? source : null;
423
+ const selectedSource = source === "codex" || source === "claude" || source === "cursor"
424
+ ? source
425
+ : null;
399
426
  if (values.help === true || command === "help") {
400
427
  return usage(command === "help" ? source : command);
401
428
  }
@@ -413,10 +440,9 @@ async function main() {
413
440
  return defaultCommand();
414
441
  }
415
442
  if (selectedSource !== null) {
416
- if (selectedSource === "codex" && values["claude-home"] !== undefined)
417
- throw new Error("--claude-home requires the Claude source.");
418
- if (selectedSource === "claude" && values["codex-home"] !== undefined)
419
- throw new Error("--codex-home requires the Codex source.");
443
+ for (const name of ["codex", "claude", "cursor"])
444
+ if (selectedSource !== name && values[`${name}-home`] !== undefined)
445
+ throw new Error(`--${name}-home requires the ${collectorSourceLabel(name)} source.`);
420
446
  }
421
447
  if (command === "connect" && selectedSource !== null) {
422
448
  await initialize(selectedSource, values);
@@ -424,7 +450,7 @@ async function main() {
424
450
  }
425
451
  if (command === "run") {
426
452
  if (source !== undefined && selectedSource === null)
427
- throw new Error("The run source must be codex or claude.");
453
+ throw new Error("The run source must be codex, claude, or cursor.");
428
454
  return runCollector(optionalString(values, "data-directory") ?? undefined, selectedSource ?? undefined);
429
455
  }
430
456
  if (command === "status" &&
@@ -441,7 +467,11 @@ async function main() {
441
467
  await uninstallBackgroundService(selectedSource);
442
468
  const paths = collectorPaths(dataDirectory(selectedSource, values));
443
469
  if (await fileExists(paths.config)) {
444
- await uninstallTurnSummary(await loadConfig(paths));
470
+ const config = await loadConfig(paths);
471
+ if ("cursorHome" in config)
472
+ await uninstallCursorHooks(config.cursorHome, config.dataDirectory);
473
+ else
474
+ await uninstallTurnSummary(config);
445
475
  }
446
476
  process.stdout.write("Collector startup removed. Local pending data was retained.\n");
447
477
  return;
@@ -2,13 +2,12 @@ import http from "node:http";
2
2
  import https from "node:https";
3
3
  const MAX_RESPONSE_BYTES = 1024 * 1024;
4
4
  const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
5
- // Node's fetch can negotiate HTTP/2 and, after a timed-out request breaks the
6
- // TLS connection, keep handing every later request the destroyed session until
7
- // the process restarts. The collector runs for days, so it uses HTTP/1.1
8
- // connections that are discarded when they fail.
5
+ // Reused connections failed uploads in production: Node's HTTP/2 fetch kept a
6
+ // destroyed session, and kept-alive sockets timed out on ~40 s server waits.
7
+ // See docs/architecture/trace-ingestion.md.
9
8
  const agents = {
10
- "http:": new http.Agent({ keepAlive: true, maxSockets: 2 }),
11
- "https:": new https.Agent({ keepAlive: true, maxSockets: 2 }),
9
+ "http:": new http.Agent({ keepAlive: false }),
10
+ "https:": new https.Agent({ keepAlive: false }),
12
11
  };
13
12
  export const collectorFetch = (input, init = {}) => {
14
13
  const url = new URL(input);
package/dist/collector.js CHANGED
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { parseTraceEventAcknowledgement, } from "iteration-trace-contracts";
4
4
  import { collectorFetch } from "./collector-fetch.js";
5
5
  import { parseClaudeLine } from "./claude-transcript.js";
6
+ import { cursorHookFiles } from "./cursor-hooks.js";
7
+ import { parseCursorHookLine, parseCursorTranscriptLine, } from "./cursor-transcript.js";
6
8
  import { parseRolloutLine } from "./rollout.js";
7
9
  import { acknowledgeEvents, appendEvents, collectorSource, collectorSourceDirectory, loadState, nextBatch, outboxSize, pendingEventIds, sourceFiles, saveState, } from "./storage.js";
8
10
  const MAX_SCAN_BYTES_PER_FILE = 16 * 1024 * 1024;
@@ -41,6 +43,10 @@ function logGap(filename, offset, reason) {
41
43
  function parseSourceRecord(source, line, filename, byteOffset) {
42
44
  if (source === "claude")
43
45
  return parseClaudeLine(line, filename, byteOffset);
46
+ if (source === "cursor")
47
+ return filename.includes(`${path.sep}cursor-hooks${path.sep}`)
48
+ ? parseCursorHookLine(line, filename)
49
+ : parseCursorTranscriptLine(line, filename, byteOffset);
44
50
  if (source === "codex") {
45
51
  const parsed = parseRolloutLine(line, filename, byteOffset);
46
52
  return parsed.kind === "event"
@@ -62,13 +68,21 @@ export async function scanSourceOnce(config, paths, signal) {
62
68
  }
63
69
  const cursors = { ...state.cursors };
64
70
  const skipping = new Set(state.skipping);
65
- const files = await sourceFiles(source, sourceDirectory);
71
+ const files = [
72
+ ...(await sourceFiles(source, sourceDirectory)),
73
+ ...(source === "cursor" ? await cursorHookFiles(config.dataDirectory) : []),
74
+ ];
75
+ const completedHooks = [];
66
76
  const queuedIds = await pendingEventIds(paths);
67
77
  const events = [];
68
78
  let remainingBudget = MAX_SCAN_BYTES_PER_TICK;
69
79
  let caughtUp = true;
70
80
  for (const filename of files) {
71
81
  signal?.throwIfAborted();
82
+ const recordLimit = source === "cursor" &&
83
+ filename.includes(`${path.sep}cursor-hooks${path.sep}`)
84
+ ? 12 * 1024 * 1024
85
+ : MAX_RECORD_BYTES;
72
86
  if (remainingBudget === 0) {
73
87
  caughtUp = false;
74
88
  break;
@@ -118,7 +132,7 @@ export async function scanSourceOnce(config, paths, signal) {
118
132
  if (lineBytes.at(-1) === 13)
119
133
  lineBytes = lineBytes.subarray(0, -1);
120
134
  const byteOffset = offset + lineStart;
121
- if (lineBytes.length > MAX_RECORD_BYTES) {
135
+ if (lineBytes.length > recordLimit) {
122
136
  logGap(filename, byteOffset, "rollout_record_exceeds_scan_limit");
123
137
  lineStart = index + 1;
124
138
  continue;
@@ -149,14 +163,22 @@ export async function scanSourceOnce(config, paths, signal) {
149
163
  }
150
164
  lineStart = index + 1;
151
165
  }
152
- if (buffer.length - lineStart > MAX_RECORD_BYTES) {
166
+ if (buffer.length - lineStart > recordLimit) {
153
167
  logGap(filename, offset + lineStart, "incomplete_rollout_record_exceeds_scan_limit");
154
168
  skipping.add(filename);
155
169
  lineStart = buffer.length;
156
170
  }
157
171
  cursors[filename] = offset + lineStart;
172
+ if (source === "cursor" &&
173
+ filename.includes(`${path.sep}cursor-hooks${path.sep}`) &&
174
+ cursors[filename] === stats.size)
175
+ completedHooks.push(filename);
158
176
  }
159
177
  await appendEvents(events, paths);
178
+ for (const filename of completedHooks) {
179
+ await fs.rm(filename);
180
+ delete cursors[filename];
181
+ }
160
182
  await saveState({
161
183
  cursors,
162
184
  skipping: [...skipping],
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ import * as z from "zod";
6
+ import { CURSOR_HOOKS, cursorHookDirectory, cursorHookFailurePath, } from "./cursor-hooks.js";
7
+ const MAX_INPUT_BYTES = 16 * 1024 * 1024;
8
+ const MAX_QUEUE_BYTES = 256 * 1024 * 1024;
9
+ const MAX_QUEUE_FILES = 2000;
10
+ const MAX_SUBAGENT_BYTES = 64 * 1024 * 1024;
11
+ const SUBAGENT_CHUNK_BYTES = 200_000;
12
+ const hookInputSchema = z
13
+ .object({
14
+ conversation_id: z.string().optional(),
15
+ parent_conversation_id: z.string().optional(),
16
+ subagent_id: z.string().optional(),
17
+ transcript_path: z.string().nullable().optional(),
18
+ agent_transcript_path: z.string().nullable().optional(),
19
+ })
20
+ .loose();
21
+ async function assertQueueCapacity(directory, incomingBytes) {
22
+ const names = (await fs.readdir(directory)).filter((name) => name.endsWith(".jsonl") || name.endsWith(".tmp"));
23
+ if (names.length >= MAX_QUEUE_FILES)
24
+ throw new Error("Cursor hook queue is full.");
25
+ let total = incomingBytes;
26
+ for (let start = 0; start < names.length; start += 64) {
27
+ const sizes = await Promise.all(names.slice(start, start + 64).map(async (name) => {
28
+ try {
29
+ return (await fs.stat(path.join(directory, name))).size;
30
+ }
31
+ catch (error) {
32
+ if (error instanceof Error &&
33
+ "code" in error &&
34
+ error.code === "ENOENT")
35
+ return 0;
36
+ throw error;
37
+ }
38
+ }));
39
+ total += sizes.reduce((sum, size) => sum + size, 0);
40
+ if (total > MAX_QUEUE_BYTES)
41
+ throw new Error("Cursor hook queue is full.");
42
+ }
43
+ }
44
+ async function writeRecord(directory, value) {
45
+ const serialized = `${JSON.stringify(value)}\n`;
46
+ const recordBytes = Buffer.byteLength(serialized, "utf8");
47
+ if (recordBytes > 12 * 1024 * 1024)
48
+ throw new Error("Cursor hook record exceeds the capture limit.");
49
+ await assertQueueCapacity(directory, recordBytes);
50
+ const id = randomUUID();
51
+ const temporary = path.join(directory, `${id}.tmp`);
52
+ const filename = path.join(directory, `${id}.jsonl`);
53
+ const handle = await fs.open(temporary, "wx", 0o600);
54
+ try {
55
+ await handle.writeFile(serialized, "utf8");
56
+ await handle.sync();
57
+ }
58
+ catch (error) {
59
+ await fs.rm(temporary, { force: true });
60
+ throw error;
61
+ }
62
+ finally {
63
+ await handle.close();
64
+ }
65
+ await fs.rename(temporary, filename);
66
+ }
67
+ async function captureSubagentTranscript(directory, input, capturedAt, transcriptBytes) {
68
+ const filename = input.agent_transcript_path;
69
+ if (filename === null || filename === undefined || !path.isAbsolute(filename))
70
+ return;
71
+ const stats = await fs.stat(filename);
72
+ if (!stats.isFile() || stats.size > MAX_SUBAGENT_BYTES)
73
+ throw new Error("Cursor subagent transcript exceeds the capture limit.");
74
+ const contents = await fs.readFile(filename);
75
+ const parts = Math.max(1, Math.ceil(contents.length / SUBAGENT_CHUNK_BYTES));
76
+ const decoder = new StringDecoder("utf8");
77
+ for (let index = 0; index < parts; index += 1) {
78
+ const bytes = contents.subarray(index * SUBAGENT_CHUNK_BYTES, (index + 1) * SUBAGENT_CHUNK_BYTES);
79
+ const text = decoder.write(bytes) + (index === parts - 1 ? decoder.end() : "");
80
+ await writeRecord(directory, {
81
+ kind: "subagent_transcript_chunk",
82
+ capturedAt,
83
+ transcriptBytes,
84
+ input: {
85
+ conversation_id: input.conversation_id,
86
+ parent_conversation_id: input.parent_conversation_id,
87
+ subagent_id: input.subagent_id,
88
+ },
89
+ subagent: {
90
+ path: filename,
91
+ part: index + 1,
92
+ totalParts: parts,
93
+ text,
94
+ base64: bytes.toString("base64"),
95
+ },
96
+ });
97
+ }
98
+ }
99
+ async function main() {
100
+ const hook = process.argv[2];
101
+ const dataDirectory = process.argv[3];
102
+ if (!hook ||
103
+ !CURSOR_HOOKS.includes(hook) ||
104
+ !dataDirectory)
105
+ throw new Error("Invalid Cursor hook command.");
106
+ const chunks = [];
107
+ let bytes = 0;
108
+ for await (const chunk of process.stdin) {
109
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
110
+ bytes += buffer.length;
111
+ if (bytes > MAX_INPUT_BYTES)
112
+ throw new Error("Cursor hook input exceeds the capture limit.");
113
+ chunks.push(buffer);
114
+ }
115
+ const input = hookInputSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
116
+ const transcriptPath = input.transcript_path ?? null;
117
+ let transcriptBytes = null;
118
+ if (transcriptPath !== null) {
119
+ try {
120
+ transcriptBytes = (await fs.stat(transcriptPath)).size;
121
+ }
122
+ catch (error) {
123
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
124
+ throw error;
125
+ }
126
+ }
127
+ const directory = cursorHookDirectory(dataDirectory);
128
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
129
+ const capturedAt = new Date().toISOString();
130
+ await writeRecord(directory, { hook, capturedAt, transcriptBytes, input });
131
+ if (hook === "subagentStop")
132
+ await captureSubagentTranscript(directory, input, capturedAt, transcriptBytes);
133
+ }
134
+ main()
135
+ .catch(async (error) => {
136
+ process.stderr.write(`Iteration Cursor capture failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
137
+ const dataDirectory = process.argv[3];
138
+ if (dataDirectory) {
139
+ try {
140
+ await fs.mkdir(dataDirectory, { recursive: true, mode: 0o700 });
141
+ const temporary = `${cursorHookFailurePath(dataDirectory)}.${randomUUID()}.tmp`;
142
+ await fs.writeFile(temporary, `${JSON.stringify({ atMs: Date.now() })}\n`, { mode: 0o600 });
143
+ await fs.rename(temporary, cursorHookFailurePath(dataDirectory));
144
+ }
145
+ catch (statusError) {
146
+ process.stderr.write(`Iteration could not save the Cursor capture failure: ${statusError instanceof Error ? statusError.message : "unknown error"}\n`);
147
+ }
148
+ }
149
+ process.exitCode = 1;
150
+ })
151
+ .finally(() => {
152
+ process.stdout.write(process.argv[2] === "preToolUse" || process.argv[2] === "subagentStart"
153
+ ? '{"permission":"allow"}\n'
154
+ : process.argv[2] === "beforeSubmitPrompt"
155
+ ? '{"continue":true}\n'
156
+ : "{}\n");
157
+ });
@@ -0,0 +1,7 @@
1
+ export declare const CURSOR_HOOKS: readonly ["sessionStart", "beforeSubmitPrompt", "preToolUse", "postToolUse", "postToolUseFailure", "afterAgentThought", "afterAgentResponse", "preCompact", "stop", "subagentStart", "subagentStop", "sessionEnd"];
2
+ export declare function cursorHookDirectory(dataDirectory: string): string;
3
+ export declare function cursorHookFailurePath(dataDirectory: string): string;
4
+ export declare function cursorHookFailureAtMs(dataDirectory: string): Promise<number | null>;
5
+ export declare function installCursorHooks(cursorHome: string, dataDirectory: string): Promise<void>;
6
+ export declare function uninstallCursorHooks(cursorHome: string, dataDirectory: string): Promise<void>;
7
+ export declare function cursorHookFiles(dataDirectory: string): Promise<string[]>;
@@ -0,0 +1,163 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import * as z from "zod";
5
+ import { ensurePrivateDirectory } from "./storage.js";
6
+ export const CURSOR_HOOKS = [
7
+ "sessionStart",
8
+ "beforeSubmitPrompt",
9
+ "preToolUse",
10
+ "postToolUse",
11
+ "postToolUseFailure",
12
+ "afterAgentThought",
13
+ "afterAgentResponse",
14
+ "preCompact",
15
+ "stop",
16
+ "subagentStart",
17
+ "subagentStop",
18
+ "sessionEnd",
19
+ ];
20
+ export function cursorHookDirectory(dataDirectory) {
21
+ return path.join(dataDirectory, "cursor-hooks");
22
+ }
23
+ export function cursorHookFailurePath(dataDirectory) {
24
+ return path.join(dataDirectory, "cursor-hook-failure.json");
25
+ }
26
+ export async function cursorHookFailureAtMs(dataDirectory) {
27
+ let contents;
28
+ try {
29
+ contents = await fs.readFile(cursorHookFailurePath(dataDirectory), "utf8");
30
+ }
31
+ catch (error) {
32
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
33
+ return null;
34
+ throw error;
35
+ }
36
+ let decoded;
37
+ try {
38
+ decoded = JSON.parse(contents);
39
+ }
40
+ catch (error) {
41
+ throw new Error("Cursor hook failure status is invalid.", { cause: error });
42
+ }
43
+ const parsed = z
44
+ .object({ atMs: z.number().int().min(0) })
45
+ .strict()
46
+ .safeParse(decoded);
47
+ if (!parsed.success)
48
+ throw new Error("Cursor hook failure status is invalid.");
49
+ return parsed.data.atMs;
50
+ }
51
+ function shellQuote(value) {
52
+ return `'${value.replaceAll("'", `'\\''`)}'`;
53
+ }
54
+ function hookCommand(hook, dataDirectory) {
55
+ const entry = fileURLToPath(new URL("./cursor-hook-entry.js", import.meta.url));
56
+ return `${shellQuote(process.execPath)} ${shellQuote(entry)} ${shellQuote(hook)} ${shellQuote(dataDirectory)}`;
57
+ }
58
+ const hookEntrySchema = z.object({ command: z.string().optional() }).loose();
59
+ const hookFileSchema = z
60
+ .object({
61
+ version: z.literal(1).optional(),
62
+ hooks: z.record(z.string(), z.array(hookEntrySchema)).optional(),
63
+ })
64
+ .loose();
65
+ async function readHooks(filename) {
66
+ let contents;
67
+ let mode = 0o600;
68
+ try {
69
+ contents = await fs.readFile(filename, "utf8");
70
+ mode = (await fs.stat(filename)).mode & 0o777;
71
+ }
72
+ catch (error) {
73
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
74
+ return { file: {}, mode };
75
+ throw new Error(`Could not read Cursor hooks at ${filename}.`, {
76
+ cause: error,
77
+ });
78
+ }
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(contents);
82
+ }
83
+ catch (error) {
84
+ throw new Error(`Cursor hooks at ${filename} are not valid JSON.`, {
85
+ cause: error,
86
+ });
87
+ }
88
+ const checked = hookFileSchema.safeParse(parsed);
89
+ if (!checked.success)
90
+ throw new Error(`Cursor hooks at ${filename} have an unexpected shape.`);
91
+ return { file: checked.data, mode };
92
+ }
93
+ async function writeHooks(filename, file, mode) {
94
+ await ensurePrivateDirectory(path.dirname(filename));
95
+ const target = await fs.realpath(filename).catch((error) => {
96
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
97
+ return filename;
98
+ throw error;
99
+ });
100
+ const temporary = `${target}.iteration-${process.pid}.tmp`;
101
+ const handle = await fs.open(temporary, "w", mode);
102
+ try {
103
+ await handle.writeFile(`${JSON.stringify(file, null, 2)}\n`, "utf8");
104
+ await handle.sync();
105
+ }
106
+ finally {
107
+ await handle.close();
108
+ }
109
+ await fs.rename(temporary, target);
110
+ }
111
+ function entries(value) {
112
+ return value ?? [];
113
+ }
114
+ export async function installCursorHooks(cursorHome, dataDirectory) {
115
+ await ensurePrivateDirectory(cursorHookDirectory(dataDirectory));
116
+ const filename = path.join(cursorHome, "hooks.json");
117
+ const { file, mode } = await readHooks(filename);
118
+ const hooks = { ...file.hooks };
119
+ for (const hook of CURSOR_HOOKS) {
120
+ const command = hookCommand(hook, dataDirectory);
121
+ const existing = entries(hooks[hook]);
122
+ if (!existing.some((entry) => entry.command === command))
123
+ hooks[hook] = [...existing, { command }];
124
+ }
125
+ await writeHooks(filename, { ...file, version: 1, hooks }, mode);
126
+ }
127
+ export async function uninstallCursorHooks(cursorHome, dataDirectory) {
128
+ const filename = path.join(cursorHome, "hooks.json");
129
+ const { file, mode } = await readHooks(filename);
130
+ const hooks = { ...file.hooks };
131
+ let changed = false;
132
+ for (const hook of CURSOR_HOOKS) {
133
+ const existing = entries(hooks[hook]);
134
+ const remaining = existing.filter((entry) => entry.command !== hookCommand(hook, dataDirectory));
135
+ if (remaining.length !== existing.length) {
136
+ changed = true;
137
+ if (remaining.length === 0)
138
+ delete hooks[hook];
139
+ else
140
+ hooks[hook] = remaining;
141
+ }
142
+ }
143
+ if (changed)
144
+ await writeHooks(filename, { ...file, hooks }, mode);
145
+ }
146
+ export async function cursorHookFiles(dataDirectory) {
147
+ const directory = cursorHookDirectory(dataDirectory);
148
+ let names;
149
+ try {
150
+ names = await fs.readdir(directory);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
154
+ return [];
155
+ throw error;
156
+ }
157
+ if (names.length > 10_000)
158
+ throw new Error("Cursor hook queue exceeds the scan limit.");
159
+ return names
160
+ .filter((name) => /^[0-9a-f-]+\.jsonl$/i.test(name))
161
+ .sort()
162
+ .map((name) => path.join(directory, name));
163
+ }
@@ -0,0 +1,8 @@
1
+ import { type TraceEvent } from "iteration-trace-contracts";
2
+ type ParseResult = Readonly<{
3
+ events: readonly TraceEvent[];
4
+ gaps: readonly string[];
5
+ }>;
6
+ export declare function parseCursorTranscriptLine(line: string, filename: string, byteOffset: number): ParseResult;
7
+ export declare function parseCursorHookLine(line: string, filename: string): ParseResult;
8
+ export {};
@@ -0,0 +1,294 @@
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 UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ function record(value) {
7
+ const parsed = z.record(z.string(), z.json()).safeParse(value);
8
+ return parsed.success ? parsed.data : null;
9
+ }
10
+ function string(value) {
11
+ const parsed = z.string().safeParse(value);
12
+ return parsed.success ? parsed.data : null;
13
+ }
14
+ function nonnegativeInteger(value) {
15
+ const parsed = z
16
+ .number()
17
+ .int()
18
+ .min(0)
19
+ .max(Number.MAX_SAFE_INTEGER)
20
+ .safeParse(value);
21
+ return parsed.success ? parsed.data : null;
22
+ }
23
+ function event(conversationId, filename, byteOffset, index, eventType, payload, callId = null, occurredAt = null) {
24
+ const hash = createHash("sha256")
25
+ .update(`${filename}:${byteOffset}:${index}:${eventType}`)
26
+ .digest("hex");
27
+ return {
28
+ eventId: `${String(index).padStart(4, "0")}:${hash}`,
29
+ sessionId: `cursor:${conversationId}`,
30
+ sequence: byteOffset * 1000 + index,
31
+ callId,
32
+ projectId: null,
33
+ occurredAt,
34
+ eventType,
35
+ payload,
36
+ };
37
+ }
38
+ export function parseCursorTranscriptLine(line, filename, byteOffset) {
39
+ const conversationId = path.basename(filename, ".jsonl");
40
+ if (!UUID.test(conversationId) ||
41
+ path.basename(path.dirname(filename)) !== conversationId)
42
+ return { events: [], gaps: ["invalid_cursor_transcript_path"] };
43
+ let decoded;
44
+ try {
45
+ decoded = JSON.parse(line);
46
+ }
47
+ catch {
48
+ return { events: [], gaps: ["malformed_jsonl_record"] };
49
+ }
50
+ const source = record(decoded);
51
+ if (source === null)
52
+ return { events: [], gaps: ["record_is_not_an_object"] };
53
+ const events = [];
54
+ const gaps = [];
55
+ if (byteOffset === 0) {
56
+ events.push({
57
+ ...event(conversationId, filename, 0, 0, "session_meta", {
58
+ id: conversationId,
59
+ transcript_path: filename,
60
+ }),
61
+ run: {
62
+ kind: "identity",
63
+ id: conversationId,
64
+ parentId: null,
65
+ ownStart: true,
66
+ startOrdinal: null,
67
+ },
68
+ });
69
+ }
70
+ const role = string(source.role);
71
+ if (role === "user" || role === "assistant") {
72
+ const message = record(source.message);
73
+ if (message === null || !Array.isArray(message.content))
74
+ gaps.push("cursor_message_without_content");
75
+ else {
76
+ for (const [index, value] of message.content.entries()) {
77
+ const block = record(value);
78
+ if (block === null) {
79
+ gaps.push("invalid_cursor_content_block");
80
+ continue;
81
+ }
82
+ const type = string(block.type);
83
+ const eventType = type === "tool_use"
84
+ ? "tool_call"
85
+ : type === "thinking"
86
+ ? "reasoning"
87
+ : type === "text"
88
+ ? role === "user"
89
+ ? "user_message"
90
+ : "assistant_message"
91
+ : "source_record";
92
+ events.push(event(conversationId, filename, byteOffset, index + 1, eventType, {
93
+ text: string(block.text) ?? string(block.thinking),
94
+ name: string(block.name),
95
+ input: block.input ?? null,
96
+ sourceType: type,
97
+ record: source,
98
+ }, string(block.id)));
99
+ if (type !== "text" && type !== "tool_use" && type !== "thinking")
100
+ gaps.push(`unsupported_cursor_content_block:${type ?? "unknown"}`);
101
+ }
102
+ }
103
+ }
104
+ else if (source.type === "turn_ended") {
105
+ events.push(event(conversationId, filename, byteOffset, 1, source.status === "success" ? "turn_complete" : "turn_aborted", { record: source }));
106
+ }
107
+ else {
108
+ gaps.push(`unsupported_cursor_record:${string(source.type) ?? "unknown"}`);
109
+ events.push(event(conversationId, filename, byteOffset, 1, "source_record", {
110
+ sourceType: string(source.type),
111
+ record: source,
112
+ }));
113
+ }
114
+ return validated(events, gaps);
115
+ }
116
+ export function parseCursorHookLine(line, filename) {
117
+ let decoded;
118
+ try {
119
+ decoded = JSON.parse(line);
120
+ }
121
+ catch {
122
+ return { events: [], gaps: ["malformed_cursor_hook_record"] };
123
+ }
124
+ const wrapper = record(decoded);
125
+ const input = wrapper === null ? null : record(wrapper.input);
126
+ const conversationId = input === null ? null : string(input.conversation_id);
127
+ const hook = wrapper === null ? null : string(wrapper.hook);
128
+ const capturedAt = wrapper === null ? null : string(wrapper.capturedAt);
129
+ if (wrapper?.kind === "subagent_transcript_chunk") {
130
+ const subagent = record(wrapper.subagent);
131
+ const part = nonnegativeInteger(subagent?.part);
132
+ const totalParts = nonnegativeInteger(subagent?.totalParts);
133
+ if (conversationId === null ||
134
+ !UUID.test(conversationId) ||
135
+ capturedAt === null ||
136
+ Number.isNaN(Date.parse(capturedAt)) ||
137
+ subagent === null ||
138
+ part === null ||
139
+ totalParts === null ||
140
+ part < 1 ||
141
+ part > totalParts ||
142
+ string(subagent.base64) === null)
143
+ return { events: [], gaps: ["invalid_cursor_subagent_transcript_chunk"] };
144
+ const transcriptBytes = nonnegativeInteger(wrapper.transcriptBytes) ?? 0;
145
+ const candidate = event(conversationId, filename, transcriptBytes, part, "source_record", {
146
+ sourceType: "cursor_subagent_transcript",
147
+ text: string(subagent.text),
148
+ base64: string(subagent.base64),
149
+ path: string(subagent.path),
150
+ part,
151
+ totalParts,
152
+ parent_conversation_id: string(input?.parent_conversation_id),
153
+ subagent_id: string(input?.subagent_id),
154
+ }, null, capturedAt);
155
+ const hash = candidate.eventId.split(":").at(-1);
156
+ return validated([
157
+ {
158
+ ...candidate,
159
+ sequence: transcriptBytes * 1000 + 500,
160
+ eventId: `${String(Date.parse(capturedAt)).padStart(13, "0")}:${String(2000 + part).padStart(4, "0")}:${hash}`,
161
+ },
162
+ ], []);
163
+ }
164
+ if (input === null ||
165
+ conversationId === null ||
166
+ !UUID.test(conversationId) ||
167
+ hook === null ||
168
+ capturedAt === null ||
169
+ Number.isNaN(Date.parse(capturedAt)))
170
+ return { events: [], gaps: ["invalid_cursor_hook_record"] };
171
+ const transcriptBytes = wrapper?.transcriptBytes;
172
+ const sequence = nonnegativeInteger(transcriptBytes) ?? 0;
173
+ const events = [];
174
+ if (hook === "beforeSubmitPrompt" || hook === "sessionStart") {
175
+ const roots = Array.isArray(input.workspace_roots)
176
+ ? input.workspace_roots
177
+ : [];
178
+ events.push({
179
+ ...event(conversationId, filename, sequence, 0, "session_meta", {
180
+ id: conversationId,
181
+ cwd: string(roots[0]),
182
+ version: string(input.cursor_version),
183
+ model: string(input.model),
184
+ transcript_path: string(input.transcript_path),
185
+ }, null, capturedAt),
186
+ run: {
187
+ kind: "identity",
188
+ id: conversationId,
189
+ parentId: null,
190
+ ownStart: true,
191
+ startOrdinal: null,
192
+ },
193
+ });
194
+ }
195
+ const rawText = JSON.stringify(input);
196
+ if (Buffer.byteLength(rawText, "utf8") <= 500_000) {
197
+ events.push(event(conversationId, filename, sequence, 1, "source_record", {
198
+ sourceType: `cursor_hook:${hook}`,
199
+ record: input,
200
+ }, null, capturedAt));
201
+ }
202
+ else {
203
+ const chunks = splitText(rawText);
204
+ for (const [index, text] of chunks.entries())
205
+ events.push(event(conversationId, filename, sequence, index + 1, "source_record", {
206
+ sourceType: `cursor_hook:${hook}`,
207
+ text,
208
+ part: index + 1,
209
+ totalParts: chunks.length,
210
+ }, null, capturedAt));
211
+ }
212
+ const next = events.length + 1;
213
+ if (hook === "preToolUse") {
214
+ events.push(event(conversationId, filename, sequence, next, "tool_call", {
215
+ name: string(input.tool_name),
216
+ input: input.tool_input ?? null,
217
+ }, string(input.tool_use_id), capturedAt));
218
+ }
219
+ if (hook === "postToolUse" || hook === "postToolUseFailure") {
220
+ const output = string(input.tool_output) ??
221
+ string(input.error_message) ??
222
+ string(input.error) ??
223
+ (input.tool_output === undefined && input.error === undefined
224
+ ? null
225
+ : JSON.stringify(input.tool_output ?? input.error));
226
+ const chunks = output === null ? [null] : splitText(output);
227
+ for (const [index, text] of chunks.entries())
228
+ events.push(event(conversationId, filename, sequence, next + index, "tool_result", {
229
+ text,
230
+ name: string(input.tool_name),
231
+ part: index + 1,
232
+ totalParts: chunks.length,
233
+ }, string(input.tool_use_id), capturedAt));
234
+ }
235
+ else if (hook === "afterAgentThought") {
236
+ events.push(event(conversationId, filename, sequence, next, "reasoning", {
237
+ text: string(input.text) ?? string(input.thought),
238
+ }, null, capturedAt));
239
+ }
240
+ else if (hook === "afterAgentResponse" &&
241
+ (input.usage !== undefined ||
242
+ input.input_tokens !== undefined ||
243
+ input.output_tokens !== undefined)) {
244
+ events.push(event(conversationId, filename, sequence, next, "token_usage_record", {
245
+ model: string(input.model),
246
+ usage: input.usage ?? {
247
+ input_tokens: input.input_tokens ?? null,
248
+ output_tokens: input.output_tokens ?? null,
249
+ cache_read_tokens: input.cache_read_tokens ?? null,
250
+ cache_write_tokens: input.cache_write_tokens ?? null,
251
+ },
252
+ }, null, capturedAt));
253
+ }
254
+ const capturedAtMs = Date.parse(capturedAt);
255
+ const ordered = events.map((candidate, index) => {
256
+ const digest = candidate.eventId.split(":").at(-1);
257
+ const thought = candidate.eventType === "reasoning"
258
+ ? createHash("sha256")
259
+ .update(`${conversationId}:${string(input.generation_id)?.slice(0, 36) ?? ""}:${candidate.payload.text}`)
260
+ .digest("hex")
261
+ : null;
262
+ return {
263
+ ...candidate,
264
+ sequence: sequence * 1000 + 500,
265
+ eventId: thought === null
266
+ ? `${String(capturedAtMs).padStart(13, "0")}:${String(index).padStart(4, "0")}:${digest}`
267
+ : `thought:${thought}`,
268
+ };
269
+ });
270
+ return validated(ordered, []);
271
+ }
272
+ function splitText(value) {
273
+ if (value.length === 0)
274
+ return [""];
275
+ const chunks = [];
276
+ for (let start = 0; start < value.length;) {
277
+ let end = Math.min(start + 250_000, value.length);
278
+ if (end < value.length && /[\uD800-\uDBFF]/u.test(value[end - 1]))
279
+ end -= 1;
280
+ chunks.push(value.slice(start, end));
281
+ start = end;
282
+ }
283
+ return chunks;
284
+ }
285
+ function validated(events, gaps) {
286
+ const valid = [];
287
+ for (const candidate of events) {
288
+ if (parseTraceEventBatch({ events: [candidate] }).success)
289
+ valid.push(candidate);
290
+ else
291
+ gaps.push(`invalid_normalized_event:${candidate.eventType}`);
292
+ }
293
+ return { events: valid, gaps };
294
+ }
package/dist/service.js CHANGED
@@ -5,7 +5,8 @@ import { connectNewDetectedSources, loadMachineConsent, } from "./auto-connect.j
5
5
  import { collectorFetch } from "./collector-fetch.js";
6
6
  import { collectorTick, TraceUploadError } from "./collector.js";
7
7
  import { refreshTurnSummaries } from "./turn-summary.js";
8
- import { collectorSource, collectorPaths, ensureClaudeHistoryBackfill, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
8
+ import { installCursorHooks } from "./cursor-hooks.js";
9
+ import { collectorSource, collectorPaths, ensureClaudeHistoryBackfill, ensureCursorHistoryBackfill, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
9
10
  function errorCode(error) {
10
11
  if (error instanceof Error && "code" in error)
11
12
  return String(error.code);
@@ -125,7 +126,11 @@ export async function runCollector(directory, expectedSource) {
125
126
  message: "Local collector status could not be read; collection continues.",
126
127
  }));
127
128
  }
128
- if ("claudeHome" in config)
129
+ if ("cursorHome" in config) {
130
+ await ensureCursorHistoryBackfill(config.cursorHome, paths);
131
+ await installCursorHooks(config.cursorHome, config.dataDirectory);
132
+ }
133
+ else if ("claudeHome" in config)
129
134
  await ensureClaudeHistoryBackfill(config.claudeHome, paths);
130
135
  else
131
136
  await ensureHistoryBackfill(config.codexHome, paths);
@@ -208,7 +213,8 @@ export async function runCollector(directory, expectedSource) {
208
213
  retryDelayMs = Math.min(retryDelayMs * 2, 60_000);
209
214
  }
210
215
  try {
211
- await refreshTurnSummaries(config, summariesRefreshedAt, Date.now(), collectorFetch, controller.signal);
216
+ if (source !== "cursor")
217
+ await refreshTurnSummaries(config, summariesRefreshedAt, Date.now(), collectorFetch, controller.signal);
212
218
  }
213
219
  catch (error) {
214
220
  if (controller.signal.aborted)
@@ -23,6 +23,7 @@ export type CliStatus = Readonly<{
23
23
  health: CollectorHealth;
24
24
  healthAvailable: boolean;
25
25
  turnSummaryNeedsApproval: boolean;
26
+ hookFailureAtMs?: number | null;
26
27
  tracesUrl: string;
27
28
  logPath: string;
28
29
  }>;
@@ -149,6 +149,10 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
149
149
  lines.push(`${pendingDescription(status)}.`);
150
150
  lines.push(`Resume with iteration start ${source}.`);
151
151
  }
152
+ else if (status.hookFailureAtMs !== null &&
153
+ status.hookFailureAtMs !== undefined) {
154
+ lines.push(`${name} hook capture needs attention`, `A Cursor hook failed ${relativeTime(status.hookFailureAtMs, nowMs)}. Some data may be missing. See iteration status cursor.`);
155
+ }
152
156
  else if (!status.healthAvailable) {
153
157
  lines.push(`${name} is running; local status needs attention`, "Upload history could not be read. Collection continues in the background.");
154
158
  if (status.pendingEvents > 0)
@@ -242,6 +246,8 @@ export function renderDetailedStatus(status, nowMs = Date.now(), source = "codex
242
246
  : "Local collector error";
243
247
  lines.push(`Last issue: ${label} (${relativeTime(failure.atMs, nowMs)})`, `Collector log: ${status.logPath}`);
244
248
  }
249
+ if (status.hookFailureAtMs !== null && status.hookFailureAtMs !== undefined)
250
+ lines.push(`Last Cursor hook capture failure: ${relativeTime(status.hookFailureAtMs, nowMs)}. Some data may be missing; inspect Cursor hook logs.`);
245
251
  if (status.turnSummaryNeedsApproval)
246
252
  lines.push(CODEX_APPROVAL_STEP);
247
253
  if (!status.running)
package/dist/storage.d.ts CHANGED
@@ -11,6 +11,9 @@ export type CollectorConfig = (CollectorConnection & Readonly<{
11
11
  }>) | (CollectorConnection & Readonly<{
12
12
  source: "claude";
13
13
  claudeHome: string;
14
+ }>) | (CollectorConnection & Readonly<{
15
+ source: "cursor";
16
+ cursorHome: string;
14
17
  }>);
15
18
  export declare function collectorSource(config: CollectorConfig): CollectorSource;
16
19
  export declare function collectorSourceDirectory(config: CollectorConfig): string;
@@ -67,6 +70,7 @@ export declare function prepareSourceInitialState(source: CollectorSource, home:
67
70
  export declare function ensureStartableState(config: CollectorConfig, paths: CollectorPaths): Promise<boolean>;
68
71
  export declare function ensureHistoryBackfill(codexHome: string, paths: CollectorPaths): Promise<boolean>;
69
72
  export declare function ensureClaudeHistoryBackfill(claudeHome: string, paths: CollectorPaths): Promise<boolean>;
73
+ export declare function ensureCursorHistoryBackfill(cursorHome: string, paths: CollectorPaths): Promise<boolean>;
70
74
  export declare function historyScanProgress(home: string, paths: CollectorPaths, source?: CollectorSource): Promise<Readonly<{
71
75
  kind: "waiting";
72
76
  } | {
@@ -84,4 +88,5 @@ export declare function historyImportSize(home: string, source?: CollectorSource
84
88
  }>>;
85
89
  export declare function claudeFiles(root: string): Promise<string[]>;
86
90
  export declare function sourceFiles(source: CollectorSource, home: string): Promise<string[]>;
91
+ export declare function cursorFiles(root: string): Promise<string[]>;
87
92
  export {};
package/dist/storage.js CHANGED
@@ -7,10 +7,18 @@ import { MAX_TRACE_EVENTS_PER_BATCH, parseTraceEventBatch, } from "iteration-tra
7
7
  import * as z from "zod";
8
8
  const MAX_OUTBOX_BYTES = 64 * 1024 * 1024;
9
9
  export function collectorSource(config) {
10
- return "claudeHome" in config ? "claude" : "codex";
10
+ return "cursorHome" in config
11
+ ? "cursor"
12
+ : "claudeHome" in config
13
+ ? "claude"
14
+ : "codex";
11
15
  }
12
16
  export function collectorSourceDirectory(config) {
13
- return "claudeHome" in config ? config.claudeHome : config.codexHome;
17
+ return "cursorHome" in config
18
+ ? config.cursorHome
19
+ : "claudeHome" in config
20
+ ? config.claudeHome
21
+ : config.codexHome;
14
22
  }
15
23
  const connectionSchema = {
16
24
  endpoint: z.string(),
@@ -28,6 +36,13 @@ const configSchema = z.union([
28
36
  claudeHome: z.string(),
29
37
  })
30
38
  .strict(),
39
+ z
40
+ .object({
41
+ ...connectionSchema,
42
+ source: z.literal("cursor"),
43
+ cursorHome: z.string(),
44
+ })
45
+ .strict(),
31
46
  ]);
32
47
  const stateSchema = z
33
48
  .object({
@@ -492,6 +507,18 @@ export async function ensureClaudeHistoryBackfill(claudeHome, paths) {
492
507
  await writeJsonAtomic(paths.historyBackfill, { version: 1 });
493
508
  return true;
494
509
  }
510
+ export async function ensureCursorHistoryBackfill(cursorHome, paths) {
511
+ const version = await readHistoryBackfillVersion(paths);
512
+ if (version !== null && version >= 1)
513
+ return false;
514
+ const state = await loadState(paths);
515
+ if (!state.initialized || state.sourceDirectory !== path.resolve(cursorHome))
516
+ throw new Error("Collector state does not match the Cursor directory; reconnect Cursor before uploading.");
517
+ await cursorFiles(path.join(cursorHome, "projects"));
518
+ await saveState({ ...state, cursors: {}, skipping: [] }, paths);
519
+ await writeJsonAtomic(paths.historyBackfill, { version: 1 });
520
+ return true;
521
+ }
495
522
  export async function historyScanProgress(home, paths, source = "codex") {
496
523
  if ((await readHistoryBackfillVersion(paths)) === null)
497
524
  return { kind: "waiting" };
@@ -677,6 +704,32 @@ export function sourceFiles(source, home) {
677
704
  return rolloutFiles(path.join(directory, "sessions"));
678
705
  if (source === "claude")
679
706
  return claudeFiles(path.join(directory, "projects"));
707
+ if (source === "cursor")
708
+ return cursorFiles(path.join(directory, "projects"));
680
709
  const unhandled = source;
681
710
  throw new Error(`Unsupported collector source: ${unhandled}`);
682
711
  }
712
+ export async function cursorFiles(root) {
713
+ const files = [];
714
+ for (const project of await directoryEntries(root)) {
715
+ if (!project.isDirectory())
716
+ continue;
717
+ const transcripts = path.join(root, project.name, "agent-transcripts");
718
+ for (const conversation of await directoryEntries(transcripts)) {
719
+ if (!conversation.isDirectory())
720
+ continue;
721
+ const filename = path.join(transcripts, conversation.name, `${conversation.name}.jsonl`);
722
+ if ((await fs.stat(filename).catch((error) => {
723
+ if (error instanceof Error &&
724
+ "code" in error &&
725
+ error.code === "ENOENT")
726
+ return null;
727
+ throw error;
728
+ }))?.isFile())
729
+ files.push(filename);
730
+ if (files.length > 10_000)
731
+ throw new Error("Cursor session file count exceeds the scan limit.");
732
+ }
733
+ }
734
+ return files.sort();
735
+ }
@@ -55,6 +55,8 @@ function hookCommand(paths) {
55
55
  return `/bin/sh ${shellQuote(paths.script)}`;
56
56
  }
57
57
  function agentHookFile(config) {
58
+ if ("cursorHome" in config)
59
+ throw new Error("Cursor uses its own trace hooks.");
58
60
  return "claudeHome" in config
59
61
  ? path.join(config.claudeHome, "settings.json")
60
62
  : path.join(config.codexHome, "hooks.json");
@@ -337,7 +339,7 @@ function codexTrustedHash(configToml, key) {
337
339
  return trustedHash;
338
340
  }
339
341
  export async function codexTurnSummaryApproval(config) {
340
- if ("claudeHome" in config)
342
+ if ("claudeHome" in config || "cursorHome" in config)
341
343
  return "approved";
342
344
  const filename = agentHookFile(config);
343
345
  const { file } = await readHookFile(filename);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "zod": "4.6.4",
21
- "iteration-trace-contracts": "0.6.0"
21
+ "iteration-trace-contracts": "0.7.0"
22
22
  },
23
23
  "scripts": {
24
24
  "build": "pnpm --filter iteration-trace-contracts build && tsc -p tsconfig.json"