iteration-cli 0.7.3 → 0.8.1

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,15 @@ 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.
13
+
14
+ The collector replaces detected secrets with `[REDACTED]` before writing trace events to the upload outbox. It also redacts pending events left by an older collector before uploading them. This applies to Codex, Claude Code, and Cursor, including Cursor subagent transcript bytes stored as base64. Detection is best effort; original local agent files and previously uploaded traces are unchanged.
11
15
 
12
16
  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
17
 
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.
18
+ 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
19
 
16
20
  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
21
 
@@ -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;
package/dist/collector.js CHANGED
@@ -3,8 +3,11 @@ 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";
8
+ import { redactTraceEvent } from "./redact-trace.js";
6
9
  import { parseRolloutLine } from "./rollout.js";
7
- import { acknowledgeEvents, appendEvents, collectorSource, collectorSourceDirectory, loadState, nextBatch, outboxSize, pendingEventIds, sourceFiles, saveState, } from "./storage.js";
10
+ import { acknowledgeEvents, appendEvents, collectorSource, collectorSourceDirectory, loadState, nextBatch, outboxSize, pendingEventIds, redactPendingOutbox, sourceFiles, saveState, } from "./storage.js";
8
11
  const MAX_SCAN_BYTES_PER_FILE = 16 * 1024 * 1024;
9
12
  const MAX_SCAN_BYTES_PER_TICK = 16 * 1024 * 1024;
10
13
  const MAX_RECORD_BYTES = 3 * 1024 * 1024;
@@ -41,6 +44,10 @@ function logGap(filename, offset, reason) {
41
44
  function parseSourceRecord(source, line, filename, byteOffset) {
42
45
  if (source === "claude")
43
46
  return parseClaudeLine(line, filename, byteOffset);
47
+ if (source === "cursor")
48
+ return filename.includes(`${path.sep}cursor-hooks${path.sep}`)
49
+ ? parseCursorHookLine(line, filename)
50
+ : parseCursorTranscriptLine(line, filename, byteOffset);
44
51
  if (source === "codex") {
45
52
  const parsed = parseRolloutLine(line, filename, byteOffset);
46
53
  return parsed.kind === "event"
@@ -62,13 +69,21 @@ export async function scanSourceOnce(config, paths, signal) {
62
69
  }
63
70
  const cursors = { ...state.cursors };
64
71
  const skipping = new Set(state.skipping);
65
- const files = await sourceFiles(source, sourceDirectory);
72
+ const files = [
73
+ ...(await sourceFiles(source, sourceDirectory)),
74
+ ...(source === "cursor" ? await cursorHookFiles(config.dataDirectory) : []),
75
+ ];
76
+ const completedHooks = [];
66
77
  const queuedIds = await pendingEventIds(paths);
67
78
  const events = [];
68
79
  let remainingBudget = MAX_SCAN_BYTES_PER_TICK;
69
80
  let caughtUp = true;
70
81
  for (const filename of files) {
71
82
  signal?.throwIfAborted();
83
+ const recordLimit = source === "cursor" &&
84
+ filename.includes(`${path.sep}cursor-hooks${path.sep}`)
85
+ ? 12 * 1024 * 1024
86
+ : MAX_RECORD_BYTES;
72
87
  if (remainingBudget === 0) {
73
88
  caughtUp = false;
74
89
  break;
@@ -118,7 +133,7 @@ export async function scanSourceOnce(config, paths, signal) {
118
133
  if (lineBytes.at(-1) === 13)
119
134
  lineBytes = lineBytes.subarray(0, -1);
120
135
  const byteOffset = offset + lineStart;
121
- if (lineBytes.length > MAX_RECORD_BYTES) {
136
+ if (lineBytes.length > recordLimit) {
122
137
  logGap(filename, byteOffset, "rollout_record_exceeds_scan_limit");
123
138
  lineStart = index + 1;
124
139
  continue;
@@ -138,25 +153,34 @@ export async function scanSourceOnce(config, paths, signal) {
138
153
  for (const parsedEvent of parsed.events) {
139
154
  if (queuedIds.has(parsedEvent.eventId))
140
155
  continue;
141
- const eventBytes = Buffer.byteLength(JSON.stringify(parsedEvent), "utf8");
156
+ const event = await redactTraceEvent(parsedEvent);
157
+ const eventBytes = Buffer.byteLength(JSON.stringify(event), "utf8");
142
158
  if (eventBytes > MAX_EVENT_BYTES) {
143
159
  logGap(filename, byteOffset, "normalized_event_exceeds_upload_limit");
144
160
  }
145
161
  else {
146
- events.push(parsedEvent);
147
- queuedIds.add(parsedEvent.eventId);
162
+ events.push(event);
163
+ queuedIds.add(event.eventId);
148
164
  }
149
165
  }
150
166
  lineStart = index + 1;
151
167
  }
152
- if (buffer.length - lineStart > MAX_RECORD_BYTES) {
168
+ if (buffer.length - lineStart > recordLimit) {
153
169
  logGap(filename, offset + lineStart, "incomplete_rollout_record_exceeds_scan_limit");
154
170
  skipping.add(filename);
155
171
  lineStart = buffer.length;
156
172
  }
157
173
  cursors[filename] = offset + lineStart;
174
+ if (source === "cursor" &&
175
+ filename.includes(`${path.sep}cursor-hooks${path.sep}`) &&
176
+ cursors[filename] === stats.size)
177
+ completedHooks.push(filename);
158
178
  }
159
179
  await appendEvents(events, paths);
180
+ for (const filename of completedHooks) {
181
+ await fs.rm(filename);
182
+ delete cursors[filename];
183
+ }
160
184
  await saveState({
161
185
  cursors,
162
186
  skipping: [...skipping],
@@ -217,6 +241,7 @@ async function uploadPendingOnce(config, paths, fetcher = collectorFetch, signal
217
241
  return uploaded;
218
242
  }
219
243
  export async function collectorTick(config, paths, fetcher = collectorFetch, signal, onAcknowledged) {
244
+ await redactPendingOutbox(paths);
220
245
  let uploaded = await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
221
246
  const scan = (await outboxSize(paths)) >= SCAN_PAUSE_OUTBOX_BYTES
222
247
  ? { discovered: 0, caughtUp: false }
@@ -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[]>;