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 +4 -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-fetch.js +5 -6
- package/dist/collector.js +25 -3
- 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/service.js +9 -3
- package/dist/status-view.d.ts +1 -0
- package/dist/status-view.js +6 -0
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +55 -2
- package/dist/turn-summary.js +3 -1
- package/package.json +2 -2
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
|
|
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
|
|
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-fetch.js
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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:
|
|
11
|
-
"https:": new https.Agent({ keepAlive:
|
|
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 =
|
|
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 >
|
|
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 >
|
|
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 {
|
|
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 ("
|
|
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
|
-
|
|
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)
|
package/dist/status-view.d.ts
CHANGED
package/dist/status-view.js
CHANGED
|
@@ -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 "
|
|
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 "
|
|
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
|
+
}
|
package/dist/turn-summary.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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"
|