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 +6 -2
- package/dist/auto-connect.d.ts +1 -0
- package/dist/auto-connect.js +18 -4
- package/dist/clerk-device-auth.js +1 -1
- package/dist/cli.js +63 -33
- package/dist/collector.js +32 -7
- package/dist/cursor-hook-entry.d.ts +1 -0
- package/dist/cursor-hook-entry.js +157 -0
- package/dist/cursor-hooks.d.ts +7 -0
- package/dist/cursor-hooks.js +163 -0
- package/dist/cursor-transcript.d.ts +8 -0
- package/dist/cursor-transcript.js +294 -0
- package/dist/redact-trace.d.ts +2 -0
- package/dist/redact-trace.js +182 -0
- package/dist/service.js +9 -3
- package/dist/status-view.d.ts +1 -0
- package/dist/status-view.js +6 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.js +125 -3
- package/dist/turn-summary.js +3 -1
- package/package.json +4 -2
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
|
|
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
|
|
package/dist/auto-connect.d.ts
CHANGED
package/dist/auto-connect.js
CHANGED
|
@@ -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
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
|
106
|
-
|
|
107
|
-
statusSnapshot(
|
|
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 ===
|
|
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
|
|
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
|
-
:
|
|
160
|
-
|
|
161
|
-
|
|
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,
|
|
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:
|
|
175
|
-
:
|
|
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
|
|
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
|
|
391
|
-
|
|
392
|
-
statusSnapshot(
|
|
393
|
-
|
|
394
|
-
process.stdout.write(`${
|
|
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"
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 >
|
|
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
|
|
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(
|
|
147
|
-
queuedIds.add(
|
|
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 >
|
|
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[]>;
|